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

knowledgepixels / nanodash / 27622721129

16 Jun 2026 01:55PM UTC coverage: 26.963% (+6.3%) from 20.697%
27622721129

Pull #483

github

web-flow
Merge 73a4d0fe1 into 663f14f46
Pull Request #483: Space/resource About pages, ref-aware spaces, and magic query params

1542 of 6717 branches covered (22.96%)

Branch coverage included in aggregate %.

3407 of 11638 relevant lines covered (29.27%)

4.31 hits per line

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

40.72
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 org.apache.wicket.MetaDataKey;
6
import org.apache.wicket.request.cycle.RequestCycle;
7
import org.eclipse.rdf4j.model.Model;
8
import org.nanopub.extra.services.*;
9
import org.slf4j.Logger;
10
import org.slf4j.LoggerFactory;
11

12
import java.util.HashMap;
13
import java.util.HashSet;
14
import java.util.List;
15
import java.util.Map;
16
import java.util.Random;
17
import java.util.concurrent.ConcurrentHashMap;
18
import java.util.concurrent.ConcurrentMap;
19
import java.util.concurrent.TimeUnit;
20

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

27
    private ApiCache() {
28
    } // no instances allowed
29

30
    private static final int MAX_CACHE_ENTRIES = 10_000;
31

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

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

44
    private static final Cache<String, ApiResponse> cachedResponses = CacheBuilder.newBuilder()
6✔
45
        .maximumSize(MAX_CACHE_ENTRIES)
9✔
46
        .expireAfterAccess(24, TimeUnit.HOURS)
6✔
47
        .removalListener(n -> cleanupMetadata(n.getKey().toString()))
18✔
48
        .build();
6✔
49
    private static final Cache<String, Model> cachedRdfModels = CacheBuilder.newBuilder()
6✔
50
        .maximumSize(MAX_CACHE_ENTRIES)
9✔
51
        .expireAfterAccess(24, TimeUnit.HOURS)
6✔
52
        .removalListener(n -> cleanupMetadata(n.getKey().toString()))
3✔
53
        .build();
6✔
54
    private transient static ConcurrentMap<String, Integer> failed = new ConcurrentHashMap<>();
12✔
55
    private static final Cache<String, Map<String, String>> cachedMaps = CacheBuilder.newBuilder()
6✔
56
        .maximumSize(MAX_CACHE_ENTRIES)
9✔
57
        .expireAfterAccess(24, TimeUnit.HOURS)
6✔
58
        .removalListener(n -> cleanupMetadata(n.getKey().toString()))
3✔
59
        .build();
6✔
60
    private transient static ConcurrentMap<String, Long> lastRefresh = new ConcurrentHashMap<>();
12✔
61
    private transient static ConcurrentMap<String, Long> refreshStart = new ConcurrentHashMap<>();
12✔
62
    private transient static ConcurrentMap<String, Long> runAfter = new ConcurrentHashMap<>();
12✔
63
    private static final Logger logger = LoggerFactory.getLogger(ApiCache.class);
9✔
64

65
    private static void cleanupMetadata(String cacheId) {
66
        lastRefresh.remove(cacheId);
12✔
67
        failed.remove(cacheId);
12✔
68
        runAfter.remove(cacheId);
12✔
69
    }
3✔
70

71
    /**
72
     * Checks if a cache refresh is currently running for the given cache ID.
73
     *
74
     * @param cacheId The unique identifier for the cache.
75
     * @return True if a refresh is running, false otherwise.
76
     */
77
    private static boolean isRunning(String cacheId) {
78
        Long start = refreshStart.get(cacheId);
15✔
79
        if (start == null) return false;
12✔
80
        return System.currentTimeMillis() - start < 60 * 1000;
33✔
81
    }
82

83
    /**
84
     * Checks if a cache refresh is currently running for the given QueryRef.
85
     *
86
     * @param queryRef The query reference
87
     * @return True if a refresh is running, false otherwise.
88
     */
89
    public static boolean isRunning(QueryRef queryRef) {
90
        return isRunning(queryRef.getAsUrlString());
12✔
91
    }
92

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

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

107
    /**
108
     * On a genuine browser reload, returns true the first time a given query is
109
     * accessed this request (and records it), so callers evict its cache once.
110
     * Returns false on non-reload requests, off the request thread, and for any
111
     * query already handled this request — so it never triggers a refresh storm.
112
     */
113
    private static boolean isForcedReload(String cacheId) {
114
        RequestCycle rc = RequestCycle.get();
6✔
115
        if (rc == null) return false;
6!
116
        Boolean force = rc.getMetaData(FORCE_REFRESH_ON_RELOAD);
15✔
117
        if (force == null || !force) return false;
12!
118
        HashSet<String> handled = rc.getMetaData(RELOAD_FORCED_IDS);
×
119
        if (handled == null) {
×
120
            handled = new HashSet<>();
×
121
            rc.setMetaData(RELOAD_FORCED_IDS, handled);
×
122
        }
123
        return handled.add(cacheId);
×
124
    }
125

126
    /**
127
     * Updates the cached API response for a specific query reference.
128
     *
129
     * @param queryRef The query reference
130
     * @throws FailedApiCallException If the API call fails.
131
     */
132
    private static void updateResponse(QueryRef queryRef, boolean forced) throws FailedApiCallException, APINotReachableException, NotEnoughAPIInstancesException {
133
        ApiResponse response;
134
        if (forced) {
6✔
135
            response = QueryApiAccess.forcedGet(queryRef);
12✔
136
        } else {
137
            response = QueryApiAccess.get(queryRef);
9✔
138
        }
139
        String cacheId = queryRef.getAsUrlString();
9✔
140
        logger.info("Updating cached API response for {}", cacheId);
12✔
141
        cachedResponses.put(cacheId, response);
12✔
142
        lastRefresh.put(cacheId, System.currentTimeMillis());
18✔
143
    }
3✔
144

145
    public static ApiResponse retrieveResponseSync(QueryRef queryRef, boolean forced) {
146
        long timeNow = System.currentTimeMillis();
6✔
147
        String cacheId = queryRef.getAsUrlString();
9✔
148
        logger.info("Retrieving cached API response synchronously for {}", cacheId);
12✔
149
        boolean needsRefresh = true;
6✔
150
        if (cachedResponses.getIfPresent(cacheId) != null) {
12✔
151
            long cacheAge = timeNow - lastRefresh.get(cacheId);
24✔
152
            needsRefresh = cacheAge > REFRESH_AGE_THRESHOLD_MS;
24✔
153
        }
154
        if (failed.get(cacheId) != null && failed.get(cacheId) > 2) {
33!
155
            failed.remove(cacheId);
12✔
156
            throw new RuntimeException("Query failed: " + cacheId);
18✔
157
        }
158
        if ((needsRefresh || forced) && !isRunning(cacheId)) {
21!
159
            logger.info("Refreshing cache for {}", cacheId);
12✔
160
            refreshStart.put(cacheId, timeNow);
18✔
161
            try {
162
                if (runAfter.containsKey(cacheId)) {
12!
163
                    while (System.currentTimeMillis() < runAfter.get(cacheId)) {
×
164
                        Thread.sleep(100);
×
165
                    }
166
                    runAfter.remove(cacheId);
×
167
                }
168
                if (failed.get(cacheId) != null) {
12!
169
                    // 1 second pause between failed attempts;
170
                    Thread.sleep(1000);
×
171
                }
172
                Thread.sleep(100 + new Random().nextLong(400));
24✔
173
            } catch (InterruptedException ex) {
×
174
                logger.error("Interrupted while waiting to refresh cache: {}", ex.getMessage());
×
175
            }
3✔
176
            try {
177
                ApiCache.updateResponse(queryRef, forced);
9✔
178
                failed.remove(cacheId);
12✔
179
            } catch (Exception ex) {
3✔
180
                logger.error("Failed to update cache for {}: {}", cacheId, ex.getMessage());
18✔
181
                // Keep stale cached data if available, only invalidate if nothing was cached
182
                if (cachedResponses.getIfPresent(cacheId) == null) {
12!
183
                    failed.merge(cacheId, 1, Integer::sum);
21✔
184
                }
185
                lastRefresh.put(cacheId, System.currentTimeMillis());
18✔
186
            } finally {
187
                refreshStart.remove(cacheId);
12✔
188
            }
189
        }
190
        return cachedResponses.getIfPresent(cacheId);
15✔
191
    }
192

193
    /**
194
     * Retrieves a cached API response for a specific QueryRef.
195
     *
196
     * @param queryRef The QueryRef object containing the query name and parameters.
197
     * @return The cached API response, or null if not cached.
198
     */
199
    public static ApiResponse retrieveResponseAsync(QueryRef queryRef) {
200
        long timeNow = System.currentTimeMillis();
6✔
201
        String cacheId = queryRef.getAsUrlString();
9✔
202
        logger.info("Retrieving cached API response asynchronously for {}", cacheId);
12✔
203
        if (isForcedReload(cacheId)) {
9!
204
            cachedResponses.invalidate(cacheId);
×
205
            lastRefresh.remove(cacheId);
×
206
        }
207
        boolean isCached = false;
6✔
208
        boolean needsRefresh = true;
6✔
209
        if (cachedResponses.getIfPresent(cacheId) != null) {
12✔
210
            long cacheAge = timeNow - lastRefresh.get(cacheId);
24✔
211
            isCached = cacheAge < MAX_CACHE_AGE_MS;
21!
212
            needsRefresh = cacheAge > REFRESH_AGE_THRESHOLD_MS;
18!
213
        }
214
        if (failed.get(cacheId) != null && failed.get(cacheId) > 2) {
12!
215
            failed.remove(cacheId);
×
216
            throw new RuntimeException("Query failed: " + cacheId);
×
217
        }
218
        if (needsRefresh && !isRunning(cacheId)) {
15!
219
            NanodashThreadPool.submit(() -> {
15✔
220
                refreshStart.put(cacheId, System.currentTimeMillis());
18✔
221
                try {
222
                    if (runAfter.containsKey(cacheId)) {
12!
223
                        while (System.currentTimeMillis() < runAfter.get(cacheId)) {
×
224
                            Thread.sleep(100);
×
225
                        }
226
                        runAfter.remove(cacheId);
×
227
                    }
228
                    if (failed.get(cacheId) != null) {
12!
229
                        // 1 second pause between failed attempts;
230
                        Thread.sleep(1000);
×
231
                    }
232
                    Thread.sleep(100 + new Random().nextLong(400));
24✔
233
                } catch (InterruptedException ex) {
×
234
                    logger.error("Interrupted while waiting to refresh cache: {}", ex.getMessage());
×
235
                }
3✔
236
                try {
237
                    ApiCache.updateResponse(queryRef, false);
9✔
238
                    failed.remove(cacheId);
12✔
239
                } catch (Exception ex) {
×
240
                    logger.error("Failed to update cache for {}: {}", cacheId, ex.getMessage());
×
241
                    if (cachedResponses.getIfPresent(cacheId) == null) {
×
242
                        failed.merge(cacheId, 1, Integer::sum);
×
243
                    }
244
                    lastRefresh.put(cacheId, System.currentTimeMillis());
×
245
                } finally {
246
                    refreshStart.remove(cacheId);
12✔
247
                }
248
            });
3✔
249
        }
250
        if (isCached) {
6✔
251
            return cachedResponses.getIfPresent(cacheId);
15✔
252
        } else {
253
            return null;
6✔
254
        }
255
    }
256

257
    /**
258
     * Updates the cached map for a specific query reference.
259
     *
260
     * @param queryRef The query reference
261
     * @throws FailedApiCallException If the API call fails.
262
     */
263
    private static void updateMap(QueryRef queryRef) throws FailedApiCallException, APINotReachableException, NotEnoughAPIInstancesException {
264
        Map<String, String> map = new HashMap<>();
×
265
        List<ApiResponseEntry> respList = QueryApiAccess.get(queryRef).getData();
×
266
        while (respList != null && !respList.isEmpty()) {
×
267
            ApiResponseEntry resultEntry = respList.removeFirst();
×
268
            map.put(resultEntry.get("key"), resultEntry.get("value"));
×
269
        }
×
270
        String cacheId = queryRef.getAsUrlString();
×
271
        cachedMaps.put(cacheId, map);
×
272
        lastRefresh.put(cacheId, System.currentTimeMillis());
×
273
    }
×
274

275
    /**
276
     * Retrieves a cached map for a specific query reference.
277
     * If the cache is stale, it triggers a background refresh.
278
     *
279
     * @param queryRef The query reference
280
     * @return The cached map, or null if not cached.
281
     */
282
    public static Map<String, String> retrieveMap(QueryRef queryRef) {
283
        long timeNow = System.currentTimeMillis();
×
284
        String cacheId = queryRef.getAsUrlString();
×
285
        if (isForcedReload(cacheId)) {
×
286
            cachedMaps.invalidate(cacheId);
×
287
            lastRefresh.remove(cacheId);
×
288
        }
289
        boolean isCached = false;
×
290
        boolean needsRefresh = true;
×
291
        if (cachedMaps.getIfPresent(cacheId) != null) {
×
292
            long cacheAge = timeNow - lastRefresh.get(cacheId);
×
293
            isCached = cacheAge < MAX_CACHE_AGE_MS;
×
294
            needsRefresh = cacheAge > REFRESH_AGE_THRESHOLD_MS;
×
295
        }
296
        if (needsRefresh && !isRunning(cacheId)) {
×
297
            NanodashThreadPool.submit(() -> {
×
298
                refreshStart.put(cacheId, System.currentTimeMillis());
×
299
                try {
300
                    if (runAfter.containsKey(cacheId)) {
×
301
                        while (System.currentTimeMillis() < runAfter.get(cacheId)) {
×
302
                            Thread.sleep(100);
×
303
                        }
304
                        runAfter.remove(cacheId);
×
305
                    }
306
                    Thread.sleep(100 + new Random().nextLong(400));
×
307
                } catch (InterruptedException ex) {
×
308
                    logger.error("Interrupted while waiting to refresh cache: {}", ex.getMessage());
×
309
                }
×
310
                try {
311
                    ApiCache.updateMap(queryRef);
×
312
                } catch (Exception ex) {
×
313
                    logger.error("Failed to update cache for {}: {}", cacheId, ex.getMessage());
×
314
                    cachedMaps.invalidate(cacheId);
×
315
                    lastRefresh.put(cacheId, System.currentTimeMillis());
×
316
                }  finally {
317
                    refreshStart.remove(cacheId);
×
318
                }
319
            });
×
320
        }
321
        if (isCached) {
×
322
            return cachedMaps.getIfPresent(cacheId);
×
323
        } else {
324
            return null;
×
325
        }
326
    }
327

328
    private static void updateRdfModel(QueryRef queryRef) throws FailedApiCallException, APINotReachableException, NotEnoughAPIInstancesException {
329
        final Model[] modelRef = new Model[1];
×
330
        QueryAccess qa = new QueryAccess() {
×
331
            @Override
332
            protected void processHeader(String[] line) {}
×
333
            @Override
334
            protected void processLine(String[] line) {}
×
335
            @Override
336
            protected void processRdfContent(Model model) {
337
                modelRef[0] = model;
×
338
            }
×
339
        };
340
        qa.call(queryRef);
×
341
        if (modelRef[0] == null) {
×
342
            throw new FailedApiCallException(new Exception("No RDF content in response for query: " + queryRef.getQueryId()));
×
343
        }
344
        String cacheId = queryRef.getAsUrlString();
×
345
        logger.info("Updating cached RDF model for {}", cacheId);
×
346
        cachedRdfModels.put(cacheId, modelRef[0]);
×
347
        lastRefresh.put(cacheId, System.currentTimeMillis());
×
348
    }
×
349

350
    /**
351
     * Retrieves a cached RDF model for a CONSTRUCT query, triggering a background fetch if needed.
352
     *
353
     * @param queryRef The QueryRef for the CONSTRUCT query.
354
     * @return The cached RDF Model, or null if not yet available.
355
     */
356
    public static Model retrieveRdfModelAsync(QueryRef queryRef) {
357
        long timeNow = System.currentTimeMillis();
×
358
        String cacheId = queryRef.getAsUrlString();
×
359
        logger.info("Retrieving cached RDF model asynchronously for {}", cacheId);
×
360
        if (isForcedReload(cacheId)) {
×
361
            cachedRdfModels.invalidate(cacheId);
×
362
            lastRefresh.remove(cacheId);
×
363
        }
364
        boolean isCached = false;
×
365
        boolean needsRefresh = true;
×
366
        if (cachedRdfModels.getIfPresent(cacheId) != null) {
×
367
            long cacheAge = timeNow - lastRefresh.get(cacheId);
×
368
            isCached = cacheAge < MAX_CACHE_AGE_MS;
×
369
            needsRefresh = cacheAge > REFRESH_AGE_THRESHOLD_MS;
×
370
        }
371
        if (failed.get(cacheId) != null && failed.get(cacheId) > 2) {
×
372
            failed.remove(cacheId);
×
373
            throw new RuntimeException("Query failed: " + cacheId);
×
374
        }
375
        if (needsRefresh && !isRunning(cacheId)) {
×
376
            NanodashThreadPool.submit(() -> {
×
377
                refreshStart.put(cacheId, System.currentTimeMillis());
×
378
                try {
379
                    if (runAfter.containsKey(cacheId)) {
×
380
                        while (System.currentTimeMillis() < runAfter.get(cacheId)) {
×
381
                            Thread.sleep(100);
×
382
                        }
383
                        runAfter.remove(cacheId);
×
384
                    }
385
                    if (failed.get(cacheId) != null) {
×
386
                        Thread.sleep(1000);
×
387
                    }
388
                    Thread.sleep(100 + new Random().nextLong(400));
×
389
                } catch (InterruptedException ex) {
×
390
                    logger.error("Interrupted while waiting to refresh RDF cache: {}", ex.getMessage());
×
391
                }
×
392
                try {
393
                    updateRdfModel(queryRef);
×
394
                    failed.remove(cacheId);
×
395
                } catch (Exception ex) {
×
396
                    logger.error("Failed to update RDF cache for {}: {}", cacheId, ex.getMessage());
×
397
                    if (cachedRdfModels.getIfPresent(cacheId) == null) {
×
398
                        failed.merge(cacheId, 1, Integer::sum);
×
399
                    }
400
                    lastRefresh.put(cacheId, System.currentTimeMillis());
×
401
                } finally {
402
                    refreshStart.remove(cacheId);
×
403
                }
404
            });
×
405
        }
406
        if (isCached) {
×
407
            return cachedRdfModels.getIfPresent(cacheId);
×
408
        } else {
409
            return null;
×
410
        }
411
    }
412

413
    /**
414
     * Clears the cached response for a specific query reference and sets a delay before the next refresh can occur.
415
     *
416
     * @param queryRef   The query reference for which to clear the cache.
417
     * @param waitMillis The amount of time in milliseconds to wait before allowing the cache to be refreshed again.
418
     */
419
    public static void clearCache(QueryRef queryRef, long waitMillis) {
420
        if (waitMillis < 0) {
12✔
421
            throw new IllegalArgumentException("waitMillis must be non-negative");
15✔
422
        }
423
        cachedResponses.invalidate(queryRef.getAsUrlString());
12✔
424
        runAfter.put(queryRef.getAsUrlString(), System.currentTimeMillis() + waitMillis);
27✔
425
    }
3✔
426

427
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc