• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

knowledgepixels / nanopub-query / 30989231494

05 Aug 2026 08:29AM UTC coverage: 61.323% (-0.2%) from 61.54%
30989231494

Pull #161

github

web-flow
Merge f82ea88bc into 8b06b129a
Pull Request #161: perf(loader): serialise repo writes with a lock instead of SERIALIZABLE

661 of 1222 branches covered (54.09%)

Branch coverage included in aggregate %.

1944 of 3026 relevant lines covered (64.24%)

9.76 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

37.86
src/main/java/com/knowledgepixels/query/JellyNanopubLoader.java
1
package com.knowledgepixels.query;
2

3
import org.apache.http.client.methods.CloseableHttpResponse;
4
import org.apache.http.client.methods.HttpGet;
5
import org.apache.http.client.methods.HttpHead;
6
import org.apache.http.impl.client.CloseableHttpClient;
7
import org.apache.http.impl.client.HttpClientBuilder;
8
import org.apache.http.util.EntityUtils;
9
import org.eclipse.rdf4j.query.QueryLanguage;
10
import org.eclipse.rdf4j.repository.RepositoryConnection;
11
import org.nanopub.NanopubUtils;
12
import org.nanopub.jelly.NanopubStream;
13
import org.slf4j.Logger;
14
import org.slf4j.LoggerFactory;
15

16
import java.io.IOException;
17
import java.util.concurrent.atomic.AtomicLong;
18

19
/**
20
 * Loads nanopubs from the attached Nanopub Registry via a restartable Jelly stream.
21
 */
22
public class JellyNanopubLoader {
×
23
    static final String registryUrl;
24
    private static long lastCommittedCounter = -1;
6✔
25
    private static Long lastKnownSetupId = null;
6✔
26
    // Latest registry metadata fields, updated on each metadata fetch and forwarded to clients
27
    static volatile String lastCoverageTypes = null;
6✔
28
    static volatile String lastCoverageAgents = null;
6✔
29
    static volatile String lastTestInstance = null;
6✔
30
    static volatile String lastNanopubCount = null;
6✔
31
    private static final CloseableHttpClient metadataClient;
32
    private static final CloseableHttpClient jellyStreamClient;
33

34
    private static final int MAX_RETRIES_METADATA = 10;
35
    private static final int RETRY_DELAY_METADATA = 3000;
36
    private static final int RETRY_DELAY_JELLY = 5000;
37

38
    /**
39
     * Circuit-breaker state. Counts consecutive {@code loadUpdates} invocations in
40
     * which the catch block fired; resets to zero on the next successful batch.
41
     * Scheduled on the single-threaded executor in {@link MainVerticle}, so a plain
42
     * {@code int} is enough — no concurrency.
43
     *
44
     * <p>When the counter reaches {@link #BREAKER_THRESHOLD}, the next invocation
45
     * sleeps {@link #BREAKER_PAUSE_MS} before proceeding. Lets the saturated RDF4J
46
     * drain instead of being hammered every {@link #UPDATES_POLL_INTERVAL} ms.
47
     *
48
     * <p>Depends on {@link com.knowledgepixels.query.TripleStore}'s socket timeouts
49
     * (change 1 of the fix plan) to turn parked commits into propagating exceptions;
50
     * without them, {@code loadBatch} can park forever, the catch never fires, and
51
     * this counter stays at zero.
52
     */
53
    static volatile int consecutiveBatchFailures = 0;
6✔
54

55
    static final int BREAKER_THRESHOLD = 3;
56
    static final long BREAKER_PAUSE_MS = 30_000L;
57

58
    /**
59
     * Epoch-millis of the last time the loader demonstrably reached the triple store —
60
     * by committing a load counter, by completing a batch, or, on an idle tick, by
61
     * passing {@link #probeStoreReachable()}. Read by {@link MetricsCollector} to expose
62
     * {@code registry.loader.last_successful_batch_age_seconds} and served as the
63
     * {@code Nanopub-Query-Loader-Last-Success-Age-Seconds} header, so an operator can
64
     * tell from outside whether an instance is still able to serve.
65
     *
66
     * <p><strong>Must only be stamped after RDF4J has actually answered.</strong> Until
67
     * 2026-08-05 the idle branch stamped it unconditionally, but an idle tick reads the
68
     * counter from in-memory {@link StatusController} state and otherwise talks only to
69
     * the registry — it never touched RDF4J. A caught-up instance therefore reported
70
     * age 0 and READY throughout an 11-hour total RDF4J outage on kpxl (Tomcat's acceptor
71
     * thread had died of {@code OutOfMemoryError}, so every connect timed out), which is
72
     * precisely the failure this signal exists to surface. A caught-up instance could
73
     * never fail the check, which made the check worthless exactly when it mattered.
74
     * Every stamp site below therefore follows a completed round trip: a committed
75
     * counter and a completed batch are both stronger proof than the ASK probe.
76
     *
77
     * <p><strong>The initial-load paths stamp it too.</strong> They did not until
78
     * 2026-08-05, and because a resync runs entirely inside {@link #loadInitial} —
79
     * {@code loadUpdates} does not run again until {@code performResync} returns, which
80
     * for a full re-stream is tens of minutes — the age climbed unbounded for the whole
81
     * of any legitimate resync. A healthy resync was indistinguishable from a wedged one
82
     * both here and in Grafana (incident 2026-08-05, query.nanodash.net: a resync stalled
83
     * with the load counter pinned at -1, and the climbing age looked exactly like the
84
     * resync itself). Read this together with {@code Nanopub-Query-Status}: a climbing
85
     * age under {@code LOADING_INITIAL} now means a resync that has stopped making
86
     * progress, not merely a long one.
87
     *
88
     * <p>On a healthy idle instance the age oscillates between 0 and
89
     * {@link #STORE_PROBE_INTERVAL_MS}, rather than sitting at 0.
90
     */
91
    static volatile long lastSuccessfulBatchAtMs = 0L;
6✔
92

93
    /**
94
     * Minimum interval between idle-path store-reachability probes, and hence the
95
     * granularity of {@link #lastSuccessfulBatchAtMs} on a caught-up instance. The idle
96
     * path runs every {@link #UPDATES_POLL_INTERVAL} ms; probing on every poll would add
97
     * 30 round trips a minute without sharpening the signal, since any alert on this
98
     * value is measured in minutes.
99
     */
100
    static final long STORE_PROBE_INTERVAL_MS = 30_000L;
101

102
    /**
103
     * Epoch-millis of the last store-reachability probe, whether it was a dedicated
104
     * {@link #probeStoreReachable()} call or a batch commit (which proves the same thing
105
     * more strongly). Confined to the single-threaded loader executor in
106
     * {@link MainVerticle}, so a plain field is enough. Package-private only so tests
107
     * can reset it between cases.
108
     */
109
    static long lastStoreProbeAtMs = 0L;
6✔
110

111
    /**
112
     * Epoch-millis of the last {@link #updateForwardingMetadata} call, i.e. of the last
113
     * time the registry-derived headers we forward to clients were refreshed. Used by
114
     * {@link #maybeRefreshForwardingMetadata} to rate-limit mid-load refreshes.
115
     */
116
    private static volatile long lastForwardingMetadataAtMs = 0L;
6✔
117

118
    /**
119
     * Minimum interval between opportunistic refreshes of the forwarded registry
120
     * metadata during a long-running load.
121
     *
122
     * <p>{@link #loadUpdates} refreshes on every poll (~{@value #UPDATES_POLL_INTERVAL} ms)
123
     * so this limiter never engages there; it exists for {@link #loadInitial}, which
124
     * used to fetch metadata exactly once and then stream for as long as the batch
125
     * took. Thirty seconds is far below the resolution anyone reads these values at
126
     * while costing one HEAD request per interval.
127
     */
128
    private static final long METADATA_REFRESH_INTERVAL_MS = 30_000L;
129

130
    /**
131
     * Heartbeat counter for loadUpdates invocations. A summary log line is emitted
132
     * every {@link #HEARTBEAT_INTERVAL_INVOCATIONS} invocations so a truncated or
133
     * sampled log still shows the loader's state evolving. At the default 2 s poll
134
     * interval, 30 invocations ≈ 1 line per minute.
135
     */
136
    private static long loadUpdatesInvocations = 0L;
6✔
137
    private static final long HEARTBEAT_INTERVAL_INVOCATIONS = 30;
138

139
    private static final Logger logger = LoggerFactory.getLogger(JellyNanopubLoader.class);
9✔
140

141
    /**
142
     * Registry metadata returned by a HEAD request.
143
     */
144
    record RegistryMetadata(long loadCounter, Long setupId, String coverageTypes,
72✔
145
                            String coverageAgents, String testInstance, String nanopubCount,
146
                            String trustStateHash) {
147
    }
148

149
    /**
150
     * The interval in milliseconds at which the updates loader should poll for new nanopubs.
151
     */
152
    public static final int UPDATES_POLL_INTERVAL = 2000;
153

154
    enum LoadingType {
9✔
155
        INITIAL,
18✔
156
        UPDATE,
18✔
157
    }
158

159
    static {
160
        // Initialize registryUrl
161
        var url = Utils.getEnvString(
12✔
162
                "REGISTRY_FIXED_URL", "https://registry.knowledgepixels.com/"
163
        );
164
        if (!url.endsWith("/")) {
12!
165
            url += "/";
×
166
        }
167
        registryUrl = url;
6✔
168

169
        metadataClient = HttpClientBuilder.create().setDefaultRequestConfig(Utils.getHttpRequestConfig()).build();
15✔
170
        jellyStreamClient = NanopubUtils.getHttpClient();
6✔
171
    }
3✔
172

173
    /**
174
     * Start or continue (after restart) the initial loading procedure. This simply loads all
175
     * nanopubs from the attached Registry.
176
     *
177
     * @param afterCounter which counter to start from (-1 for the beginning)
178
     */
179
    public static void loadInitial(long afterCounter) {
180
        RegistryMetadata metadata = fetchRegistryMetadata();
6✔
181
        updateForwardingMetadata(metadata);
6✔
182
        TrustStateLoader.maybeUpdate(metadata.trustStateHash());
9✔
183
        long targetCounter = metadata.loadCounter();
9✔
184
        logger.info("Fetched Registry load counter: {}", targetCounter);
15✔
185
        // Store setupId on initial load
186
        if (metadata.setupId() != null && lastKnownSetupId == null) {
9!
187
            lastKnownSetupId = metadata.setupId();
×
188
            StatusController.get().setRegistrySetupId(metadata.setupId());
×
189
        }
190
        lastCommittedCounter = afterCounter;
6✔
191
        while (lastCommittedCounter < targetCounter) {
12!
192
            // Keep the forwarded registry headers moving even across a batch that
193
            // fails and retries without loading anything. Rate-limited, so the first
194
            // iteration (right after the fetch above) is a no-op. Note this refreshes
195
            // only what we forward to clients — targetCounter stays as sampled at
196
            // entry, and anything the registry gained since is picked up by
197
            // loadUpdates once this initial load returns.
198
            maybeRefreshForwardingMetadata();
×
199
            // Same circuit-breaker logic as loadUpdates: after BREAKER_THRESHOLD
200
            // consecutive failed batches, pause before retrying so a saturated RDF4J
201
            // (e.g. during a restart storm) can drain instead of being hammered on the
202
            // 5-second RETRY_DELAY_JELLY cadence.
203
            if (consecutiveBatchFailures >= BREAKER_THRESHOLD) {
×
204
                logger.warn("Circuit breaker active during initial load after {} consecutive batch failures; pausing {} ms before next attempt",
×
205
                        consecutiveBatchFailures, BREAKER_PAUSE_MS);
×
206
                try {
207
                    Thread.sleep(BREAKER_PAUSE_MS);
×
208
                } catch (InterruptedException e) {
×
209
                    Thread.currentThread().interrupt();
×
210
                    throw new RuntimeException("Interrupted while waiting for circuit breaker.");
×
211
                }
×
212
            }
213
            try {
214
                loadBatch(lastCommittedCounter, LoadingType.INITIAL);
×
215
                consecutiveBatchFailures = 0;
×
216
                // A completed batch wrote to RDF4J, so it satisfies the "only stamp
217
                // after the store answered" rule and, like an update batch, is a
218
                // stronger reachability proof than the ASK probe.
219
                lastSuccessfulBatchAtMs = System.currentTimeMillis();
×
220
                lastStoreProbeAtMs = lastSuccessfulBatchAtMs;
×
221
                logger.info("Initial load: loaded batch up to counter {}", lastCommittedCounter);
×
222
            } catch (Exception e) {
×
223
                consecutiveBatchFailures++;
×
224
                logger.info("Failed to load batch starting from counter {} (consecutive failures: {})",
×
225
                        lastCommittedCounter, consecutiveBatchFailures);
×
226
                logger.info("Failure reason: ", e);
×
227
                try {
228
                    Thread.sleep(RETRY_DELAY_JELLY);
×
229
                } catch (InterruptedException e2) {
×
230
                    throw new RuntimeException("Interrupted while waiting to retry loading batch.");
×
231
                }
×
232
            }
×
233
        }
234
        logger.info("Initial load complete.");
9✔
235
    }
3✔
236

237
    /**
238
     * Check if the Registry has any new nanopubs. If it does, load them.
239
     * This method should be called periodically, and you should wait for it to finish before
240
     * calling it again.
241
     */
242
    public static void loadUpdates() {
243
        // Circuit breaker: after BREAKER_THRESHOLD consecutive failed batches, pause
244
        // before the next attempt so a saturated RDF4J can drain. Check happens before
245
        // any RDF4J-touching work so the sleep isn't itself under the broken regime.
246
        if (consecutiveBatchFailures >= BREAKER_THRESHOLD) {
9!
247
            logger.warn("Circuit breaker active after {} consecutive batch failures; pausing {} ms before next attempt",
×
248
                    consecutiveBatchFailures, BREAKER_PAUSE_MS);
×
249
            try {
250
                Thread.sleep(BREAKER_PAUSE_MS);
×
251
            } catch (InterruptedException e) {
×
252
                // Preserve interruption semantics so a graceful shutdown (e.g. via
253
                // MainVerticle's shutdown hook) isn't blocked by the pause.
254
                Thread.currentThread().interrupt();
×
255
                return;
×
256
            }
×
257
        }
258
        try {
259
            final var status = StatusController.get().getState();
9✔
260
            lastCommittedCounter = status.loadCounter;
9✔
261
            RegistryMetadata metadata = fetchRegistryMetadata();
6✔
262
            updateForwardingMetadata(metadata);
6✔
263
            TrustStateLoader.maybeUpdate(metadata.trustStateHash());
9✔
264
            long targetCounter = metadata.loadCounter();
9✔
265
            Long currentSetupId = metadata.setupId();
9✔
266

267
            // Detect reset via setupId change
268
            if (lastKnownSetupId != null && currentSetupId != null
6!
269
                && !lastKnownSetupId.equals(currentSetupId)) {
×
270
                logger.warn("Registry reset detected: setupId {} -> {}", lastKnownSetupId, currentSetupId);
×
271
                performResync(currentSetupId);
×
272
                return;
×
273
            }
274
            // Detect reset via counter decrease (also covers first run after upgrade
275
            // where no setupId was persisted yet but the registry has already been reset)
276
            if (lastCommittedCounter > 0 && targetCounter >= 0
36!
277
                && targetCounter < lastCommittedCounter) {
278
                logger.warn("Registry counter decreased {} -> {}, triggering resync",
×
279
                        lastCommittedCounter, targetCounter);
×
280
                performResync(currentSetupId);
×
281
                return;
×
282
            }
283

284
            // Update lastKnownSetupId on first successful poll
285
            if (currentSetupId != null && lastKnownSetupId == null) {
6!
286
                if (lastCommittedCounter > 0) {
×
287
                    // Upgrade from a version without setupId tracking. The DB has data but
288
                    // we can't verify it matches the current registry. Force a resync.
289
                    logger.warn("No stored setupId but DB has data (counter: {}). "
×
290
                                + "Forcing resync to ensure data consistency.", lastCommittedCounter);
×
291
                    performResync(currentSetupId);
×
292
                    return;
×
293
                }
294
                lastKnownSetupId = currentSetupId;
×
295
                StatusController.get().setRegistrySetupId(currentSetupId);
×
296
            }
297

298
            if (lastCommittedCounter >= targetCounter) {
12!
299
                // Nothing to do. Keep state at READY (setReady is idempotent) and
300
                // skip the redundant setLoadingUpdates → setReady admin-repo write
301
                // that the old flow did on every idle poll. Also reset the breaker
302
                // counter — a successful "nothing to do" is still a successful tick
303
                // and should clear stale failure state from earlier transient errors.
304
                //
305
                // Probe the store *before* recording liveness: nothing else on this
306
                // path touches RDF4J, so without it "caught up" would keep passing
307
                // for liveness while the store was unreachable. A failing probe
308
                // throws into the catch below, which increments the breaker and
309
                // leaves lastSuccessfulBatchAtMs to go stale — the intended signal.
310
                boolean probed = probeStoreReachable();
6✔
311
                StatusController.get().setReady();
6✔
312
                consecutiveBatchFailures = 0;
6✔
313
                if (probed) {
6✔
314
                    lastSuccessfulBatchAtMs = System.currentTimeMillis();
6✔
315
                }
316
                maybeLogHeartbeat(targetCounter, true);
9✔
317
                return;
3✔
318
            }
319
            StatusController.get().setLoadingUpdates(status.loadCounter);
×
320
            loadBatch(lastCommittedCounter, LoadingType.UPDATE);
×
321
            // Batch completed without an exception — reset the breaker counter.
322
            consecutiveBatchFailures = 0;
×
323
            lastSuccessfulBatchAtMs = System.currentTimeMillis();
×
324
            // A committed batch is a stronger reachability proof than the ASK probe,
325
            // so it also defers the next one.
326
            lastStoreProbeAtMs = lastSuccessfulBatchAtMs;
×
327
            maybeLogHeartbeat(targetCounter, false);
×
328
            logger.info("Loaded {} update(s). Counter: {}, target was: {}",
×
329
                    lastCommittedCounter - status.loadCounter, lastCommittedCounter, targetCounter);
×
330
            if (lastCommittedCounter < targetCounter) {
×
331
                logger.info("Warning: expected to load nanopubs up to (inclusive) counter {} based on the counter reported in Registry's headers, but loaded only up to {}.", targetCounter, lastCommittedCounter);
×
332
            }
333
        } catch (Exception e) {
3✔
334
            consecutiveBatchFailures++;
12✔
335
            logger.warn("Failed to load updates. Current counter: {} (consecutive failures: {})",
24✔
336
                    lastCommittedCounter, consecutiveBatchFailures, e);
33✔
337
        } finally {
338
            try {
339
                StatusController.get().setReady();
6✔
340
            } catch (Exception e) {
×
341
                logger.info("Update loader: failed to set status to READY.");
×
342
                logger.info("Failure Reason: ", e);
×
343
            }
3✔
344
        }
345
    }
3✔
346

347
    /**
348
     * Verify that the triple store is reachable and answering, so that
349
     * {@link #lastSuccessfulBatchAtMs} means "this instance can still serve" rather than
350
     * merely "the loader loop is still running".
351
     *
352
     * <p>Uses {@code ASK {}} against the admin repo: it matches the empty group pattern
353
     * without touching any index, so the cost is one HTTP round trip and essentially no
354
     * query work — while still exercising the exact client, connection pool and endpoint
355
     * that a real query would use.
356
     *
357
     * <p>Throttled to one probe per {@link #STORE_PROBE_INTERVAL_MS}. Callers must treat
358
     * a {@code false} return as "not verified now" and leave the liveness timestamp
359
     * alone, so the stamp always refers to a round trip that actually happened.
360
     *
361
     * @return true if a probe ran and succeeded; false if one ran too recently to repeat
362
     * @throws RuntimeException (from RDF4J) if the store did not answer
363
     */
364
    private static boolean probeStoreReachable() {
365
        long now = System.currentTimeMillis();
6✔
366
        if (now - lastStoreProbeAtMs < STORE_PROBE_INTERVAL_MS) {
18✔
367
            return false;
6✔
368
        }
369
        try (RepositoryConnection conn = TripleStore.get().getAdminRepoConnection()) {
9✔
370
            conn.prepareBooleanQuery(QueryLanguage.SPARQL, "ASK {}").evaluate();
18✔
371
        }
372
        lastStoreProbeAtMs = now;
6✔
373
        return true;
6✔
374
    }
375

376
    /**
377
     * Emit a heartbeat summary log line roughly every
378
     * {@link #HEARTBEAT_INTERVAL_INVOCATIONS} invocations of {@link #loadUpdates}.
379
     * Lets an operator reconstruct loader progress from a sparse or sampled log
380
     * export, independent of Prometheus retention.
381
     */
382
    private static void maybeLogHeartbeat(long targetCounter, boolean idle) {
383
        loadUpdatesInvocations++;
12✔
384
        if (loadUpdatesInvocations % HEARTBEAT_INTERVAL_INVOCATIONS != 0) {
18!
385
            return;
3✔
386
        }
387
        logger.info("Loader heartbeat: counter={} target={} idle={} consecutiveBatchFailures={} breakerActive={}",
×
388
                lastCommittedCounter, targetCounter, idle, consecutiveBatchFailures,
×
389
                consecutiveBatchFailures >= BREAKER_THRESHOLD);
×
390
    }
×
391

392
    /**
393
     * Re-stream all nanopubs from the registry after a reset is detected.
394
     * Existing nanopubs are skipped by NanopubLoader's per-repo dedup.
395
     *
396
     * @param newSetupId the new setup ID from the registry, or null if unknown
397
     */
398
    private static void performResync(Long newSetupId) {
399
        logger.warn("Starting resync with registry. New setupId: {}", newSetupId);
×
400
        StatusController.get().setResetting();
×
401
        lastKnownSetupId = newSetupId;
×
402
        if (newSetupId != null) {
×
403
            StatusController.get().setRegistrySetupId(newSetupId);
×
404
        }
405
        StatusController.get().setLoadingInitial(-1);
×
406
        loadInitial(-1);
×
407
        StatusController.get().setReady();
×
408
        logger.warn("Resync complete. Counter: {}", lastCommittedCounter);
×
409
    }
×
410

411
    /**
412
     * Load a batch of nanopubs from the Jelly stream.
413
     * <p>
414
     * The method requests the list of all nanopubs from the Registry and reads it for as long
415
     * as it can. If the stream is interrupted, the method will throw an exception, and you
416
     * can resume loading from the last known counter.
417
     *
418
     * @param afterCounter the last known nanopub counter to have been committed in the DB
419
     * @param type         the type of loading operation (initial or update)
420
     */
421
    static void loadBatch(long afterCounter, LoadingType type) {
422
        CloseableHttpResponse response;
423
        try {
424
            var request = new HttpGet(makeStreamFetchUrl(afterCounter));
×
425
            response = jellyStreamClient.execute(request);
×
426
        } catch (IOException e) {
×
427
            throw new RuntimeException("Failed to fetch Jelly stream from the Registry (I/O error).", e);
×
428
        }
×
429

430
        int httpStatus = response.getStatusLine().getStatusCode();
×
431
        if (httpStatus < 200 || httpStatus >= 300) {
×
432
            EntityUtils.consumeQuietly(response.getEntity());
×
433
            throw new RuntimeException("Jelly stream HTTP status is not 2xx: " + httpStatus + ".");
×
434
        }
435

436
        try (
437
                var is = response.getEntity().getContent();
×
438
                var npStream = NanopubStream.fromByteStream(is).getAsNanopubs()
×
439
        ) {
440
            AtomicLong checkpointTime = new AtomicLong(System.currentTimeMillis());
×
441
            AtomicLong checkpointCounter = new AtomicLong(lastCommittedCounter);
×
442
            AtomicLong lastSavedCounter = new AtomicLong(lastCommittedCounter);
×
443
            AtomicLong loaded = new AtomicLong(0L);
×
444

445
            npStream.forEach(m -> {
×
446
                if (!m.isSuccess()) {
×
447
                    throw new RuntimeException("Failed to load " +
×
448
                                               "nanopub from Jelly stream. Last known counter: " + lastCommittedCounter,
449
                            m.getException()
×
450
                    );
451
                }
452
                if (m.getCounter() < lastCommittedCounter) {
×
453
                    throw new RuntimeException("Received a nanopub with a counter lower than " +
×
454
                                               "the last known counter. Last known counter: " + lastCommittedCounter +
455
                                               ", received counter: " + m.getCounter());
×
456
                }
457
                NanopubLoader.load(m.getNanopub(), m.getCounter());
×
458
                // Bump the in-memory counter BEFORE persisting it. The previous order
459
                // wrote the *previous* nanopub's counter to the DB at each checkpoint,
460
                // so a crash-restart silently re-processed one extra nanopub and the
461
                // contract "saved counter == last fully loaded nanopub" was violated.
462
                lastCommittedCounter = m.getCounter();
×
463
                if (m.getCounter() % 10 == 0) {
×
464
                    // Save the committed counter only every 10 nanopubs to reduce DB load
465
                    saveCommittedCounter(type);
×
466
                    lastSavedCounter.set(m.getCounter());
×
467
                }
468
                loaded.getAndIncrement();
×
469

470
                if (loaded.get() % 50 == 0) {
×
471
                    long currTime = System.currentTimeMillis();
×
472
                    double speed = 50 / ((currTime - checkpointTime.get()) / 1000.0);
×
473
                    logger.info("Loading speed: {} np/s. Counter: {}", String.format("%.2f", speed), lastCommittedCounter);
×
474
                    checkpointTime.set(currTime);
×
475
                    checkpointCounter.set(lastCommittedCounter);
×
476
                    // A full re-stream is a single loadBatch call lasting tens of
477
                    // minutes; without this the forwarded registry count would hold
478
                    // its entry-time value for the whole of it, and the sync-lag
479
                    // gauge derived from it would drift with it.
480
                    maybeRefreshForwardingMetadata();
×
481
                }
482
            });
×
483
            // Make sure to save the last committed counter at the end of the batch
484
            if (lastCommittedCounter >= lastSavedCounter.get()) {
×
485
                saveCommittedCounter(type);
×
486
            }
487
        } catch (IOException e) {
×
488
            throw new RuntimeException("I/O error while reading the response Jelly stream.", e);
×
489
        } finally {
490
            try {
491
                response.close();
×
492
            } catch (IOException e) {
×
493
                logger.info("Failed to close the Jelly stream response.");
×
494
            }
×
495
        }
496
    }
×
497

498
    /**
499
     * Save the last committed counter to the DB. Do this every N nanopubs to reduce DB load.
500
     * Remember to call this method at the end of the batch as well.
501
     *
502
     * @param type the type of loading operation (initial or update)
503
     */
504
    private static void saveCommittedCounter(LoadingType type) {
505
        try {
506
            if (type == LoadingType.INITIAL) {
9!
507
                StatusController.get().setLoadingInitial(lastCommittedCounter);
12✔
508
            } else {
509
                StatusController.get().setLoadingUpdates(lastCommittedCounter);
×
510
            }
511
            // A committed counter is an admin-repo write that RDF4J acknowledged, so it
512
            // is the finest-grained evidence of liveness there is — and the only one
513
            // that ticks inside a batch rather than at its end, which is what makes a
514
            // long initial load legible. Stamped after the call, never before: a failed
515
            // commit is exactly how the loader wedges, and must not read as progress.
516
            lastSuccessfulBatchAtMs = System.currentTimeMillis();
6✔
517
            lastStoreProbeAtMs = lastSuccessfulBatchAtMs;
6✔
518
        } catch (Exception e) {
3✔
519
            throw new RuntimeException("Could not update the nanopub counter in DB", e);
18✔
520
        }
3✔
521
    }
3✔
522

523
    /**
524
     * Set the last known setup ID. Called from MainVerticle on startup to restore persisted state.
525
     *
526
     * @param setupId the setup ID to set, or null if not known
527
     */
528
    static void setLastKnownSetupId(Long setupId) {
529
        lastKnownSetupId = setupId;
×
530
    }
×
531

532
    /**
533
     * Update the cached metadata fields used for forwarding to clients.
534
     */
535
    private static void updateForwardingMetadata(RegistryMetadata metadata) {
536
        lastCoverageTypes = metadata.coverageTypes();
9✔
537
        lastCoverageAgents = metadata.coverageAgents();
9✔
538
        lastTestInstance = metadata.testInstance();
9✔
539
        lastNanopubCount = metadata.nanopubCount();
9✔
540
        lastForwardingMetadataAtMs = System.currentTimeMillis();
6✔
541
    }
3✔
542

543
    /**
544
     * Re-read the registry's metadata headers mid-load so the values forwarded to
545
     * clients don't freeze for the duration of a long batch.
546
     *
547
     * <p>Rate-limited to one fetch per {@link #METADATA_REFRESH_INTERVAL_MS} and
548
     * best-effort: on failure the previous values stay in place and the caller
549
     * continues. Deliberately calls {@link #fetchRegistryMetadataInner} rather than
550
     * {@link #fetchRegistryMetadata} — the latter retries {@value #MAX_RETRIES_METADATA}
551
     * times with {@value #RETRY_DELAY_METADATA} ms between attempts, which against an
552
     * unhealthy registry would park the nanopub stream for half a minute to refresh a
553
     * header. One attempt, bounded by the client's socket timeout, is the right trade
554
     * for a value that is only ever advisory.
555
     *
556
     * <p>Refreshes only the forwarded fields. It must not touch the load counter the
557
     * caller is driving its loop from: re-targeting mid-load would let a busy registry
558
     * keep {@code loadInitial} running indefinitely instead of handing over to
559
     * {@code loadUpdates}.
560
     */
561
    private static void maybeRefreshForwardingMetadata() {
562
        if (System.currentTimeMillis() - lastForwardingMetadataAtMs < METADATA_REFRESH_INTERVAL_MS) {
18!
563
            return;
3✔
564
        }
565
        try {
566
            updateForwardingMetadata(fetchRegistryMetadataInner());
×
567
        } catch (Exception e) {
×
568
            // Stamp anyway so a persistently failing registry is retried on the same
569
            // interval rather than on every single call.
570
            lastForwardingMetadataAtMs = System.currentTimeMillis();
×
571
            logger.info("Mid-load registry metadata refresh failed; keeping previous values: {}", e.toString());
×
572
        }
×
573
    }
×
574

575
    /**
576
     * Run a HEAD request to the Registry to fetch its current metadata (load counter and setup ID).
577
     *
578
     * @return the registry metadata
579
     */
580
    static RegistryMetadata fetchRegistryMetadata() {
581
        int tries = 0;
6✔
582
        RegistryMetadata metadata = null;
6✔
583
        while (metadata == null && tries < MAX_RETRIES_METADATA) {
15!
584
            try {
585
                metadata = fetchRegistryMetadataInner();
×
586
            } catch (Exception e) {
3✔
587
                tries++;
3✔
588
                logger.info("Failed to fetch registry metadata, try {}. Retrying in {}ms...", tries, RETRY_DELAY_METADATA);
21✔
589
                logger.info("Failure Reason: ", e);
12✔
590
                try {
591
                    Thread.sleep(RETRY_DELAY_METADATA);
6✔
592
                } catch (InterruptedException e2) {
×
593
                    throw new RuntimeException(
×
594
                            "Interrupted while waiting to retry fetching registry metadata.");
595
                }
3✔
596
            }
3✔
597
        }
598
        if (metadata == null) {
6!
599
            throw new RuntimeException("Failed to fetch registry metadata after " + MAX_RETRIES_METADATA + " retries.");
15✔
600
        }
601
        return metadata;
×
602
    }
603

604
    /**
605
     * Inner logic for fetching the registry metadata via HEAD request.
606
     *
607
     * @return the registry metadata (load counter and setup ID)
608
     * @throws IOException if the HTTP request fails
609
     */
610
    private static RegistryMetadata fetchRegistryMetadataInner() throws IOException {
611
        var request = new HttpHead(registryUrl);
15✔
612
        try (var response = metadataClient.execute(request)) {
8✔
613
            int status = response.getStatusLine().getStatusCode();
8✔
614
            EntityUtils.consumeQuietly(response.getEntity());
6✔
615
            if (status < 200 || status >= 300) {
12!
616
                throw new RuntimeException("Registry metadata HTTP status is not 2xx: " +
×
617
                                           status + ".");
618
            }
619

620
            // Check if the registry is ready
621
            var hStatus = response.getHeaders("Nanopub-Registry-Status");
8✔
622
            if (hStatus.length == 0) {
6!
623
                throw new RuntimeException("Registry did not return a Nanopub-Registry-Status header.");
×
624
            }
625
            if (!"ready".equals(hStatus[0].getValue()) && !"updating".equals(hStatus[0].getValue())) {
28!
626
                throw new RuntimeException("Registry is not in ready state.");
10✔
627
            }
628

629
            // Get the load counter
630
            var hCounter = response.getHeaders("Nanopub-Registry-Load-Counter");
×
631
            if (hCounter.length == 0) {
×
632
                throw new RuntimeException("Registry did not return a Nanopub-Registry-Load-Counter header.");
×
633
            }
634
            long loadCounter = Long.parseLong(hCounter[0].getValue());
×
635

636
            // Get the setup ID (optional — older registries may not have it)
637
            Long setupId = null;
×
638
            var hSetupId = response.getHeaders("Nanopub-Registry-Setup-Id");
×
639
            if (hSetupId.length > 0) {
×
640
                try {
641
                    setupId = Long.parseLong(hSetupId[0].getValue());
×
642
                } catch (NumberFormatException e) {
×
643
                    logger.info("Could not parse Nanopub-Registry-Setup-Id header: {}", hSetupId[0].getValue());
×
644
                }
×
645
            }
646

647
            // Read metadata headers for forwarding to clients
648
            String coverageTypes = getHeaderValue(response, "Nanopub-Registry-Coverage-Types");
×
649
            String coverageAgents = getHeaderValue(response, "Nanopub-Registry-Coverage-Agents");
×
650
            String testInstance = getHeaderValue(response, "Nanopub-Registry-Test-Instance");
×
651
            String nanopubCount = getHeaderValue(response, "Nanopub-Registry-Nanopub-Count");
×
652
            // Optional — older registries (without trust calculation) won't set this header.
653
            String trustStateHash = getHeaderValue(response, "Nanopub-Registry-Trust-State-Hash");
×
654

655
            return new RegistryMetadata(loadCounter, setupId, coverageTypes, coverageAgents,
×
656
                    testInstance, nanopubCount, trustStateHash);
657
        }
658
    }
659

660
    private static String getHeaderValue(CloseableHttpResponse response, String name) {
661
        var headers = response.getHeaders(name);
×
662
        return headers.length > 0 ? headers[0].getValue() : null;
×
663
    }
664

665
    /**
666
     * Construct the URL for fetching the Jelly stream.
667
     *
668
     * @param afterCounter the last known counter to have been committed in the DB
669
     * @return the URL for fetching the Jelly stream
670
     */
671
    private static String makeStreamFetchUrl(long afterCounter) {
672
        return registryUrl + "nanopubs.jelly?afterCounter=" + afterCounter;
×
673
    }
674
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc