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

knowledgepixels / nanodash / 22767166801

06 Mar 2026 02:14PM UTC coverage: 15.73% (-0.1%) from 15.877%
22767166801

Pull #381

github

web-flow
Merge eade858ec into 45247de7b
Pull Request #381: feat(QueryPage): support CONSTRUCT queries

705 of 5435 branches covered (12.97%)

Branch coverage included in aggregate %.

1743 of 10128 relevant lines covered (17.21%)

2.35 hits per line

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

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

3
import org.eclipse.rdf4j.model.Model;
4
import org.nanopub.extra.services.*;
5
import org.slf4j.Logger;
6
import org.slf4j.LoggerFactory;
7

8
import java.util.HashMap;
9
import java.util.List;
10
import java.util.Map;
11
import java.util.Random;
12
import java.util.concurrent.ConcurrentHashMap;
13
import java.util.concurrent.ConcurrentMap;
14

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

21
    private ApiCache() {
22
    } // no instances allowed
23

24
    private transient static ConcurrentMap<String, ApiResponse> cachedResponses = new ConcurrentHashMap<>();
12✔
25
    private transient static ConcurrentMap<String, Model> cachedRdfModels = new ConcurrentHashMap<>();
12✔
26
    private transient static ConcurrentMap<String, Integer> failed = new ConcurrentHashMap<>();
12✔
27
    private transient static ConcurrentMap<String, Map<String, String>> cachedMaps = new ConcurrentHashMap<>();
12✔
28
    private transient static ConcurrentMap<String, Long> lastRefresh = new ConcurrentHashMap<>();
12✔
29
    private transient static ConcurrentMap<String, Long> refreshStart = new ConcurrentHashMap<>();
12✔
30
    private transient static ConcurrentMap<String, Long> runAfter = new ConcurrentHashMap<>();
12✔
31
    private static final Logger logger = LoggerFactory.getLogger(ApiCache.class);
12✔
32

33
    /**
34
     * Checks if a cache refresh is currently running for the given cache ID.
35
     *
36
     * @param cacheId The unique identifier for the cache.
37
     * @return True if a refresh is running, false otherwise.
38
     */
39
    private static boolean isRunning(String cacheId) {
40
        if (!refreshStart.containsKey(cacheId)) return false;
18✔
41
        return System.currentTimeMillis() - refreshStart.get(cacheId) < 60 * 1000;
42✔
42
    }
43

44
    /**
45
     * Checks if a cache refresh is currently running for the given QueryRef.
46
     *
47
     * @param queryRef The query reference
48
     * @return True if a refresh is running, false otherwise.
49
     */
50
    public static boolean isRunning(QueryRef queryRef) {
51
        return isRunning(queryRef.getAsUrlString());
12✔
52
    }
53

54
    /**
55
     * Updates the cached API response for a specific query reference.
56
     *
57
     * @param queryRef The query reference
58
     * @throws FailedApiCallException If the API call fails.
59
     */
60
    private static void updateResponse(QueryRef queryRef, boolean forced) throws FailedApiCallException, APINotReachableException, NotEnoughAPIInstancesException {
61
        ApiResponse response;
62
        if (forced) {
6✔
63
            response = QueryApiAccess.forcedGet(queryRef);
12✔
64
        } else {
65
            response = QueryApiAccess.get(queryRef);
9✔
66
        }
67
        String cacheId = queryRef.getAsUrlString();
9✔
68
        logger.info("Updating cached API response for {}", cacheId);
12✔
69
        cachedResponses.put(cacheId, response);
15✔
70
        lastRefresh.put(cacheId, System.currentTimeMillis());
18✔
71
    }
3✔
72

73
    public static ApiResponse retrieveResponseSync(QueryRef queryRef, boolean forced) {
74
        long timeNow = System.currentTimeMillis();
6✔
75
        String cacheId = queryRef.getAsUrlString();
9✔
76
        logger.info("Retrieving cached API response synchronously for {}", cacheId);
12✔
77
        boolean needsRefresh = true;
6✔
78
        if (cachedResponses.containsKey(cacheId) && cachedResponses.get(cacheId) != null) {
24!
79
            long cacheAge = timeNow - lastRefresh.get(cacheId);
24✔
80
            needsRefresh = cacheAge > 60 * 1000;
24✔
81
        }
82
        if (failed.get(cacheId) != null && failed.get(cacheId) > 2) {
33!
83
            failed.remove(cacheId);
12✔
84
            throw new RuntimeException("Query failed: " + cacheId);
18✔
85
        }
86
        if (needsRefresh && !isRunning(cacheId)) {
15✔
87
            logger.info("Refreshing cache for {}", cacheId);
12✔
88
            refreshStart.put(cacheId, timeNow);
18✔
89
            try {
90
                if (runAfter.containsKey(cacheId)) {
12!
91
                    while (System.currentTimeMillis() < runAfter.get(cacheId)) {
×
92
                        Thread.sleep(100);
×
93
                    }
94
                    runAfter.remove(cacheId);
×
95
                }
96
                if (failed.get(cacheId) != null) {
12!
97
                    // 1 second pause between failed attempts;
98
                    Thread.sleep(1000);
×
99
                }
100
                Thread.sleep(100 + new Random().nextLong(400));
24✔
101
            } catch (InterruptedException ex) {
×
102
                logger.error("Interrupted while waiting to refresh cache: {}", ex.getMessage());
×
103
            }
3✔
104
            try {
105
                ApiCache.updateResponse(queryRef, forced);
9✔
106
            } catch (Exception ex) {
3✔
107
                logger.error("Failed to update cache for {}: {}", cacheId, ex.getMessage());
18✔
108
                cachedResponses.remove(cacheId);
12✔
109
                failed.merge(cacheId, 1, Integer::sum);
21✔
110
                lastRefresh.put(cacheId, System.currentTimeMillis());
18✔
111
            } finally {
112
                refreshStart.remove(cacheId);
12✔
113
            }
114
        }
115
        return cachedResponses.getOrDefault(cacheId, null);
18✔
116
    }
117

118
    /**
119
     * Retrieves a cached API response for a specific QueryRef.
120
     *
121
     * @param queryRef The QueryRef object containing the query name and parameters.
122
     * @return The cached API response, or null if not cached.
123
     */
124
    public static ApiResponse retrieveResponseAsync(QueryRef queryRef) {
125
        long timeNow = System.currentTimeMillis();
6✔
126
        String cacheId = queryRef.getAsUrlString();
9✔
127
        logger.info("Retrieving cached API response asynchronously for {}", cacheId);
12✔
128
        boolean isCached = false;
6✔
129
        boolean needsRefresh = true;
6✔
130
        if (cachedResponses.containsKey(cacheId) && cachedResponses.get(cacheId) != null) {
24!
131
            long cacheAge = timeNow - lastRefresh.get(cacheId);
24✔
132
            isCached = cacheAge < 24 * 60 * 60 * 1000;
21!
133
            needsRefresh = cacheAge > 60 * 1000;
18!
134
        }
135
        if (failed.get(cacheId) != null && failed.get(cacheId) > 2) {
12!
136
            failed.remove(cacheId);
×
137
            throw new RuntimeException("Query failed: " + cacheId);
×
138
        }
139
        if (needsRefresh && !isRunning(cacheId)) {
15!
140
            refreshStart.put(cacheId, timeNow);
18✔
141
            new Thread(() -> {
18✔
142
                try {
143
                    if (runAfter.containsKey(cacheId)) {
12!
144
                        while (System.currentTimeMillis() < runAfter.get(cacheId)) {
×
145
                            Thread.sleep(100);
×
146
                        }
147
                        runAfter.remove(cacheId);
×
148
                    }
149
                    if (failed.get(cacheId) != null) {
12!
150
                        // 1 second pause between failed attempts;
151
                        Thread.sleep(1000);
×
152
                    }
153
                    Thread.sleep(100 + new Random().nextLong(400));
24✔
154
                } catch (InterruptedException ex) {
×
155
                    logger.error("Interrupted while waiting to refresh cache: {}", ex.getMessage());
×
156
                }
3✔
157
                try {
158
                    ApiCache.updateResponse(queryRef, false);
9✔
159
                } catch (Exception ex) {
×
160
                    logger.error("Failed to update cache for {}: {}", cacheId, ex.getMessage());
×
161
                    cachedResponses.remove(cacheId);
×
162
                    failed.merge(cacheId, 1, Integer::sum);
×
163
                    lastRefresh.put(cacheId, System.currentTimeMillis());
×
164
                } finally {
165
                    refreshStart.remove(cacheId);
12✔
166
                }
167
            }).start();
6✔
168
        }
169
        if (isCached) {
6✔
170
            return cachedResponses.get(cacheId);
15✔
171
        } else {
172
            return null;
6✔
173
        }
174
    }
175

176
    /**
177
     * Updates the cached map for a specific query reference.
178
     *
179
     * @param queryRef The query reference
180
     * @throws FailedApiCallException If the API call fails.
181
     */
182
    private static void updateMap(QueryRef queryRef) throws FailedApiCallException, APINotReachableException, NotEnoughAPIInstancesException {
183
        Map<String, String> map = new HashMap<>();
×
184
        List<ApiResponseEntry> respList = QueryApiAccess.get(queryRef).getData();
×
185
        while (respList != null && !respList.isEmpty()) {
×
186
            ApiResponseEntry resultEntry = respList.removeFirst();
×
187
            map.put(resultEntry.get("key"), resultEntry.get("value"));
×
188
        }
×
189
        String cacheId = queryRef.getAsUrlString();
×
190
        cachedMaps.put(cacheId, map);
×
191
        lastRefresh.put(cacheId, System.currentTimeMillis());
×
192
    }
×
193

194
    /**
195
     * Retrieves a cached map for a specific query reference.
196
     * If the cache is stale, it triggers a background refresh.
197
     *
198
     * @param queryRef The query reference
199
     * @return The cached map, or null if not cached.
200
     */
201
    public static synchronized Map<String, String> retrieveMap(QueryRef queryRef) {
202
        long timeNow = System.currentTimeMillis();
×
203
        String cacheId = queryRef.getAsUrlString();
×
204
        boolean isCached = false;
×
205
        boolean needsRefresh = true;
×
206
        if (cachedMaps.containsKey(cacheId)) {
×
207
            long cacheAge = timeNow - lastRefresh.get(cacheId);
×
208
            isCached = cacheAge < 24 * 60 * 60 * 1000;
×
209
            needsRefresh = cacheAge > 60 * 1000;
×
210
        }
211
        if (needsRefresh && !isRunning(cacheId)) {
×
212
            refreshStart.put(cacheId, timeNow);
×
213
            new Thread(() -> {
×
214
                try {
215
                    if (runAfter.containsKey(cacheId)) {
×
216
                        while (System.currentTimeMillis() < runAfter.get(cacheId)) {
×
217
                            Thread.sleep(100);
×
218
                        }
219
                        runAfter.remove(cacheId);
×
220
                    }
221
                    Thread.sleep(100 + new Random().nextLong(400));
×
222
                } catch (InterruptedException ex) {
×
223
                    logger.error("Interrupted while waiting to refresh cache: {}", ex.getMessage());
×
224
                }
×
225
                try {
226
                    ApiCache.updateMap(queryRef);
×
227
                } catch (Exception ex) {
×
228
                    logger.error("Failed to update cache for {}: {}", cacheId, ex.getMessage());
×
229
                    cachedResponses.put(cacheId, null);
×
230
                    lastRefresh.put(cacheId, System.currentTimeMillis());
×
231
                } finally {
232
                    refreshStart.remove(cacheId);
×
233
                }
234
            }).start();
×
235
        }
236
        if (isCached) {
×
237
            if (cachedResponses.get(cacheId) == null) {
×
238
                cachedResponses.remove(cacheId);
×
239
                throw new RuntimeException("Query failed: " + cacheId);
×
240
            }
241
            return cachedMaps.get(cacheId);
×
242
        } else {
243
            return null;
×
244
        }
245
    }
246

247
    private static void updateRdfModel(QueryRef queryRef) throws FailedApiCallException, APINotReachableException, NotEnoughAPIInstancesException {
248
        final Model[] modelRef = new Model[1];
×
249
        QueryAccess qa = new QueryAccess() {
×
250
            @Override
251
            protected void processHeader(String[] line) {}
×
252
            @Override
253
            protected void processLine(String[] line) {}
×
254
            @Override
255
            protected void processRdfContent(Model model) {
256
                modelRef[0] = model;
×
257
            }
×
258
        };
259
        qa.call(queryRef);
×
260
        if (modelRef[0] == null) {
×
261
            throw new FailedApiCallException(new Exception("No RDF content in response for query: " + queryRef.getQueryId()));
×
262
        }
263
        String cacheId = queryRef.getAsUrlString();
×
264
        logger.info("Updating cached RDF model for {}", cacheId);
×
265
        cachedRdfModels.put(cacheId, modelRef[0]);
×
266
        lastRefresh.put(cacheId, System.currentTimeMillis());
×
267
    }
×
268

269
    /**
270
     * Retrieves a cached RDF model for a CONSTRUCT query, triggering a background fetch if needed.
271
     *
272
     * @param queryRef The QueryRef for the CONSTRUCT query.
273
     * @return The cached RDF Model, or null if not yet available.
274
     */
275
    public static Model retrieveRdfModelAsync(QueryRef queryRef) {
276
        long timeNow = System.currentTimeMillis();
×
277
        String cacheId = queryRef.getAsUrlString();
×
278
        logger.info("Retrieving cached RDF model asynchronously for {}", cacheId);
×
279
        boolean isCached = false;
×
280
        boolean needsRefresh = true;
×
281
        if (cachedRdfModels.containsKey(cacheId) && cachedRdfModels.get(cacheId) != null) {
×
282
            long cacheAge = timeNow - lastRefresh.get(cacheId);
×
283
            isCached = cacheAge < 24 * 60 * 60 * 1000;
×
284
            needsRefresh = cacheAge > 60 * 1000;
×
285
        }
286
        if (failed.get(cacheId) != null && failed.get(cacheId) > 2) {
×
287
            failed.remove(cacheId);
×
288
            throw new RuntimeException("Query failed: " + cacheId);
×
289
        }
290
        if (needsRefresh && !isRunning(cacheId)) {
×
291
            refreshStart.put(cacheId, timeNow);
×
292
            new Thread(() -> {
×
293
                try {
294
                    if (runAfter.containsKey(cacheId)) {
×
295
                        while (System.currentTimeMillis() < runAfter.get(cacheId)) {
×
296
                            Thread.sleep(100);
×
297
                        }
298
                        runAfter.remove(cacheId);
×
299
                    }
300
                    if (failed.get(cacheId) != null) {
×
301
                        Thread.sleep(1000);
×
302
                    }
303
                    Thread.sleep(100 + new Random().nextLong(400));
×
304
                } catch (InterruptedException ex) {
×
305
                    logger.error("Interrupted while waiting to refresh RDF cache: {}", ex.getMessage());
×
306
                }
×
307
                try {
308
                    updateRdfModel(queryRef);
×
309
                } catch (Exception ex) {
×
310
                    logger.error("Failed to update RDF cache for {}: {}", cacheId, ex.getMessage());
×
311
                    cachedRdfModels.remove(cacheId);
×
312
                    failed.merge(cacheId, 1, Integer::sum);
×
313
                    lastRefresh.put(cacheId, System.currentTimeMillis());
×
314
                } finally {
315
                    refreshStart.remove(cacheId);
×
316
                }
317
            }).start();
×
318
        }
319
        if (isCached) {
×
320
            return cachedRdfModels.get(cacheId);
×
321
        } else {
322
            return null;
×
323
        }
324
    }
325

326
    /**
327
     * Clears the cached response for a specific query reference and sets a delay before the next refresh can occur.
328
     *
329
     * @param queryRef   The query reference for which to clear the cache.
330
     * @param waitMillis The amount of time in milliseconds to wait before allowing the cache to be refreshed again.
331
     */
332
    public static void clearCache(QueryRef queryRef, long waitMillis) {
333
        if (waitMillis < 0) {
12✔
334
            throw new IllegalArgumentException("waitMillis must be non-negative");
15✔
335
        }
336
        cachedResponses.remove(queryRef.getAsUrlString());
15✔
337
        runAfter.put(queryRef.getAsUrlString(), System.currentTimeMillis() + waitMillis);
27✔
338
    }
3✔
339

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