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

knowledgepixels / nanopub-query / 30989925143

05 Aug 2026 08:39AM UTC coverage: 61.794% (+0.3%) from 61.54%
30989925143

push

github

web-flow
Merge pull request #161 from knowledgepixels/fix/loader-drop-serializable

perf(loader): serialise repo writes with a lock instead of SERIALIZABLE

664 of 1222 branches covered (54.34%)

Branch coverage included in aggregate %.

1961 of 3026 relevant lines covered (64.81%)

9.75 hits per line

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

70.62
src/main/java/com/knowledgepixels/query/NanopubLoader.java
1
package com.knowledgepixels.query;
2

3
import net.trustyuri.TrustyUriUtils;
4
import org.apache.http.client.HttpClient;
5
import org.apache.http.impl.client.HttpClientBuilder;
6
import org.eclipse.rdf4j.common.exception.RDF4JException;
7
import org.eclipse.rdf4j.common.transaction.IsolationLevels;
8
import org.eclipse.rdf4j.model.*;
9
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
10
import org.eclipse.rdf4j.model.vocabulary.DCTERMS;
11
import org.eclipse.rdf4j.model.vocabulary.RDFS;
12
import org.eclipse.rdf4j.query.BindingSet;
13
import org.eclipse.rdf4j.query.QueryLanguage;
14
import org.eclipse.rdf4j.query.TupleQuery;
15
import org.eclipse.rdf4j.query.TupleQueryResult;
16
import org.eclipse.rdf4j.repository.RepositoryConnection;
17
import org.eclipse.rdf4j.repository.RepositoryResult;
18
import org.nanopub.Nanopub;
19
import org.nanopub.NanopubUtils;
20
import org.nanopub.SimpleCreatorPattern;
21
import org.nanopub.SimpleTimestampPattern;
22
import org.nanopub.extra.security.KeyDeclaration;
23
import org.nanopub.extra.security.MalformedCryptoElementException;
24
import org.nanopub.extra.security.NanopubSignatureElement;
25
import org.nanopub.extra.security.SignatureUtils;
26
import org.nanopub.extra.server.GetNanopub;
27
import org.nanopub.extra.setting.IntroNanopub;
28
import org.nanopub.vocabulary.NP;
29
import org.nanopub.vocabulary.NPA;
30
import org.nanopub.vocabulary.NPX;
31
import org.nanopub.vocabulary.PAV;
32
import org.slf4j.Logger;
33
import org.slf4j.LoggerFactory;
34

35
import java.security.GeneralSecurityException;
36
import java.util.*;
37
import java.util.concurrent.*;
38
import java.util.concurrent.locks.ReentrantLock;
39
import java.util.function.Consumer;
40

41
/**
42
 * Utility class for loading nanopublications into the database.
43
 */
44
public class NanopubLoader {
45

46
    private static HttpClient httpClient;
47
    private static final ThreadPoolExecutor loadingPool = (ThreadPoolExecutor) Executors.newFixedThreadPool(4);
12✔
48

49
    /**
50
     * One write lock per repo, held for the whole read-modify-write of that repo's
51
     * nanopub count / XOR checksum chain.
52
     *
53
     * <p>This replaces {@code IsolationLevels.SERIALIZABLE} as the mechanism protecting the
54
     * chain, and it is a strictly local substitution: this process is the only writer of
55
     * those triples, so in-process mutual exclusion gives exactly what the serializable
56
     * transaction was buying. The chain invariant is unchanged.
57
     *
58
     * <p>Why bother: in RDF4J 5.3.x, {@code SailSourceBranch.derivedFromSerializable} sets a
59
     * branch-level {@code serializable} sink the first time <em>any</em> transaction on that
60
     * branch asks for a SERIALIZABLE-compatible level, and clears it only when the branch
61
     * closes. While it is set, every dataset opened on that branch — including ordinary read
62
     * queries at any isolation — is wrapped in an {@code ObservingSailDataset}, which on close
63
     * runs {@code compressChanges -> prepare -> sinkObserved -> Changeset.observeAll} while
64
     * holding the branch semaphore. Hashing observed {@code SimpleStatementPattern}s there is
65
     * expensive ({@code LmdbIRI.hashCode} goes to a synchronized {@code ValueStore} map), so
66
     * one writer can stall every reader of the repo.
67
     *
68
     * <p>Measured on kpxl 2026-08-05: a thread dump showed 171 threads parked on a single
69
     * {@code ReentrantLock} in {@code derivedFromSerializable} for the {@code full} repo, one
70
     * thread inside {@code observeAll}, and the whole servlet container unable to answer even
71
     * a 404 for a nonexistent repo. Dropping to SNAPSHOT leaves {@code serializable} null, so
72
     * no {@code ObservingSailDataset} is created, {@code observed} stays null and
73
     * {@code sinkObserved} early-returns.
74
     *
75
     * <p>Keyed by repo name, so writers to different repos never contend. Calls are never
76
     * nested across repos — the invalidator paths loop and call one repo at a time — so there
77
     * is no lock-ordering hazard.
78
     */
79
    private static final ConcurrentHashMap<String, ReentrantLock> repoWriteLocks = new ConcurrentHashMap<>();
12✔
80

81
    static ReentrantLock repoWriteLock(String repoName) {
82
        return repoWriteLocks.computeIfAbsent(repoName, k -> new ReentrantLock());
30✔
83
    }
84

85
    /**
86
     * Cached count of nanopubs ever loaded into the {@code meta} repo. Maintained
87
     * for {@link MainVerticle}'s {@code Nanopub-Query-Loaded-Nanopub-Count}
88
     * response header. Mirrors the persisted {@code npa:hasNanopubCount} triple
89
     * that {@link #loadNanopubToRepo} maintains; invalidations don't decrement
90
     * (they're recorded as separate {@code npx:invalidates} markers), so this
91
     * is a cumulative count including superseded/retracted nanopubs — matching
92
     * the registry-side {@code Nanopub-Registry-Nanopub-Count} semantics.
93
     *
94
     * <p>The {@code meta} repo (not {@code full}) is the source because the meta
95
     * task is submitted only after all other per-nanopub tasks succeed (see
96
     * {@link #executeLoading}), making it the authoritative "fully completed
97
     * loads" indicator. The {@code full} repo is feature-flagged and may be
98
     * disabled, in which case it cannot be the source. Populated lazily on first
99
     * read; bumped post-commit on each fresh meta load.
100
     */
101
    static volatile Long loadedNanopubCount = null;
6✔
102

103
    /**
104
     * Cached checksum of nanopubs ever loaded into the {@code meta} repo.
105
     * Maintained for {@link MainVerticle}'s
106
     * {@code Nanopub-Query-Loaded-Nanopub-Checksum} response header. Mirrors the
107
     * persisted {@code npa:hasNanopubChecksum} triple that {@link #loadNanopubToRepo}
108
     * maintains — an order-independent XOR over the trusty URIs of all loaded
109
     * nanopubs, Base64-encoded. Sourced from {@code meta} for the same reasons
110
     * as {@link #loadedNanopubCount}; the two fields are bumped together so the
111
     * count and checksum always describe the same point in the load sequence.
112
     */
113
    static volatile String loadedNanopubChecksum = null;
6✔
114

115
    /**
116
     * Retry budget for the five (with #71 merged: six) structurally identical
117
     * retry loops in this file. Previously the shape was flat {@code 10 s × 30} —
118
     * five minutes of constant hammering at RDF4J that did not help a slow server.
119
     * The new shape is bounded exponential backoff with ±50 % jitter:
120
     * {@code base = 1, 2, 4, 8, 16, 32, 60, 60 s} for attempts 1…8, each perturbed
121
     * by up to half its base value. Jitter prevents the 4-thread loadingPool from
122
     * retrying in lock-step after a shared RDF4J failure (GC pause / overload spike).
123
     * Worst-case wall time per failing task drops from ~35 min (post-change-1
124
     * timeouts × 30 flat retries) to ~11 min (8 retries × 60 s timeout + backoff
125
     * sleeps). This is the figure that sets the circuit-breaker trip time in
126
     * {@link JellyNanopubLoader}.
127
     */
128
    private static final int MAX_RETRIES = 8;
129
    private static final long[] BACKOFF_BASE_MS =
105✔
130
            {1_000L, 2_000L, 4_000L, 8_000L, 16_000L, 32_000L, 60_000L, 60_000L};
131

132
    /**
133
     * Returns the sleep delay in ms for the given 1-indexed retry attempt. Delay
134
     * is {@link #BACKOFF_BASE_MS}{@code [attempt-1]} perturbed by ±50 % uniform
135
     * jitter, clamped to be non-negative.
136
     *
137
     * @param attempt 1-indexed retry attempt number
138
     * @return the computed sleep delay in ms
139
     */
140
    static long computeBackoffMillis(int attempt) {
141
        long base = BACKOFF_BASE_MS[Math.min(attempt - 1, BACKOFF_BASE_MS.length - 1)];
×
142
        long jitter = ThreadLocalRandom.current().nextLong(base + 1) - base / 2;
×
143
        return Math.max(0L, base + jitter);
×
144
    }
145

146
    private Nanopub np;
147
    private NanopubSignatureElement el = null;
9✔
148
    private List<Statement> metaStatements = new ArrayList<>();
15✔
149
    private List<Statement> nanopubStatements = new ArrayList<>();
15✔
150
    private List<Statement> literalStatements = new ArrayList<>();
15✔
151
    private List<Statement> invalidateStatements = new ArrayList<>();
15✔
152
    private List<Statement> textStatements, allStatements, invalidatingStatements;
153
    private List<Statement> spaceExtractionStatements = new ArrayList<>();
15✔
154
    private Calendar timestamp = null;
9✔
155
    private Statement pubkeyStatement, pubkeyStatementX;
156
    private List<String> notes = new ArrayList<>();
15✔
157
    private boolean aborted = false;
9✔
158
    private static final Logger logger = LoggerFactory.getLogger(NanopubLoader.class);
9✔
159

160

161
    NanopubLoader(Nanopub np, long counter) {
6✔
162
        this.np = np;
9✔
163
        if (counter >= 0) {
12✔
164
            logger.info("Loading nanopub #{}: <{}>", counter, np.getUri());
24✔
165
        } else {
166
            logger.info("Loading nanopub: <{}>", np.getUri());
15✔
167
        }
168

169
        // TODO Ensure proper synchronization and DB rollbacks
170

171
        // TODO Check for null characters ("\0"), which can cause problems in Virtuoso.
172

173
        String ac = TrustyUriUtils.getArtifactCode(np.getUri().toString());
15✔
174
        if (!np.getHeadUri().toString().contains(ac) || !np.getAssertionUri().toString().contains(ac) || !np.getProvenanceUri().toString().contains(ac) || !np.getPubinfoUri().toString().contains(ac)) {
72!
175
            notes.add("could not load nanopub as not all graphs contained the artifact code");
×
176
            aborted = true;
×
177
            return;
×
178
        }
179

180
        try {
181
            el = SignatureUtils.getSignatureElement(np);
12✔
182
        } catch (MalformedCryptoElementException ex) {
×
183
            notes.add("Signature error");
×
184
        }
3✔
185
        if (!hasValidSignature(el)) {
12✔
186
            // Audit trail for the silent-false path: without this, an aborted nanopub
187
            // is invisible in the admin repo and only detectable as a gap between the
188
            // stream counter and the loaded count. See issue around RDF4J-instability
189
            // load gaps (full vs registry, meta vs full) where signature validation
190
            // returned false but no GeneralSecurityException was thrown.
191
            if (notes.isEmpty()) {
12!
192
                notes.add("Invalid signature");
15✔
193
            }
194
            aborted = true;
9✔
195
            return;
3✔
196
        }
197

198
        pubkeyStatement = vf.createStatement(np.getUri(), NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY, vf.createLiteral(el.getPublicKeyString()), NPA.GRAPH);
39✔
199
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasValidSignatureForPublicKey, FULL_PUBKEY, npa:graph, meta, full pubkey if signature is valid
200
        metaStatements.add(pubkeyStatement);
18✔
201
        pubkeyStatementX = vf.createStatement(np.getUri(), NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH, vf.createLiteral(Utils.createHash(el.getPublicKeyString())), NPA.GRAPH);
42✔
202
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasValidSignatureForPublicKeyHash, PUBKEY_HASH, npa:graph, meta, hex-encoded SHA256 hash if signature is valid
203
        metaStatements.add(pubkeyStatementX);
18✔
204

205
        if (el.getSigners().size() == 1) {  // > 1 is deprecated
18!
206
            metaStatements.add(vf.createStatement(np.getUri(), NPX.SIGNED_BY, el.getSigners().iterator().next(), NPA.GRAPH));
48✔
207
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:signedBy, SIGNER, npa:graph, meta, ID of signer
208
        }
209

210
        Set<IRI> subIris = new HashSet<>();
12✔
211
        Set<IRI> otherNps = new HashSet<>();
12✔
212
        Set<IRI> invalidated = new HashSet<>();
12✔
213
        Set<IRI> retracted = new HashSet<>();
12✔
214
        Set<IRI> superseded = new HashSet<>();
12✔
215
        String combinedLiterals = "";
6✔
216
        for (Statement st : NanopubUtils.getStatements(np)) {
33✔
217
            nanopubStatements.add(st);
15✔
218

219
            if (st.getPredicate().toString().contains(ac)) {
18!
220
                subIris.add(st.getPredicate());
×
221
            } else {
222
                IRI b = getBaseTrustyUri(st.getPredicate());
12✔
223
                if (b != null) {
6!
224
                    otherNps.add(b);
×
225
                }
226
            }
227
            if (st.getPredicate().equals(NPX.RETRACTS) && st.getObject() instanceof IRI) {
15!
228
                retracted.add((IRI) st.getObject());
×
229
            }
230
            if (st.getPredicate().equals(NPX.INVALIDATES) && st.getObject() instanceof IRI) {
15!
231
                invalidated.add((IRI) st.getObject());
×
232
            }
233
            if (st.getSubject().equals(np.getUri()) && st.getObject() instanceof IRI) {
30✔
234
                if (st.getPredicate().equals(NPX.SUPERSEDES)) {
15✔
235
                    superseded.add((IRI) st.getObject());
18✔
236
                }
237
                if (st.getObject().toString().matches(".*[^A-Za-z0-9\\-_]RA[A-Za-z0-9\\-_]{43}")) {
18✔
238
                    metaStatements.add(vf.createStatement(np.getUri(), st.getPredicate(), st.getObject(), NPA.NETWORK_GRAPH));
39✔
239
                    // @ADMIN-TRIPLE-TABLE@ NANOPUB1, RELATION, NANOPUB2, npa:networkGraph, meta, any inter-nanopub relation found in NANOPUB1
240
                }
241
                if (st.getContext().equals(np.getPubinfoUri())) {
18✔
242
                    if (st.getPredicate().equals(NPX.INTRODUCES) || st.getPredicate().equals(NPX.DESCRIBES) || st.getPredicate().equals(NPX.EMBEDS)) {
45!
243
                        metaStatements.add(vf.createStatement(np.getUri(), st.getPredicate(), st.getObject(), NPA.GRAPH));
39✔
244
                        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:introduces, THING, npa:graph, meta, when such a triple is present in pubinfo of NANOPUB
245
                        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:describes, THING, npa:graph, meta, when such a triple is present in pubinfo of NANOPUB
246
                        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:embeds, THING, npa:graph, meta, when such a triple is present in pubinfo of NANOPUB
247
                    }
248
                }
249
            }
250
            if (st.getSubject().toString().contains(ac)) {
18✔
251
                subIris.add((IRI) st.getSubject());
21✔
252
            } else {
253
                IRI b = getBaseTrustyUri(st.getSubject());
12✔
254
                if (b != null) {
6!
255
                    otherNps.add(b);
×
256
                }
257
            }
258
            if (st.getObject() instanceof IRI) {
12✔
259
                if (st.getObject().toString().contains(ac)) {
18✔
260
                    subIris.add((IRI) st.getObject());
21✔
261
                } else {
262
                    IRI b = getBaseTrustyUri(st.getObject());
12✔
263
                    if (b != null) {
6✔
264
                        otherNps.add(b);
12✔
265
                    }
266
                }
3✔
267
            } else {
268
                combinedLiterals += st.getObject().stringValue().replaceAll("\\s+", " ") + "\n";
27✔
269
//                                if (st.getSubject().equals(np.getUri()) && !st.getSubject().equals(HAS_FILTER_LITERAL)) {
270
//                                        literalStatements.add(vf.createStatement(np.getUri(), st.getPredicate(), st.getObject(), LITERAL_GRAPH));
271
//                                } else {
272
//                                        literalStatements.add(vf.createStatement(np.getUri(), HAS_LITERAL, st.getObject(), LITERAL_GRAPH));
273
//                                }
274
            }
275
        }
3✔
276
        subIris.remove(np.getUri());
15✔
277
        subIris.remove(np.getAssertionUri());
15✔
278
        subIris.remove(np.getProvenanceUri());
15✔
279
        subIris.remove(np.getPubinfoUri());
15✔
280
        for (IRI i : subIris) {
30✔
281
            metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_SUB_IRI, i, NPA.GRAPH));
33✔
282
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasSubIri, SUB_IRI, npa:graph, meta, for any IRI minted in the namespace of the NANOPUB
283
        }
3✔
284
        for (IRI i : otherNps) {
30✔
285
            metaStatements.add(vf.createStatement(np.getUri(), NPA.REFERS_TO_NANOPUB, i, NPA.NETWORK_GRAPH));
33✔
286
            // @ADMIN-TRIPLE-TABLE@ NANOPUB1, npa:refersToNanopub, NANOPUB2, npa:networkGraph, meta, generic inter-nanopub relation
287
        }
3✔
288
        for (IRI i : invalidated) {
18!
289
            invalidateStatements.add(vf.createStatement(np.getUri(), NPX.INVALIDATES, i, NPA.GRAPH));
×
290
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:invalidates, INVALIDATED_NANOPUB, npa:graph, meta, if the NANOPUB retracts or supersedes another nanopub
291
        }
×
292
        for (IRI i : retracted) {
18!
293
            invalidateStatements.add(vf.createStatement(np.getUri(), NPX.INVALIDATES, i, NPA.GRAPH));
×
294
            metaStatements.add(vf.createStatement(np.getUri(), NPX.RETRACTS, i, NPA.GRAPH));
×
295
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:retracts, RETRACTED_NANOPUB, npa:graph, meta, if the NANOPUB retracts another nanopub
296
        }
×
297
        for (IRI i : superseded) {
30✔
298
            invalidateStatements.add(vf.createStatement(np.getUri(), NPX.INVALIDATES, i, NPA.GRAPH));
33✔
299
            metaStatements.add(vf.createStatement(np.getUri(), NPX.SUPERSEDES, i, NPA.GRAPH));
33✔
300
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:supersedes, SUPERSEDED_NANOPUB, npa:graph, meta, if the NANOPUB supersedes another nanopub
301
        }
3✔
302

303
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_HEAD_GRAPH, np.getHeadUri(), NPA.GRAPH));
36✔
304
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasHeadGraph, HEAD_GRAPH, npa:graph, meta, direct link to the head graph of the NANOPUB
305
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_GRAPH, np.getHeadUri(), NPA.GRAPH));
36✔
306
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasGraph, GRAPH, npa:graph, meta, generic link to all four graphs of the given NANOPUB
307
        metaStatements.add(vf.createStatement(np.getUri(), NP.HAS_ASSERTION, np.getAssertionUri(), NPA.GRAPH));
36✔
308
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, np:hasAssertion, ASSERTION_GRAPH, npa:graph, meta, direct link to the assertion graph of the NANOPUB
309
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_GRAPH, np.getAssertionUri(), NPA.GRAPH));
36✔
310
        metaStatements.add(vf.createStatement(np.getUri(), NP.HAS_PROVENANCE, np.getProvenanceUri(), NPA.GRAPH));
36✔
311
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, np:hasProvenance, PROVENANCE_GRAPH, npa:graph, meta, direct link to the provenance graph of the NANOPUB
312
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_GRAPH, np.getProvenanceUri(), NPA.GRAPH));
36✔
313
        metaStatements.add(vf.createStatement(np.getUri(), NP.HAS_PUBINFO, np.getPubinfoUri(), NPA.GRAPH));
36✔
314
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, np:hasPublicationInfo, PUBINFO_GRAPH, npa:graph, meta, direct link to the pubinfo graph of the NANOPUB
315
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_GRAPH, np.getPubinfoUri(), NPA.GRAPH));
36✔
316

317
        String artifactCode = TrustyUriUtils.getArtifactCode(np.getUri().stringValue());
15✔
318
        metaStatements.add(vf.createStatement(np.getUri(), NPA.ARTIFACT_CODE, vf.createLiteral(artifactCode), NPA.GRAPH));
39✔
319
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:artifactCode, ARTIFACT_CODE, npa:graph, meta, artifact code starting with 'RA...'
320

321
        if (isIntroNanopub(np)) {
9✔
322
            IntroNanopub introNp = new IntroNanopub(np);
15✔
323
            metaStatements.add(vf.createStatement(np.getUri(), NPA.IS_INTRODUCTION_OF, introNp.getUser(), NPA.GRAPH));
36✔
324
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:isIntroductionOf, AGENT, npa:graph, meta, linking intro nanopub to the agent it is introducing
325
            for (KeyDeclaration kc : introNp.getKeyDeclarations()) {
33✔
326
                metaStatements.add(vf.createStatement(np.getUri(), NPA.DECLARES_PUBKEY, vf.createLiteral(kc.getPublicKeyString()), NPA.GRAPH));
42✔
327
                // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:declaresPubkey, FULL_PUBKEY, npa:graph, meta, full pubkey declared by the given intro NANOPUB
328
            }
3✔
329
        }
330

331
        try {
332
            timestamp = SimpleTimestampPattern.getCreationTime(np);
12✔
333
        } catch (IllegalArgumentException ex) {
×
334
            notes.add("Illegal date/time");
×
335
        }
3✔
336
        if (timestamp != null) {
9!
337
            metaStatements.add(vf.createStatement(np.getUri(), DCTERMS.CREATED, vf.createLiteral(timestamp.getTime()), NPA.GRAPH));
45✔
338
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, dct:created, CREATION_DATE, npa:graph, meta, normalized creation timestamp
339
        }
340

341
        String literalFilter = "_pubkey_" + Utils.createHash(el.getPublicKeyString());
18✔
342
        for (IRI typeIri : NanopubUtils.getTypes(np)) {
33✔
343
            metaStatements.add(vf.createStatement(np.getUri(), NPX.HAS_NANOPUB_TYPE, typeIri, NPA.GRAPH));
33✔
344
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:hasNanopubType, NANOPUB_TYPE, npa:graph, meta, type of NANOPUB
345
            literalFilter += " _type_" + Utils.createHash(typeIri);
15✔
346
        }
3✔
347
        String label = NanopubUtils.getLabel(np);
9✔
348
        if (label != null) {
6!
349
            metaStatements.add(vf.createStatement(np.getUri(), RDFS.LABEL, vf.createLiteral(label), NPA.GRAPH));
39✔
350
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, rdfs:label, LABEL, npa:graph, meta, label of NANOPUB
351
        }
352
        String description = NanopubUtils.getDescription(np);
9✔
353
        if (description != null) {
6✔
354
            metaStatements.add(vf.createStatement(np.getUri(), DCTERMS.DESCRIPTION, vf.createLiteral(description), NPA.GRAPH));
39✔
355
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, dct:description, LABEL, npa:graph, meta, description of NANOPUB
356
        }
357
        for (IRI creatorIri : SimpleCreatorPattern.getCreators(np)) {
33✔
358
            metaStatements.add(vf.createStatement(np.getUri(), DCTERMS.CREATOR, creatorIri, NPA.GRAPH));
33✔
359
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, dct:creator, CREATOR, npa:graph, meta, creator of NANOPUB (can be several)
360
        }
3✔
361
        for (IRI authorIri : SimpleCreatorPattern.getAuthors(np)) {
21!
362
            metaStatements.add(vf.createStatement(np.getUri(), PAV.AUTHORED_BY, authorIri, NPA.GRAPH));
×
363
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, pav:authoredBy, AUTHOR, npa:graph, meta, author of NANOPUB (can be several)
364
        }
×
365

366
        if (!combinedLiterals.isEmpty()) {
9!
367
            literalStatements.add(vf.createStatement(np.getUri(), NPA.HAS_FILTER_LITERAL, vf.createLiteral(literalFilter + "\n" + combinedLiterals), NPA.GRAPH));
45✔
368
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasFilterLiteral, FILTER_LITERAL, npa:graph, literal, auxiliary literal for filtering by type and pubkey in text repo
369
        }
370

371
        // Any statements that express that the currently processed nanopub is already invalidated:
372
        invalidatingStatements = getInvalidatingStatements(np.getUri());
15✔
373

374
        metaStatements.addAll(invalidateStatements);
18✔
375

376
        allStatements = new ArrayList<>(nanopubStatements);
21✔
377
        allStatements.addAll(metaStatements);
18✔
378
        allStatements.addAll(invalidatingStatements);
18✔
379

380
        textStatements = new ArrayList<>(literalStatements);
21✔
381
        textStatements.addAll(metaStatements);
18✔
382
        textStatements.addAll(invalidatingStatements);
18✔
383

384
        if (FeatureFlags.spacesEnabled()) {
6!
385
            IRI signedBy = (el.getSigners().size() == 1) ? el.getSigners().iterator().next() : null;
42!
386
            String pubkeyHash = Utils.createHash(el.getPublicKeyString());
15✔
387
            Date createdAt = (timestamp != null) ? timestamp.getTime() : null;
24!
388
            SpacesExtractor.Context ctx = new SpacesExtractor.Context(ac, signedBy, pubkeyHash, createdAt);
24✔
389
            spaceExtractionStatements = SpacesExtractor.extract(np, ctx);
15✔
390
        }
391
    }
3✔
392

393
    /**
394
     * Get the HTTP client used for fetching nanopublications.
395
     *
396
     * @return the HTTP client
397
     */
398
    static HttpClient getHttpClient() {
399
        if (httpClient == null) {
6✔
400
            httpClient = HttpClientBuilder.create().setDefaultRequestConfig(Utils.getHttpRequestConfig()).build();
15✔
401
        }
402
        return httpClient;
6✔
403
    }
404

405
    /**
406
     * Load the given nanopublication into the database.
407
     *
408
     * @param nanopubUri Nanopublication identifier (URI)
409
     */
410
    public static void load(String nanopubUri) {
411
        if (isNanopubLoaded(nanopubUri)) {
9!
412
            logger.info("Skipping already-loaded nanopub: <{}>", nanopubUri);
×
413
        } else {
414
            Nanopub np = GetNanopub.get(nanopubUri, getHttpClient());
12✔
415
            load(np, -1);
9✔
416
        }
417
    }
3✔
418

419
    /**
420
     * Load a nanopub into the database.
421
     *
422
     * @param np      the nanopub to load
423
     * @param counter the load counter, only used for logging (or -1 if not known)
424
     * @throws RDF4JException if the loading fails
425
     */
426
    public static void load(Nanopub np, long counter) throws RDF4JException {
427
        NanopubLoader loader = new NanopubLoader(np, counter);
18✔
428
        loader.executeLoading();
6✔
429
    }
3✔
430

431
    @GeneratedFlagForDependentElements
432
    private void executeLoading() {
433
        var runningTasks = new ArrayList<Future<?>>();
434
        Consumer<Runnable> runTask = t -> runningTasks.add(loadingPool.submit(t));
×
435

436
        for (String note : notes) {
437
            loadNoteToRepo(np.getUri(), note);
438
        }
439

440
        if (!aborted) {
441
            // Submit all tasks except the "meta" task
442
            if (timestamp != null) {
443
                if (new Date().getTime() - timestamp.getTimeInMillis() < THIRTY_DAYS) {
444
                    if (FeatureFlags.last30dRepoEnabled()) {
445
                        runTask.accept(() -> loadNanopubToLatest(np.getUri(), allStatements));
×
446
                    }
447
                }
448
            }
449

450
            if (FeatureFlags.textRepoEnabled()) {
451
                runTask.accept(() -> loadNanopubToRepo(np.getUri(), textStatements, "text"));
×
452
            }
453
            if (FeatureFlags.fullRepoEnabled()) {
454
                runTask.accept(() -> loadNanopubToRepo(np.getUri(), allStatements, "full"));
×
455
            }
456
            // Note: "meta" task is deferred until all other tasks complete successfully
457

458
            runTask.accept(() -> loadNanopubToRepo(np.getUri(), allStatements, "pubkey_" + Utils.createHash(el.getPublicKeyString())));
×
459
            //                loadNanopubToRepo(np.getUri(), textStatements, "text-pubkey_" + Utils.createHash(el.getPublicKeyString()));
460
            for (IRI typeIri : NanopubUtils.getTypes(np)) {
461
                // Exclude locally minted IRIs:
462
                if (typeIri.stringValue().startsWith(np.getUri().stringValue())) {
463
                    continue;
464
                }
465
                if (!typeIri.stringValue().matches("https?://.*")) {
466
                    continue;
467
                }
468
                runTask.accept(() -> loadNanopubToRepo(np.getUri(), allStatements, "type_" + Utils.createHash(typeIri)));
×
469
                //                        loadNanopubToRepo(np.getUri(), textStatements, "text-type_" + Utils.createHash(typeIri));
470
            }
471
            //                for (IRI creatorIri : SimpleCreatorPattern.getCreators(np)) {
472
            //                        // Exclude locally minted IRIs:
473
            //                        if (creatorIri.stringValue().startsWith(np.getUri().stringValue())) continue;
474
            //                        if (!creatorIri.stringValue().matches("https?://.*")) continue;
475
            //                        loadNanopubToRepo(np.getUri(), allStatements, "user_" + Utils.createHash(creatorIri));
476
            //                        loadNanopubToRepo(np.getUri(), textStatements, "text-user_" + Utils.createHash(creatorIri));
477
            //                }
478
            //                for (IRI authorIri : SimpleCreatorPattern.getAuthors(np)) {
479
            //                        // Exclude locally minted IRIs:
480
            //                        if (authorIri.stringValue().startsWith(np.getUri().stringValue())) continue;
481
            //                        if (!authorIri.stringValue().matches("https?://.*")) continue;
482
            //                        loadNanopubToRepo(np.getUri(), allStatements, "user_" + Utils.createHash(authorIri));
483
            //                        loadNanopubToRepo(np.getUri(), textStatements, "text-user_" + Utils.createHash(authorIri));
484
            //                }
485

486
            // Write to the spaces repo only when the nanopub carries its own space-relevant
487
            // extractions. Invalidators of space-relevant nanopubs are propagated to spaces
488
            // symmetrically below (forward path in loadInvalidateStatements, reverse path in
489
            // the invalidatorPubkeys block) — mirrors the per-type-repo propagation added in
490
            // PR #103 (commit 09eeb32). The materialiser's invalidation joins
491
            // (?invNp npx:invalidates ?np + ?invNp npa:hasLoadNumber ?ln in the spaces repo's
492
            // npa:graph) stay populated because the invalidators that matter are exactly the
493
            // ones we propagate.
494
            boolean thisNpIsSpaceRelevant = FeatureFlags.spacesEnabled() && !spaceExtractionStatements.isEmpty();
495
            if (thisNpIsSpaceRelevant) {
496
                runTask.accept(() -> loadToSpacesRepo(np.getUri(), allStatements, spaceExtractionStatements));
×
497
            }
498

499
            for (Statement st : invalidateStatements) {
500
                runTask.accept(() -> loadInvalidateStatements(np, el.getPublicKeyString(), st, pubkeyStatement, pubkeyStatementX, allStatements));
×
501
            }
502

503
            // Reverse-order symmetry: when retractors were loaded before this nanopub,
504
            // getInvalidatingStatements (in the constructor) captured their
505
            // `npx:invalidates` markers into invalidatingStatements. Mirror what
506
            // loadInvalidateStatements does in the forward case — load each retractor's
507
            // full content into this nanopub's per-type repos (those types the
508
            // retractor doesn't itself carry), sourced from the retractor's per-pubkey
509
            // repo (the one shard guaranteed to be populated for every successfully
510
            // loaded nanopub). When this nanopub is space-relevant, additionally load
511
            // each retractor into the spaces repo so the materialiser's invalidation
512
            // join sees them regardless of load order.
513
            Map<IRI, String> invalidatorPubkeys = collectInvalidatorPubkeys(invalidatingStatements);
514
            if (!invalidatorPubkeys.isEmpty()) {
515
                Set<IRI> thisNpTypes = NanopubUtils.getTypes(np);
516
                for (Map.Entry<IRI, String> e : invalidatorPubkeys.entrySet()) {
517
                    IRI invIri = e.getKey();
518
                    String invPubkey = e.getValue();
519
                    runTask.accept(() -> loadInvalidatorIntoTypeRepos(invIri, invPubkey, np.getUri(), thisNpTypes));
×
520
                    if (thisNpIsSpaceRelevant) {
521
                        runTask.accept(() -> loadInvalidatorIntoSpacesRepo(invIri, invPubkey, np.getUri()));
×
522
                    }
523
                }
524
            }
525

526
            // Wait for all non-meta tasks to complete successfully before submitting the meta task.
527
            // On failure, cancel the remaining futures so orphaned tasks don't keep running in the
528
            // shared loadingPool and race with the next batch retry (which re-submits the same
529
            // nanopub against the same repos).
530
            for (var task : runningTasks) {
531
                try {
532
                    task.get();
533
                } catch (ExecutionException | InterruptedException ex) {
534
                    for (var t : runningTasks) {
535
                        if (!t.isDone()) {
536
                            t.cancel(true);
537
                        }
538
                    }
539
                    throw new RuntimeException("Error in nanopub loading thread", ex.getCause());
540
                }
541
            }
542

543
            // Now submit and wait for the "meta" task after all other tasks have completed successfully
544
            Future<?> metaTask = loadingPool.submit(() -> loadNanopubToRepo(np.getUri(), metaStatements, "meta"));
×
545
            try {
546
                metaTask.get();
547
            } catch (ExecutionException | InterruptedException ex) {
548
                throw new RuntimeException("Error in nanopub loading thread (meta task)", ex.getCause());
549
            }
550
        }
551
    }
552

553
    private static Long lastUpdateOfLatestRepo = null;
6✔
554
    private static long THIRTY_DAYS = 1000L * 60 * 60 * 24 * 30;
6✔
555
    private static long ONE_HOUR = 1000L * 60 * 60;
6✔
556

557
    @GeneratedFlagForDependentElements
558
    private static void loadNanopubToLatest(IRI npId, List<Statement> statements) {
559
        boolean success = false;
560
        int retries = 0;
561
        while (!success) {
562
            RepositoryConnection conn = TripleStore.get().getRepoConnection("last30d");
563
            try (conn) {
564
                // Read committed, because deleting old nanopubs is idempotent. Inserts do not collide
565
                // with deletes, because we are not inserting old nanopubs.
566
                conn.begin(IsolationLevels.READ_COMMITTED);
567
                conn.add(statements);
568
                if (lastUpdateOfLatestRepo == null || new Date().getTime() - lastUpdateOfLatestRepo > ONE_HOUR) {
569
                    logger.debug("Pruning nanopubs older than 30 days from last30d repo...");
570
                    Literal thirtyDaysAgo = vf.createLiteral(new Date(new Date().getTime() - THIRTY_DAYS));
571
                    TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * { graph <" + NPA.GRAPH + "> { " + "?np <" + DCTERMS.CREATED + "> ?date . " + "filter ( ?date < ?thirtydaysago ) " + "} }");
572
                    q.setBinding("thirtydaysago", thirtyDaysAgo);
573
                    try (TupleQueryResult r = q.evaluate()) {
574
                        while (r.hasNext()) {
575
                            BindingSet b = r.next();
576
                            IRI oldNpId = (IRI) b.getBinding("np").getValue();
577
                            logger.debug("Pruning expired nanopub from last30d repo: <{}>", oldNpId);
578
                            for (Value v : Utils.getObjectsForPattern(conn, NPA.GRAPH, oldNpId, NPA.HAS_GRAPH)) {
579
                                // Remove all four nanopub graphs:
580
                                conn.remove((Resource) null, (IRI) null, (Value) null, (IRI) v);
581
                            }
582
                            // Remove nanopubs in admin graphs:
583
                            conn.remove(oldNpId, null, null, NPA.GRAPH);
584
                            conn.remove(oldNpId, null, null, NPA.NETWORK_GRAPH);
585
                        }
586
                    }
587
                    lastUpdateOfLatestRepo = new Date().getTime();
588
                }
589
                conn.commit();
590
                success = true;
591
            } catch (Exception ex) {
592
                logger.warn("Failed to load nanopub <{}> to last30d repo: {}", npId, ex.getMessage(), ex);
593
                if (conn.isActive()) {
594
                    conn.rollback();
595
                }
596
            }
597
            if (!success) {
598
                retries++;
599
                if (retries >= MAX_RETRIES) {
600
                    throw new RuntimeException("Failed to load nanopub " + npId + " to last30d repo after " + MAX_RETRIES + " retries");
601
                }
602
                long delay = computeBackoffMillis(retries);
603
                logger.info("Retrying load of <{}> to last30d repo in {} ms (attempt {}/{})...", npId, delay, retries, MAX_RETRIES);
604
                try {
605
                    Thread.sleep(delay);
606
                } catch (InterruptedException x) {
607
                    Thread.currentThread().interrupt();
608
                }
609
            }
610
        }
611
    }
612

613
    @GeneratedFlagForDependentElements
614
    private static void loadNanopubToRepo(IRI npId, List<Statement> statements, String repoName) {
615
        boolean success = false;
616
        int retries = 0;
617
        while (!success) {
618
            // The count/checksum chain must not suffer write skew, so this read-modify-write
619
            // is serialised — by repoWriteLock rather than by a SERIALIZABLE transaction. This
620
            // process is the only writer of those triples, so the guarantee is the same; see
621
            // repoWriteLocks for why the isolation level is the expensive way to buy it.
622
            // Held across the whole transaction, released before any retry back-off.
623
            ReentrantLock repoLock = repoWriteLock(repoName);
624
            repoLock.lock();
625
            try {
626
                RepositoryConnection conn = TripleStore.get().getRepoConnection(repoName);
627
                long newCountForCache = -1;
628
                String newChecksumForCache = null;
629
                try (conn) {
630
                    conn.begin(IsolationLevels.SNAPSHOT);
631
                    var repoStatus = fetchRepoStatus(conn, npId);
632
                    if (repoStatus.isLoaded) {
633
                        // INFO, not DEBUG: this skip decides that a shard write is unnecessary
634
                        // based on a single store read. When the backend misbehaves (issue #139:
635
                        // a shard "successfully" written yet not durable), this line is the only
636
                        // trace distinguishing a false skip from a lost commit.
637
                        logger.info("Skipping already-loaded nanopub <{}> in repo '{}'", npId, repoName);
638
                    } else {
639
                        String newChecksum = NanopubUtils.updateXorChecksum(npId, repoStatus.checksum);
640
                        conn.remove(NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT, null, NPA.GRAPH);
641
                        conn.remove(NPA.THIS_REPO, NPA.HAS_NANOPUB_CHECKSUM, null, NPA.GRAPH);
642
                        conn.add(NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT, vf.createLiteral(repoStatus.count + 1), NPA.GRAPH);
643
                        // @ADMIN-TRIPLE-TABLE@ REPO, npa:hasNanopubCount, NANOPUB_COUNT, npa:graph, admin, number of nanopubs loaded
644
                        conn.add(NPA.THIS_REPO, NPA.HAS_NANOPUB_CHECKSUM, vf.createLiteral(newChecksum), NPA.GRAPH);
645
                        // @ADMIN-TRIPLE-TABLE@ REPO, npa:hasNanopubChecksum, NANOPUB_CHECKSUM, npa:graph, admin, checksum of all loaded nanopubs (order-independent XOR checksum on trusty URIs in Base64 notation)
646
                        conn.add(npId, NPA.HAS_LOAD_NUMBER, vf.createLiteral(repoStatus.count), NPA.GRAPH);
647
                        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasLoadNumber, LOAD_NUMBER, npa:graph, admin, the sequential number at which this NANOPUB was loaded
648
                        conn.add(npId, NPA.HAS_LOAD_CHECKSUM, vf.createLiteral(newChecksum), NPA.GRAPH);
649
                        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasLoadChecksum, LOAD_CHECKSUM, npa:graph, admin, the checksum of all loaded nanopubs after loading the given NANOPUB
650
                        conn.add(npId, NPA.HAS_LOAD_TIMESTAMP, vf.createLiteral(new Date()), NPA.GRAPH);
651
                        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasLoadTimestamp, LOAD_TIMESTAMP, npa:graph, admin, the time point at which this NANOPUB was loaded
652
                        conn.add(statements);
653
                        if ("meta".equals(repoName)) {
654
                            newCountForCache = repoStatus.count + 1;
655
                            newChecksumForCache = newChecksum;
656
                        }
657
                    }
658
                    conn.commit();
659
                    if (newCountForCache >= 0) {
660
                        loadedNanopubCount = newCountForCache;
661
                    }
662
                    if (newChecksumForCache != null) {
663
                        loadedNanopubChecksum = newChecksumForCache;
664
                    }
665
                    success = true;
666
                } catch (Exception ex) {
667
                    logger.warn("Failed to load nanopub <{}> to repo '{}': {}", npId, repoName, ex.getMessage(), ex);
668
                    if (conn.isActive()) {
669
                        conn.rollback();
670
                    }
671
                }
672
            } finally {
673
                repoLock.unlock();
674
            }
675
            if (!success) {
676
                retries++;
677
                if (retries >= MAX_RETRIES) {
678
                    throw new RuntimeException("Failed to load nanopub " + npId + " to repo " + repoName + " after " + MAX_RETRIES + " retries");
679
                }
680
                long delay = computeBackoffMillis(retries);
681
                logger.info("Retrying load of <{}> to repo '{}' in {} ms (attempt {}/{})...", npId, repoName, delay, retries, MAX_RETRIES);
682
                try {
683
                    Thread.sleep(delay);
684
                } catch (InterruptedException x) {
685
                    Thread.currentThread().interrupt();
686
                }
687
            }
688
        }
689
    }
690

691
    /**
692
     * Writes the raw nanopub statements (all four graphs) into the {@code spaces}
693
     * repo alongside the pre-computed extraction statements (which target
694
     * {@code npa:spacesGraph}). Stamps the load-number on the nanopub IRI and bumps
695
     * {@code npa:thisRepo npa:currentLoadCounter} in {@code npa:graph}, all within
696
     * one serializable transaction.
697
     *
698
     * <p>Idempotent: if the nanopub already has a {@code npa:hasLoadNumber} stamp in
699
     * {@code npa:graph} of the {@code spaces} repo, this is a no-op.
700
     *
701
     * @param npId            nanopub IRI
702
     * @param nanopubTriples  raw nanopub statements (all four graphs + meta)
703
     * @param spaceExtraction summary triples destined for {@code npa:spacesGraph}
704
     */
705
    @GeneratedFlagForDependentElements
706
    private static void loadToSpacesRepo(IRI npId, List<Statement> nanopubTriples,
707
                                         List<Statement> spaceExtraction) {
708
        boolean success = false;
709
        int retries = 0;
710
        while (!success) {
711
            // Same substitution as loadNanopubToRepo: the spaces load counter is a
712
            // read-modify-write, serialised by repoWriteLock instead of by the isolation
713
            // level. NOTE: the spaces branch is also written by AuthorityResolver, which
714
            // still uses SERIALIZABLE — so the ObservingSailDataset cost described on
715
            // repoWriteLocks is not yet gone for this repo. Changing that is a separate
716
            // step; this keeps the two loader paths consistent in the meantime.
717
            ReentrantLock repoLock = repoWriteLock("spaces");
718
            repoLock.lock();
719
            try {
720
                RepositoryConnection conn = TripleStore.get().getRepoConnection("spaces");
721
                try (conn) {
722
                    conn.begin(IsolationLevels.SNAPSHOT);
723
                    // Idempotency: skip if this nanopub is already stamped in this repo.
724
                    if (Utils.getObjectForPattern(conn, NPA.GRAPH, npId, NPA.HAS_LOAD_NUMBER) != null) {
725
                        // INFO for the same reason as the loadNanopubToRepo skip (issue #139).
726
                        logger.info("Skipping already-loaded nanopub <{}> in spaces repo", npId);
727
                        conn.commit();
728
                        success = true;
729
                        continue;
730
                    }
731
                    long newCounter = fetchSpacesLoadCounter(conn) + 1;
732
                    conn.remove(NPA.THIS_REPO,
733
                            com.knowledgepixels.query.vocabulary.SpacesVocab.CURRENT_LOAD_COUNTER,
734
                            null, NPA.GRAPH);
735
                    conn.add(NPA.THIS_REPO,
736
                            com.knowledgepixels.query.vocabulary.SpacesVocab.CURRENT_LOAD_COUNTER,
737
                            vf.createLiteral(newCounter), NPA.GRAPH);
738
                    conn.add(npId, NPA.HAS_LOAD_NUMBER, vf.createLiteral(newCounter), NPA.GRAPH);
739
                    conn.add(nanopubTriples);
740
                    conn.add(spaceExtraction);
741
                    conn.commit();
742
                    success = true;
743
                } catch (Exception ex) {
744
                    logger.warn("Failed to load nanopub <{}> to spaces repo: {}", npId, ex.getMessage(), ex);
745
                    if (conn.isActive()) {
746
                        conn.rollback();
747
                    }
748
                }
749
            } finally {
750
                repoLock.unlock();
751
            }
752
            if (!success) {
753
                retries++;
754
                if (retries >= MAX_RETRIES) {
755
                    throw new RuntimeException("Failed to load nanopub " + npId + " to spaces repo after " + MAX_RETRIES + " retries");
756
                }
757
                long delay = computeBackoffMillis(retries);
758
                logger.info("Retrying load of <{}> to spaces repo in {} ms (attempt {}/{})...", npId, delay, retries, MAX_RETRIES);
759
                try {
760
                    Thread.sleep(delay);
761
                } catch (InterruptedException x) {
762
                    Thread.currentThread().interrupt();
763
                }
764
            }
765
        }
766
    }
767

768
    /**
769
     * Returns the cumulative count of nanopubs ever loaded into the {@code meta}
770
     * repo, or {@code null} if the value cannot be determined (e.g. the store
771
     * hasn't been initialised yet). Reads the persisted {@code npa:hasNanopubCount}
772
     * triple on first call and caches it in {@link #loadedNanopubCount};
773
     * subsequent fresh loads update the cache in-place.
774
     *
775
     * <p><b>Blocks</b> on a cold cache — use {@link #getCachedLoadedNanopubCount()}
776
     * from the event loop.
777
     */
778
    public static Long getLoadedNanopubCount() {
779
        Long v = loadedNanopubCount;
6✔
780
        if (v != null) {
6✔
781
            return v;
6✔
782
        }
783
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection("meta")) {
12✔
784
            Value val = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT);
×
785
            if (val != null) {
×
786
                v = Long.parseLong(val.stringValue());
×
787
                loadedNanopubCount = v;
×
788
                return v;
×
789
            }
790
        } catch (NumberFormatException ex) {
×
791
            logger.warn("Malformed npa:hasNanopubCount literal in meta repo (value not parseable as long): {}", ex.getMessage(), ex);
×
792
        } catch (Exception ex) {
3✔
793
            logger.warn("Could not read npa:hasNanopubCount from meta repo", ex);
12✔
794
        }
×
795
        return null;
6✔
796
    }
797

798
    /**
799
     * The cached loaded-nanopub count, without the store read that
800
     * {@link #getLoadedNanopubCount()} falls back to, or {@code null} if nothing has
801
     * populated it yet.
802
     *
803
     * <p>For callers that must not block — specifically
804
     * {@link MainVerticle#applyGlobalHeaders}, which runs on the Vert.x event loop
805
     * for every inbound request. The lazy fallback does blocking HTTP, and on a cold
806
     * cache (i.e. after every restart) that stalled the event loop until the store
807
     * answered; Vert.x's BlockedThreadChecker fired repeatedly on 2026-07-31. Worse,
808
     * with the store unreachable it would have blocked every request for the full
809
     * 10 s connect timeout, turning an RDF4J outage into a total outage of the HTTP
810
     * layer — including the status headers used to diagnose it.
811
     *
812
     * <p>Kept warm off the event loop by {@link #primeHeaderCaches()}.
813
     *
814
     * @return the cached count, or null if not yet known
815
     */
816
    public static Long getCachedLoadedNanopubCount() {
817
        return loadedNanopubCount;
6✔
818
    }
819

820
    /**
821
     * The cached loaded-nanopub checksum, without the store read that
822
     * {@link #getLoadedNanopubChecksum()} falls back to, or {@code null} if nothing
823
     * has populated it yet. Same event-loop rationale as
824
     * {@link #getCachedLoadedNanopubCount()}.
825
     *
826
     * @return the cached checksum, or null if not yet known
827
     */
828
    public static String getCachedLoadedNanopubChecksum() {
829
        return loadedNanopubChecksum;
6✔
830
    }
831

832
    /**
833
     * Populates the caches read by {@link #getCachedLoadedNanopubCount()} and
834
     * {@link #getCachedLoadedNanopubChecksum()}, going to the store if they are cold.
835
     *
836
     * <p>Both values are otherwise only refreshed when a nanopub is actually loaded,
837
     * so an instance that starts up already caught up would never populate them and
838
     * would serve those headers empty forever. Something off the event loop has to
839
     * prime them; {@link MetricsCollector#updateMetrics()} does, on its own executor.
840
     *
841
     * <p><b>Blocks.</b> Never call from the Vert.x event loop.
842
     */
843
    public static void primeHeaderCaches() {
844
        getLoadedNanopubCount();
6✔
845
        getLoadedNanopubChecksum();
6✔
846
    }
3✔
847

848
    /**
849
     * Returns the order-independent XOR checksum (Base64-encoded) of trusty URIs
850
     * of all nanopubs ever loaded into the {@code meta} repo, or {@code null} if
851
     * the value cannot be determined (e.g. the store hasn't been initialised
852
     * yet). Reads the persisted {@code npa:hasNanopubChecksum} triple on first
853
     * call and caches it in {@link #loadedNanopubChecksum}; subsequent fresh
854
     * loads update the cache in-place alongside {@link #loadedNanopubCount}.
855
     *
856
     * <p><b>Blocks</b> on a cold cache — use {@link #getCachedLoadedNanopubChecksum()}
857
     * from the event loop.
858
     */
859
    public static String getLoadedNanopubChecksum() {
860
        String v = loadedNanopubChecksum;
6✔
861
        if (v != null) {
6!
862
            return v;
×
863
        }
864
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection("meta")) {
12✔
865
            Value val = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO, NPA.HAS_NANOPUB_CHECKSUM);
×
866
            if (val != null) {
×
867
                v = val.stringValue();
×
868
                loadedNanopubChecksum = v;
×
869
                return v;
×
870
            }
871
        } catch (Exception ex) {
3!
872
            logger.warn("Could not read npa:hasNanopubChecksum from meta repo", ex);
12✔
873
        }
×
874
        return null;
6✔
875
    }
876

877
    private static long fetchSpacesLoadCounter(RepositoryConnection conn) {
878
        Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
879
                com.knowledgepixels.query.vocabulary.SpacesVocab.CURRENT_LOAD_COUNTER);
880
        if (v == null) {
×
881
            return 0;
×
882
        }
883
        try {
884
            return Long.parseLong(v.stringValue());
×
885
        } catch (NumberFormatException ex) {
×
886
            logger.warn("Malformed npa:currentLoadCounter literal in spaces repo (value not parseable as long): \"{}\"", v.stringValue());
×
887
            return 0;
×
888
        }
889
    }
890

891
    private record RepoStatus(boolean isLoaded, long count, String checksum) {
×
892
    }
893

894
    /**
895
     * To execute before loading a nanopub: check if the nanopub is already loaded and what is the
896
     * current load counter and checksum. This effectively batches three queries into one.
897
     * This method must be called from within a transaction.
898
     *
899
     * @param conn repo connection
900
     * @param npId nanopub ID
901
     * @return the current status
902
     */
903
    @GeneratedFlagForDependentElements
904
    private static RepoStatus fetchRepoStatus(RepositoryConnection conn, IRI npId) {
905
        var result = conn.prepareTupleQuery(QueryLanguage.SPARQL, REPO_STATUS_QUERY_TEMPLATE.formatted(npId)).evaluate();
906
        try (result) {
907
            if (!result.hasNext()) {
908
                // This may happen if the repo was created, but is completely empty.
909
                return new RepoStatus(false, 0, NanopubUtils.INIT_CHECKSUM);
910
            }
911
            var row = result.next();
912
            return new RepoStatus(row.hasBinding("loadNumber"), Long.parseLong(row.getBinding("count").getValue().stringValue()), row.getBinding("checksum").getValue().stringValue());
913
        }
914
    }
915

916
    @GeneratedFlagForDependentElements
917
    private static void loadInvalidateStatements(Nanopub thisNp, String thisPubkey, Statement invalidateStatement, Statement pubkeyStatement, Statement pubkeyStatementX, List<Statement> thisAllStatements) {
918
        boolean success = false;
919
        int retries = 0;
920
        List<IRI> typesToLoadFullInto = new ArrayList<>();
921
        boolean targetIsSpaceRelevant = false;
922
        while (!success) {
923
            typesToLoadFullInto.clear();
924
            targetIsSpaceRelevant = false;
925
            List<RepositoryConnection> connections = new ArrayList<>();
926
            RepositoryConnection metaConn = TripleStore.get().getRepoConnection("meta");
927
            try {
928
                IRI invalidatedNpId = (IRI) invalidateStatement.getObject();
929
                // Basic isolation because here we only read append-only data.
930
                metaConn.begin(IsolationLevels.READ_COMMITTED);
931

932
                Value pubkeyValue = Utils.getObjectForPattern(metaConn, NPA.GRAPH, invalidatedNpId, NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY);
933
                if (pubkeyValue != null) {
934
                    String pubkey = pubkeyValue.stringValue();
935

936
                    if (!pubkey.equals(thisPubkey)) {
937
                        //logger.info("Adding invalidation expressed in " + thisNp.getUri() + " also to repo for pubkey " + pubkey);
938
                        connections.add(loadStatements("pubkey_" + Utils.createHash(pubkey), invalidateStatement, pubkeyStatement, pubkeyStatementX));
939
//                                                connections.add(loadStatements("text-pubkey_" + Utils.createHash(pubkey), invalidateStatement, pubkeyStatement));
940
                    }
941

942
                    Set<IRI> thisNpTypes = NanopubUtils.getTypes(thisNp);
943
                    for (Value v : Utils.getObjectsForPattern(metaConn, NPA.GRAPH, invalidatedNpId, NPX.HAS_NANOPUB_TYPE)) {
944
                        if (v instanceof IRI typeIri) {
945
                            if (!thisNpTypes.contains(typeIri)) {
946
                                // Defer until after the meta-read commits — full load goes
947
                                // through loadNanopubToRepo, which has its own transaction
948
                                // and retry loop (see post-loop block below).
949
                                typesToLoadFullInto.add(typeIri);
950
                            }
951
                            if (SpacesExtractor.TRIGGER_TYPES.contains(typeIri)) {
952
                                // Target carries a space-relevant type — propagate the
953
                                // retractor into the spaces repo too (deferred, same
954
                                // reason as above).
955
                                targetIsSpaceRelevant = true;
956
                            }
957
                        }
958
                    }
959

960
//                                        for (Value v : Utils.getObjectsForPattern(metaConn, NPA.GRAPH, invalidatedNpId, DCTERMS.CREATOR)) {
961
//                                                IRI creatorIri = (IRI) v;
962
//                                                if (!SimpleCreatorPattern.getCreators(thisNp).contains(creatorIri)) {
963
//                                                        //logger.info("Adding invalidation expressed in " + thisNp.getUri() + " also to repo for user " + creatorIri);
964
//                                                        connections.add(loadStatements("user_" + Utils.createHash(creatorIri), invalidateStatement, pubkeyStatement));
965
//                                                        connections.add(loadStatements("text-user_" + Utils.createHash(creatorIri), invalidateStatement, pubkeyStatement));
966
//                                                }
967
//                                        }
968
                }
969

970
                metaConn.commit();
971
                // TODO handle case that some commits succeed and some fail
972
                for (RepositoryConnection c : connections) c.commit();
973
                success = true;
974
            } catch (Exception ex) {
975
                logger.warn("Failed to load invalidation statements from <{}> to target repos: {}", thisNp.getUri(), ex.getMessage(), ex);
976
                if (metaConn.isActive()) {
977
                    metaConn.rollback();
978
                }
979
                for (RepositoryConnection c : connections) {
980
                    if (c.isActive()) {
981
                        c.rollback();
982
                    }
983
                }
984
            } finally {
985
                metaConn.close();
986
                for (RepositoryConnection c : connections) c.close();
987
            }
988
            if (!success) {
989
                retries++;
990
                if (retries >= MAX_RETRIES) {
991
                    throw new RuntimeException("Failed to load invalidate statements for " + thisNp.getUri() + " after " + MAX_RETRIES + " retries");
992
                }
993
                long delay = computeBackoffMillis(retries);
994
                logger.info("Retrying invalidation-statement load for <{}> in {} ms (attempt {}/{})...", thisNp.getUri(), delay, retries, MAX_RETRIES);
995
                try {
996
                    Thread.sleep(delay);
997
                } catch (InterruptedException x) {
998
                    Thread.currentThread().interrupt();
999
                }
1000
            }
1001
        }
1002
        // Mirror the Registries' behaviour: index a retraction under the types of the
1003
        // nanopub it invalidates, even when the retractor itself doesn't carry those
1004
        // types. Load the full retracting nanopub (not just the npx:invalidates marker)
1005
        // so a query against a type repo can fetch the retractor's own assertion /
1006
        // provenance / pubinfo, not only the join handle.
1007
        // loadNanopubToRepo is idempotent (early-exit on npa:hasLoadNumber) and runs
1008
        // its own SERIALIZABLE transaction + retry loop, so it's safe to call here.
1009
        for (IRI typeIri : typesToLoadFullInto) {
1010
            loadNanopubToRepo(thisNp.getUri(), thisAllStatements, "type_" + Utils.createHash(typeIri));
1011
        }
1012
        // Same rationale for the spaces repo: when the invalidated nanopub is itself
1013
        // space-relevant, the retractor needs to land in the spaces repo so the
1014
        // materialiser's invalidation join (?invNp npx:invalidates ?np +
1015
        // ?invNp npa:hasLoadNumber ?ln in npa:graph) finds it. spaceExtractionStatements
1016
        // is empty here — the retractor is not space-relevant by itself, otherwise it
1017
        // would have already been loaded to spaces by the regular spaces-load task.
1018
        if (targetIsSpaceRelevant && FeatureFlags.spacesEnabled()) {
1019
            loadToSpacesRepo(thisNp.getUri(), thisAllStatements, Collections.emptyList());
1020
        }
1021
    }
1022

1023
    /**
1024
     * Extracts a map from invalidator IRI to that invalidator's pubkey literal
1025
     * out of an {@code invalidatingStatements} list as produced by
1026
     * {@link #getInvalidatingStatements}. The list interleaves
1027
     * {@code (?inv, npx:invalidates, ?np)} and
1028
     * {@code (?inv, npa:hasValidSignatureForPublicKey, ?pubkey)} triples per
1029
     * invalidator; this helper picks out only the pubkey-binding triples.
1030
     */
1031
    private static Map<IRI, String> collectInvalidatorPubkeys(List<Statement> invalidatingStatements) {
1032
        Map<IRI, String> result = new LinkedHashMap<>();
×
1033
        for (Statement st : invalidatingStatements) {
×
1034
            if (st.getPredicate().equals(NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY)
×
1035
                && st.getSubject() instanceof IRI invIri) {
×
1036
                result.put(invIri, st.getObject().stringValue());
×
1037
            }
1038
        }
×
1039
        return result;
×
1040
    }
1041

1042
    /**
1043
     * Reverse-order counterpart of the forward-order full-content propagation in
1044
     * {@link #loadInvalidateStatements}: when this nanopub had already-loaded
1045
     * retractors at the time of its own load (captured by
1046
     * {@link #getInvalidatingStatements}), load each retractor's full content
1047
     * into this nanopub's per-type repos — restricted to those types the
1048
     * retractor doesn't itself carry (the retractor's own load already populated
1049
     * the type repos it covers).
1050
     *
1051
     * <p>Source is the retractor's per-pubkey repo, which is the one shard
1052
     * unconditionally populated for every successfully-loaded nanopub. The
1053
     * retractor's types are read from the meta repo so we can skip type repos
1054
     * the retractor's own regular load already populated.
1055
     */
1056
    @GeneratedFlagForDependentElements
1057
    private static void loadInvalidatorIntoTypeRepos(IRI invIri, String invPubkey, IRI thisNpId, Set<IRI> thisNpTypes) {
1058
        Set<IRI> invTypes = readInvalidatorTypesFromMeta(invIri, thisNpId);
1059

1060
        List<IRI> typesToLoadInto = new ArrayList<>();
1061
        for (IRI typeIri : thisNpTypes) {
1062
            // Match the regular per-type load loop's exclusion of locally-minted IRIs.
1063
            if (typeIri.stringValue().startsWith(thisNpId.stringValue())) {
1064
                continue;
1065
            }
1066
            if (!typeIri.stringValue().matches("https?://.*")) {
1067
                continue;
1068
            }
1069
            if (!invTypes.contains(typeIri)) {
1070
                typesToLoadInto.add(typeIri);
1071
            }
1072
        }
1073
        if (typesToLoadInto.isEmpty()) {
1074
            return;
1075
        }
1076

1077
        List<Statement> invContent = fetchNanopubAllStatementsFromPubkeyRepo(invIri, invPubkey);
1078
        for (IRI typeIri : typesToLoadInto) {
1079
            loadNanopubToRepo(invIri, invContent, "type_" + Utils.createHash(typeIri));
1080
        }
1081
    }
1082

1083
    /**
1084
     * Reverse-order counterpart for the spaces repo: when a space-relevant nanopub
1085
     * is loaded and {@link #getInvalidatingStatements} captured already-loaded
1086
     * retractors of it, load each retractor's full content into the spaces repo so
1087
     * the materialiser's invalidation join (?invNp npx:invalidates ?np +
1088
     * ?invNp npa:hasLoadNumber ?ln in npa:graph) finds it regardless of the
1089
     * load order between target and retractor.
1090
     *
1091
     * <p>Source is the retractor's per-pubkey repo (the one shard unconditionally
1092
     * populated for every successfully-loaded nanopub). Passes an empty
1093
     * {@code spaceExtraction} list to {@link #loadToSpacesRepo}: by construction
1094
     * the retractor would already be in the spaces repo by its regular spaces-load
1095
     * task if it carried space-relevant extractions of its own.
1096
     * {@link #loadToSpacesRepo} is idempotent on {@code npa:hasLoadNumber}, so
1097
     * the double-load case is a no-op.
1098
     */
1099
    @GeneratedFlagForDependentElements
1100
    private static void loadInvalidatorIntoSpacesRepo(IRI invIri, String invPubkey, IRI thisNpId) {
1101
        List<Statement> invContent = fetchNanopubAllStatementsFromPubkeyRepo(invIri, invPubkey);
1102
        loadToSpacesRepo(invIri, invContent, Collections.emptyList());
1103
    }
1104

1105
    @GeneratedFlagForDependentElements
1106
    private static Set<IRI> readInvalidatorTypesFromMeta(IRI invIri, IRI thisNpId) {
1107
        Set<IRI> invTypes = new HashSet<>();
1108
        boolean success = false;
1109
        int retries = 0;
1110
        while (!success) {
1111
            invTypes.clear();
1112
            RepositoryConnection metaConn = TripleStore.get().getRepoConnection("meta");
1113
            try (metaConn) {
1114
                metaConn.begin(IsolationLevels.READ_COMMITTED);
1115
                for (Value v : Utils.getObjectsForPattern(metaConn, NPA.GRAPH, invIri, NPX.HAS_NANOPUB_TYPE)) {
1116
                    if (v instanceof IRI ti) {
1117
                        invTypes.add(ti);
1118
                    }
1119
                }
1120
                metaConn.commit();
1121
                success = true;
1122
            } catch (Exception ex) {
1123
                logger.warn("Failed to read types for invalidator <{}> (needed for target <{}>): {}", invIri, thisNpId, ex.getMessage(), ex);
1124
                if (metaConn.isActive()) {
1125
                    metaConn.rollback();
1126
                }
1127
            }
1128
            if (!success) {
1129
                retries++;
1130
                if (retries >= MAX_RETRIES) {
1131
                    throw new RuntimeException("Failed to read invalidator types for " + invIri + " after " + MAX_RETRIES + " retries");
1132
                }
1133
                long delay = computeBackoffMillis(retries);
1134
                logger.info("Retrying type-read for invalidator <{}> in {} ms (attempt {}/{})...", invIri, delay, retries, MAX_RETRIES);
1135
                try {
1136
                    Thread.sleep(delay);
1137
                } catch (InterruptedException x) {
1138
                    Thread.currentThread().interrupt();
1139
                }
1140
            }
1141
        }
1142
        return invTypes;
1143
    }
1144

1145
    /**
1146
     * Reads a nanopub's content back from its per-pubkey repo as a list of
1147
     * statements that mirrors what its original load produced into
1148
     * {@code allStatements}, minus the per-repo bookkeeping triples
1149
     * ({@code npa:hasLoadNumber}, {@code npa:hasLoadChecksum},
1150
     * {@code npa:hasLoadTimestamp}). {@link #loadNanopubToRepo} stamps those
1151
     * fresh on every destination repo, so they must be filtered out of the
1152
     * source set.
1153
     *
1154
     * <p>Fetched content:
1155
     * <ul>
1156
     *   <li>All triples in the nanopub's four named graphs, discovered via
1157
     *       {@code <npId> npa:hasGraph ?g} in {@code npa:graph}.</li>
1158
     *   <li>{@code (<npId>, ?p, ?o)} in {@code npa:graph}, excluding the per-repo
1159
     *       bookkeeping predicates above.</li>
1160
     *   <li>{@code (?inv, npx:invalidates, <npId>)} in {@code npa:graph}, plus
1161
     *       the matching {@code npa:hasValidSignatureForPublicKey[Hash]} triples
1162
     *       of each {@code ?inv}, so propagation carries the nanopub's full
1163
     *       invalidator history (which doesn't affect query results — see the
1164
     *       one-hop filter in {@code Utils#defaultQuery} — but keeps repos
1165
     *       consistent).</li>
1166
     *   <li>{@code (<npId>, ?p, ?o)} in {@code npa:networkGraph}.</li>
1167
     * </ul>
1168
     */
1169
    @GeneratedFlagForDependentElements
1170
    private static List<Statement> fetchNanopubAllStatementsFromPubkeyRepo(IRI npId, String pubkey) {
1171
        String repoName = "pubkey_" + Utils.createHash(pubkey);
1172
        boolean success = false;
1173
        int retries = 0;
1174
        List<Statement> result = new ArrayList<>();
1175
        while (!success) {
1176
            result.clear();
1177
            RepositoryConnection conn = TripleStore.get().getRepoConnection(repoName);
1178
            try (conn) {
1179
                // Append-only data + idempotent re-load downstream: READ_COMMITTED suffices.
1180
                conn.begin(IsolationLevels.READ_COMMITTED);
1181

1182
                List<IRI> npGraphs = new ArrayList<>();
1183
                try (RepositoryResult<Statement> r = conn.getStatements(npId, NPA.HAS_GRAPH, null, NPA.GRAPH)) {
1184
                    while (r.hasNext()) {
1185
                        Value o = r.next().getObject();
1186
                        if (o instanceof IRI iri) {
1187
                            npGraphs.add(iri);
1188
                        }
1189
                    }
1190
                }
1191

1192
                for (IRI g : npGraphs) {
1193
                    try (RepositoryResult<Statement> r = conn.getStatements(null, null, null, g)) {
1194
                        while (r.hasNext()) result.add(r.next());
1195
                    }
1196
                }
1197

1198
                try (RepositoryResult<Statement> r = conn.getStatements(npId, null, null, NPA.GRAPH)) {
1199
                    while (r.hasNext()) {
1200
                        Statement st = r.next();
1201
                        IRI p = st.getPredicate();
1202
                        if (p.equals(NPA.HAS_LOAD_NUMBER)
1203
                            || p.equals(NPA.HAS_LOAD_CHECKSUM)
1204
                            || p.equals(NPA.HAS_LOAD_TIMESTAMP)) {
1205
                            continue;
1206
                        }
1207
                        result.add(st);
1208
                    }
1209
                }
1210

1211
                Set<IRI> invalidators = new HashSet<>();
1212
                try (RepositoryResult<Statement> r = conn.getStatements(null, NPX.INVALIDATES, npId, NPA.GRAPH)) {
1213
                    while (r.hasNext()) {
1214
                        Statement st = r.next();
1215
                        result.add(st);
1216
                        if (st.getSubject() instanceof IRI invIri) {
1217
                            invalidators.add(invIri);
1218
                        }
1219
                    }
1220
                }
1221
                for (IRI invIri : invalidators) {
1222
                    try (RepositoryResult<Statement> r = conn.getStatements(invIri, NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY, null, NPA.GRAPH)) {
1223
                        while (r.hasNext()) result.add(r.next());
1224
                    }
1225
                    try (RepositoryResult<Statement> r = conn.getStatements(invIri, NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH, null, NPA.GRAPH)) {
1226
                        while (r.hasNext()) result.add(r.next());
1227
                    }
1228
                }
1229

1230
                try (RepositoryResult<Statement> r = conn.getStatements(npId, null, null, NPA.NETWORK_GRAPH)) {
1231
                    while (r.hasNext()) result.add(r.next());
1232
                }
1233

1234
                conn.commit();
1235
                success = true;
1236
            } catch (Exception ex) {
1237
                logger.warn("Failed to fetch content of nanopub <{}> from repo '{}': {}", npId, repoName, ex.getMessage(), ex);
1238
                if (conn.isActive()) {
1239
                    conn.rollback();
1240
                }
1241
            }
1242
            if (!success) {
1243
                retries++;
1244
                if (retries >= MAX_RETRIES) {
1245
                    throw new RuntimeException("Failed to fetch nanopub content from " + repoName + " for " + npId + " after " + MAX_RETRIES + " retries");
1246
                }
1247
                long delay = computeBackoffMillis(retries);
1248
                logger.info("Retrying content-fetch of <{}> from repo '{}' in {} ms (attempt {}/{})...", npId, repoName, delay, retries, MAX_RETRIES);
1249
                try {
1250
                    Thread.sleep(delay);
1251
                } catch (InterruptedException x) {
1252
                    Thread.currentThread().interrupt();
1253
                }
1254
            }
1255
        }
1256
        return result;
1257
    }
1258

1259
    @GeneratedFlagForDependentElements
1260
    private static RepositoryConnection loadStatements(String repoName, Statement... statements) {
1261
        RepositoryConnection conn = TripleStore.get().getRepoConnection(repoName);
1262
        // Basic isolation: we only append new statements
1263
        conn.begin(IsolationLevels.READ_COMMITTED);
1264
        for (Statement st : statements) {
1265
            conn.add(st);
1266
        }
1267
        return conn;
1268
    }
1269

1270
    @GeneratedFlagForDependentElements
1271
    static List<Statement> getInvalidatingStatements(IRI npId) {
1272
        List<Statement> invalidatingStatements = new ArrayList<>();
1273
        boolean success = false;
1274
        int retries = 0;
1275
        while (!success) {
1276
            RepositoryConnection conn = TripleStore.get().getRepoConnection("meta");
1277
            try (conn) {
1278
                // Basic isolation because here we only read append-only data.
1279
                conn.begin(IsolationLevels.READ_COMMITTED);
1280

1281
                TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * { graph <" + NPA.GRAPH + "> { " + "?np <" + NPX.INVALIDATES + "> <" + npId + "> ; <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY + "> ?pubkey . " + "} }").evaluate();
1282
                try (r) {
1283
                    while (r.hasNext()) {
1284
                        BindingSet b = r.next();
1285
                        invalidatingStatements.add(vf.createStatement((IRI) b.getBinding("np").getValue(), NPX.INVALIDATES, npId, NPA.GRAPH));
1286
                        invalidatingStatements.add(vf.createStatement((IRI) b.getBinding("np").getValue(), NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY, b.getBinding("pubkey").getValue(), NPA.GRAPH));
1287
                    }
1288
                }
1289
                conn.commit();
1290
                success = true;
1291
            } catch (Exception ex) {
1292
                logger.warn("Failed to query existing invalidators of <{}> from meta repo: {}", npId, ex.getMessage(), ex);
1293
                if (conn.isActive()) {
1294
                    conn.rollback();
1295
                }
1296
            }
1297
            if (!success) {
1298
                retries++;
1299
                if (retries >= MAX_RETRIES) {
1300
                    throw new RuntimeException("Failed to get invalidating statements for " + npId + " after " + MAX_RETRIES + " retries");
1301
                }
1302
                long delay = computeBackoffMillis(retries);
1303
                logger.info("Retrying invalidator-query for <{}> in {} ms (attempt {}/{})...", npId, delay, retries, MAX_RETRIES);
1304
                try {
1305
                    Thread.sleep(delay);
1306
                } catch (InterruptedException x) {
1307
                    Thread.currentThread().interrupt();
1308
                }
1309
            }
1310
        }
1311
        return invalidatingStatements;
1312
    }
1313

1314
    @GeneratedFlagForDependentElements
1315
    private static void loadNoteToRepo(Resource subj, String note) {
1316
        boolean success = false;
1317
        int retries = 0;
1318
        while (!success) {
1319
            RepositoryConnection conn = TripleStore.get().getAdminRepoConnection();
1320
            try (conn) {
1321
                List<Statement> statements = new ArrayList<>();
1322
                statements.add(vf.createStatement(subj, NPA.NOTE, vf.createLiteral(note), NPA.GRAPH));
1323
                conn.add(statements);
1324
                success = true;
1325
            } catch (Exception ex) {
1326
                logger.warn("Failed to write note \"{}\" to admin repo for <{}>: {}", note, subj, ex.getMessage(), ex);
1327
            }
1328
            if (!success) {
1329
                retries++;
1330
                if (retries >= MAX_RETRIES) {
1331
                    throw new RuntimeException("Failed to load note to repo for " + subj + " after " + MAX_RETRIES + " retries");
1332
                }
1333
                long delay = computeBackoffMillis(retries);
1334
                logger.info("Retrying note-write for <{}> in {} ms (attempt {}/{})...", subj, delay, retries, MAX_RETRIES);
1335
                try {
1336
                    Thread.sleep(delay);
1337
                } catch (InterruptedException x) {
1338
                    Thread.currentThread().interrupt();
1339
                }
1340
            }
1341
        }
1342
    }
1343

1344
    static boolean hasValidSignature(NanopubSignatureElement el) {
1345
        if (el == null) {
6!
1346
            logger.warn("Signature validation skipped: signature element is null (nanopub has no signature)");
×
1347
            return false;
×
1348
        }
1349
        try {
1350
            if (SignatureUtils.hasValidSignature(el) && el.getPublicKeyString() != null) {
18!
1351
                return true;
6✔
1352
            }
1353
            logger.warn("Signature invalid for <{}> (pubkey: {})",
12✔
1354
                    el.getUri(), el.getPublicKeyString() != null ? el.getPublicKeyString() : "none");
21!
1355
        } catch (GeneralSecurityException ex) {
3✔
1356
            logger.warn("Signature verification threw a security exception for <{}>: {}", el.getUri(), ex.getMessage(), ex);
57✔
1357
        }
3✔
1358
        return false;
6✔
1359
    }
1360

1361
    private static IRI getBaseTrustyUri(Value v) {
1362
        if (!(v instanceof IRI)) {
9!
1363
            return null;
×
1364
        }
1365
        String s = v.stringValue();
9✔
1366
        if (!s.matches(".*[^A-Za-z0-9\\-_]RA[A-Za-z0-9\\-_]{43}([^A-Za-z0-9\\\\-_].{0,43})?")) {
12✔
1367
            return null;
6✔
1368
        }
1369
        return vf.createIRI(s.replaceFirst("^(.*[^A-Za-z0-9\\-_]RA[A-Za-z0-9\\-_]{43})([^A-Za-z0-9\\\\-_].{0,43})?$", "$1"));
21✔
1370
    }
1371

1372
    // TODO: Move this to nanopub library:
1373
    private static boolean isIntroNanopub(Nanopub np) {
1374
        for (Statement st : np.getAssertion()) {
33✔
1375
            if (st.getPredicate().equals(NPX.DECLARED_BY)) {
15✔
1376
                return true;
6✔
1377
            }
1378
        }
3✔
1379
        return false;
6✔
1380
    }
1381

1382
    /**
1383
     * Check if a nanopub is already loaded in the admin graph.
1384
     *
1385
     * @param npId the nanopub ID
1386
     * @return true if the nanopub is loaded, false otherwise
1387
     */
1388
    @GeneratedFlagForDependentElements
1389
    static boolean isNanopubLoaded(String npId) {
1390
        boolean loaded = false;
1391
        RepositoryConnection conn = TripleStore.get().getRepoConnection("meta");
1392
        try (conn) {
1393
            if (Utils.getObjectForPattern(conn, NPA.GRAPH, vf.createIRI(npId), NPA.HAS_LOAD_NUMBER) != null) {
1394
                loaded = true;
1395
            }
1396
        } catch (Exception ex) {
1397
            logger.warn("Could not check load status of <{}>: {}", npId, ex.getMessage(), ex);
1398
        }
1399
        return loaded;
1400
    }
1401

1402
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
1403

1404
    // TODO remove the constants and use the ones from the nanopub library instead
1405

1406
    /**
1407
     * Template for the query that fetches the status of a repository.
1408
     */
1409
    // Template for .fetchRepoStatus
1410
    private static final String REPO_STATUS_QUERY_TEMPLATE = """
84✔
1411
            SELECT * { graph <%s> {
1412
              OPTIONAL { <%s> <%s> ?loadNumber . }
1413
              <%s> <%s> ?count ;
1414
                   <%s> ?checksum .
1415
            } }
1416
            """.formatted(NPA.GRAPH, "%s", NPA.HAS_LOAD_NUMBER, NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT, NPA.HAS_NANOPUB_CHECKSUM);
6✔
1417
}
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