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

knowledgepixels / nanopub-query / 30980151495

05 Aug 2026 06:04AM UTC coverage: 59.947% (+0.9%) from 59.035%
30980151495

push

github

web-flow
Merge pull request #158 from knowledgepixels/fix/loader-liveness-requires-store

fix(loader): only report liveness after RDF4J has actually answered

637 of 1206 branches covered (52.82%)

Branch coverage included in aggregate %.

1870 of 2976 relevant lines covered (62.84%)

9.57 hits per line

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

38.41
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 {@code loadUpdates} invocation that demonstrably reached
60
     * the triple store — either by committing a batch, or, on an idle tick, by passing
61
     * {@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
     *
75
     * <p>On a healthy idle instance the age now oscillates between 0 and
76
     * {@link #STORE_PROBE_INTERVAL_MS}, rather than sitting at 0.
77
     */
78
    static volatile long lastSuccessfulBatchAtMs = 0L;
6✔
79

80
    /**
81
     * Minimum interval between idle-path store-reachability probes, and hence the
82
     * granularity of {@link #lastSuccessfulBatchAtMs} on a caught-up instance. The idle
83
     * path runs every {@link #UPDATES_POLL_INTERVAL} ms; probing on every poll would add
84
     * 30 round trips a minute without sharpening the signal, since any alert on this
85
     * value is measured in minutes.
86
     */
87
    static final long STORE_PROBE_INTERVAL_MS = 30_000L;
88

89
    /**
90
     * Epoch-millis of the last store-reachability probe, whether it was a dedicated
91
     * {@link #probeStoreReachable()} call or a batch commit (which proves the same thing
92
     * more strongly). Confined to the single-threaded loader executor in
93
     * {@link MainVerticle}, so a plain field is enough. Package-private only so tests
94
     * can reset it between cases.
95
     */
96
    static long lastStoreProbeAtMs = 0L;
6✔
97

98
    /**
99
     * Heartbeat counter for loadUpdates invocations. A summary log line is emitted
100
     * every {@link #HEARTBEAT_INTERVAL_INVOCATIONS} invocations so a truncated or
101
     * sampled log still shows the loader's state evolving. At the default 2 s poll
102
     * interval, 30 invocations ≈ 1 line per minute.
103
     */
104
    private static long loadUpdatesInvocations = 0L;
6✔
105
    private static final long HEARTBEAT_INTERVAL_INVOCATIONS = 30;
106

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

109
    /**
110
     * Registry metadata returned by a HEAD request.
111
     */
112
    record RegistryMetadata(long loadCounter, Long setupId, String coverageTypes,
72✔
113
                            String coverageAgents, String testInstance, String nanopubCount,
114
                            String trustStateHash) {
115
    }
116

117
    /**
118
     * The interval in milliseconds at which the updates loader should poll for new nanopubs.
119
     */
120
    public static final int UPDATES_POLL_INTERVAL = 2000;
121

122
    enum LoadingType {
×
123
        INITIAL,
×
124
        UPDATE,
×
125
    }
126

127
    static {
128
        // Initialize registryUrl
129
        var url = Utils.getEnvString(
12✔
130
                "REGISTRY_FIXED_URL", "https://registry.knowledgepixels.com/"
131
        );
132
        if (!url.endsWith("/")) {
12!
133
            url += "/";
×
134
        }
135
        registryUrl = url;
6✔
136

137
        metadataClient = HttpClientBuilder.create().setDefaultRequestConfig(Utils.getHttpRequestConfig()).build();
15✔
138
        jellyStreamClient = NanopubUtils.getHttpClient();
6✔
139
    }
3✔
140

141
    /**
142
     * Start or continue (after restart) the initial loading procedure. This simply loads all
143
     * nanopubs from the attached Registry.
144
     *
145
     * @param afterCounter which counter to start from (-1 for the beginning)
146
     */
147
    public static void loadInitial(long afterCounter) {
148
        RegistryMetadata metadata = fetchRegistryMetadata();
6✔
149
        updateForwardingMetadata(metadata);
6✔
150
        TrustStateLoader.maybeUpdate(metadata.trustStateHash());
9✔
151
        long targetCounter = metadata.loadCounter();
9✔
152
        logger.info("Fetched Registry load counter: {}", targetCounter);
15✔
153
        // Store setupId on initial load
154
        if (metadata.setupId() != null && lastKnownSetupId == null) {
9!
155
            lastKnownSetupId = metadata.setupId();
×
156
            StatusController.get().setRegistrySetupId(metadata.setupId());
×
157
        }
158
        lastCommittedCounter = afterCounter;
6✔
159
        while (lastCommittedCounter < targetCounter) {
12!
160
            // Same circuit-breaker logic as loadUpdates: after BREAKER_THRESHOLD
161
            // consecutive failed batches, pause before retrying so a saturated RDF4J
162
            // (e.g. during a restart storm) can drain instead of being hammered on the
163
            // 5-second RETRY_DELAY_JELLY cadence.
164
            if (consecutiveBatchFailures >= BREAKER_THRESHOLD) {
×
165
                logger.warn("Circuit breaker active during initial load after {} consecutive batch failures; pausing {} ms before next attempt",
×
166
                        consecutiveBatchFailures, BREAKER_PAUSE_MS);
×
167
                try {
168
                    Thread.sleep(BREAKER_PAUSE_MS);
×
169
                } catch (InterruptedException e) {
×
170
                    Thread.currentThread().interrupt();
×
171
                    throw new RuntimeException("Interrupted while waiting for circuit breaker.");
×
172
                }
×
173
            }
174
            try {
175
                loadBatch(lastCommittedCounter, LoadingType.INITIAL);
×
176
                consecutiveBatchFailures = 0;
×
177
                logger.info("Initial load: loaded batch up to counter {}", lastCommittedCounter);
×
178
            } catch (Exception e) {
×
179
                consecutiveBatchFailures++;
×
180
                logger.info("Failed to load batch starting from counter {} (consecutive failures: {})",
×
181
                        lastCommittedCounter, consecutiveBatchFailures);
×
182
                logger.info("Failure reason: ", e);
×
183
                try {
184
                    Thread.sleep(RETRY_DELAY_JELLY);
×
185
                } catch (InterruptedException e2) {
×
186
                    throw new RuntimeException("Interrupted while waiting to retry loading batch.");
×
187
                }
×
188
            }
×
189
        }
190
        logger.info("Initial load complete.");
9✔
191
    }
3✔
192

193
    /**
194
     * Check if the Registry has any new nanopubs. If it does, load them.
195
     * This method should be called periodically, and you should wait for it to finish before
196
     * calling it again.
197
     */
198
    public static void loadUpdates() {
199
        // Circuit breaker: after BREAKER_THRESHOLD consecutive failed batches, pause
200
        // before the next attempt so a saturated RDF4J can drain. Check happens before
201
        // any RDF4J-touching work so the sleep isn't itself under the broken regime.
202
        if (consecutiveBatchFailures >= BREAKER_THRESHOLD) {
9!
203
            logger.warn("Circuit breaker active after {} consecutive batch failures; pausing {} ms before next attempt",
×
204
                    consecutiveBatchFailures, BREAKER_PAUSE_MS);
×
205
            try {
206
                Thread.sleep(BREAKER_PAUSE_MS);
×
207
            } catch (InterruptedException e) {
×
208
                // Preserve interruption semantics so a graceful shutdown (e.g. via
209
                // MainVerticle's shutdown hook) isn't blocked by the pause.
210
                Thread.currentThread().interrupt();
×
211
                return;
×
212
            }
×
213
        }
214
        try {
215
            final var status = StatusController.get().getState();
9✔
216
            lastCommittedCounter = status.loadCounter;
9✔
217
            RegistryMetadata metadata = fetchRegistryMetadata();
6✔
218
            updateForwardingMetadata(metadata);
6✔
219
            TrustStateLoader.maybeUpdate(metadata.trustStateHash());
9✔
220
            long targetCounter = metadata.loadCounter();
9✔
221
            Long currentSetupId = metadata.setupId();
9✔
222

223
            // Detect reset via setupId change
224
            if (lastKnownSetupId != null && currentSetupId != null
6!
225
                && !lastKnownSetupId.equals(currentSetupId)) {
×
226
                logger.warn("Registry reset detected: setupId {} -> {}", lastKnownSetupId, currentSetupId);
×
227
                performResync(currentSetupId);
×
228
                return;
×
229
            }
230
            // Detect reset via counter decrease (also covers first run after upgrade
231
            // where no setupId was persisted yet but the registry has already been reset)
232
            if (lastCommittedCounter > 0 && targetCounter >= 0
36!
233
                && targetCounter < lastCommittedCounter) {
234
                logger.warn("Registry counter decreased {} -> {}, triggering resync",
×
235
                        lastCommittedCounter, targetCounter);
×
236
                performResync(currentSetupId);
×
237
                return;
×
238
            }
239

240
            // Update lastKnownSetupId on first successful poll
241
            if (currentSetupId != null && lastKnownSetupId == null) {
6!
242
                if (lastCommittedCounter > 0) {
×
243
                    // Upgrade from a version without setupId tracking. The DB has data but
244
                    // we can't verify it matches the current registry. Force a resync.
245
                    logger.warn("No stored setupId but DB has data (counter: {}). "
×
246
                                + "Forcing resync to ensure data consistency.", lastCommittedCounter);
×
247
                    performResync(currentSetupId);
×
248
                    return;
×
249
                }
250
                lastKnownSetupId = currentSetupId;
×
251
                StatusController.get().setRegistrySetupId(currentSetupId);
×
252
            }
253

254
            if (lastCommittedCounter >= targetCounter) {
12!
255
                // Nothing to do. Keep state at READY (setReady is idempotent) and
256
                // skip the redundant setLoadingUpdates → setReady admin-repo write
257
                // that the old flow did on every idle poll. Also reset the breaker
258
                // counter — a successful "nothing to do" is still a successful tick
259
                // and should clear stale failure state from earlier transient errors.
260
                //
261
                // Probe the store *before* recording liveness: nothing else on this
262
                // path touches RDF4J, so without it "caught up" would keep passing
263
                // for liveness while the store was unreachable. A failing probe
264
                // throws into the catch below, which increments the breaker and
265
                // leaves lastSuccessfulBatchAtMs to go stale — the intended signal.
266
                boolean probed = probeStoreReachable();
6✔
267
                StatusController.get().setReady();
6✔
268
                consecutiveBatchFailures = 0;
6✔
269
                if (probed) {
6✔
270
                    lastSuccessfulBatchAtMs = System.currentTimeMillis();
6✔
271
                }
272
                maybeLogHeartbeat(targetCounter, true);
9✔
273
                return;
3✔
274
            }
275
            StatusController.get().setLoadingUpdates(status.loadCounter);
×
276
            loadBatch(lastCommittedCounter, LoadingType.UPDATE);
×
277
            // Batch completed without an exception — reset the breaker counter.
278
            consecutiveBatchFailures = 0;
×
279
            lastSuccessfulBatchAtMs = System.currentTimeMillis();
×
280
            // A committed batch is a stronger reachability proof than the ASK probe,
281
            // so it also defers the next one.
282
            lastStoreProbeAtMs = lastSuccessfulBatchAtMs;
×
283
            maybeLogHeartbeat(targetCounter, false);
×
284
            logger.info("Loaded {} update(s). Counter: {}, target was: {}",
×
285
                    lastCommittedCounter - status.loadCounter, lastCommittedCounter, targetCounter);
×
286
            if (lastCommittedCounter < targetCounter) {
×
287
                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);
×
288
            }
289
        } catch (Exception e) {
3✔
290
            consecutiveBatchFailures++;
12✔
291
            logger.warn("Failed to load updates. Current counter: {} (consecutive failures: {})",
24✔
292
                    lastCommittedCounter, consecutiveBatchFailures, e);
33✔
293
        } finally {
294
            try {
295
                StatusController.get().setReady();
6✔
296
            } catch (Exception e) {
×
297
                logger.info("Update loader: failed to set status to READY.");
×
298
                logger.info("Failure Reason: ", e);
×
299
            }
3✔
300
        }
301
    }
3✔
302

303
    /**
304
     * Verify that the triple store is reachable and answering, so that
305
     * {@link #lastSuccessfulBatchAtMs} means "this instance can still serve" rather than
306
     * merely "the loader loop is still running".
307
     *
308
     * <p>Uses {@code ASK {}} against the admin repo: it matches the empty group pattern
309
     * without touching any index, so the cost is one HTTP round trip and essentially no
310
     * query work — while still exercising the exact client, connection pool and endpoint
311
     * that a real query would use.
312
     *
313
     * <p>Throttled to one probe per {@link #STORE_PROBE_INTERVAL_MS}. Callers must treat
314
     * a {@code false} return as "not verified now" and leave the liveness timestamp
315
     * alone, so the stamp always refers to a round trip that actually happened.
316
     *
317
     * @return true if a probe ran and succeeded; false if one ran too recently to repeat
318
     * @throws RuntimeException (from RDF4J) if the store did not answer
319
     */
320
    private static boolean probeStoreReachable() {
321
        long now = System.currentTimeMillis();
6✔
322
        if (now - lastStoreProbeAtMs < STORE_PROBE_INTERVAL_MS) {
18✔
323
            return false;
6✔
324
        }
325
        try (RepositoryConnection conn = TripleStore.get().getAdminRepoConnection()) {
9✔
326
            conn.prepareBooleanQuery(QueryLanguage.SPARQL, "ASK {}").evaluate();
18✔
327
        }
328
        lastStoreProbeAtMs = now;
6✔
329
        return true;
6✔
330
    }
331

332
    /**
333
     * Emit a heartbeat summary log line roughly every
334
     * {@link #HEARTBEAT_INTERVAL_INVOCATIONS} invocations of {@link #loadUpdates}.
335
     * Lets an operator reconstruct loader progress from a sparse or sampled log
336
     * export, independent of Prometheus retention.
337
     */
338
    private static void maybeLogHeartbeat(long targetCounter, boolean idle) {
339
        loadUpdatesInvocations++;
12✔
340
        if (loadUpdatesInvocations % HEARTBEAT_INTERVAL_INVOCATIONS != 0) {
18!
341
            return;
3✔
342
        }
343
        logger.info("Loader heartbeat: counter={} target={} idle={} consecutiveBatchFailures={} breakerActive={}",
×
344
                lastCommittedCounter, targetCounter, idle, consecutiveBatchFailures,
×
345
                consecutiveBatchFailures >= BREAKER_THRESHOLD);
×
346
    }
×
347

348
    /**
349
     * Re-stream all nanopubs from the registry after a reset is detected.
350
     * Existing nanopubs are skipped by NanopubLoader's per-repo dedup.
351
     *
352
     * @param newSetupId the new setup ID from the registry, or null if unknown
353
     */
354
    private static void performResync(Long newSetupId) {
355
        logger.warn("Starting resync with registry. New setupId: {}", newSetupId);
×
356
        StatusController.get().setResetting();
×
357
        lastKnownSetupId = newSetupId;
×
358
        if (newSetupId != null) {
×
359
            StatusController.get().setRegistrySetupId(newSetupId);
×
360
        }
361
        StatusController.get().setLoadingInitial(-1);
×
362
        loadInitial(-1);
×
363
        StatusController.get().setReady();
×
364
        logger.warn("Resync complete. Counter: {}", lastCommittedCounter);
×
365
    }
×
366

367
    /**
368
     * Load a batch of nanopubs from the Jelly stream.
369
     * <p>
370
     * The method requests the list of all nanopubs from the Registry and reads it for as long
371
     * as it can. If the stream is interrupted, the method will throw an exception, and you
372
     * can resume loading from the last known counter.
373
     *
374
     * @param afterCounter the last known nanopub counter to have been committed in the DB
375
     * @param type         the type of loading operation (initial or update)
376
     */
377
    static void loadBatch(long afterCounter, LoadingType type) {
378
        CloseableHttpResponse response;
379
        try {
380
            var request = new HttpGet(makeStreamFetchUrl(afterCounter));
×
381
            response = jellyStreamClient.execute(request);
×
382
        } catch (IOException e) {
×
383
            throw new RuntimeException("Failed to fetch Jelly stream from the Registry (I/O error).", e);
×
384
        }
×
385

386
        int httpStatus = response.getStatusLine().getStatusCode();
×
387
        if (httpStatus < 200 || httpStatus >= 300) {
×
388
            EntityUtils.consumeQuietly(response.getEntity());
×
389
            throw new RuntimeException("Jelly stream HTTP status is not 2xx: " + httpStatus + ".");
×
390
        }
391

392
        try (
393
                var is = response.getEntity().getContent();
×
394
                var npStream = NanopubStream.fromByteStream(is).getAsNanopubs()
×
395
        ) {
396
            AtomicLong checkpointTime = new AtomicLong(System.currentTimeMillis());
×
397
            AtomicLong checkpointCounter = new AtomicLong(lastCommittedCounter);
×
398
            AtomicLong lastSavedCounter = new AtomicLong(lastCommittedCounter);
×
399
            AtomicLong loaded = new AtomicLong(0L);
×
400

401
            npStream.forEach(m -> {
×
402
                if (!m.isSuccess()) {
×
403
                    throw new RuntimeException("Failed to load " +
×
404
                                               "nanopub from Jelly stream. Last known counter: " + lastCommittedCounter,
405
                            m.getException()
×
406
                    );
407
                }
408
                if (m.getCounter() < lastCommittedCounter) {
×
409
                    throw new RuntimeException("Received a nanopub with a counter lower than " +
×
410
                                               "the last known counter. Last known counter: " + lastCommittedCounter +
411
                                               ", received counter: " + m.getCounter());
×
412
                }
413
                NanopubLoader.load(m.getNanopub(), m.getCounter());
×
414
                // Bump the in-memory counter BEFORE persisting it. The previous order
415
                // wrote the *previous* nanopub's counter to the DB at each checkpoint,
416
                // so a crash-restart silently re-processed one extra nanopub and the
417
                // contract "saved counter == last fully loaded nanopub" was violated.
418
                lastCommittedCounter = m.getCounter();
×
419
                if (m.getCounter() % 10 == 0) {
×
420
                    // Save the committed counter only every 10 nanopubs to reduce DB load
421
                    saveCommittedCounter(type);
×
422
                    lastSavedCounter.set(m.getCounter());
×
423
                }
424
                loaded.getAndIncrement();
×
425

426
                if (loaded.get() % 50 == 0) {
×
427
                    long currTime = System.currentTimeMillis();
×
428
                    double speed = 50 / ((currTime - checkpointTime.get()) / 1000.0);
×
429
                    logger.info("Loading speed: {} np/s. Counter: {}", String.format("%.2f", speed), lastCommittedCounter);
×
430
                    checkpointTime.set(currTime);
×
431
                    checkpointCounter.set(lastCommittedCounter);
×
432
                }
433
            });
×
434
            // Make sure to save the last committed counter at the end of the batch
435
            if (lastCommittedCounter >= lastSavedCounter.get()) {
×
436
                saveCommittedCounter(type);
×
437
            }
438
        } catch (IOException e) {
×
439
            throw new RuntimeException("I/O error while reading the response Jelly stream.", e);
×
440
        } finally {
441
            try {
442
                response.close();
×
443
            } catch (IOException e) {
×
444
                logger.info("Failed to close the Jelly stream response.");
×
445
            }
×
446
        }
447
    }
×
448

449
    /**
450
     * Save the last committed counter to the DB. Do this every N nanopubs to reduce DB load.
451
     * Remember to call this method at the end of the batch as well.
452
     *
453
     * @param type the type of loading operation (initial or update)
454
     */
455
    private static void saveCommittedCounter(LoadingType type) {
456
        try {
457
            if (type == LoadingType.INITIAL) {
×
458
                StatusController.get().setLoadingInitial(lastCommittedCounter);
×
459
            } else {
460
                StatusController.get().setLoadingUpdates(lastCommittedCounter);
×
461
            }
462
        } catch (Exception e) {
×
463
            throw new RuntimeException("Could not update the nanopub counter in DB", e);
×
464
        }
×
465
    }
×
466

467
    /**
468
     * Set the last known setup ID. Called from MainVerticle on startup to restore persisted state.
469
     *
470
     * @param setupId the setup ID to set, or null if not known
471
     */
472
    static void setLastKnownSetupId(Long setupId) {
473
        lastKnownSetupId = setupId;
×
474
    }
×
475

476
    /**
477
     * Update the cached metadata fields used for forwarding to clients.
478
     */
479
    private static void updateForwardingMetadata(RegistryMetadata metadata) {
480
        lastCoverageTypes = metadata.coverageTypes();
9✔
481
        lastCoverageAgents = metadata.coverageAgents();
9✔
482
        lastTestInstance = metadata.testInstance();
9✔
483
        lastNanopubCount = metadata.nanopubCount();
9✔
484
    }
3✔
485

486
    /**
487
     * Run a HEAD request to the Registry to fetch its current metadata (load counter and setup ID).
488
     *
489
     * @return the registry metadata
490
     */
491
    static RegistryMetadata fetchRegistryMetadata() {
492
        int tries = 0;
6✔
493
        RegistryMetadata metadata = null;
6✔
494
        while (metadata == null && tries < MAX_RETRIES_METADATA) {
15!
495
            try {
496
                metadata = fetchRegistryMetadataInner();
6✔
497
            } catch (Exception e) {
×
498
                tries++;
×
499
                logger.info("Failed to fetch registry metadata, try {}. Retrying in {}ms...", tries, RETRY_DELAY_METADATA);
×
500
                logger.info("Failure Reason: ", e);
×
501
                try {
502
                    Thread.sleep(RETRY_DELAY_METADATA);
×
503
                } catch (InterruptedException e2) {
×
504
                    throw new RuntimeException(
×
505
                            "Interrupted while waiting to retry fetching registry metadata.");
506
                }
×
507
            }
3✔
508
        }
509
        if (metadata == null) {
6!
510
            throw new RuntimeException("Failed to fetch registry metadata after " + MAX_RETRIES_METADATA + " retries.");
×
511
        }
512
        return metadata;
6✔
513
    }
514

515
    /**
516
     * Inner logic for fetching the registry metadata via HEAD request.
517
     *
518
     * @return the registry metadata (load counter and setup ID)
519
     * @throws IOException if the HTTP request fails
520
     */
521
    private static RegistryMetadata fetchRegistryMetadataInner() throws IOException {
522
        var request = new HttpHead(registryUrl);
15✔
523
        try (var response = metadataClient.execute(request)) {
12✔
524
            int status = response.getStatusLine().getStatusCode();
12✔
525
            EntityUtils.consumeQuietly(response.getEntity());
9✔
526
            if (status < 200 || status >= 300) {
18!
527
                throw new RuntimeException("Registry metadata HTTP status is not 2xx: " +
×
528
                                           status + ".");
529
            }
530

531
            // Check if the registry is ready
532
            var hStatus = response.getHeaders("Nanopub-Registry-Status");
12✔
533
            if (hStatus.length == 0) {
9!
534
                throw new RuntimeException("Registry did not return a Nanopub-Registry-Status header.");
×
535
            }
536
            if (!"ready".equals(hStatus[0].getValue()) && !"updating".equals(hStatus[0].getValue())) {
21!
537
                throw new RuntimeException("Registry is not in ready state.");
×
538
            }
539

540
            // Get the load counter
541
            var hCounter = response.getHeaders("Nanopub-Registry-Load-Counter");
12✔
542
            if (hCounter.length == 0) {
9!
543
                throw new RuntimeException("Registry did not return a Nanopub-Registry-Load-Counter header.");
×
544
            }
545
            long loadCounter = Long.parseLong(hCounter[0].getValue());
18✔
546

547
            // Get the setup ID (optional — older registries may not have it)
548
            Long setupId = null;
6✔
549
            var hSetupId = response.getHeaders("Nanopub-Registry-Setup-Id");
12✔
550
            if (hSetupId.length > 0) {
9!
551
                try {
552
                    setupId = Long.parseLong(hSetupId[0].getValue());
21✔
553
                } catch (NumberFormatException e) {
×
554
                    logger.info("Could not parse Nanopub-Registry-Setup-Id header: {}", hSetupId[0].getValue());
×
555
                }
3✔
556
            }
557

558
            // Read metadata headers for forwarding to clients
559
            String coverageTypes = getHeaderValue(response, "Nanopub-Registry-Coverage-Types");
12✔
560
            String coverageAgents = getHeaderValue(response, "Nanopub-Registry-Coverage-Agents");
12✔
561
            String testInstance = getHeaderValue(response, "Nanopub-Registry-Test-Instance");
12✔
562
            String nanopubCount = getHeaderValue(response, "Nanopub-Registry-Nanopub-Count");
12✔
563
            // Optional — older registries (without trust calculation) won't set this header.
564
            String trustStateHash = getHeaderValue(response, "Nanopub-Registry-Trust-State-Hash");
12✔
565

566
            return new RegistryMetadata(loadCounter, setupId, coverageTypes, coverageAgents,
39✔
567
                    testInstance, nanopubCount, trustStateHash);
568
        }
569
    }
570

571
    private static String getHeaderValue(CloseableHttpResponse response, String name) {
572
        var headers = response.getHeaders(name);
12✔
573
        return headers.length > 0 ? headers[0].getValue() : null;
27!
574
    }
575

576
    /**
577
     * Construct the URL for fetching the Jelly stream.
578
     *
579
     * @param afterCounter the last known counter to have been committed in the DB
580
     * @return the URL for fetching the Jelly stream
581
     */
582
    private static String makeStreamFetchUrl(long afterCounter) {
583
        return registryUrl + "nanopubs.jelly?afterCounter=" + afterCounter;
×
584
    }
585
}
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