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

knowledgepixels / nanopub-query / 35984697157

24 Sep 2026 10:00AM UTC coverage: 77.385% (+0.08%) from 77.307%
35984697157

push

github

web-flow
Merge pull request #227 from knowledgepixels/fix/thread-safe-singletons

fix: make the TripleStore and TrustStateRegistry singletons thread-safe

915 of 1332 branches covered (68.69%)

Branch coverage included in aggregate %.

2654 of 3280 relevant lines covered (80.91%)

12.66 hits per line

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

72.96
src/main/java/com/knowledgepixels/query/TripleStore.java
1
package com.knowledgepixels.query;
2

3
import org.apache.http.client.config.RequestConfig;
4
import org.apache.http.client.methods.CloseableHttpResponse;
5
import org.apache.http.client.methods.HttpUriRequest;
6
import org.apache.http.client.methods.RequestBuilder;
7
import org.apache.http.entity.StringEntity;
8
import org.apache.http.impl.client.BasicResponseHandler;
9
import org.apache.http.impl.client.CloseableHttpClient;
10
import org.apache.http.impl.client.HttpClients;
11
import com.google.common.hash.Hashing;
12
import org.eclipse.rdf4j.common.transaction.IsolationLevels;
13
import org.eclipse.rdf4j.model.ValueFactory;
14
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
15
import org.eclipse.rdf4j.repository.Repository;
16
import org.eclipse.rdf4j.repository.RepositoryConnection;
17
import org.eclipse.rdf4j.repository.base.RepositoryConnectionWrapper;
18
import org.eclipse.rdf4j.repository.http.HTTPRepository;
19
import org.nanopub.NanopubUtils;
20
import org.nanopub.vocabulary.NPA;
21
import org.slf4j.Logger;
22
import org.slf4j.LoggerFactory;
23

24
import java.io.BufferedReader;
25
import java.io.IOException;
26
import java.io.InputStreamReader;
27
import java.nio.charset.StandardCharsets;
28
import java.util.*;
29
import java.util.Map.Entry;
30
import java.util.concurrent.ConcurrentHashMap;
31
import java.util.concurrent.TimeUnit;
32
import java.util.concurrent.atomic.AtomicBoolean;
33
import java.util.concurrent.atomic.AtomicInteger;
34
import java.util.concurrent.locks.ReadWriteLock;
35
import java.util.concurrent.locks.ReentrantReadWriteLock;
36

37
/**
38
 * Class to access the database in the form of triple stores.
39
 */
40
public class TripleStore {
41

42
    /**
43
     * Name of the admin graph.
44
     */
45
    public static final String ADMIN_REPO = "admin";
46

47
    private static ValueFactory vf = SimpleValueFactory.getInstance();
6 ✔
48

49
    private static final Logger logger = LoggerFactory.getLogger(TripleStore.class);
9 ✔
50

51
    private final Map<String, Repository> repositories = new LinkedHashMap<>();
15 ✔
52

53
    /**
54
     * Per-repo open-connection counter, read by the eviction loop to skip repos that
55
     * still have live connections. Incremented in {@link #getRepoConnection(String)}
56
     * just before the connection is handed out and decremented via a
57
     * {@link RepositoryConnectionWrapper} that intercepts {@code close()} exactly once.
58
     */
59
    private final ConcurrentHashMap<String, AtomicInteger> openConnections = new ConcurrentHashMap<>();
15 ✔
60

61
    /**
62
     * Handles evicted from {@link #repositories} but not yet shut down. Eviction is
63
     * deferred and idle-only: calling {@link HTTPRepository#shutDown()} closes the
64
     * session manager and kills any live (possibly streaming) server-side transaction
65
     * with the {@code MMapIndexInput - Already closed} error, which can wedge the
66
     * underlying LMDB store on the server for <em>every</em> client. Instead of shutting
67
     * a handle down the instant its app-side connection count hits zero — a count that
68
     * can momentarily read zero between operations, or undercount a result set still
69
     * being streamed — we park it here and only shut it down from
70
     * {@link #reapPendingShutdowns()} once it has stayed idle for {@link #SHUTDOWN_GRACE_MS}.
71
     * Guarded by the {@code this} monitor, like {@link #repositories}.
72
     */
73
    private final Map<String, Repository> pendingShutdown = new LinkedHashMap<>();
15 ✔
74
    private final Map<String, Long> pendingShutdownSince = new HashMap<>();
15 ✔
75

76
    /** Idle grace period before a parked handle is actually shut down. */
77
    private static final long SHUTDOWN_GRACE_MS = 60_000;
78

79
    /**
80
     * Repos that are never evicted. The core named repos plus the view-critical type
81
     * repos: ResourceView and ViewDisplay are queried and federated into constantly by
82
     * the spaces/view UI, so they churn through the {@code cap 100} LRU fastest and were
83
     * the handles most exposed to the shutdown-during-use race. Pinning a handful of
84
     * them is cheap (a few always-warm LMDB envs) and removes the thrash on exactly the
85
     * repos that wedge.
86
     */
87
    private static final Set<String> PINNED_REPO_NAMES = buildPinnedRepoNames();
6 ✔
88

89
    private static Set<String> buildPinnedRepoNames() {
90
        Set<String> names = new HashSet<>(Arrays.asList("admin", "empty", "meta", "full", "spaces", "last30d"));
93 ✔
91
        for (String typeIri : new String[] {
75 ✔
92
                "https://w3id.org/kpxl/gen/terms/ResourceView",
93
                "https://w3id.org/kpxl/gen/terms/ViewDisplay" }) {
94
            names.add("type_" + Hashing.sha256().hashString(typeIri, StandardCharsets.UTF_8).toString());
27 ✔
95
        }
96
        return Collections.unmodifiableSet(names);
9 ✔
97
    }
98

99
    private String endpointBase = null;
9 ✔
100
    private String endpointType = null;
9 ✔
101

102
    private static volatile TripleStore tripleStoreInstance;
103

104
    /**
105
     * Creates the singleton instance. A field rather than a direct constructor call only
106
     * so that tests can substitute a slow factory to exercise concurrent first calls.
107
     */
108
    private static InstanceFactory instanceFactory = TripleStore::new;
9 ✔
109

110
    @FunctionalInterface
111
    interface InstanceFactory {
112
        TripleStore create() throws IOException;
113
    }
114

115
    /**
116
     * Returns singleton triple store instance.
117
     *
118
     * <p>Thread-safe: the HTTP server accepts requests before the loader thread starts, so
119
     * several threads make the first call at once. Before this was synchronized, each of
120
     * them could construct its own instance, and since the constructor creates the
121
     * {@code admin} repo and RDF4J answers concurrent create requests for a new repo with
122
     * 204 each, every extra instance on a fresh store wrote its own
123
     * {@code npa:hasRepoInitId} into {@code admin}. Every extra instance also leaked its
124
     * HTTP client and that client's idle-connection thread.
125
     *
126
     * @return Triple store instance
127
     */
128
    public static TripleStore get() {
129
        TripleStore instance = tripleStoreInstance;
6 ✔
130
        if (instance != null) {
6 !
131
            return instance;
×
132
        }
133
        synchronized (TripleStore.class) {
12 ✔
134
            if (tripleStoreInstance == null) {
6 ✔
135
                try {
136
                    tripleStoreInstance = instanceFactory.create();
9 ✔
137
                } catch (IOException ex) {
×
138
                    logger.info("Could not init TripleStore. ", ex);
×
139
                }
3 ✔
140
            }
141
            return tripleStoreInstance;
12 ✔
142
        }
143
    }
144

145
    private TripleStore() throws IOException {
6 ✔
146
        // Read directly from the JVM's in-memory environment block rather than via
147
        // Apache Commons Exec's EnvironmentUtils.getProcEnvironment(), which forks a
148
        // subprocess and can throw under memory pressure / a restart storm — leaving
149
        // the singleton uninitialised. Same fragile mechanism that caused issue #117
150
        // on the per-nanopub hot path (see Utils.getEnvString); retired here too.
151
        endpointBase = System.getenv("ENDPOINT_BASE");
12 ✔
152
        logger.info("Endpoint base: {}", endpointBase);
15 ✔
153
        endpointType = System.getenv("ENDPOINT_TYPE");
12 ✔
154

155
        getRepository("empty");  // Make sure empty repo exists
×
156
    }
×
157

158
    /**
159
     * Shared HTTP client for all RDF4J traffic. Apache HttpClient treats all requests
160
     * to a given host + port + protocol as one "route", so every `HTTPRepository` in
161
     * this process funnels through this single client's connection pool — plus the
162
     * two ad-hoc users in {@link #createRepo} and {@link #getRepositoryNames}, which
163
     * have been consolidated onto this client too.
164
     *
165
     * <p>The Apache defaults (maxPerRoute=2 / maxTotal=20) throttle four concurrent
166
     * loader-pool threads down to two-way parallelism at the HTTP layer — invisible
167
     * client-side serialisation the code is actively fighting. Raised to 10 / 40
168
     * here: comfortable headroom for the 4-thread loader pool plus admin-repo
169
     * transactions and the metrics tick, small enough to be a conservative first
170
     * step with room to grow later.
171
     *
172
     * <p>Timeouts set via {@code setDefaultRequestConfig}:
173
     * <ul>
174
     *   <li><b>socket-read = 60 s</b> — an individual HTTP response body must arrive
175
     *       within this window. Healthy per-nanopub commits complete in milliseconds,
176
     *       so 60 s is pure safety margin; its job is to turn the silent "threads
177
     *       parked forever inside a commit" wedge (observed in the April test) into
178
     *       a recoverable error that feeds the existing retry path.</li>
179
     *   <li><b>connection-request = 30 s</b> — a caller waiting for a pooled connection
180
     *       gives up after this long. Prevents the invisible self-deadlock in
181
     *       {@code loadInvalidateStatements} (one thread holding N connections while
182
     *       waiting for an (N+1)th) from hanging forever.</li>
183
     *   <li><b>connect = 10 s</b> — kills TCP handshakes that stall. Generous but
184
     *       bounded.</li>
185
     * </ul>
186
     * Without these defaults, HttpClient uses {@code -1} everywhere, which means
187
     * "wait forever".
188
     */
189
    private final CloseableHttpClient httpclient = HttpClients.custom()
9 ✔
190
            .setMaxConnPerRoute(10)
6 ✔
191
            .setMaxConnTotal(40)
3 ✔
192
            .setDefaultRequestConfig(RequestConfig.custom()
9 ✔
193
                    .setSocketTimeout(60_000)
6 ✔
194
                    .setConnectionRequestTimeout(30_000)
6 ✔
195
                    .setConnectTimeout(10_000)
3 ✔
196
                    .build())
3 ✔
197
            // Hygiene: kill pooled connections that RDF4J has quietly closed server-side
198
            // before we try to reuse them. Without this, a half-broken connection is
199
            // only noticed when the next request fails, spending the full socket-read
200
            // timeout discovering it.
201
            .evictExpiredConnections()
9 ✔
202
            .evictIdleConnections(30, TimeUnit.SECONDS)
3 ✔
203
            .build();
6 ✔
204

205
    @GeneratedFlagForDependentElements
206
    Repository getRepository(String name) {
207
        synchronized (this) {
208
            // Reap parked handles that have now been idle long enough, even when we're
209
            // not over cap, so a burst that parked some repos doesn't leave them warm
210
            // forever if load then drops. Cheap: early-returns when nothing is parked.
211
            reapPendingShutdowns();
212
            if (repositories.size() > 100) {
213
                evictIdleRepos();
214
            }
215
            if (repositories.containsKey(name)) {
216
                // Move to the end of the list:
217
                Repository repo = repositories.remove(name);
218
                repositories.put(name, repo);
219
            } else if (pendingShutdown.containsKey(name)) {
220
                // Needed again before its grace period elapsed: resurrect the parked
221
                // handle instead of shutting it down and re-creating, avoiding both the
222
                // re-init round-trip and any shutdown/use race.
223
                Repository repo = pendingShutdown.remove(name);
224
                pendingShutdownSince.remove(name);
225
                repositories.put(name, repo);
226
            } else {
227
                Repository repository = null;
228
                if (endpointType == null || endpointType.equals("rdf4j")) {
229
                    HTTPRepository hr = new HTTPRepository(endpointBase + "repositories/" + name);
230
                    hr.setHttpClient(httpclient);
231
                    repository = hr;
232
//                        } else if (endpointType.equals("virtuoso")) {
233
//                                repository = new VirtuosoRepository(endpointBase + name, username, password);
234
                } else {
235
                    throw new RuntimeException("Unknown repository type: " + endpointType);
236
                }
237
                repositories.put(name, repository);
238
                createRepo(name);
239
                getRepoConnection(name).close();
240
            }
241
            return repositories.get(name);
242
        }
243
    }
244

245
    /**
246
     * Return the repository connection for the given repository name.
247
     *
248
     * @param name repository name
249
     * @return repository connection
250
     */
251
    @GeneratedFlagForDependentElements
252
    public RepositoryConnection getRepoConnection(String name) {
253
        // The increment has to happen under the same monitor that guards eviction,
254
        // otherwise another thread could evict this repo in the window between
255
        // getRepository() returning and the counter going above zero. getConnection()
256
        // on HTTPRepository is local (it doesn't do HTTP), so holding the lock here
257
        // is cheap.
258
        synchronized (this) {
259
            Repository repo = getRepository(name);
260
            if (repo == null) {
261
                return null;
262
            }
263
            AtomicInteger counter = openConnections.computeIfAbsent(name, k -> new AtomicInteger());
×
264
            counter.incrementAndGet();
265
            try {
266
                return new CountingRepositoryConnection(repo, repo.getConnection(), counter);
267
            } catch (Throwable t) {
268
                // If getConnection() or the wrapper constructor throws after we bumped
269
                // the counter, decrement so the repo isn't pinned against eviction by
270
                // a phantom "active" connection it doesn't actually have.
271
                counter.decrementAndGet();
272
                throw t;
273
            }
274
        }
275
    }
276

277
    /**
278
     * Evicts the eldest cache entries until either the size is back within the
279
     * 100-entry cap or every remaining entry is pinned or has an open connection.
280
     * The cap is load-bearing — each cached entry keeps an LMDB environment alive
281
     * on the RDF4J server (in-memory cache, mmap pages, native memory), so
282
     * exceeding it by much risks server-side OOM.
283
     *
284
     * <p>Eviction is <em>non-destructive</em>: it only unlinks the handle from the
285
     * live cache into {@link #pendingShutdown}; it does <strong>not</strong> call
286
     * {@link org.eclipse.rdf4j.repository.http.HTTPRepository#shutDown()} here.
287
     * Shutting a handle down closes the session manager and kills any live transaction
288
     * on that repo with a connection-close error (the {@code MMapIndexInput – Already
289
     * closed} failure mode observed on query-3 in the April test), which can wedge the
290
     * server-side LMDB store for every client. The actual shutdown happens later in
291
     * {@link #reapPendingShutdowns()}, only after a repo has stayed provably idle for
292
     * {@link #SHUTDOWN_GRACE_MS}. Pinned and actively-used repos are skipped entirely.
293
     * The cap is still enforced immediately, because unlinked handles leave
294
     * {@link #repositories} right away.
295
     */
296
    /**
297
     * Current time in millis. A seam so tests can drive the eviction grace period
298
     * deterministically instead of sleeping.
299
     */
300
    long nowMillis() {
301
        return System.currentTimeMillis();
×
302
    }
303

304
    @GeneratedFlagForDependentElements
305
    void evictIdleRepos() {
306
        reapPendingShutdowns();
307
        List<String> skipped = new ArrayList<>();
308
        long now = nowMillis();
309
        Iterator<Entry<String, Repository>> iter = repositories.entrySet().iterator();
310
        while (iter.hasNext() && repositories.size() > 100) {
311
            Entry<String, Repository> e = iter.next();
312
            String name = e.getKey();
313
            if (PINNED_REPO_NAMES.contains(name)) {
314
                skipped.add(name);
315
                continue;
316
            }
317
            AtomicInteger active = openConnections.get(name);
318
            if (active != null && active.get() > 0) {
319
                skipped.add(name);
320
                continue;
321
            }
322
            iter.remove();
323
            // Park for deferred shutdown rather than shutting down in this hot path.
324
            pendingShutdown.put(name, e.getValue());
325
            pendingShutdownSince.put(name, now);
326
        }
327
        if (!skipped.isEmpty()) {
328
            logger.warn("Skipped eviction for {} active/pinned repo(s); cache size is now {} (cap 100). Active names: {}",
329
                    skipped.size(), repositories.size(), skipped);
330
        }
331
    }
332

333
    /**
334
     * Shuts down handles parked in {@link #pendingShutdown} that have stayed idle
335
     * ({@code openConnections == 0}) for at least {@link #SHUTDOWN_GRACE_MS}. The grace
336
     * period keeps eviction from killing a server-side transaction whose app-side
337
     * connection count merely dipped to zero between operations, or that is still
338
     * streaming a result set. A handle re-requested before it is reaped is resurrected
339
     * in {@link #getRepository(String)} and never shut down. Must be called under the
340
     * {@code this} monitor.
341
     */
342
    void reapPendingShutdowns() {
343
        if (pendingShutdown.isEmpty()) {
12 ✔
344
            return;
3 ✔
345
        }
346
        long now = nowMillis();
9 ✔
347
        Iterator<Entry<String, Repository>> iter = pendingShutdown.entrySet().iterator();
15 ✔
348
        while (iter.hasNext()) {
9 ✔
349
            Entry<String, Repository> e = iter.next();
12 ✔
350
            String name = e.getKey();
12 ✔
351
            AtomicInteger active = openConnections.get(name);
18 ✔
352
            Long since = pendingShutdownSince.get(name);
18 ✔
353
            if ((active == null || active.get() == 0) && since != null && now - since >= SHUTDOWN_GRACE_MS) {
42 !
354
                iter.remove();
6 ✔
355
                pendingShutdownSince.remove(name);
15 ✔
356
                logger.info("Shutting down idle repo (deferred): {}", name);
12 ✔
357
                try {
358
                    e.getValue().shutDown();
12 ✔
359
                } catch (Exception ex) {
×
360
                    logger.warn("Deferred shutdown failed for repo '{}': {}", name, ex.getMessage());
×
361
                }
3 ✔
362
                logger.info("Shutdown complete");
9 ✔
363
            }
364
        }
3 ✔
365
    }
3 ✔
366

367
    /**
368
     * Minimal wrapper around a delegate {@link RepositoryConnection} whose only job
369
     * is to decrement the per-repo open-connection counter exactly once when the
370
     * caller closes it. Uses {@link AtomicBoolean} so that repeated/idempotent
371
     * {@code close()} calls (common with try-with-resources plus explicit close)
372
     * don't decrement more than once.
373
     */
374
    private static final class CountingRepositoryConnection extends RepositoryConnectionWrapper {
375

376
        private final AtomicInteger counter;
377
        private final AtomicBoolean closed = new AtomicBoolean();
×
378

379
        CountingRepositoryConnection(Repository repo, RepositoryConnection delegate, AtomicInteger counter) {
380
            super(repo, delegate);
×
381
            this.counter = counter;
×
382
        }
×
383

384
        @Override
385
        public void close() {
386
            try {
387
                super.close();
×
388
            } finally {
389
                if (closed.compareAndSet(false, true)) {
×
390
                    counter.decrementAndGet();
×
391
                }
392
            }
393
        }
×
394

395
    }
396

397
    @GeneratedFlagForDependentElements
398
    private void createRepo(String repoName) {
399
        if (!repoName.equals(ADMIN_REPO)) {
400
            getRepository(ADMIN_REPO);  // make sure admin repo is loaded first
401
        }
402
        // Uses the shared this.httpclient rather than a per-call client so it inherits
403
        // the configured pool sizes (and, once change 1 of the fix plan lands, the
404
        // socket/connection-request timeouts).
405
        try {
406
            //logger.info("Trying to creating repo " + name);
407

408
            // TODO new syntax somehow doesn't work for the Lucene case:
409

410
//                        String createRegularRepoQueryString = "@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.\n"
411
//                                        + "@prefix config: <tag:rdf4j.org,2023:config/>.\n"
412
//                                        + "[] a config:Repository ;\n"
413
//                                        + "    config:rep.id \"" + name + "\" ;\n"
414
//                                        + "    rdfs:label \"" + name + " native store\" ;\n"
415
//                                        + "    config:rep.impl [\n"
416
//                                        + "        config:rep.type \"openrdf:SailRepository\" ;\n"
417
//                                        + "        config:sail.impl [\n"
418
//                                        + "            config:sail.type \"openrdf:NativeStore\" ;\n"
419
//                                        + "            config:sail.iterationCacheSyncThreshold \"10000\";\n"
420
//                                        + "            config:native.tripleIndexes \"spoc,posc,ospc,opsc,psoc,sopc,spoc,cpos,cosp,cops,cpso,csop\" ;\n"
421
//                                        + "            config:sail.defaultQueryEvaluationMode \"STANDARD\"\n"
422
//                                        + "        ]\n"
423
//                                        + "    ].";
424
//                        String createTextRepoQueryString = "@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.\n"
425
//                                        + "@prefix config: <tag:rdf4j.org,2023:config/>.\n"
426
//                                        + "[] a config:Repository ;\n"
427
//                                        + "    config:rep.id \"" + name + "\" ;\n"
428
//                                        + "    rdfs:label \"" + name + " native store\" ;\n"
429
//                                        + "    config:rep.impl [\n"
430
//                                        + "        config:rep.type \"openrdf:SailRepository\" ;\n"
431
//                                        + "        config:sail.impl [\n"
432
//                                        + "            config:sail.type \"openrdf:LuceneSail\" ;\n"
433
//                                        + "            config:sail.lucene.indexDir \"index/\" ;\n"
434
//                                        + "            config:delegate [\n"
435
//                                        + "                config:rep.type \"openrdf:SailRepository\" ;\n"
436
//                                        + "                config:sail.impl [\n"
437
//                                        + "                    config:sail.type \"openrdf:NativeStore\" ;\n"
438
//                                        + "                    config:sail.iterationCacheSyncThreshold \"10000\";\n"
439
//                                        + "                    config:native.tripleIndexes \"spoc,posc,ospc,opsc,psoc,sopc,spoc,cpos,cosp,cops,cpso,csop\" ;\n"
440
//                                        + "                    config:sail.defaultQueryEvaluationMode \"STANDARD\"\n"
441
//                                        + "                ]\n"
442
//                                        + "            ]\n"
443
//                                        + "        ]\n"
444
//                                        + "    ].";
445

446
            String indexTypes = "spoc,posc,ospc,cspo,cpos,cosp";
447
            if (repoName.startsWith("meta") || repoName.startsWith("text")) {
448
                indexTypes = "spoc,posc,ospc";
449
            }
450

451
            // valueEvictionInterval 0 disables LMDB's value-cache garbage collection.
452
            // Without it, rdf4j frees unused value IDs and REUSES them; a stale reference
453
            // then dereferences to a different value, which is the value-ID-remap
454
            // corruption we hit eight times between 2026-08-20 and 2026-08-31 (upstream
455
            // eclipse-rdf4j/rdf4j#5970). Crucially the eviction pass "is also run after
456
            // opening the database" (ValueStore.java), which explains why every one of
457
            // those events followed a restart of the affected store. Disabling it is the
458
            // workaround recommended upstream on 2026-08-31; it costs disk (dead values
459
            // are never reclaimed) but not memory — gcIds() returns early, so nothing
460
            // accumulates on the heap. Revisit once the upstream race is fixed.
461
            String createRegularRepoQueryString =
462
                    "@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.\n" +
463
                    "@prefix rep: <http://www.openrdf.org/config/repository#>.\n" +
464
                    "@prefix sr: <http://www.openrdf.org/config/repository/sail#>.\n" +
465
                    "@prefix sail: <http://www.openrdf.org/config/sail#>.\n" +
466
                    "@prefix sail-luc: <http://www.openrdf.org/config/sail/lucene#>.\n" +
467
                    "@prefix lmdb: <http://rdf4j.org/config/sail/lmdb#>.\n" +
468
                    "@prefix sb: <http://www.openrdf.org/config/sail/base#>.\n" +
469
                    "\n" +
470
                    "[] a rep:Repository ;\n" +
471
                    "    rep:repositoryID \"" + repoName + "\" ;\n" +
472
                    "    rdfs:label \"" + repoName + " LMDB store\" ;\n" +
473
                    "    rep:repositoryImpl [\n" +
474
                    "        rep:repositoryType \"openrdf:SailRepository\" ;\n" +
475
                    "        sr:sailImpl [\n" +
476
                    "            sail:sailType \"rdf4j:LmdbStore\" ;\n" +
477
                    "            sail:iterationCacheSyncThreshold \"10000\";\n" +
478
                    "            lmdb:tripleIndexes \"" + indexTypes + "\" ;\n" +
479
                    "            lmdb:valueEvictionInterval 0 ;\n" +
480
                    "            sb:defaultQueryEvaluationMode \"STANDARD\"\n" +
481
                    "        ]\n"
482
                    + "    ].\n";
483

484
            // TODO Index npa:hasFilterLiteral predicate too (see https://groups.google.com/g/rdf4j-users/c/epF4Af1jXGU):
485
            String createTextRepoQueryString =
486
                    "@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.\n" +
487
                    "@prefix rep: <http://www.openrdf.org/config/repository#>.\n" +
488
                    "@prefix sr: <http://www.openrdf.org/config/repository/sail#>.\n" +
489
                    "@prefix sail: <http://www.openrdf.org/config/sail#>.\n" +
490
                    "@prefix sail-luc: <http://www.openrdf.org/config/sail/lucene#>.\n" +
491
                    "@prefix lmdb: <http://rdf4j.org/config/sail/lmdb#>.\n" +
492
                    "@prefix sb: <http://www.openrdf.org/config/sail/base#>.\n" +
493
                    "\n"
494
                    + "[] a rep:Repository ;\n" +
495
                    "    rep:repositoryID \"" + repoName + "\" ;\n" +
496
                    "    rdfs:label \"" + repoName + " store\" ;\n" +
497
                    "    rep:repositoryImpl [\n" +
498
                    "        rep:repositoryType \"openrdf:SailRepository\" ;\n" +
499
                    "        sr:sailImpl [\n" +
500
                    "            sail:sailType \"openrdf:LuceneSail\" ;\n" +
501
                    "            sail-luc:indexDir \"index/\" ;\n" +
502
                    "            sail-luc:transactional false ;\n" +
503
                    "            sail:delegate [\n" +
504
                    "              sail:sailType \"rdf4j:LmdbStore\" ;\n" +
505
                    "              sail:iterationCacheSyncThreshold \"10000\";\n" +
506
                    "              lmdb:tripleIndexes \"" + indexTypes + "\" ;\n" +
507
                    "              lmdb:valueEvictionInterval 0 ;\n" +
508
                    "              sb:defaultQueryEvaluationMode \"STANDARD\"\n" +
509
                    "            ]\n" +
510
                    "        ]\n" +
511
                    "    ].";
512

513
            String createRepoQueryString = createRegularRepoQueryString;
514
            if (repoName.startsWith("text")) {
515
                createRepoQueryString = createTextRepoQueryString;
516
            }
517

518
            HttpUriRequest createRepoRequest = RequestBuilder.put().setUri(endpointBase + "repositories/" + repoName).addHeader("Content-Type", "text/turtle").setEntity(new StringEntity(createRepoQueryString)).build();
519

520
            // Response is try-with-resources'd so the connection is released back to
521
            // the shared pool rather than leaked.
522
            try (CloseableHttpResponse response = httpclient.execute(createRepoRequest)) {
523
                int statusCode = response.getStatusLine().getStatusCode();
524
                if (statusCode == 409) {
525
                    //logger.info("Already exists.");
526
                    getRepository(repoName).init();
527
                } else if (statusCode >= 200 && statusCode < 300) {
528
                    //logger.info("Successfully created.");
529
                    initNewRepo(repoName);
530
                } else {
531
                    logger.info("Status code: {}", response.getStatusLine().getStatusCode());
532
                    logger.info(response.getStatusLine().getReasonPhrase());
533
                    String handledResponse = new BasicResponseHandler().handleResponse(response);
534
                    logger.info("Response: {}", handledResponse);
535
                }
536
            }
537
        } catch (IOException ex) {
538
            logger.info("Could not create repo.", ex);
539
        }
540
    }
541

542
    /**
543
     * Sends shutdown signal to all repositories.
544
     */
545
    @GeneratedFlagForDependentElements
546
    public void shutdownRepositories() {
547
        for (Repository repo : repositories.values()) {
548
            if (repo != null && repo.isInitialized()) {
549
                repo.shutDown();
550
            }
551
        }
552
    }
553

554
    /**
555
     * Returns admin repo connection.
556
     *
557
     * @return repository connection to admin repository
558
     */
559
    @GeneratedFlagForDependentElements
560
    public RepositoryConnection getAdminRepoConnection() {
561
        return get().getRepoConnection(ADMIN_REPO);
562
    }
563

564
    private Set<String> cachedRepositoryNames = Set.of();
9 ✔
565
    private boolean repoNamesCacheValid = false;
9 ✔
566
    private final ReadWriteLock repoNamesCacheLock = new ReentrantReadWriteLock();
15 ✔
567

568
    /**
569
     * Returns set of all repository names.
570
     *
571
     * @return Repository name set
572
     */
573
    public Set<String> getRepositoryNames() {
574
        // See if the repository names are cached:
575
        final var readLock = repoNamesCacheLock.readLock();
12 ✔
576
        try {
577
            readLock.lock();
6 ✔
578
            if (repoNamesCacheValid) {
9 ✔
579
                return cachedRepositoryNames;
15 ✔
580
            }
581
        } finally {
582
            readLock.unlock();
6 ✔
583
        }
584

585
        // Not cached, get from server:
586
        final var writeLock = repoNamesCacheLock.writeLock();
12 ✔
587
        try {
588
            writeLock.lock();
6 ✔
589
            // Check again if another thread has already updated the cache:
590
            if (repoNamesCacheValid) {
9 !
591
                return cachedRepositoryNames;
×
592
            }
593
            Map<String, Boolean> repositoryNames = null;
6 ✔
594
            // Uses the shared this.httpclient; response try-with-resources releases
595
            // the pooled connection when done.
596
            try (CloseableHttpResponse resp = httpclient.execute(RequestBuilder.get()
24 ✔
597
                    .setUri(endpointBase + "/repositories")
9 ✔
598
                    .addHeader("Content-Type", "text/csv")
3 ✔
599
                    .build())) {
3 ✔
600
                BufferedReader reader = new BufferedReader(new InputStreamReader(resp.getEntity().getContent()));
30 ✔
601
                int code = resp.getStatusLine().getStatusCode();
12 ✔
602
                if (code < 200 || code >= 300) return null;
30 !
603
                repositoryNames = new HashMap<>();
12 ✔
604
                int lineCount = 0;
6 ✔
605
                while (true) {
606
                    String line = reader.readLine();
9 ✔
607
                    if (line == null) break;
9 ✔
608
                    if (lineCount > 0) {
6 ✔
609
                        String repoName = line.split(",")[1];
18 ✔
610
                        repositoryNames.put(repoName, true);
18 ✔
611
                    }
612
                    lineCount = lineCount + 1;
12 ✔
613
                }
3 ✔
614
            } catch (IOException ex) {
15 !
615
                logger.info("Could not get repository names.", ex);
12 ✔
616
                return null;
12 ✔
617
            }
3 ✔
618
            cachedRepositoryNames = repositoryNames.keySet();
12 ✔
619
            repoNamesCacheValid = true;
9 ✔
620
            return cachedRepositoryNames;
15 ✔
621
        } finally {
622
            writeLock.unlock();
6 ✔
623
        }
624
    }
625

626
    /**
627
     * Invalidates the repository names cache. Call this method when a repository is created or deleted.
628
     */
629
    private void invalidateRepositoryNamesCache() {
630
        final var writeLock = repoNamesCacheLock.writeLock();
×
631
        try {
632
            writeLock.lock();
×
633
            repoNamesCacheValid = false;
×
634
        } finally {
635
            writeLock.unlock();
×
636
        }
637
    }
×
638

639
    @GeneratedFlagForDependentElements
640
    private void initNewRepo(String repoName) {
641
        String repoInitId = new Random().nextLong() + "";
642
        getRepository(repoName).init();
643
        if (!repoName.equals("empty")) {
644
            RepositoryConnection conn = getRepoConnection(repoName);
645
            try (conn) {
646
                // Append-only writes to a repo nothing else references yet; see
647
                // NanopubLoader#repoWriteLocks for why SERIALIZABLE is avoided.
648
                conn.begin(IsolationLevels.READ_COMMITTED);
649
                conn.add(NPA.THIS_REPO, NPA.HAS_REPO_INIT_ID, vf.createLiteral(repoInitId), NPA.GRAPH);
650
                if (tracksNanopubCountAndChecksum(repoName)) {
651
                    conn.add(NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT, vf.createLiteral(0L), NPA.GRAPH);
652
                    conn.add(NPA.THIS_REPO, NPA.HAS_NANOPUB_CHECKSUM, vf.createLiteral(NanopubUtils.INIT_CHECKSUM), NPA.GRAPH);
653
                }
654
                if (repoName.startsWith("pubkey_") || repoName.startsWith("type_")) {
655
                    String h = repoName.replaceFirst("^[^_]+_", "");
656
                    conn.add(NPA.THIS_REPO, NPA.HAS_COVERAGE_ITEM, Utils.getObjectForHash(h), NPA.GRAPH);
657
                    conn.add(NPA.THIS_REPO, NPA.HAS_COVERAGE_HASH, vf.createLiteral(h), NPA.GRAPH);
658
                    conn.add(NPA.THIS_REPO, NPA.HAS_COVERAGE_FILTER, vf.createLiteral("_" + repoName), NPA.GRAPH);
659
                }
660
                conn.commit();
661
            }
662
            // Refresh repository names cache
663
            invalidateRepositoryNamesCache();
664
        }
665
    }
666

667
    /**
668
     * Whether the given repo participates in the cumulative nanopub-count / XOR-checksum
669
     * tracking. Repos that don't produce their own content-addressed population
670
     * skip the {@code npa:hasNanopubCount} and {@code npa:hasNanopubChecksum}
671
     * initial triples — leaving them at {@code 0} and the empty-XOR placeholder
672
     * forever would just be misleading. Currently excluded:
673
     * <ul>
674
     *   <li>{@code admin} — holds metadata only.</li>
675
     *   <li>{@code last30d} — content expires on a periodic cleanup.</li>
676
     *   <li>{@code trust} — holds derived trust state, mirrored from the registry.</li>
677
     *   <li>{@code spaces} — holds space-relevant raw nanopubs (a filtered projection
678
     *       of {@code full}) plus extraction triples; its XOR checksum over loaded
679
     *       trusty URIs would diverge from {@code full}'s without adding value.</li>
680
     * </ul>
681
     */
682
    private static boolean tracksNanopubCountAndChecksum(String repoName) {
683
        return !repoName.equals(ADMIN_REPO)
×
684
                && !repoName.equals("last30d")
×
685
                && !repoName.equals("trust")
×
686
                && !repoName.equals("spaces");
×
687
    }
688

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