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

knowledgepixels / nanodash / 34872809402

14 Sep 2026 05:07PM UTC coverage: 48.057% (-0.04%) from 48.099%
34872809402

push

github

web-flow
Merge pull request #711 from knowledgepixels/chore/use-upstream-ntemplate-terms

chore(template): use the ntemplate terms nanopub-java already declares

4734 of 10609 branches covered (44.62%)

Branch coverage included in aggregate %.

8475 of 16877 relevant lines covered (50.22%)

8.04 hits per line

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

67.8
src/main/java/com/knowledgepixels/nanodash/ApiCache.java
1
package com.knowledgepixels.nanodash;
2

3
import com.google.common.cache.Cache;
4
import com.google.common.cache.CacheBuilder;
5
import com.google.common.cache.RemovalCause;
6
import com.google.common.cache.RemovalNotification;
7
import org.apache.wicket.MetaDataKey;
8
import org.apache.wicket.request.cycle.RequestCycle;
9
import org.eclipse.rdf4j.model.Model;
10
import org.nanopub.extra.services.*;
11
import org.slf4j.Logger;
12
import org.slf4j.LoggerFactory;
13

14
import java.io.Serializable;
15
import java.util.HashMap;
16
import java.util.HashSet;
17
import java.util.List;
18
import java.util.Map;
19
import java.util.Random;
20
import java.util.Set;
21
import java.util.concurrent.ConcurrentHashMap;
22
import java.util.concurrent.ConcurrentMap;
23
import java.util.concurrent.TimeUnit;
24

25
/**
26
 * A utility class for caching API responses and maps to reduce redundant API calls.
27
 * This class is thread-safe and ensures that cached data is refreshed periodically.
28
 */
29
public class ApiCache {
30

31
    private ApiCache() {
32
    } // no instances allowed
33

34
    private static final int MAX_CACHE_ENTRIES = 10_000;
35

36
    // How stale a cached response may be before the next access triggers a
37
    // background re-fetch. Must stay reasonably high: every render that finds a
38
    // query older than this submits a refresh to the shared pool, which uses a
39
    // CallerRunsPolicy — so too low a value turns page renders into a refresh
40
    // storm that can run queries synchronously on the request thread.
41
    private static final long REFRESH_AGE_THRESHOLD_MS = 60 * 1000;
42

43
    // How long a cached response is still served immediately (while refreshing in
44
    // the background) before it is treated as absent and the caller waits for a
45
    // fresh fetch. Acts as the stale-data fallback during API outages.
46
    private static final long MAX_CACHE_AGE_MS = 24 * 60 * 60 * 1000;
47

48
    // Upper bound a synchronous caller waits for an in-flight refresh started by
49
    // another thread when it has nothing cached yet. Without this wait the caller
50
    // returns null, letting repositories memoise an empty snapshot (see
51
    // retrieveResponseSync).
52
    private static final long SYNC_WAIT_FOR_INFLIGHT_MS = 10 * 1000;
53

54
    private static final Cache<String, ApiResponse> cachedResponses = CacheBuilder.newBuilder()
6✔
55
        .maximumSize(MAX_CACHE_ENTRIES)
9✔
56
        .expireAfterAccess(24, TimeUnit.HOURS)
6✔
57
        .removalListener(ApiCache::cleanupMetadataOnRemoval)
3✔
58
        .build();
6✔
59
    private static final Cache<String, Model> cachedRdfModels = CacheBuilder.newBuilder()
6✔
60
        .maximumSize(MAX_CACHE_ENTRIES)
9✔
61
        .expireAfterAccess(24, TimeUnit.HOURS)
6✔
62
        .removalListener(ApiCache::cleanupMetadataOnRemoval)
3✔
63
        .build();
6✔
64
    private transient static ConcurrentMap<String, Integer> failed = new ConcurrentHashMap<>();
12✔
65
    private static final Cache<String, Map<String, String>> cachedMaps = CacheBuilder.newBuilder()
6✔
66
        .maximumSize(MAX_CACHE_ENTRIES)
9✔
67
        .expireAfterAccess(24, TimeUnit.HOURS)
6✔
68
        .removalListener(ApiCache::cleanupMetadataOnRemoval)
3✔
69
        .build();
6✔
70
    private transient static ConcurrentMap<String, Long> lastRefresh = new ConcurrentHashMap<>();
12✔
71
    private transient static ConcurrentMap<String, Long> refreshStart = new ConcurrentHashMap<>();
12✔
72
    private transient static ConcurrentMap<String, Long> runAfter = new ConcurrentHashMap<>();
12✔
73

74
    // Cache ids that must be re-fetched before their entry counts as current again: a
75
    // genuine browser reload or an explicit clearCache (e.g. after publishing). The
76
    // cached value itself is deliberately kept, so callers can go on showing the
77
    // outdated content while the refresh runs instead of only a spinner (issue #599);
78
    // it is retrieved with retrieveStaleResponse(). The flag is dropped once a refresh
79
    // attempt has completed, successfully or not.
80
    private static final Set<String> forcedRefresh = ConcurrentHashMap.newKeySet();
6✔
81

82
    // How long we keep polling for a just-published nanopub to show up at the query
83
    // services before giving up and refreshing anyway (issue #629). A hard bound: the
84
    // probe is a single indexed lookup, but an unbounded retry loop from many publishing
85
    // sessions is the load shape that has wedged the query API before.
86
    private static final long INGEST_CONFIRM_MAX_WAIT_MS = 20 * 1000;
87
    private static final long INGEST_CONFIRM_POLL_INTERVAL_MS = 1000;
88
    // Margin after a positive probe: the confirming instance has the nanopub, but its
89
    // other repos and the other instances may trail slightly behind.
90
    private static final long INGEST_CONFIRM_MARGIN_MS = 1000;
91

92
    // Cache ids whose next refresh should wait for the given nanopub to be ingested
93
    // rather than (only) sit out the blind runAfter delay; set by clearCache after a
94
    // publish, consumed by waitOutIngestDelay in the background refresh.
95
    private transient static ConcurrentMap<String, String> awaitIngest = new ConcurrentHashMap<>();
12✔
96
    // Shared probe results, so several views refreshing after the same publish cost one
97
    // polling loop, not one each. False (timed out or probe failed) is cached too, to
98
    // keep late arrivals from re-running a full polling round that already gave up.
99
    private static final Cache<String, Boolean> ingestConfirmResults = CacheBuilder.newBuilder()
6✔
100
        .maximumSize(1000)
9✔
101
        .expireAfterWrite(60, TimeUnit.SECONDS)
3✔
102
        .build();
6✔
103
    private transient static ConcurrentMap<String, Object> ingestConfirmLocks = new ConcurrentHashMap<>();
12✔
104

105
    private static final Logger logger = LoggerFactory.getLogger(ApiCache.class);
9✔
106

107
    // Guava fires removal notifications also when an entry is REPLACED (every routine
108
    // refresh's put), and processes them lazily during later cache operations. Cleaning
109
    // up on a replacement would wipe the metadata of a still-cached entry — in
110
    // particular a missing lastRefresh timestamp used to make retrieveResponseSync
111
    // throw an NPE on every call until the entry expired.
112
    private static void cleanupMetadataOnRemoval(RemovalNotification<String, ?> n) {
113
        if (n.getCause() == RemovalCause.REPLACED) return;
15✔
114
        cleanupMetadata(n.getKey());
12✔
115
    }
3✔
116

117
    private static void cleanupMetadata(String cacheId) {
118
        lastRefresh.remove(cacheId);
12✔
119
        failed.remove(cacheId);
12✔
120
        runAfter.remove(cacheId);
12✔
121
        forcedRefresh.remove(cacheId);
12✔
122
        awaitIngest.remove(cacheId);
12✔
123
    }
3✔
124

125
    /**
126
     * Fills a memory miss from the per-entry store (see
127
     * {@link ApiCachePersistence#loadEntry}): the stored response goes back into the
128
     * in-memory cache with its <em>original</em> refresh timestamp, so the normal staleness
129
     * logic takes over from there — the restored content is served while anything older than
130
     * {@link #REFRESH_AGE_THRESHOLD_MS} re-fetches in the background. This is what makes
131
     * memory eviction invisible to callers: the persistent tier never evicts, so content
132
     * that once arrived stays available (however outdated) until a re-fetch replaces it.
133
     * A timestamp from the future (a clock jump) is not adopted, leaving the entry to count
134
     * as stale rather than as fresh indefinitely.
135
     *
136
     * @param cacheId the cache id (the query's URL string)
137
     * @return the restored response, or null if the store has none
138
     */
139
    private static ApiResponse loadResponseFromStore(String cacheId) {
140
        ApiCachePersistence.PersistedEntry entry = ApiCachePersistence.loadEntry(cacheId);
9✔
141
        if (entry == null || !(entry.value instanceof ApiResponse response)) return null;
42!
142
        cachedResponses.put(cacheId, response);
12✔
143
        if (entry.lastRefresh <= System.currentTimeMillis()) {
15!
144
            lastRefresh.putIfAbsent(cacheId, entry.lastRefresh);
21✔
145
        }
146
        return response;
6✔
147
    }
148

149
    /**
150
     * The map counterpart of {@link #loadResponseFromStore(String)}.
151
     */
152
    @SuppressWarnings("unchecked")
153
    private static Map<String, String> loadMapFromStore(String cacheId) {
154
        ApiCachePersistence.PersistedEntry entry = ApiCachePersistence.loadEntry(cacheId);
9✔
155
        if (entry == null || !(entry.value instanceof Map<?, ?> map)) return null;
36!
156
        cachedMaps.put(cacheId, (Map<String, String>) map);
12✔
157
        if (entry.lastRefresh <= System.currentTimeMillis()) {
15!
158
            lastRefresh.putIfAbsent(cacheId, entry.lastRefresh);
21✔
159
        }
160
        return (Map<String, String>) map;
6✔
161
    }
162

163
    /**
164
     * Checks if a cache refresh is currently running for the given cache ID.
165
     *
166
     * @param cacheId The unique identifier for the cache.
167
     * @return True if a refresh is running, false otherwise.
168
     */
169
    private static boolean isRunning(String cacheId) {
170
        Long start = refreshStart.get(cacheId);
15✔
171
        if (start == null) return false;
12✔
172
        return System.currentTimeMillis() - start < 60 * 1000;
33✔
173
    }
174

175
    /**
176
     * Checks if a cache refresh is currently running for the given QueryRef.
177
     *
178
     * @param queryRef The query reference
179
     * @return True if a refresh is running, false otherwise.
180
     */
181
    public static boolean isRunning(QueryRef queryRef) {
182
        return isRunning(queryRef.getAsUrlString());
12✔
183
    }
184

185
    /**
186
     * Request-scoped flag set by {@code NanodashPage} when the current request is a
187
     * genuine browser reload (the browser sends {@code Cache-Control: max-age=0} or
188
     * {@code no-cache}). When set, the first access to each query during the page
189
     * render evicts that query's cache so it re-fetches fresh, while normal
190
     * navigation, Ajax updates, and the auto-refresh redirect keep serving the
191
     * cache. Public so the page layer can set it.
192
     */
193
    public static final MetaDataKey<Boolean> FORCE_REFRESH_ON_RELOAD = new MetaDataKey<>() {};
21✔
194

195
    // The query cache-ids already force-evicted during the current reload request,
196
    // so each is evicted only once (the lazy-load that follows must not re-evict).
197
    private static final MetaDataKey<HashSet<String>> RELOAD_FORCED_IDS = new MetaDataKey<>() {};
24✔
198

199
    /**
200
     * On a genuine browser reload, returns true the first time a given query is
201
     * accessed this request (and records it), so callers evict its cache once.
202
     * Returns false on non-reload requests, off the request thread, and for any
203
     * query already handled this request — so it never triggers a refresh storm.
204
     */
205
    private static boolean isForcedReload(String cacheId) {
206
        RequestCycle rc = RequestCycle.get();
6✔
207
        if (rc == null) return false;
12✔
208
        Boolean force = rc.getMetaData(FORCE_REFRESH_ON_RELOAD);
15✔
209
        if (force == null || !force) return false;
12!
210
        HashSet<String> handled = rc.getMetaData(RELOAD_FORCED_IDS);
×
211
        if (handled == null) {
×
212
            handled = new HashSet<>();
×
213
            rc.setMetaData(RELOAD_FORCED_IDS, handled);
×
214
        }
215
        return handled.add(cacheId);
×
216
    }
217

218
    /**
219
     * Waits out the post-publish ingest delay for a cache entry, if one is pending,
220
     * before its refresh is allowed to run. With a nanopub to wait for (see
221
     * {@link #clearCache(QueryRef, long, String)}), the wait is a measurement: poll
222
     * until the query services report the nanopub as loaded, plus a small margin. If
223
     * there is none, or the probe fails or times out, this falls back to the blind
224
     * runAfter delay, so a broken probe never makes publishing worse than before.
225
     * Runs on background threads only; request threads are diverted beforehand.
226
     *
227
     * @param cacheId the cache id (the query's URL string)
228
     */
229
    private static void waitOutIngestDelay(String cacheId) throws InterruptedException {
230
        String npId = awaitIngest.remove(cacheId);
15✔
231
        if (npId != null && awaitNanopubLoaded(npId)) {
15✔
232
            Thread.sleep(INGEST_CONFIRM_MARGIN_MS);
6✔
233
            runAfter.remove(cacheId);
12✔
234
            return;
3✔
235
        }
236
        Long after = runAfter.get(cacheId);
15✔
237
        if (after != null) {
6✔
238
            while (System.currentTimeMillis() < after) {
15!
239
                Thread.sleep(100);
×
240
            }
241
            runAfter.remove(cacheId);
12✔
242
        }
243
    }
3✔
244

245
    /**
246
     * Polls the query services until they report the given nanopub as loaded, bounded by
247
     * {@link #INGEST_CONFIRM_MAX_WAIT_MS}. Concurrent callers for the same nanopub (the
248
     * several views refreshing after one publish) share a single polling loop: the first
249
     * caller polls, the others wait on its result.
250
     *
251
     * @param npId the nanopub id to wait for
252
     * @return true if the nanopub was confirmed as loaded, false if the probe timed out
253
     * or failed (callers then fall back to the blind delay)
254
     */
255
    private static boolean awaitNanopubLoaded(String npId) throws InterruptedException {
256
        Boolean known = ingestConfirmResults.getIfPresent(npId);
15✔
257
        if (known != null) return known;
15✔
258
        Object lock = ingestConfirmLocks.computeIfAbsent(npId, k -> new Object());
27✔
259
        synchronized (lock) {
12✔
260
            try {
261
                known = ingestConfirmResults.getIfPresent(npId);
15✔
262
                if (known != null) return known;
6!
263
                long deadline = System.currentTimeMillis() + INGEST_CONFIRM_MAX_WAIT_MS;
12✔
264
                boolean loaded = false;
6✔
265
                while (true) {
266
                    try {
267
                        loaded = QueryApiAccess.isNanopubLoaded(npId);
9✔
268
                    } catch (Exception ex) {
3✔
269
                        logger.warn("Nanopub load probe failed for {}: {}", npId, ex.getMessage());
18✔
270
                        break;
3✔
271
                    }
3✔
272
                    if (loaded || System.currentTimeMillis() + INGEST_CONFIRM_POLL_INTERVAL_MS > deadline) break;
6!
273
                    Thread.sleep(INGEST_CONFIRM_POLL_INTERVAL_MS);
×
274
                }
275
                if (!loaded) {
6✔
276
                    logger.info("Nanopub {} not confirmed as loaded, falling back to blind delay", npId);
12✔
277
                }
278
                ingestConfirmResults.put(npId, loaded);
15✔
279
                return loaded;
12✔
280
            } finally {
281
                ingestConfirmLocks.remove(npId, lock);
21✔
282
            }
283
        }
284
    }
285

286
    /**
287
     * Updates the cached API response for a specific query reference.
288
     *
289
     * @param queryRef The query reference
290
     * @throws FailedApiCallException If the API call fails.
291
     */
292
    private static void updateResponse(QueryRef queryRef, boolean forced) throws FailedApiCallException, APINotReachableException, NotEnoughAPIInstancesException {
293
        ApiResponse response;
294
        if (forced) {
6✔
295
            response = QueryApiAccess.forcedGet(queryRef);
12✔
296
        } else {
297
            response = QueryApiAccess.get(queryRef);
9✔
298
        }
299
        String cacheId = queryRef.getAsUrlString();
9✔
300
        logger.info("Updating cached API response for {}", cacheId);
12✔
301
        long timeNow = System.currentTimeMillis();
6✔
302
        cachedResponses.put(cacheId, response);
12✔
303
        lastRefresh.put(cacheId, timeNow);
18✔
304
        ApiCachePersistence.storeEntry(cacheId, response, timeNow);
12✔
305
    }
3✔
306

307
    /**
308
     * The response for a query if it can be had, and null if it cannot — nothing cached yet,
309
     * or a query the service could not answer.
310
     * <p>
311
     * For the callers that hold the state whole pages are built from, where a query that
312
     * cannot be answered means "nothing to show yet" rather than an error to raise. They
313
     * already treat a missing response that way; without this they would treat a failing one
314
     * as fatal, and a cold instance whose query service is unavailable could then not build a
315
     * page at all, its own error page included (issue #684).
316
     *
317
     * @param queryRef The query reference
318
     * @return the response, or null if there is none to be had
319
     */
320
    public static ApiResponse retrieveResponseIfAvailable(QueryRef queryRef) {
321
        try {
322
            return retrieveResponseSync(queryRef, false);
12✔
323
        } catch (Exception ex) {
3✔
324
            logger.error("Could not retrieve {}: {}", queryRef.getAsUrlString(), ex.toString());
21✔
325
            return null;
6✔
326
        }
327
    }
328

329
    public static ApiResponse retrieveResponseSync(QueryRef queryRef, boolean forced) {
330
        long timeNow = System.currentTimeMillis();
6✔
331
        String cacheId = queryRef.getAsUrlString();
9✔
332
        logger.debug("Retrieving cached API response synchronously for {}", cacheId);
12✔
333
        if (cachedResponses.getIfPresent(cacheId) == null) {
12✔
334
            loadResponseFromStore(cacheId);
9✔
335
        }
336
        boolean needsRefresh = true;
6✔
337
        if (cachedResponses.getIfPresent(cacheId) != null) {
12✔
338
            // lastRefresh can be missing for a cached entry (racing invalidation or
339
            // refresh); treat that as stale rather than NPEing on the unboxing.
340
            Long lastRefreshTime = lastRefresh.get(cacheId);
15✔
341
            // A pending forced refresh (clearCache, e.g. after publishing) always counts as
342
            // stale here: the entry is kept only so the UI can show the outdated content,
343
            // never to be handed to a synchronous caller as if it were current.
344
            needsRefresh = forcedRefresh.contains(cacheId) || lastRefreshTime == null
24!
345
                    || timeNow - lastRefreshTime > REFRESH_AGE_THRESHOLD_MS;
27✔
346
        }
347
        Integer failedCount = failed.get(cacheId);
15✔
348
        if (failedCount != null && failedCount > 2) {
18!
349
            failed.remove(cacheId);
12✔
350
            throw new RuntimeException("Query failed: " + cacheId);
18✔
351
        }
352
        // Waiting around is for background threads. A request thread must not sit out an
353
        // ingest delay or a politeness pause on the user's time: it takes what the cache has
354
        // and leaves the refresh to a thread that can afford to wait.
355
        boolean onRequestThread = RequestCycle.get() != null;
18✔
356
        Long after = runAfter.get(cacheId);
15✔
357
        boolean waitingForIngest = (after != null && System.currentTimeMillis() < after)
27✔
358
                || awaitIngest.containsKey(cacheId);
18✔
359
        if (onRequestThread && waitingForIngest) {
12✔
360
            logger.debug("Not waiting out the ingest delay for {} on a request thread", cacheId);
12✔
361
            // Hand the refresh to the background, where waiting out the delay costs nobody
362
            // anything, and answer with what we have meanwhile.
363
            retrieveResponseAsync(queryRef);
9✔
364
            return cachedResponses.getIfPresent(cacheId);
15✔
365
        }
366
        // A merely outdated entry is served right away on any thread, with the re-fetch
367
        // handed to the background, instead of running the query inline: a synchronous
368
        // caller that is fine with data from the last refresh cycle must not block on the
369
        // network for it, whether it serves a user directly (a request thread) or builds
370
        // the state pages are gated on (the repository and resource-data threads). This is
371
        // also what lets a restart come back up warm from the persisted snapshot (issue
372
        // #570) — every restored entry is older than the refresh threshold, and re-fetching
373
        // them synchronously would stall the first page render on the very queries the
374
        // snapshot was meant to cover. Callers that genuinely need current data say so, and
375
        // keep their blocking fetch: a forced call, or an entry marked by clearCache (e.g.
376
        // just after publishing).
377
        if (needsRefresh && !forced && !forcedRefresh.contains(cacheId)
30✔
378
                && cachedResponses.getIfPresent(cacheId) != null) {
6✔
379
            logger.debug("Serving outdated response for {}, refreshing in the background", cacheId);
12✔
380
            retrieveResponseAsync(queryRef);
9✔
381
            return cachedResponses.getIfPresent(cacheId);
15✔
382
        }
383
        if ((needsRefresh || forced) && !isRunning(cacheId)) {
21!
384
            logger.info("Refreshing cache for {}", cacheId);
12✔
385
            refreshStart.put(cacheId, timeNow);
18✔
386
            try {
387
                waitOutIngestDelay(cacheId);
6✔
388
                if (!onRequestThread) {
6✔
389
                    if (failed.get(cacheId) != null) {
12!
390
                        // 1 second pause between failed attempts;
391
                        Thread.sleep(1000);
×
392
                    }
393
                    // Jitter, so that background refreshes of many queries do not arrive at
394
                    // the API in lockstep. Pure latency on a request thread, so skipped there.
395
                    Thread.sleep(100 + new Random().nextLong(400));
24✔
396
                }
397
            } catch (InterruptedException ex) {
×
398
                logger.error("Interrupted while waiting to refresh cache: {}", ex.getMessage());
×
399
            }
3✔
400
            try {
401
                ApiCache.updateResponse(queryRef, forced);
9✔
402
                failed.remove(cacheId);
12✔
403
            } catch (Exception ex) {
3✔
404
                logger.error("Failed to update cache for {}: {}", cacheId, ex.getMessage());
18✔
405
                // Keep stale cached data if available, only invalidate if nothing was cached
406
                if (cachedResponses.getIfPresent(cacheId) == null) {
12✔
407
                    failed.merge(cacheId, 1, Integer::sum);
21✔
408
                }
409
                lastRefresh.put(cacheId, System.currentTimeMillis());
18✔
410
            } finally {
411
                refreshStart.remove(cacheId);
12✔
412
                forcedRefresh.remove(cacheId);
12✔
413
            }
3✔
414
        } else if (isRunning(cacheId)
9!
415
                && (cachedResponses.getIfPresent(cacheId) == null || forcedRefresh.contains(cacheId))) {
×
416
            // Another thread is fetching this query and what we hold is either nothing at
417
            // all or an entry that is only being kept for the stale-content display. Wait
418
            // for that fetch rather than returning null or the outdated entry: a null here
419
            // lets a caller (e.g. SpaceRepository) memoise an EMPTY snapshot, which then
420
            // poisons MaintainedResourceRepository.build() and breaks the home page until
421
            // the next refresh. This adds no new work; it only waits on the refresh
422
            // already in flight.
423
            try {
424
                long deadline = timeNow + SYNC_WAIT_FOR_INFLIGHT_MS;
×
425
                while (isRunning(cacheId)
×
426
                        && (cachedResponses.getIfPresent(cacheId) == null || forcedRefresh.contains(cacheId))
×
427
                        && System.currentTimeMillis() < deadline) {
×
428
                    Thread.sleep(50);
×
429
                }
430
            } catch (InterruptedException ex) {
×
431
                Thread.currentThread().interrupt();
×
432
            }
×
433
        }
434
        return cachedResponses.getIfPresent(cacheId);
15✔
435
    }
436

437
    /**
438
     * Retrieves a cached API response for a specific QueryRef.
439
     *
440
     * @param queryRef The QueryRef object containing the query name and parameters.
441
     * @return The cached API response, or null if not cached.
442
     */
443
    public static ApiResponse retrieveResponseAsync(QueryRef queryRef) {
444
        long timeNow = System.currentTimeMillis();
6✔
445
        String cacheId = queryRef.getAsUrlString();
9✔
446
        logger.debug("Retrieving cached API response asynchronously for {}", cacheId);
12✔
447
        if (isForcedReload(cacheId)) {
9!
448
            // Keep the entry (see retrieveStaleResponse) but stop treating it as current,
449
            // so the reload re-queries while the outdated content stays on screen.
450
            forcedRefresh.add(cacheId);
×
451
        }
452
        boolean forced = forcedRefresh.contains(cacheId);
12✔
453
        if (cachedResponses.getIfPresent(cacheId) == null) {
12✔
454
            loadResponseFromStore(cacheId);
9✔
455
        }
456
        boolean isCached = false;
6✔
457
        boolean needsRefresh = true;
6✔
458
        if (cachedResponses.getIfPresent(cacheId) != null) {
12✔
459
            Long lastRefreshTime = lastRefresh.get(cacheId);
15✔
460
            isCached = !forced && lastRefreshTime != null && timeNow - lastRefreshTime < MAX_CACHE_AGE_MS;
45!
461
            needsRefresh = forced || lastRefreshTime == null || timeNow - lastRefreshTime > REFRESH_AGE_THRESHOLD_MS;
45!
462
        }
463
        Integer failedCount = failed.get(cacheId);
15✔
464
        if (failedCount != null && failedCount > 2) {
6!
465
            failed.remove(cacheId);
×
466
            throw new RuntimeException("Query failed: " + cacheId);
×
467
        }
468
        if (needsRefresh && !isRunning(cacheId)) {
15✔
469
            NanodashThreadPool.submit(() -> {
15✔
470
                refreshStart.put(cacheId, System.currentTimeMillis());
18✔
471
                try {
472
                    waitOutIngestDelay(cacheId);
6✔
473
                    if (failed.get(cacheId) != null) {
12!
474
                        // 1 second pause between failed attempts;
475
                        Thread.sleep(1000);
×
476
                    }
477
                    Thread.sleep(100 + new Random().nextLong(400));
24✔
478
                } catch (InterruptedException ex) {
×
479
                    logger.error("Interrupted while waiting to refresh cache: {}", ex.getMessage());
×
480
                }
3✔
481
                try {
482
                    ApiCache.updateResponse(queryRef, false);
9✔
483
                    failed.remove(cacheId);
12✔
484
                } catch (Exception ex) {
×
485
                    logger.error("Failed to update cache for {}: {}", cacheId, ex.getMessage());
×
486
                    if (cachedResponses.getIfPresent(cacheId) == null) {
×
487
                        failed.merge(cacheId, 1, Integer::sum);
×
488
                    }
489
                    lastRefresh.put(cacheId, System.currentTimeMillis());
×
490
                } finally {
491
                    refreshStart.remove(cacheId);
12✔
492
                    // Dropped on failure too: a query we cannot reach must not keep every
493
                    // later render on the stale path, re-submitting a refresh each time.
494
                    forcedRefresh.remove(cacheId);
12✔
495
                }
496
            });
3✔
497
        }
498
        if (isCached) {
6✔
499
            return cachedResponses.getIfPresent(cacheId);
15✔
500
        } else {
501
            return null;
6✔
502
        }
503
    }
504

505
    /**
506
     * Updates the cached map for a specific query reference.
507
     *
508
     * @param queryRef The query reference
509
     * @throws FailedApiCallException If the API call fails.
510
     */
511
    private static void updateMap(QueryRef queryRef) throws FailedApiCallException, APINotReachableException, NotEnoughAPIInstancesException {
512
        Map<String, String> map = new HashMap<>();
12✔
513
        List<ApiResponseEntry> respList = QueryApiAccess.get(queryRef).getData();
×
514
        while (respList != null && !respList.isEmpty()) {
×
515
            ApiResponseEntry resultEntry = respList.removeFirst();
×
516
            map.put(resultEntry.get("key"), resultEntry.get("value"));
×
517
        }
×
518
        String cacheId = queryRef.getAsUrlString();
×
519
        long timeNow = System.currentTimeMillis();
×
520
        cachedMaps.put(cacheId, map);
×
521
        lastRefresh.put(cacheId, timeNow);
×
522
        ApiCachePersistence.storeEntry(cacheId, (Serializable) map, timeNow);
×
523
    }
×
524

525
    /**
526
     * Retrieves a cached map for a specific query reference.
527
     * If the cache is stale, it triggers a background refresh.
528
     *
529
     * @param queryRef The query reference
530
     * @return The cached map, or null if not cached.
531
     */
532
    public static Map<String, String> retrieveMap(QueryRef queryRef) {
533
        long timeNow = System.currentTimeMillis();
6✔
534
        String cacheId = queryRef.getAsUrlString();
9✔
535
        if (isForcedReload(cacheId)) {
9!
536
            cachedMaps.invalidate(cacheId);
×
537
            lastRefresh.remove(cacheId);
×
538
        }
539
        if (cachedMaps.getIfPresent(cacheId) == null) {
12✔
540
            loadMapFromStore(cacheId);
9✔
541
        }
542
        boolean isCached = false;
6✔
543
        boolean needsRefresh = true;
6✔
544
        if (cachedMaps.getIfPresent(cacheId) != null) {
12!
545
            Long lastRefreshTime = lastRefresh.get(cacheId);
15✔
546
            isCached = lastRefreshTime != null && timeNow - lastRefreshTime < MAX_CACHE_AGE_MS;
36!
547
            needsRefresh = lastRefreshTime == null || timeNow - lastRefreshTime > REFRESH_AGE_THRESHOLD_MS;
39!
548
        }
549
        if (needsRefresh && !isRunning(cacheId)) {
15!
550
            NanodashThreadPool.submit(() -> {
15✔
551
                refreshStart.put(cacheId, System.currentTimeMillis());
18✔
552
                try {
553
                    waitOutIngestDelay(cacheId);
6✔
554
                    Thread.sleep(100 + new Random().nextLong(400));
24✔
555
                } catch (InterruptedException ex) {
×
556
                    logger.error("Interrupted while waiting to refresh cache: {}", ex.getMessage());
×
557
                }
3✔
558
                try {
559
                    ApiCache.updateMap(queryRef);
×
560
                } catch (Exception ex) {
3✔
561
                    logger.error("Failed to update cache for {}: {}", cacheId, ex.getMessage());
18✔
562
                    // Keep whatever is cached, as the response and RDF-model paths do: a query we
563
                    // cannot reach right now is a reason to go on showing the previous data, never
564
                    // to throw it away. Only the refresh timestamp is bumped, so the next attempt
565
                    // waits out the usual interval instead of retrying on every access.
566
                    lastRefresh.put(cacheId, System.currentTimeMillis());
18✔
567
                }  finally {
568
                    refreshStart.remove(cacheId);
12✔
569
                }
570
            });
3✔
571
        }
572
        if (isCached) {
6!
573
            return cachedMaps.getIfPresent(cacheId);
15✔
574
        } else {
575
            return null;
×
576
        }
577
    }
578

579
    private static void updateRdfModel(QueryRef queryRef) throws FailedApiCallException, APINotReachableException, NotEnoughAPIInstancesException {
580
        final Model[] modelRef = new Model[1];
×
581
        QueryAccess qa = new QueryAccess() {
×
582
            @Override
583
            protected void processHeader(String[] line) {}
×
584
            @Override
585
            protected void processLine(String[] line) {}
×
586
            @Override
587
            protected void processRdfContent(Model model) {
588
                modelRef[0] = model;
×
589
            }
×
590
        };
591
        qa.call(queryRef);
×
592
        if (modelRef[0] == null) {
×
593
            throw new FailedApiCallException(new Exception("No RDF content in response for query: " + queryRef.getQueryId()));
×
594
        }
595
        String cacheId = queryRef.getAsUrlString();
×
596
        logger.info("Updating cached RDF model for {}", cacheId);
×
597
        cachedRdfModels.put(cacheId, modelRef[0]);
×
598
        lastRefresh.put(cacheId, System.currentTimeMillis());
×
599
    }
×
600

601
    /**
602
     * Retrieves a cached RDF model for a CONSTRUCT query, triggering a background fetch if needed.
603
     *
604
     * @param queryRef The QueryRef for the CONSTRUCT query.
605
     * @return The cached RDF Model, or null if not yet available.
606
     */
607
    public static Model retrieveRdfModelAsync(QueryRef queryRef) {
608
        long timeNow = System.currentTimeMillis();
×
609
        String cacheId = queryRef.getAsUrlString();
×
610
        logger.debug("Retrieving cached RDF model asynchronously for {}", cacheId);
×
611
        if (isForcedReload(cacheId)) {
×
612
            // As in retrieveResponseAsync: mark for re-fetch but keep the model, so a failed
613
            // refresh still has the previous one to fall back on.
614
            forcedRefresh.add(cacheId);
×
615
        }
616
        boolean forced = forcedRefresh.contains(cacheId);
×
617
        boolean isCached = false;
×
618
        boolean needsRefresh = true;
×
619
        if (cachedRdfModels.getIfPresent(cacheId) != null) {
×
620
            Long lastRefreshTime = lastRefresh.get(cacheId);
×
621
            isCached = !forced && lastRefreshTime != null && timeNow - lastRefreshTime < MAX_CACHE_AGE_MS;
×
622
            needsRefresh = forced || lastRefreshTime == null || timeNow - lastRefreshTime > REFRESH_AGE_THRESHOLD_MS;
×
623
        }
624
        Integer failedCount = failed.get(cacheId);
×
625
        if (failedCount != null && failedCount > 2) {
×
626
            failed.remove(cacheId);
×
627
            throw new RuntimeException("Query failed: " + cacheId);
×
628
        }
629
        if (needsRefresh && !isRunning(cacheId)) {
×
630
            NanodashThreadPool.submit(() -> {
×
631
                refreshStart.put(cacheId, System.currentTimeMillis());
×
632
                try {
633
                    waitOutIngestDelay(cacheId);
×
634
                    if (failed.get(cacheId) != null) {
×
635
                        Thread.sleep(1000);
×
636
                    }
637
                    Thread.sleep(100 + new Random().nextLong(400));
×
638
                } catch (InterruptedException ex) {
×
639
                    logger.error("Interrupted while waiting to refresh RDF cache: {}", ex.getMessage());
×
640
                }
×
641
                try {
642
                    updateRdfModel(queryRef);
×
643
                    failed.remove(cacheId);
×
644
                } catch (Exception ex) {
×
645
                    logger.error("Failed to update RDF cache for {}: {}", cacheId, ex.getMessage());
×
646
                    if (cachedRdfModels.getIfPresent(cacheId) == null) {
×
647
                        failed.merge(cacheId, 1, Integer::sum);
×
648
                    }
649
                    lastRefresh.put(cacheId, System.currentTimeMillis());
×
650
                } finally {
651
                    refreshStart.remove(cacheId);
×
652
                    forcedRefresh.remove(cacheId);
×
653
                }
654
            });
×
655
        }
656
        if (isCached) {
×
657
            return cachedRdfModels.getIfPresent(cacheId);
×
658
        } else {
659
            return null;
×
660
        }
661
    }
662

663
    /**
664
     * Returns whatever response is cached for a query reference, however outdated, without
665
     * triggering a refresh. A memory miss falls through to the per-entry store, which never
666
     * evicts, so this finds any response that ever arrived for the query — restored quickly
667
     * from a local file, never the network. Meant for showing the previous content
668
     * while a refresh is in flight (issue #599) — never as a substitute for the current data,
669
     * which is what {@link #retrieveResponseAsync(QueryRef)} and
670
     * {@link #retrieveResponseSync(QueryRef, boolean)} return.
671
     *
672
     * @param queryRef The query reference
673
     * @return The cached response of any age, or null if nothing is cached.
674
     */
675
    public static ApiResponse retrieveStaleResponse(QueryRef queryRef) {
676
        String cacheId = queryRef.getAsUrlString();
9✔
677
        ApiResponse response = cachedResponses.getIfPresent(cacheId);
15✔
678
        if (response != null) return response;
12✔
679
        return loadResponseFromStore(cacheId);
9✔
680
    }
681

682
    /**
683
     * The cache content worth carrying across restarts: the query responses and maps together
684
     * with when each was last refreshed. The transient bookkeeping (running refreshes, failure
685
     * counts, ingest delays, forced-refresh markings) is process-local by nature and stays out.
686
     * The cached RDF models are also left out for now: they would need a text serialization of
687
     * their own, and their queries re-fetch quickly enough.
688
     */
689
    static class Snapshot implements Serializable {
690

691
        private static final long serialVersionUID = 1L;
692

693
        private final Map<String, ApiResponse> responses;
694
        private final Map<String, Map<String, String>> maps;
695
        private final Map<String, Long> refreshTimes;
696

697
        private Snapshot(Map<String, ApiResponse> responses, Map<String, Map<String, String>> maps, Map<String, Long> refreshTimes) {
6✔
698
            this.responses = responses;
9✔
699
            this.maps = maps;
9✔
700
            this.refreshTimes = refreshTimes;
9✔
701
        }
3✔
702

703
        boolean isEmpty() {
704
            return responses.isEmpty() && maps.isEmpty();
36!
705
        }
706

707
        int size() {
708
            return responses.size() + maps.size();
24✔
709
        }
710

711
    }
712

713
    /**
714
     * Captures the persistable cache content (see {@link Snapshot}). Entries whose refresh
715
     * timestamp is missing — typically because their first fetch is still in flight — are
716
     * left out, since without a timestamp the importer could not tell how stale they are.
717
     *
718
     * @return a snapshot of the current cache content
719
     */
720
    static Snapshot exportSnapshot() {
721
        Map<String, ApiResponse> responses = new HashMap<>(cachedResponses.asMap());
18✔
722
        Map<String, Map<String, String>> maps = new HashMap<>(cachedMaps.asMap());
18✔
723
        Map<String, Long> refreshTimes = new HashMap<>();
12✔
724
        for (String cacheId : responses.keySet()) {
33✔
725
            Long t = lastRefresh.get(cacheId);
15✔
726
            if (t != null) refreshTimes.put(cacheId, t);
21✔
727
        }
3✔
728
        for (String cacheId : maps.keySet()) {
33✔
729
            Long t = lastRefresh.get(cacheId);
15✔
730
            if (t != null) refreshTimes.put(cacheId, t);
21!
731
        }
3✔
732
        responses.keySet().retainAll(refreshTimes.keySet());
18✔
733
        maps.keySet().retainAll(refreshTimes.keySet());
18✔
734
        return new Snapshot(responses, maps, refreshTimes);
21✔
735
    }
736

737
    /**
738
     * Restores a snapshot into the cache, meant to run once at startup before the instance
739
     * serves requests. Each entry keeps its original refresh timestamp, so the normal age
740
     * logic takes over from there: anything older than {@link #REFRESH_AGE_THRESHOLD_MS} is
741
     * re-fetched in the background on first access while the restored content is shown
742
     * meanwhile — the same stale-but-displayable behavior as within a single run.
743
     *
744
     * <p>Entries already present in the cache are left alone, as are entries older than the
745
     * given maximum age or carrying a timestamp from the future (a clock jump must not
746
     * produce entries that would count as fresh indefinitely).</p>
747
     *
748
     * @param snapshot the snapshot to restore
749
     * @param maxAgeMs entries whose last refresh lies further back than this are dropped
750
     * @return the number of restored entries
751
     */
752
    static int importSnapshot(Snapshot snapshot, long maxAgeMs) {
753
        long timeNow = System.currentTimeMillis();
6✔
754
        int count = 0;
6✔
755
        for (Map.Entry<String, ApiResponse> e : snapshot.responses.entrySet()) {
36✔
756
            Long t = snapshot.refreshTimes.get(e.getKey());
21✔
757
            if (t == null || t > timeNow || timeNow - t > maxAgeMs) continue;
45!
758
            if (e.getValue() == null || cachedResponses.getIfPresent(e.getKey()) != null) continue;
27!
759
            cachedResponses.put(e.getKey(), e.getValue());
24✔
760
            lastRefresh.putIfAbsent(e.getKey(), t);
21✔
761
            count++;
3✔
762
        }
3✔
763
        for (Map.Entry<String, Map<String, String>> e : snapshot.maps.entrySet()) {
36✔
764
            Long t = snapshot.refreshTimes.get(e.getKey());
21✔
765
            if (t == null || t > timeNow || timeNow - t > maxAgeMs) continue;
42!
766
            if (e.getValue() == null || cachedMaps.getIfPresent(e.getKey()) != null) continue;
24!
767
            cachedMaps.put(e.getKey(), e.getValue());
24✔
768
            lastRefresh.putIfAbsent(e.getKey(), t);
21✔
769
            count++;
3✔
770
        }
3✔
771
        return count;
6✔
772
    }
773

774
    /**
775
     * Copies a restored snapshot's entries into the per-entry store, so content saved by a
776
     * version from before the store existed is not lost to memory eviction again. Entries
777
     * the store already has are left alone (its version is at least as new), and no age
778
     * limit applies — unlike the in-memory import, the store keeps everything. Meant to run
779
     * once at startup, right after the snapshot file is read.
780
     *
781
     * @param snapshot the restored snapshot
782
     */
783
    static void backfillEntryStore(Snapshot snapshot) {
784
        for (Map.Entry<String, ApiResponse> e : snapshot.responses.entrySet()) {
36✔
785
            Long t = snapshot.refreshTimes.get(e.getKey());
21✔
786
            if (t == null || e.getValue() == null) continue;
15!
787
            ApiCachePersistence.storeEntryIfAbsent(e.getKey(), e.getValue(), t);
27✔
788
        }
3✔
789
        for (Map.Entry<String, Map<String, String>> e : snapshot.maps.entrySet()) {
36✔
790
            Long t = snapshot.refreshTimes.get(e.getKey());
21✔
791
            if (t == null || e.getValue() == null) continue;
15!
792
            ApiCachePersistence.storeEntryIfAbsent(e.getKey(), (Serializable) e.getValue(), t);
27✔
793
        }
3✔
794
    }
3✔
795

796
    /**
797
     * Marks the cached response for a specific query reference as outdated and sets a delay
798
     * before the next refresh can occur. The previous response is kept and remains available
799
     * through {@link #retrieveStaleResponse(QueryRef)} until the refresh lands, but it is no
800
     * longer served as current data.
801
     *
802
     * @param queryRef   The query reference for which to clear the cache.
803
     * @param waitMillis The amount of time in milliseconds to wait before allowing the cache to be refreshed again.
804
     */
805
    public static void clearCache(QueryRef queryRef, long waitMillis) {
806
        clearCache(queryRef, waitMillis, null);
12✔
807
    }
3✔
808

809
    /**
810
     * Like {@link #clearCache(QueryRef, long)}, but for the refresh after a publish: the
811
     * refresh is released as soon as the query services confirm the given nanopub as
812
     * loaded (plus a small margin), instead of after the blind delay (issue #629). The
813
     * delay stays in place as the fallback for when the confirmation probe fails, and the
814
     * confirmation wait itself is bounded by {@link #INGEST_CONFIRM_MAX_WAIT_MS}.
815
     *
816
     * @param queryRef   The query reference for which to clear the cache.
817
     * @param waitMillis The fallback delay in milliseconds, used if the nanopub's arrival cannot be confirmed.
818
     * @param nanopubId  The id of the just-published nanopub to wait for, or null for the plain delay.
819
     */
820
    public static void clearCache(QueryRef queryRef, long waitMillis, String nanopubId) {
821
        if (waitMillis < 0) {
12✔
822
            throw new IllegalArgumentException("waitMillis must be non-negative");
15✔
823
        }
824
        String cacheId = queryRef.getAsUrlString();
9✔
825
        forcedRefresh.add(cacheId);
12✔
826
        runAfter.put(cacheId, System.currentTimeMillis() + waitMillis);
24✔
827
        if (nanopubId != null) awaitIngest.put(cacheId, nanopubId);
21✔
828
    }
3✔
829

830
}
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