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

knowledgepixels / nanodash / 23528008920

25 Mar 2026 06:26AM UTC coverage: 16.386% (+0.004%) from 16.382%
23528008920

push

github

tkuhn
fix: read 'label' URL parameter on ResourcePartPage

The label param was ignored — the page always derived the label from
the resource ID. Now it uses the URL parameter when provided, falling
back to the ID-derived label otherwise.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

751 of 5639 branches covered (13.32%)

Branch coverage included in aggregate %.

1900 of 10539 relevant lines covered (18.03%)

2.47 hits per line

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

42.0
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.eclipse.rdf4j.model.Model;
6
import org.nanopub.extra.services.*;
7
import org.slf4j.Logger;
8
import org.slf4j.LoggerFactory;
9

10
import java.util.HashMap;
11
import java.util.List;
12
import java.util.Map;
13
import java.util.Random;
14
import java.util.concurrent.ConcurrentHashMap;
15
import java.util.concurrent.ConcurrentMap;
16
import java.util.concurrent.TimeUnit;
17

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

24
    private ApiCache() {
25
    } // no instances allowed
26

27
    private static final int MAX_CACHE_ENTRIES = 10_000;
28

29
    private static final Cache<String, ApiResponse> cachedResponses = CacheBuilder.newBuilder()
6✔
30
        .maximumSize(MAX_CACHE_ENTRIES)
9✔
31
        .expireAfterAccess(24, TimeUnit.HOURS)
6✔
32
        .removalListener(n -> cleanupMetadata(n.getKey().toString()))
18✔
33
        .build();
6✔
34
    private static final Cache<String, Model> cachedRdfModels = CacheBuilder.newBuilder()
6✔
35
        .maximumSize(MAX_CACHE_ENTRIES)
9✔
36
        .expireAfterAccess(24, TimeUnit.HOURS)
6✔
37
        .removalListener(n -> cleanupMetadata(n.getKey().toString()))
3✔
38
        .build();
6✔
39
    private transient static ConcurrentMap<String, Integer> failed = new ConcurrentHashMap<>();
12✔
40
    private static final Cache<String, Map<String, String>> cachedMaps = CacheBuilder.newBuilder()
6✔
41
        .maximumSize(MAX_CACHE_ENTRIES)
9✔
42
        .expireAfterAccess(24, TimeUnit.HOURS)
6✔
43
        .removalListener(n -> cleanupMetadata(n.getKey().toString()))
3✔
44
        .build();
6✔
45
    private transient static ConcurrentMap<String, Long> lastRefresh = new ConcurrentHashMap<>();
12✔
46
    private transient static ConcurrentMap<String, Long> refreshStart = new ConcurrentHashMap<>();
12✔
47
    private transient static ConcurrentMap<String, Long> runAfter = new ConcurrentHashMap<>();
12✔
48
    private static final Logger logger = LoggerFactory.getLogger(ApiCache.class);
12✔
49

50
    private static void cleanupMetadata(String cacheId) {
51
        lastRefresh.remove(cacheId);
12✔
52
        failed.remove(cacheId);
12✔
53
        runAfter.remove(cacheId);
12✔
54
    }
3✔
55

56
    /**
57
     * Checks if a cache refresh is currently running for the given cache ID.
58
     *
59
     * @param cacheId The unique identifier for the cache.
60
     * @return True if a refresh is running, false otherwise.
61
     */
62
    private static boolean isRunning(String cacheId) {
63
        Long start = refreshStart.get(cacheId);
15✔
64
        if (start == null) return false;
12✔
65
        return System.currentTimeMillis() - start < 60 * 1000;
33✔
66
    }
67

68
    /**
69
     * Checks if a cache refresh is currently running for the given QueryRef.
70
     *
71
     * @param queryRef The query reference
72
     * @return True if a refresh is running, false otherwise.
73
     */
74
    public static boolean isRunning(QueryRef queryRef) {
75
        return isRunning(queryRef.getAsUrlString());
12✔
76
    }
77

78
    /**
79
     * Updates the cached API response for a specific query reference.
80
     *
81
     * @param queryRef The query reference
82
     * @throws FailedApiCallException If the API call fails.
83
     */
84
    private static void updateResponse(QueryRef queryRef, boolean forced) throws FailedApiCallException, APINotReachableException, NotEnoughAPIInstancesException {
85
        ApiResponse response;
86
        if (forced) {
6✔
87
            response = QueryApiAccess.forcedGet(queryRef);
12✔
88
        } else {
89
            response = QueryApiAccess.get(queryRef);
9✔
90
        }
91
        String cacheId = queryRef.getAsUrlString();
9✔
92
        logger.info("Updating cached API response for {}", cacheId);
12✔
93
        cachedResponses.put(cacheId, response);
12✔
94
        lastRefresh.put(cacheId, System.currentTimeMillis());
18✔
95
    }
3✔
96

97
    public static ApiResponse retrieveResponseSync(QueryRef queryRef, boolean forced) {
98
        long timeNow = System.currentTimeMillis();
6✔
99
        String cacheId = queryRef.getAsUrlString();
9✔
100
        logger.info("Retrieving cached API response synchronously for {}", cacheId);
12✔
101
        boolean needsRefresh = true;
6✔
102
        if (cachedResponses.getIfPresent(cacheId) != null) {
12✔
103
            long cacheAge = timeNow - lastRefresh.get(cacheId);
24✔
104
            needsRefresh = cacheAge > 60 * 1000;
24✔
105
        }
106
        if (failed.get(cacheId) != null && failed.get(cacheId) > 2) {
33!
107
            failed.remove(cacheId);
12✔
108
            throw new RuntimeException("Query failed: " + cacheId);
18✔
109
        }
110
        if ((needsRefresh || forced) && !isRunning(cacheId)) {
21!
111
            logger.info("Refreshing cache for {}", cacheId);
12✔
112
            refreshStart.put(cacheId, timeNow);
18✔
113
            try {
114
                if (runAfter.containsKey(cacheId)) {
12!
115
                    while (System.currentTimeMillis() < runAfter.get(cacheId)) {
×
116
                        Thread.sleep(100);
×
117
                    }
118
                    runAfter.remove(cacheId);
×
119
                }
120
                if (failed.get(cacheId) != null) {
12!
121
                    // 1 second pause between failed attempts;
122
                    Thread.sleep(1000);
×
123
                }
124
                Thread.sleep(100 + new Random().nextLong(400));
24✔
125
            } catch (InterruptedException ex) {
×
126
                logger.error("Interrupted while waiting to refresh cache: {}", ex.getMessage());
×
127
            }
3✔
128
            try {
129
                ApiCache.updateResponse(queryRef, forced);
9✔
130
                failed.remove(cacheId);
12✔
131
            } catch (Exception ex) {
3✔
132
                logger.error("Failed to update cache for {}: {}", cacheId, ex.getMessage());
18✔
133
                // Keep stale cached data if available, only invalidate if nothing was cached
134
                if (cachedResponses.getIfPresent(cacheId) == null) {
12!
135
                    failed.merge(cacheId, 1, Integer::sum);
21✔
136
                }
137
                lastRefresh.put(cacheId, System.currentTimeMillis());
18✔
138
            } finally {
139
                refreshStart.remove(cacheId);
12✔
140
            }
141
        }
142
        return cachedResponses.getIfPresent(cacheId);
15✔
143
    }
144

145
    /**
146
     * Retrieves a cached API response for a specific QueryRef.
147
     *
148
     * @param queryRef The QueryRef object containing the query name and parameters.
149
     * @return The cached API response, or null if not cached.
150
     */
151
    public static ApiResponse retrieveResponseAsync(QueryRef queryRef) {
152
        long timeNow = System.currentTimeMillis();
6✔
153
        String cacheId = queryRef.getAsUrlString();
9✔
154
        logger.info("Retrieving cached API response asynchronously for {}", cacheId);
12✔
155
        boolean isCached = false;
6✔
156
        boolean needsRefresh = true;
6✔
157
        if (cachedResponses.getIfPresent(cacheId) != null) {
12✔
158
            long cacheAge = timeNow - lastRefresh.get(cacheId);
24✔
159
            isCached = cacheAge < 24 * 60 * 60 * 1000;
21!
160
            needsRefresh = cacheAge > 60 * 1000;
18!
161
        }
162
        if (failed.get(cacheId) != null && failed.get(cacheId) > 2) {
12!
163
            failed.remove(cacheId);
×
164
            throw new RuntimeException("Query failed: " + cacheId);
×
165
        }
166
        if (needsRefresh && !isRunning(cacheId)) {
15!
167
            NanodashThreadPool.submit(() -> {
15✔
168
                refreshStart.put(cacheId, System.currentTimeMillis());
18✔
169
                try {
170
                    if (runAfter.containsKey(cacheId)) {
12!
171
                        while (System.currentTimeMillis() < runAfter.get(cacheId)) {
×
172
                            Thread.sleep(100);
×
173
                        }
174
                        runAfter.remove(cacheId);
×
175
                    }
176
                    if (failed.get(cacheId) != null) {
12!
177
                        // 1 second pause between failed attempts;
178
                        Thread.sleep(1000);
×
179
                    }
180
                    Thread.sleep(100 + new Random().nextLong(400));
24✔
181
                } catch (InterruptedException ex) {
×
182
                    logger.error("Interrupted while waiting to refresh cache: {}", ex.getMessage());
×
183
                }
3✔
184
                try {
185
                    ApiCache.updateResponse(queryRef, false);
9✔
186
                    failed.remove(cacheId);
12✔
187
                } catch (Exception ex) {
×
188
                    logger.error("Failed to update cache for {}: {}", cacheId, ex.getMessage());
×
189
                    if (cachedResponses.getIfPresent(cacheId) == null) {
×
190
                        failed.merge(cacheId, 1, Integer::sum);
×
191
                    }
192
                    lastRefresh.put(cacheId, System.currentTimeMillis());
×
193
                } finally {
194
                    refreshStart.remove(cacheId);
12✔
195
                }
196
            });
3✔
197
        }
198
        if (isCached) {
6✔
199
            return cachedResponses.getIfPresent(cacheId);
15✔
200
        } else {
201
            return null;
6✔
202
        }
203
    }
204

205
    /**
206
     * Updates the cached map for a specific query reference.
207
     *
208
     * @param queryRef The query reference
209
     * @throws FailedApiCallException If the API call fails.
210
     */
211
    private static void updateMap(QueryRef queryRef) throws FailedApiCallException, APINotReachableException, NotEnoughAPIInstancesException {
212
        Map<String, String> map = new HashMap<>();
×
213
        List<ApiResponseEntry> respList = QueryApiAccess.get(queryRef).getData();
×
214
        while (respList != null && !respList.isEmpty()) {
×
215
            ApiResponseEntry resultEntry = respList.removeFirst();
×
216
            map.put(resultEntry.get("key"), resultEntry.get("value"));
×
217
        }
×
218
        String cacheId = queryRef.getAsUrlString();
×
219
        cachedMaps.put(cacheId, map);
×
220
        lastRefresh.put(cacheId, System.currentTimeMillis());
×
221
    }
×
222

223
    /**
224
     * Retrieves a cached map for a specific query reference.
225
     * If the cache is stale, it triggers a background refresh.
226
     *
227
     * @param queryRef The query reference
228
     * @return The cached map, or null if not cached.
229
     */
230
    public static Map<String, String> retrieveMap(QueryRef queryRef) {
231
        long timeNow = System.currentTimeMillis();
×
232
        String cacheId = queryRef.getAsUrlString();
×
233
        boolean isCached = false;
×
234
        boolean needsRefresh = true;
×
235
        if (cachedMaps.getIfPresent(cacheId) != null) {
×
236
            long cacheAge = timeNow - lastRefresh.get(cacheId);
×
237
            isCached = cacheAge < 24 * 60 * 60 * 1000;
×
238
            needsRefresh = cacheAge > 60 * 1000;
×
239
        }
240
        if (needsRefresh && !isRunning(cacheId)) {
×
241
            NanodashThreadPool.submit(() -> {
×
242
                refreshStart.put(cacheId, System.currentTimeMillis());
×
243
                try {
244
                    if (runAfter.containsKey(cacheId)) {
×
245
                        while (System.currentTimeMillis() < runAfter.get(cacheId)) {
×
246
                            Thread.sleep(100);
×
247
                        }
248
                        runAfter.remove(cacheId);
×
249
                    }
250
                    Thread.sleep(100 + new Random().nextLong(400));
×
251
                } catch (InterruptedException ex) {
×
252
                    logger.error("Interrupted while waiting to refresh cache: {}", ex.getMessage());
×
253
                }
×
254
                try {
255
                    ApiCache.updateMap(queryRef);
×
256
                } catch (Exception ex) {
×
257
                    logger.error("Failed to update cache for {}: {}", cacheId, ex.getMessage());
×
258
                    cachedMaps.invalidate(cacheId);
×
259
                    lastRefresh.put(cacheId, System.currentTimeMillis());
×
260
                }  finally {
261
                    refreshStart.remove(cacheId);
×
262
                }
263
            });
×
264
        }
265
        if (isCached) {
×
266
            return cachedMaps.getIfPresent(cacheId);
×
267
        } else {
268
            return null;
×
269
        }
270
    }
271

272
    private static void updateRdfModel(QueryRef queryRef) throws FailedApiCallException, APINotReachableException, NotEnoughAPIInstancesException {
273
        final Model[] modelRef = new Model[1];
×
274
        QueryAccess qa = new QueryAccess() {
×
275
            @Override
276
            protected void processHeader(String[] line) {}
×
277
            @Override
278
            protected void processLine(String[] line) {}
×
279
            @Override
280
            protected void processRdfContent(Model model) {
281
                modelRef[0] = model;
×
282
            }
×
283
        };
284
        qa.call(queryRef);
×
285
        if (modelRef[0] == null) {
×
286
            throw new FailedApiCallException(new Exception("No RDF content in response for query: " + queryRef.getQueryId()));
×
287
        }
288
        String cacheId = queryRef.getAsUrlString();
×
289
        logger.info("Updating cached RDF model for {}", cacheId);
×
290
        cachedRdfModels.put(cacheId, modelRef[0]);
×
291
        lastRefresh.put(cacheId, System.currentTimeMillis());
×
292
    }
×
293

294
    /**
295
     * Retrieves a cached RDF model for a CONSTRUCT query, triggering a background fetch if needed.
296
     *
297
     * @param queryRef The QueryRef for the CONSTRUCT query.
298
     * @return The cached RDF Model, or null if not yet available.
299
     */
300
    public static Model retrieveRdfModelAsync(QueryRef queryRef) {
301
        long timeNow = System.currentTimeMillis();
×
302
        String cacheId = queryRef.getAsUrlString();
×
303
        logger.info("Retrieving cached RDF model asynchronously for {}", cacheId);
×
304
        boolean isCached = false;
×
305
        boolean needsRefresh = true;
×
306
        if (cachedRdfModels.getIfPresent(cacheId) != null) {
×
307
            long cacheAge = timeNow - lastRefresh.get(cacheId);
×
308
            isCached = cacheAge < 24 * 60 * 60 * 1000;
×
309
            needsRefresh = cacheAge > 60 * 1000;
×
310
        }
311
        if (failed.get(cacheId) != null && failed.get(cacheId) > 2) {
×
312
            failed.remove(cacheId);
×
313
            throw new RuntimeException("Query failed: " + cacheId);
×
314
        }
315
        if (needsRefresh && !isRunning(cacheId)) {
×
316
            NanodashThreadPool.submit(() -> {
×
317
                refreshStart.put(cacheId, System.currentTimeMillis());
×
318
                try {
319
                    if (runAfter.containsKey(cacheId)) {
×
320
                        while (System.currentTimeMillis() < runAfter.get(cacheId)) {
×
321
                            Thread.sleep(100);
×
322
                        }
323
                        runAfter.remove(cacheId);
×
324
                    }
325
                    if (failed.get(cacheId) != null) {
×
326
                        Thread.sleep(1000);
×
327
                    }
328
                    Thread.sleep(100 + new Random().nextLong(400));
×
329
                } catch (InterruptedException ex) {
×
330
                    logger.error("Interrupted while waiting to refresh RDF cache: {}", ex.getMessage());
×
331
                }
×
332
                try {
333
                    updateRdfModel(queryRef);
×
334
                    failed.remove(cacheId);
×
335
                } catch (Exception ex) {
×
336
                    logger.error("Failed to update RDF cache for {}: {}", cacheId, ex.getMessage());
×
337
                    if (cachedRdfModels.getIfPresent(cacheId) == null) {
×
338
                        failed.merge(cacheId, 1, Integer::sum);
×
339
                    }
340
                    lastRefresh.put(cacheId, System.currentTimeMillis());
×
341
                } finally {
342
                    refreshStart.remove(cacheId);
×
343
                }
344
            });
×
345
        }
346
        if (isCached) {
×
347
            return cachedRdfModels.getIfPresent(cacheId);
×
348
        } else {
349
            return null;
×
350
        }
351
    }
352

353
    /**
354
     * Clears the cached response for a specific query reference and sets a delay before the next refresh can occur.
355
     *
356
     * @param queryRef   The query reference for which to clear the cache.
357
     * @param waitMillis The amount of time in milliseconds to wait before allowing the cache to be refreshed again.
358
     */
359
    public static void clearCache(QueryRef queryRef, long waitMillis) {
360
        if (waitMillis < 0) {
12✔
361
            throw new IllegalArgumentException("waitMillis must be non-negative");
15✔
362
        }
363
        cachedResponses.invalidate(queryRef.getAsUrlString());
12✔
364
        runAfter.put(queryRef.getAsUrlString(), System.currentTimeMillis() + waitMillis);
27✔
365
    }
3✔
366

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