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

knowledgepixels / nanopub-query / 30636847881

31 Jul 2026 02:01PM UTC coverage: 58.987% (-0.01%) from 59.0%
30636847881

push

github

web-flow
Merge pull request #150 from knowledgepixels/fix/no-blocking-io-in-global-headers

fix(headers): stop blocking the event loop on a cold header cache

625 of 1202 branches covered (52.0%)

Branch coverage included in aggregate %.

1833 of 2965 relevant lines covered (61.82%)

9.48 hits per line

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

70.47
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.function.Consumer;
39

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

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

48
    /**
49
     * Cached count of nanopubs ever loaded into the {@code meta} repo. Maintained
50
     * for {@link MainVerticle}'s {@code Nanopub-Query-Loaded-Nanopub-Count}
51
     * response header. Mirrors the persisted {@code npa:hasNanopubCount} triple
52
     * that {@link #loadNanopubToRepo} maintains; invalidations don't decrement
53
     * (they're recorded as separate {@code npx:invalidates} markers), so this
54
     * is a cumulative count including superseded/retracted nanopubs — matching
55
     * the registry-side {@code Nanopub-Registry-Nanopub-Count} semantics.
56
     *
57
     * <p>The {@code meta} repo (not {@code full}) is the source because the meta
58
     * task is submitted only after all other per-nanopub tasks succeed (see
59
     * {@link #executeLoading}), making it the authoritative "fully completed
60
     * loads" indicator. The {@code full} repo is feature-flagged and may be
61
     * disabled, in which case it cannot be the source. Populated lazily on first
62
     * read; bumped post-commit on each fresh meta load.
63
     */
64
    static volatile Long loadedNanopubCount = null;
6✔
65

66
    /**
67
     * Cached checksum of nanopubs ever loaded into the {@code meta} repo.
68
     * Maintained for {@link MainVerticle}'s
69
     * {@code Nanopub-Query-Loaded-Nanopub-Checksum} response header. Mirrors the
70
     * persisted {@code npa:hasNanopubChecksum} triple that {@link #loadNanopubToRepo}
71
     * maintains — an order-independent XOR over the trusty URIs of all loaded
72
     * nanopubs, Base64-encoded. Sourced from {@code meta} for the same reasons
73
     * as {@link #loadedNanopubCount}; the two fields are bumped together so the
74
     * count and checksum always describe the same point in the load sequence.
75
     */
76
    static volatile String loadedNanopubChecksum = null;
6✔
77

78
    /**
79
     * Retry budget for the five (with #71 merged: six) structurally identical
80
     * retry loops in this file. Previously the shape was flat {@code 10 s × 30} —
81
     * five minutes of constant hammering at RDF4J that did not help a slow server.
82
     * The new shape is bounded exponential backoff with ±50 % jitter:
83
     * {@code base = 1, 2, 4, 8, 16, 32, 60, 60 s} for attempts 1…8, each perturbed
84
     * by up to half its base value. Jitter prevents the 4-thread loadingPool from
85
     * retrying in lock-step after a shared RDF4J failure (GC pause / overload spike).
86
     * Worst-case wall time per failing task drops from ~35 min (post-change-1
87
     * timeouts × 30 flat retries) to ~11 min (8 retries × 60 s timeout + backoff
88
     * sleeps). This is the figure that sets the circuit-breaker trip time in
89
     * {@link JellyNanopubLoader}.
90
     */
91
    private static final int MAX_RETRIES = 8;
92
    private static final long[] BACKOFF_BASE_MS =
105✔
93
            {1_000L, 2_000L, 4_000L, 8_000L, 16_000L, 32_000L, 60_000L, 60_000L};
94

95
    /**
96
     * Returns the sleep delay in ms for the given 1-indexed retry attempt. Delay
97
     * is {@link #BACKOFF_BASE_MS}{@code [attempt-1]} perturbed by ±50 % uniform
98
     * jitter, clamped to be non-negative.
99
     *
100
     * @param attempt 1-indexed retry attempt number
101
     * @return the computed sleep delay in ms
102
     */
103
    static long computeBackoffMillis(int attempt) {
104
        long base = BACKOFF_BASE_MS[Math.min(attempt - 1, BACKOFF_BASE_MS.length - 1)];
×
105
        long jitter = ThreadLocalRandom.current().nextLong(base + 1) - base / 2;
×
106
        return Math.max(0L, base + jitter);
×
107
    }
108

109
    private Nanopub np;
110
    private NanopubSignatureElement el = null;
9✔
111
    private List<Statement> metaStatements = new ArrayList<>();
15✔
112
    private List<Statement> nanopubStatements = new ArrayList<>();
15✔
113
    private List<Statement> literalStatements = new ArrayList<>();
15✔
114
    private List<Statement> invalidateStatements = new ArrayList<>();
15✔
115
    private List<Statement> textStatements, allStatements, invalidatingStatements;
116
    private List<Statement> spaceExtractionStatements = new ArrayList<>();
15✔
117
    private Calendar timestamp = null;
9✔
118
    private Statement pubkeyStatement, pubkeyStatementX;
119
    private List<String> notes = new ArrayList<>();
15✔
120
    private boolean aborted = false;
9✔
121
    private static final Logger logger = LoggerFactory.getLogger(NanopubLoader.class);
9✔
122

123

124
    NanopubLoader(Nanopub np, long counter) {
6✔
125
        this.np = np;
9✔
126
        if (counter >= 0) {
12✔
127
            logger.info("Loading nanopub #{}: <{}>", counter, np.getUri());
24✔
128
        } else {
129
            logger.info("Loading nanopub: <{}>", np.getUri());
15✔
130
        }
131

132
        // TODO Ensure proper synchronization and DB rollbacks
133

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

136
        String ac = TrustyUriUtils.getArtifactCode(np.getUri().toString());
15✔
137
        if (!np.getHeadUri().toString().contains(ac) || !np.getAssertionUri().toString().contains(ac) || !np.getProvenanceUri().toString().contains(ac) || !np.getPubinfoUri().toString().contains(ac)) {
72!
138
            notes.add("could not load nanopub as not all graphs contained the artifact code");
×
139
            aborted = true;
×
140
            return;
×
141
        }
142

143
        try {
144
            el = SignatureUtils.getSignatureElement(np);
12✔
145
        } catch (MalformedCryptoElementException ex) {
×
146
            notes.add("Signature error");
×
147
        }
3✔
148
        if (!hasValidSignature(el)) {
12✔
149
            // Audit trail for the silent-false path: without this, an aborted nanopub
150
            // is invisible in the admin repo and only detectable as a gap between the
151
            // stream counter and the loaded count. See issue around RDF4J-instability
152
            // load gaps (full vs registry, meta vs full) where signature validation
153
            // returned false but no GeneralSecurityException was thrown.
154
            if (notes.isEmpty()) {
12!
155
                notes.add("Invalid signature");
15✔
156
            }
157
            aborted = true;
9✔
158
            return;
3✔
159
        }
160

161
        pubkeyStatement = vf.createStatement(np.getUri(), NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY, vf.createLiteral(el.getPublicKeyString()), NPA.GRAPH);
39✔
162
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasValidSignatureForPublicKey, FULL_PUBKEY, npa:graph, meta, full pubkey if signature is valid
163
        metaStatements.add(pubkeyStatement);
18✔
164
        pubkeyStatementX = vf.createStatement(np.getUri(), NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH, vf.createLiteral(Utils.createHash(el.getPublicKeyString())), NPA.GRAPH);
42✔
165
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasValidSignatureForPublicKeyHash, PUBKEY_HASH, npa:graph, meta, hex-encoded SHA256 hash if signature is valid
166
        metaStatements.add(pubkeyStatementX);
18✔
167

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

173
        Set<IRI> subIris = new HashSet<>();
12✔
174
        Set<IRI> otherNps = new HashSet<>();
12✔
175
        Set<IRI> invalidated = new HashSet<>();
12✔
176
        Set<IRI> retracted = new HashSet<>();
12✔
177
        Set<IRI> superseded = new HashSet<>();
12✔
178
        String combinedLiterals = "";
6✔
179
        for (Statement st : NanopubUtils.getStatements(np)) {
33✔
180
            nanopubStatements.add(st);
15✔
181

182
            if (st.getPredicate().toString().contains(ac)) {
18!
183
                subIris.add(st.getPredicate());
×
184
            } else {
185
                IRI b = getBaseTrustyUri(st.getPredicate());
12✔
186
                if (b != null) {
6!
187
                    otherNps.add(b);
×
188
                }
189
            }
190
            if (st.getPredicate().equals(NPX.RETRACTS) && st.getObject() instanceof IRI) {
15!
191
                retracted.add((IRI) st.getObject());
×
192
            }
193
            if (st.getPredicate().equals(NPX.INVALIDATES) && st.getObject() instanceof IRI) {
15!
194
                invalidated.add((IRI) st.getObject());
×
195
            }
196
            if (st.getSubject().equals(np.getUri()) && st.getObject() instanceof IRI) {
30✔
197
                if (st.getPredicate().equals(NPX.SUPERSEDES)) {
15✔
198
                    superseded.add((IRI) st.getObject());
18✔
199
                }
200
                if (st.getObject().toString().matches(".*[^A-Za-z0-9\\-_]RA[A-Za-z0-9\\-_]{43}")) {
18✔
201
                    metaStatements.add(vf.createStatement(np.getUri(), st.getPredicate(), st.getObject(), NPA.NETWORK_GRAPH));
39✔
202
                    // @ADMIN-TRIPLE-TABLE@ NANOPUB1, RELATION, NANOPUB2, npa:networkGraph, meta, any inter-nanopub relation found in NANOPUB1
203
                }
204
                if (st.getContext().equals(np.getPubinfoUri())) {
18✔
205
                    if (st.getPredicate().equals(NPX.INTRODUCES) || st.getPredicate().equals(NPX.DESCRIBES) || st.getPredicate().equals(NPX.EMBEDS)) {
45!
206
                        metaStatements.add(vf.createStatement(np.getUri(), st.getPredicate(), st.getObject(), NPA.GRAPH));
39✔
207
                        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:introduces, THING, npa:graph, meta, when such a triple is present in pubinfo of NANOPUB
208
                        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:describes, THING, npa:graph, meta, when such a triple is present in pubinfo of NANOPUB
209
                        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:embeds, THING, npa:graph, meta, when such a triple is present in pubinfo of NANOPUB
210
                    }
211
                }
212
            }
213
            if (st.getSubject().toString().contains(ac)) {
18✔
214
                subIris.add((IRI) st.getSubject());
21✔
215
            } else {
216
                IRI b = getBaseTrustyUri(st.getSubject());
12✔
217
                if (b != null) {
6!
218
                    otherNps.add(b);
×
219
                }
220
            }
221
            if (st.getObject() instanceof IRI) {
12✔
222
                if (st.getObject().toString().contains(ac)) {
18✔
223
                    subIris.add((IRI) st.getObject());
21✔
224
                } else {
225
                    IRI b = getBaseTrustyUri(st.getObject());
12✔
226
                    if (b != null) {
6✔
227
                        otherNps.add(b);
12✔
228
                    }
229
                }
3✔
230
            } else {
231
                combinedLiterals += st.getObject().stringValue().replaceAll("\\s+", " ") + "\n";
27✔
232
//                                if (st.getSubject().equals(np.getUri()) && !st.getSubject().equals(HAS_FILTER_LITERAL)) {
233
//                                        literalStatements.add(vf.createStatement(np.getUri(), st.getPredicate(), st.getObject(), LITERAL_GRAPH));
234
//                                } else {
235
//                                        literalStatements.add(vf.createStatement(np.getUri(), HAS_LITERAL, st.getObject(), LITERAL_GRAPH));
236
//                                }
237
            }
238
        }
3✔
239
        subIris.remove(np.getUri());
15✔
240
        subIris.remove(np.getAssertionUri());
15✔
241
        subIris.remove(np.getProvenanceUri());
15✔
242
        subIris.remove(np.getPubinfoUri());
15✔
243
        for (IRI i : subIris) {
30✔
244
            metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_SUB_IRI, i, NPA.GRAPH));
33✔
245
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasSubIri, SUB_IRI, npa:graph, meta, for any IRI minted in the namespace of the NANOPUB
246
        }
3✔
247
        for (IRI i : otherNps) {
30✔
248
            metaStatements.add(vf.createStatement(np.getUri(), NPA.REFERS_TO_NANOPUB, i, NPA.NETWORK_GRAPH));
33✔
249
            // @ADMIN-TRIPLE-TABLE@ NANOPUB1, npa:refersToNanopub, NANOPUB2, npa:networkGraph, meta, generic inter-nanopub relation
250
        }
3✔
251
        for (IRI i : invalidated) {
18!
252
            invalidateStatements.add(vf.createStatement(np.getUri(), NPX.INVALIDATES, i, NPA.GRAPH));
×
253
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:invalidates, INVALIDATED_NANOPUB, npa:graph, meta, if the NANOPUB retracts or supersedes another nanopub
254
        }
×
255
        for (IRI i : retracted) {
18!
256
            invalidateStatements.add(vf.createStatement(np.getUri(), NPX.INVALIDATES, i, NPA.GRAPH));
×
257
            metaStatements.add(vf.createStatement(np.getUri(), NPX.RETRACTS, i, NPA.GRAPH));
×
258
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:retracts, RETRACTED_NANOPUB, npa:graph, meta, if the NANOPUB retracts another nanopub
259
        }
×
260
        for (IRI i : superseded) {
30✔
261
            invalidateStatements.add(vf.createStatement(np.getUri(), NPX.INVALIDATES, i, NPA.GRAPH));
33✔
262
            metaStatements.add(vf.createStatement(np.getUri(), NPX.SUPERSEDES, i, NPA.GRAPH));
33✔
263
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:supersedes, SUPERSEDED_NANOPUB, npa:graph, meta, if the NANOPUB supersedes another nanopub
264
        }
3✔
265

266
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_HEAD_GRAPH, np.getHeadUri(), NPA.GRAPH));
36✔
267
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasHeadGraph, HEAD_GRAPH, npa:graph, meta, direct link to the head graph of the NANOPUB
268
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_GRAPH, np.getHeadUri(), NPA.GRAPH));
36✔
269
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasGraph, GRAPH, npa:graph, meta, generic link to all four graphs of the given NANOPUB
270
        metaStatements.add(vf.createStatement(np.getUri(), NP.HAS_ASSERTION, np.getAssertionUri(), NPA.GRAPH));
36✔
271
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, np:hasAssertion, ASSERTION_GRAPH, npa:graph, meta, direct link to the assertion graph of the NANOPUB
272
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_GRAPH, np.getAssertionUri(), NPA.GRAPH));
36✔
273
        metaStatements.add(vf.createStatement(np.getUri(), NP.HAS_PROVENANCE, np.getProvenanceUri(), NPA.GRAPH));
36✔
274
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, np:hasProvenance, PROVENANCE_GRAPH, npa:graph, meta, direct link to the provenance graph of the NANOPUB
275
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_GRAPH, np.getProvenanceUri(), NPA.GRAPH));
36✔
276
        metaStatements.add(vf.createStatement(np.getUri(), NP.HAS_PUBINFO, np.getPubinfoUri(), NPA.GRAPH));
36✔
277
        // @ADMIN-TRIPLE-TABLE@ NANOPUB, np:hasPublicationInfo, PUBINFO_GRAPH, npa:graph, meta, direct link to the pubinfo graph of the NANOPUB
278
        metaStatements.add(vf.createStatement(np.getUri(), NPA.HAS_GRAPH, np.getPubinfoUri(), NPA.GRAPH));
36✔
279

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

284
        if (isIntroNanopub(np)) {
9✔
285
            IntroNanopub introNp = new IntroNanopub(np);
15✔
286
            metaStatements.add(vf.createStatement(np.getUri(), NPA.IS_INTRODUCTION_OF, introNp.getUser(), NPA.GRAPH));
36✔
287
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:isIntroductionOf, AGENT, npa:graph, meta, linking intro nanopub to the agent it is introducing
288
            for (KeyDeclaration kc : introNp.getKeyDeclarations()) {
33✔
289
                metaStatements.add(vf.createStatement(np.getUri(), NPA.DECLARES_PUBKEY, vf.createLiteral(kc.getPublicKeyString()), NPA.GRAPH));
42✔
290
                // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:declaresPubkey, FULL_PUBKEY, npa:graph, meta, full pubkey declared by the given intro NANOPUB
291
            }
3✔
292
        }
293

294
        try {
295
            timestamp = SimpleTimestampPattern.getCreationTime(np);
12✔
296
        } catch (IllegalArgumentException ex) {
×
297
            notes.add("Illegal date/time");
×
298
        }
3✔
299
        if (timestamp != null) {
9!
300
            metaStatements.add(vf.createStatement(np.getUri(), DCTERMS.CREATED, vf.createLiteral(timestamp.getTime()), NPA.GRAPH));
45✔
301
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, dct:created, CREATION_DATE, npa:graph, meta, normalized creation timestamp
302
        }
303

304
        String literalFilter = "_pubkey_" + Utils.createHash(el.getPublicKeyString());
18✔
305
        for (IRI typeIri : NanopubUtils.getTypes(np)) {
33✔
306
            metaStatements.add(vf.createStatement(np.getUri(), NPX.HAS_NANOPUB_TYPE, typeIri, NPA.GRAPH));
33✔
307
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, npx:hasNanopubType, NANOPUB_TYPE, npa:graph, meta, type of NANOPUB
308
            literalFilter += " _type_" + Utils.createHash(typeIri);
15✔
309
        }
3✔
310
        String label = NanopubUtils.getLabel(np);
9✔
311
        if (label != null) {
6!
312
            metaStatements.add(vf.createStatement(np.getUri(), RDFS.LABEL, vf.createLiteral(label), NPA.GRAPH));
39✔
313
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, rdfs:label, LABEL, npa:graph, meta, label of NANOPUB
314
        }
315
        String description = NanopubUtils.getDescription(np);
9✔
316
        if (description != null) {
6✔
317
            metaStatements.add(vf.createStatement(np.getUri(), DCTERMS.DESCRIPTION, vf.createLiteral(description), NPA.GRAPH));
39✔
318
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, dct:description, LABEL, npa:graph, meta, description of NANOPUB
319
        }
320
        for (IRI creatorIri : SimpleCreatorPattern.getCreators(np)) {
33✔
321
            metaStatements.add(vf.createStatement(np.getUri(), DCTERMS.CREATOR, creatorIri, NPA.GRAPH));
33✔
322
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, dct:creator, CREATOR, npa:graph, meta, creator of NANOPUB (can be several)
323
        }
3✔
324
        for (IRI authorIri : SimpleCreatorPattern.getAuthors(np)) {
21!
325
            metaStatements.add(vf.createStatement(np.getUri(), PAV.AUTHORED_BY, authorIri, NPA.GRAPH));
×
326
            // @ADMIN-TRIPLE-TABLE@ NANOPUB, pav:authoredBy, AUTHOR, npa:graph, meta, author of NANOPUB (can be several)
327
        }
×
328

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

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

337
        metaStatements.addAll(invalidateStatements);
18✔
338

339
        allStatements = new ArrayList<>(nanopubStatements);
21✔
340
        allStatements.addAll(metaStatements);
18✔
341
        allStatements.addAll(invalidatingStatements);
18✔
342

343
        textStatements = new ArrayList<>(literalStatements);
21✔
344
        textStatements.addAll(metaStatements);
18✔
345
        textStatements.addAll(invalidatingStatements);
18✔
346

347
        if (FeatureFlags.spacesEnabled()) {
6!
348
            IRI signedBy = (el.getSigners().size() == 1) ? el.getSigners().iterator().next() : null;
42!
349
            String pubkeyHash = Utils.createHash(el.getPublicKeyString());
15✔
350
            Date createdAt = (timestamp != null) ? timestamp.getTime() : null;
24!
351
            SpacesExtractor.Context ctx = new SpacesExtractor.Context(ac, signedBy, pubkeyHash, createdAt);
24✔
352
            spaceExtractionStatements = SpacesExtractor.extract(np, ctx);
15✔
353
        }
354
    }
3✔
355

356
    /**
357
     * Get the HTTP client used for fetching nanopublications.
358
     *
359
     * @return the HTTP client
360
     */
361
    static HttpClient getHttpClient() {
362
        if (httpClient == null) {
6✔
363
            httpClient = HttpClientBuilder.create().setDefaultRequestConfig(Utils.getHttpRequestConfig()).build();
15✔
364
        }
365
        return httpClient;
6✔
366
    }
367

368
    /**
369
     * Load the given nanopublication into the database.
370
     *
371
     * @param nanopubUri Nanopublication identifier (URI)
372
     */
373
    public static void load(String nanopubUri) {
374
        if (isNanopubLoaded(nanopubUri)) {
9!
375
            logger.info("Skipping already-loaded nanopub: <{}>", nanopubUri);
×
376
        } else {
377
            Nanopub np = GetNanopub.get(nanopubUri, getHttpClient());
12✔
378
            load(np, -1);
9✔
379
        }
380
    }
3✔
381

382
    /**
383
     * Load a nanopub into the database.
384
     *
385
     * @param np      the nanopub to load
386
     * @param counter the load counter, only used for logging (or -1 if not known)
387
     * @throws RDF4JException if the loading fails
388
     */
389
    public static void load(Nanopub np, long counter) throws RDF4JException {
390
        NanopubLoader loader = new NanopubLoader(np, counter);
18✔
391
        loader.executeLoading();
6✔
392
    }
3✔
393

394
    @GeneratedFlagForDependentElements
395
    private void executeLoading() {
396
        var runningTasks = new ArrayList<Future<?>>();
397
        Consumer<Runnable> runTask = t -> runningTasks.add(loadingPool.submit(t));
×
398

399
        for (String note : notes) {
400
            loadNoteToRepo(np.getUri(), note);
401
        }
402

403
        if (!aborted) {
404
            // Submit all tasks except the "meta" task
405
            if (timestamp != null) {
406
                if (new Date().getTime() - timestamp.getTimeInMillis() < THIRTY_DAYS) {
407
                    if (FeatureFlags.last30dRepoEnabled()) {
408
                        runTask.accept(() -> loadNanopubToLatest(np.getUri(), allStatements));
×
409
                    }
410
                }
411
            }
412

413
            if (FeatureFlags.textRepoEnabled()) {
414
                runTask.accept(() -> loadNanopubToRepo(np.getUri(), textStatements, "text"));
×
415
            }
416
            if (FeatureFlags.fullRepoEnabled()) {
417
                runTask.accept(() -> loadNanopubToRepo(np.getUri(), allStatements, "full"));
×
418
            }
419
            // Note: "meta" task is deferred until all other tasks complete successfully
420

421
            runTask.accept(() -> loadNanopubToRepo(np.getUri(), allStatements, "pubkey_" + Utils.createHash(el.getPublicKeyString())));
×
422
            //                loadNanopubToRepo(np.getUri(), textStatements, "text-pubkey_" + Utils.createHash(el.getPublicKeyString()));
423
            for (IRI typeIri : NanopubUtils.getTypes(np)) {
424
                // Exclude locally minted IRIs:
425
                if (typeIri.stringValue().startsWith(np.getUri().stringValue())) {
426
                    continue;
427
                }
428
                if (!typeIri.stringValue().matches("https?://.*")) {
429
                    continue;
430
                }
431
                runTask.accept(() -> loadNanopubToRepo(np.getUri(), allStatements, "type_" + Utils.createHash(typeIri)));
×
432
                //                        loadNanopubToRepo(np.getUri(), textStatements, "text-type_" + Utils.createHash(typeIri));
433
            }
434
            //                for (IRI creatorIri : SimpleCreatorPattern.getCreators(np)) {
435
            //                        // Exclude locally minted IRIs:
436
            //                        if (creatorIri.stringValue().startsWith(np.getUri().stringValue())) continue;
437
            //                        if (!creatorIri.stringValue().matches("https?://.*")) continue;
438
            //                        loadNanopubToRepo(np.getUri(), allStatements, "user_" + Utils.createHash(creatorIri));
439
            //                        loadNanopubToRepo(np.getUri(), textStatements, "text-user_" + Utils.createHash(creatorIri));
440
            //                }
441
            //                for (IRI authorIri : SimpleCreatorPattern.getAuthors(np)) {
442
            //                        // Exclude locally minted IRIs:
443
            //                        if (authorIri.stringValue().startsWith(np.getUri().stringValue())) continue;
444
            //                        if (!authorIri.stringValue().matches("https?://.*")) continue;
445
            //                        loadNanopubToRepo(np.getUri(), allStatements, "user_" + Utils.createHash(authorIri));
446
            //                        loadNanopubToRepo(np.getUri(), textStatements, "text-user_" + Utils.createHash(authorIri));
447
            //                }
448

449
            // Write to the spaces repo only when the nanopub carries its own space-relevant
450
            // extractions. Invalidators of space-relevant nanopubs are propagated to spaces
451
            // symmetrically below (forward path in loadInvalidateStatements, reverse path in
452
            // the invalidatorPubkeys block) — mirrors the per-type-repo propagation added in
453
            // PR #103 (commit 09eeb32). The materialiser's invalidation joins
454
            // (?invNp npx:invalidates ?np + ?invNp npa:hasLoadNumber ?ln in the spaces repo's
455
            // npa:graph) stay populated because the invalidators that matter are exactly the
456
            // ones we propagate.
457
            boolean thisNpIsSpaceRelevant = FeatureFlags.spacesEnabled() && !spaceExtractionStatements.isEmpty();
458
            if (thisNpIsSpaceRelevant) {
459
                runTask.accept(() -> loadToSpacesRepo(np.getUri(), allStatements, spaceExtractionStatements));
×
460
            }
461

462
            for (Statement st : invalidateStatements) {
463
                runTask.accept(() -> loadInvalidateStatements(np, el.getPublicKeyString(), st, pubkeyStatement, pubkeyStatementX, allStatements));
×
464
            }
465

466
            // Reverse-order symmetry: when retractors were loaded before this nanopub,
467
            // getInvalidatingStatements (in the constructor) captured their
468
            // `npx:invalidates` markers into invalidatingStatements. Mirror what
469
            // loadInvalidateStatements does in the forward case — load each retractor's
470
            // full content into this nanopub's per-type repos (those types the
471
            // retractor doesn't itself carry), sourced from the retractor's per-pubkey
472
            // repo (the one shard guaranteed to be populated for every successfully
473
            // loaded nanopub). When this nanopub is space-relevant, additionally load
474
            // each retractor into the spaces repo so the materialiser's invalidation
475
            // join sees them regardless of load order.
476
            Map<IRI, String> invalidatorPubkeys = collectInvalidatorPubkeys(invalidatingStatements);
477
            if (!invalidatorPubkeys.isEmpty()) {
478
                Set<IRI> thisNpTypes = NanopubUtils.getTypes(np);
479
                for (Map.Entry<IRI, String> e : invalidatorPubkeys.entrySet()) {
480
                    IRI invIri = e.getKey();
481
                    String invPubkey = e.getValue();
482
                    runTask.accept(() -> loadInvalidatorIntoTypeRepos(invIri, invPubkey, np.getUri(), thisNpTypes));
×
483
                    if (thisNpIsSpaceRelevant) {
484
                        runTask.accept(() -> loadInvalidatorIntoSpacesRepo(invIri, invPubkey, np.getUri()));
×
485
                    }
486
                }
487
            }
488

489
            // Wait for all non-meta tasks to complete successfully before submitting the meta task.
490
            // On failure, cancel the remaining futures so orphaned tasks don't keep running in the
491
            // shared loadingPool and race with the next batch retry (which re-submits the same
492
            // nanopub against the same repos).
493
            for (var task : runningTasks) {
494
                try {
495
                    task.get();
496
                } catch (ExecutionException | InterruptedException ex) {
497
                    for (var t : runningTasks) {
498
                        if (!t.isDone()) {
499
                            t.cancel(true);
500
                        }
501
                    }
502
                    throw new RuntimeException("Error in nanopub loading thread", ex.getCause());
503
                }
504
            }
505

506
            // Now submit and wait for the "meta" task after all other tasks have completed successfully
507
            Future<?> metaTask = loadingPool.submit(() -> loadNanopubToRepo(np.getUri(), metaStatements, "meta"));
×
508
            try {
509
                metaTask.get();
510
            } catch (ExecutionException | InterruptedException ex) {
511
                throw new RuntimeException("Error in nanopub loading thread (meta task)", ex.getCause());
512
            }
513
        }
514
    }
515

516
    private static Long lastUpdateOfLatestRepo = null;
6✔
517
    private static long THIRTY_DAYS = 1000L * 60 * 60 * 24 * 30;
6✔
518
    private static long ONE_HOUR = 1000L * 60 * 60;
6✔
519

520
    @GeneratedFlagForDependentElements
521
    private static void loadNanopubToLatest(IRI npId, List<Statement> statements) {
522
        boolean success = false;
523
        int retries = 0;
524
        while (!success) {
525
            RepositoryConnection conn = TripleStore.get().getRepoConnection("last30d");
526
            try (conn) {
527
                // Read committed, because deleting old nanopubs is idempotent. Inserts do not collide
528
                // with deletes, because we are not inserting old nanopubs.
529
                conn.begin(IsolationLevels.READ_COMMITTED);
530
                conn.add(statements);
531
                if (lastUpdateOfLatestRepo == null || new Date().getTime() - lastUpdateOfLatestRepo > ONE_HOUR) {
532
                    logger.debug("Pruning nanopubs older than 30 days from last30d repo...");
533
                    Literal thirtyDaysAgo = vf.createLiteral(new Date(new Date().getTime() - THIRTY_DAYS));
534
                    TupleQuery q = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * { graph <" + NPA.GRAPH + "> { " + "?np <" + DCTERMS.CREATED + "> ?date . " + "filter ( ?date < ?thirtydaysago ) " + "} }");
535
                    q.setBinding("thirtydaysago", thirtyDaysAgo);
536
                    try (TupleQueryResult r = q.evaluate()) {
537
                        while (r.hasNext()) {
538
                            BindingSet b = r.next();
539
                            IRI oldNpId = (IRI) b.getBinding("np").getValue();
540
                            logger.debug("Pruning expired nanopub from last30d repo: <{}>", oldNpId);
541
                            for (Value v : Utils.getObjectsForPattern(conn, NPA.GRAPH, oldNpId, NPA.HAS_GRAPH)) {
542
                                // Remove all four nanopub graphs:
543
                                conn.remove((Resource) null, (IRI) null, (Value) null, (IRI) v);
544
                            }
545
                            // Remove nanopubs in admin graphs:
546
                            conn.remove(oldNpId, null, null, NPA.GRAPH);
547
                            conn.remove(oldNpId, null, null, NPA.NETWORK_GRAPH);
548
                        }
549
                    }
550
                    lastUpdateOfLatestRepo = new Date().getTime();
551
                }
552
                conn.commit();
553
                success = true;
554
            } catch (Exception ex) {
555
                logger.warn("Failed to load nanopub <{}> to last30d repo: {}", npId, ex.getMessage(), ex);
556
                if (conn.isActive()) {
557
                    conn.rollback();
558
                }
559
            }
560
            if (!success) {
561
                retries++;
562
                if (retries >= MAX_RETRIES) {
563
                    throw new RuntimeException("Failed to load nanopub " + npId + " to last30d repo after " + MAX_RETRIES + " retries");
564
                }
565
                long delay = computeBackoffMillis(retries);
566
                logger.info("Retrying load of <{}> to last30d repo in {} ms (attempt {}/{})...", npId, delay, retries, MAX_RETRIES);
567
                try {
568
                    Thread.sleep(delay);
569
                } catch (InterruptedException x) {
570
                    Thread.currentThread().interrupt();
571
                }
572
            }
573
        }
574
    }
575

576
    @GeneratedFlagForDependentElements
577
    private static void loadNanopubToRepo(IRI npId, List<Statement> statements, String repoName) {
578
        boolean success = false;
579
        int retries = 0;
580
        while (!success) {
581
            RepositoryConnection conn = TripleStore.get().getRepoConnection(repoName);
582
            long newCountForCache = -1;
583
            String newChecksumForCache = null;
584
            try (conn) {
585
                // Serializable, because write skew would cause the chain of hashes to be broken.
586
                // The inserts must be done serially.
587
                conn.begin(IsolationLevels.SERIALIZABLE);
588
                var repoStatus = fetchRepoStatus(conn, npId);
589
                if (repoStatus.isLoaded) {
590
                    // INFO, not DEBUG: this skip decides that a shard write is unnecessary
591
                    // based on a single store read. When the backend misbehaves (issue #139:
592
                    // a shard "successfully" written yet not durable), this line is the only
593
                    // trace distinguishing a false skip from a lost commit.
594
                    logger.info("Skipping already-loaded nanopub <{}> in repo '{}'", npId, repoName);
595
                } else {
596
                    String newChecksum = NanopubUtils.updateXorChecksum(npId, repoStatus.checksum);
597
                    conn.remove(NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT, null, NPA.GRAPH);
598
                    conn.remove(NPA.THIS_REPO, NPA.HAS_NANOPUB_CHECKSUM, null, NPA.GRAPH);
599
                    conn.add(NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT, vf.createLiteral(repoStatus.count + 1), NPA.GRAPH);
600
                    // @ADMIN-TRIPLE-TABLE@ REPO, npa:hasNanopubCount, NANOPUB_COUNT, npa:graph, admin, number of nanopubs loaded
601
                    conn.add(NPA.THIS_REPO, NPA.HAS_NANOPUB_CHECKSUM, vf.createLiteral(newChecksum), NPA.GRAPH);
602
                    // @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)
603
                    conn.add(npId, NPA.HAS_LOAD_NUMBER, vf.createLiteral(repoStatus.count), NPA.GRAPH);
604
                    // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasLoadNumber, LOAD_NUMBER, npa:graph, admin, the sequential number at which this NANOPUB was loaded
605
                    conn.add(npId, NPA.HAS_LOAD_CHECKSUM, vf.createLiteral(newChecksum), NPA.GRAPH);
606
                    // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasLoadChecksum, LOAD_CHECKSUM, npa:graph, admin, the checksum of all loaded nanopubs after loading the given NANOPUB
607
                    conn.add(npId, NPA.HAS_LOAD_TIMESTAMP, vf.createLiteral(new Date()), NPA.GRAPH);
608
                    // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasLoadTimestamp, LOAD_TIMESTAMP, npa:graph, admin, the time point at which this NANOPUB was loaded
609
                    conn.add(statements);
610
                    if ("meta".equals(repoName)) {
611
                        newCountForCache = repoStatus.count + 1;
612
                        newChecksumForCache = newChecksum;
613
                    }
614
                }
615
                conn.commit();
616
                if (newCountForCache >= 0) {
617
                    loadedNanopubCount = newCountForCache;
618
                }
619
                if (newChecksumForCache != null) {
620
                    loadedNanopubChecksum = newChecksumForCache;
621
                }
622
                success = true;
623
            } catch (Exception ex) {
624
                logger.warn("Failed to load nanopub <{}> to repo '{}': {}", npId, repoName, ex.getMessage(), ex);
625
                if (conn.isActive()) {
626
                    conn.rollback();
627
                }
628
            }
629
            if (!success) {
630
                retries++;
631
                if (retries >= MAX_RETRIES) {
632
                    throw new RuntimeException("Failed to load nanopub " + npId + " to repo " + repoName + " after " + MAX_RETRIES + " retries");
633
                }
634
                long delay = computeBackoffMillis(retries);
635
                logger.info("Retrying load of <{}> to repo '{}' in {} ms (attempt {}/{})...", npId, repoName, delay, retries, MAX_RETRIES);
636
                try {
637
                    Thread.sleep(delay);
638
                } catch (InterruptedException x) {
639
                    Thread.currentThread().interrupt();
640
                }
641
            }
642
        }
643
    }
644

645
    /**
646
     * Writes the raw nanopub statements (all four graphs) into the {@code spaces}
647
     * repo alongside the pre-computed extraction statements (which target
648
     * {@code npa:spacesGraph}). Stamps the load-number on the nanopub IRI and bumps
649
     * {@code npa:thisRepo npa:currentLoadCounter} in {@code npa:graph}, all within
650
     * one serializable transaction.
651
     *
652
     * <p>Idempotent: if the nanopub already has a {@code npa:hasLoadNumber} stamp in
653
     * {@code npa:graph} of the {@code spaces} repo, this is a no-op.
654
     *
655
     * @param npId            nanopub IRI
656
     * @param nanopubTriples  raw nanopub statements (all four graphs + meta)
657
     * @param spaceExtraction summary triples destined for {@code npa:spacesGraph}
658
     */
659
    @GeneratedFlagForDependentElements
660
    private static void loadToSpacesRepo(IRI npId, List<Statement> nanopubTriples,
661
                                         List<Statement> spaceExtraction) {
662
        boolean success = false;
663
        int retries = 0;
664
        while (!success) {
665
            RepositoryConnection conn = TripleStore.get().getRepoConnection("spaces");
666
            try (conn) {
667
                conn.begin(IsolationLevels.SERIALIZABLE);
668
                // Idempotency: skip if this nanopub is already stamped in this repo.
669
                if (Utils.getObjectForPattern(conn, NPA.GRAPH, npId, NPA.HAS_LOAD_NUMBER) != null) {
670
                    // INFO for the same reason as the loadNanopubToRepo skip (issue #139).
671
                    logger.info("Skipping already-loaded nanopub <{}> in spaces repo", npId);
672
                    conn.commit();
673
                    success = true;
674
                    continue;
675
                }
676
                long newCounter = fetchSpacesLoadCounter(conn) + 1;
677
                conn.remove(NPA.THIS_REPO,
678
                        com.knowledgepixels.query.vocabulary.SpacesVocab.CURRENT_LOAD_COUNTER,
679
                        null, NPA.GRAPH);
680
                conn.add(NPA.THIS_REPO,
681
                        com.knowledgepixels.query.vocabulary.SpacesVocab.CURRENT_LOAD_COUNTER,
682
                        vf.createLiteral(newCounter), NPA.GRAPH);
683
                conn.add(npId, NPA.HAS_LOAD_NUMBER, vf.createLiteral(newCounter), NPA.GRAPH);
684
                conn.add(nanopubTriples);
685
                conn.add(spaceExtraction);
686
                conn.commit();
687
                success = true;
688
            } catch (Exception ex) {
689
                logger.warn("Failed to load nanopub <{}> to spaces repo: {}", npId, ex.getMessage(), ex);
690
                if (conn.isActive()) {
691
                    conn.rollback();
692
                }
693
            }
694
            if (!success) {
695
                retries++;
696
                if (retries >= MAX_RETRIES) {
697
                    throw new RuntimeException("Failed to load nanopub " + npId + " to spaces repo after " + MAX_RETRIES + " retries");
698
                }
699
                long delay = computeBackoffMillis(retries);
700
                logger.info("Retrying load of <{}> to spaces repo in {} ms (attempt {}/{})...", npId, delay, retries, MAX_RETRIES);
701
                try {
702
                    Thread.sleep(delay);
703
                } catch (InterruptedException x) {
704
                    Thread.currentThread().interrupt();
705
                }
706
            }
707
        }
708
    }
709

710
    /**
711
     * Returns the cumulative count of nanopubs ever loaded into the {@code meta}
712
     * repo, or {@code null} if the value cannot be determined (e.g. the store
713
     * hasn't been initialised yet). Reads the persisted {@code npa:hasNanopubCount}
714
     * triple on first call and caches it in {@link #loadedNanopubCount};
715
     * subsequent fresh loads update the cache in-place.
716
     *
717
     * <p><b>Blocks</b> on a cold cache — use {@link #getCachedLoadedNanopubCount()}
718
     * from the event loop.
719
     */
720
    public static Long getLoadedNanopubCount() {
721
        Long v = loadedNanopubCount;
6✔
722
        if (v != null) {
6✔
723
            return v;
6✔
724
        }
725
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection("meta")) {
12✔
726
            Value val = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT);
×
727
            if (val != null) {
×
728
                v = Long.parseLong(val.stringValue());
×
729
                loadedNanopubCount = v;
×
730
                return v;
×
731
            }
732
        } catch (NumberFormatException ex) {
×
733
            logger.warn("Malformed npa:hasNanopubCount literal in meta repo (value not parseable as long): {}", ex.getMessage(), ex);
×
734
        } catch (Exception ex) {
3✔
735
            logger.warn("Could not read npa:hasNanopubCount from meta repo", ex);
12✔
736
        }
×
737
        return null;
6✔
738
    }
739

740
    /**
741
     * The cached loaded-nanopub count, without the store read that
742
     * {@link #getLoadedNanopubCount()} falls back to, or {@code null} if nothing has
743
     * populated it yet.
744
     *
745
     * <p>For callers that must not block — specifically
746
     * {@link MainVerticle#applyGlobalHeaders}, which runs on the Vert.x event loop
747
     * for every inbound request. The lazy fallback does blocking HTTP, and on a cold
748
     * cache (i.e. after every restart) that stalled the event loop until the store
749
     * answered; Vert.x's BlockedThreadChecker fired repeatedly on 2026-07-31. Worse,
750
     * with the store unreachable it would have blocked every request for the full
751
     * 10 s connect timeout, turning an RDF4J outage into a total outage of the HTTP
752
     * layer — including the status headers used to diagnose it.
753
     *
754
     * <p>Kept warm off the event loop by {@link #primeHeaderCaches()}.
755
     *
756
     * @return the cached count, or null if not yet known
757
     */
758
    public static Long getCachedLoadedNanopubCount() {
759
        return loadedNanopubCount;
6✔
760
    }
761

762
    /**
763
     * The cached loaded-nanopub checksum, without the store read that
764
     * {@link #getLoadedNanopubChecksum()} falls back to, or {@code null} if nothing
765
     * has populated it yet. Same event-loop rationale as
766
     * {@link #getCachedLoadedNanopubCount()}.
767
     *
768
     * @return the cached checksum, or null if not yet known
769
     */
770
    public static String getCachedLoadedNanopubChecksum() {
771
        return loadedNanopubChecksum;
6✔
772
    }
773

774
    /**
775
     * Populates the caches read by {@link #getCachedLoadedNanopubCount()} and
776
     * {@link #getCachedLoadedNanopubChecksum()}, going to the store if they are cold.
777
     *
778
     * <p>Both values are otherwise only refreshed when a nanopub is actually loaded,
779
     * so an instance that starts up already caught up would never populate them and
780
     * would serve those headers empty forever. Something off the event loop has to
781
     * prime them; {@link MetricsCollector#updateMetrics()} does, on its own executor.
782
     *
783
     * <p><b>Blocks.</b> Never call from the Vert.x event loop.
784
     */
785
    public static void primeHeaderCaches() {
786
        getLoadedNanopubCount();
6✔
787
        getLoadedNanopubChecksum();
6✔
788
    }
3✔
789

790
    /**
791
     * Returns the order-independent XOR checksum (Base64-encoded) of trusty URIs
792
     * of all nanopubs ever loaded into the {@code meta} repo, or {@code null} if
793
     * the value cannot be determined (e.g. the store hasn't been initialised
794
     * yet). Reads the persisted {@code npa:hasNanopubChecksum} triple on first
795
     * call and caches it in {@link #loadedNanopubChecksum}; subsequent fresh
796
     * loads update the cache in-place alongside {@link #loadedNanopubCount}.
797
     *
798
     * <p><b>Blocks</b> on a cold cache — use {@link #getCachedLoadedNanopubChecksum()}
799
     * from the event loop.
800
     */
801
    public static String getLoadedNanopubChecksum() {
802
        String v = loadedNanopubChecksum;
6✔
803
        if (v != null) {
6!
804
            return v;
×
805
        }
806
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection("meta")) {
12✔
807
            Value val = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO, NPA.HAS_NANOPUB_CHECKSUM);
×
808
            if (val != null) {
×
809
                v = val.stringValue();
×
810
                loadedNanopubChecksum = v;
×
811
                return v;
×
812
            }
813
        } catch (Exception ex) {
3!
814
            logger.warn("Could not read npa:hasNanopubChecksum from meta repo", ex);
12✔
815
        }
×
816
        return null;
6✔
817
    }
818

819
    private static long fetchSpacesLoadCounter(RepositoryConnection conn) {
820
        Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
821
                com.knowledgepixels.query.vocabulary.SpacesVocab.CURRENT_LOAD_COUNTER);
822
        if (v == null) {
×
823
            return 0;
×
824
        }
825
        try {
826
            return Long.parseLong(v.stringValue());
×
827
        } catch (NumberFormatException ex) {
×
828
            logger.warn("Malformed npa:currentLoadCounter literal in spaces repo (value not parseable as long): \"{}\"", v.stringValue());
×
829
            return 0;
×
830
        }
831
    }
832

833
    private record RepoStatus(boolean isLoaded, long count, String checksum) {
×
834
    }
835

836
    /**
837
     * To execute before loading a nanopub: check if the nanopub is already loaded and what is the
838
     * current load counter and checksum. This effectively batches three queries into one.
839
     * This method must be called from within a transaction.
840
     *
841
     * @param conn repo connection
842
     * @param npId nanopub ID
843
     * @return the current status
844
     */
845
    @GeneratedFlagForDependentElements
846
    private static RepoStatus fetchRepoStatus(RepositoryConnection conn, IRI npId) {
847
        var result = conn.prepareTupleQuery(QueryLanguage.SPARQL, REPO_STATUS_QUERY_TEMPLATE.formatted(npId)).evaluate();
848
        try (result) {
849
            if (!result.hasNext()) {
850
                // This may happen if the repo was created, but is completely empty.
851
                return new RepoStatus(false, 0, NanopubUtils.INIT_CHECKSUM);
852
            }
853
            var row = result.next();
854
            return new RepoStatus(row.hasBinding("loadNumber"), Long.parseLong(row.getBinding("count").getValue().stringValue()), row.getBinding("checksum").getValue().stringValue());
855
        }
856
    }
857

858
    @GeneratedFlagForDependentElements
859
    private static void loadInvalidateStatements(Nanopub thisNp, String thisPubkey, Statement invalidateStatement, Statement pubkeyStatement, Statement pubkeyStatementX, List<Statement> thisAllStatements) {
860
        boolean success = false;
861
        int retries = 0;
862
        List<IRI> typesToLoadFullInto = new ArrayList<>();
863
        boolean targetIsSpaceRelevant = false;
864
        while (!success) {
865
            typesToLoadFullInto.clear();
866
            targetIsSpaceRelevant = false;
867
            List<RepositoryConnection> connections = new ArrayList<>();
868
            RepositoryConnection metaConn = TripleStore.get().getRepoConnection("meta");
869
            try {
870
                IRI invalidatedNpId = (IRI) invalidateStatement.getObject();
871
                // Basic isolation because here we only read append-only data.
872
                metaConn.begin(IsolationLevels.READ_COMMITTED);
873

874
                Value pubkeyValue = Utils.getObjectForPattern(metaConn, NPA.GRAPH, invalidatedNpId, NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY);
875
                if (pubkeyValue != null) {
876
                    String pubkey = pubkeyValue.stringValue();
877

878
                    if (!pubkey.equals(thisPubkey)) {
879
                        //logger.info("Adding invalidation expressed in " + thisNp.getUri() + " also to repo for pubkey " + pubkey);
880
                        connections.add(loadStatements("pubkey_" + Utils.createHash(pubkey), invalidateStatement, pubkeyStatement, pubkeyStatementX));
881
//                                                connections.add(loadStatements("text-pubkey_" + Utils.createHash(pubkey), invalidateStatement, pubkeyStatement));
882
                    }
883

884
                    Set<IRI> thisNpTypes = NanopubUtils.getTypes(thisNp);
885
                    for (Value v : Utils.getObjectsForPattern(metaConn, NPA.GRAPH, invalidatedNpId, NPX.HAS_NANOPUB_TYPE)) {
886
                        if (v instanceof IRI typeIri) {
887
                            if (!thisNpTypes.contains(typeIri)) {
888
                                // Defer until after the meta-read commits — full load goes
889
                                // through loadNanopubToRepo, which has its own transaction
890
                                // and retry loop (see post-loop block below).
891
                                typesToLoadFullInto.add(typeIri);
892
                            }
893
                            if (SpacesExtractor.TRIGGER_TYPES.contains(typeIri)) {
894
                                // Target carries a space-relevant type — propagate the
895
                                // retractor into the spaces repo too (deferred, same
896
                                // reason as above).
897
                                targetIsSpaceRelevant = true;
898
                            }
899
                        }
900
                    }
901

902
//                                        for (Value v : Utils.getObjectsForPattern(metaConn, NPA.GRAPH, invalidatedNpId, DCTERMS.CREATOR)) {
903
//                                                IRI creatorIri = (IRI) v;
904
//                                                if (!SimpleCreatorPattern.getCreators(thisNp).contains(creatorIri)) {
905
//                                                        //logger.info("Adding invalidation expressed in " + thisNp.getUri() + " also to repo for user " + creatorIri);
906
//                                                        connections.add(loadStatements("user_" + Utils.createHash(creatorIri), invalidateStatement, pubkeyStatement));
907
//                                                        connections.add(loadStatements("text-user_" + Utils.createHash(creatorIri), invalidateStatement, pubkeyStatement));
908
//                                                }
909
//                                        }
910
                }
911

912
                metaConn.commit();
913
                // TODO handle case that some commits succeed and some fail
914
                for (RepositoryConnection c : connections) c.commit();
915
                success = true;
916
            } catch (Exception ex) {
917
                logger.warn("Failed to load invalidation statements from <{}> to target repos: {}", thisNp.getUri(), ex.getMessage(), ex);
918
                if (metaConn.isActive()) {
919
                    metaConn.rollback();
920
                }
921
                for (RepositoryConnection c : connections) {
922
                    if (c.isActive()) {
923
                        c.rollback();
924
                    }
925
                }
926
            } finally {
927
                metaConn.close();
928
                for (RepositoryConnection c : connections) c.close();
929
            }
930
            if (!success) {
931
                retries++;
932
                if (retries >= MAX_RETRIES) {
933
                    throw new RuntimeException("Failed to load invalidate statements for " + thisNp.getUri() + " after " + MAX_RETRIES + " retries");
934
                }
935
                long delay = computeBackoffMillis(retries);
936
                logger.info("Retrying invalidation-statement load for <{}> in {} ms (attempt {}/{})...", thisNp.getUri(), delay, retries, MAX_RETRIES);
937
                try {
938
                    Thread.sleep(delay);
939
                } catch (InterruptedException x) {
940
                    Thread.currentThread().interrupt();
941
                }
942
            }
943
        }
944
        // Mirror the Registries' behaviour: index a retraction under the types of the
945
        // nanopub it invalidates, even when the retractor itself doesn't carry those
946
        // types. Load the full retracting nanopub (not just the npx:invalidates marker)
947
        // so a query against a type repo can fetch the retractor's own assertion /
948
        // provenance / pubinfo, not only the join handle.
949
        // loadNanopubToRepo is idempotent (early-exit on npa:hasLoadNumber) and runs
950
        // its own SERIALIZABLE transaction + retry loop, so it's safe to call here.
951
        for (IRI typeIri : typesToLoadFullInto) {
952
            loadNanopubToRepo(thisNp.getUri(), thisAllStatements, "type_" + Utils.createHash(typeIri));
953
        }
954
        // Same rationale for the spaces repo: when the invalidated nanopub is itself
955
        // space-relevant, the retractor needs to land in the spaces repo so the
956
        // materialiser's invalidation join (?invNp npx:invalidates ?np +
957
        // ?invNp npa:hasLoadNumber ?ln in npa:graph) finds it. spaceExtractionStatements
958
        // is empty here — the retractor is not space-relevant by itself, otherwise it
959
        // would have already been loaded to spaces by the regular spaces-load task.
960
        if (targetIsSpaceRelevant && FeatureFlags.spacesEnabled()) {
961
            loadToSpacesRepo(thisNp.getUri(), thisAllStatements, Collections.emptyList());
962
        }
963
    }
964

965
    /**
966
     * Extracts a map from invalidator IRI to that invalidator's pubkey literal
967
     * out of an {@code invalidatingStatements} list as produced by
968
     * {@link #getInvalidatingStatements}. The list interleaves
969
     * {@code (?inv, npx:invalidates, ?np)} and
970
     * {@code (?inv, npa:hasValidSignatureForPublicKey, ?pubkey)} triples per
971
     * invalidator; this helper picks out only the pubkey-binding triples.
972
     */
973
    private static Map<IRI, String> collectInvalidatorPubkeys(List<Statement> invalidatingStatements) {
974
        Map<IRI, String> result = new LinkedHashMap<>();
×
975
        for (Statement st : invalidatingStatements) {
×
976
            if (st.getPredicate().equals(NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY)
×
977
                && st.getSubject() instanceof IRI invIri) {
×
978
                result.put(invIri, st.getObject().stringValue());
×
979
            }
980
        }
×
981
        return result;
×
982
    }
983

984
    /**
985
     * Reverse-order counterpart of the forward-order full-content propagation in
986
     * {@link #loadInvalidateStatements}: when this nanopub had already-loaded
987
     * retractors at the time of its own load (captured by
988
     * {@link #getInvalidatingStatements}), load each retractor's full content
989
     * into this nanopub's per-type repos — restricted to those types the
990
     * retractor doesn't itself carry (the retractor's own load already populated
991
     * the type repos it covers).
992
     *
993
     * <p>Source is the retractor's per-pubkey repo, which is the one shard
994
     * unconditionally populated for every successfully-loaded nanopub. The
995
     * retractor's types are read from the meta repo so we can skip type repos
996
     * the retractor's own regular load already populated.
997
     */
998
    @GeneratedFlagForDependentElements
999
    private static void loadInvalidatorIntoTypeRepos(IRI invIri, String invPubkey, IRI thisNpId, Set<IRI> thisNpTypes) {
1000
        Set<IRI> invTypes = readInvalidatorTypesFromMeta(invIri, thisNpId);
1001

1002
        List<IRI> typesToLoadInto = new ArrayList<>();
1003
        for (IRI typeIri : thisNpTypes) {
1004
            // Match the regular per-type load loop's exclusion of locally-minted IRIs.
1005
            if (typeIri.stringValue().startsWith(thisNpId.stringValue())) {
1006
                continue;
1007
            }
1008
            if (!typeIri.stringValue().matches("https?://.*")) {
1009
                continue;
1010
            }
1011
            if (!invTypes.contains(typeIri)) {
1012
                typesToLoadInto.add(typeIri);
1013
            }
1014
        }
1015
        if (typesToLoadInto.isEmpty()) {
1016
            return;
1017
        }
1018

1019
        List<Statement> invContent = fetchNanopubAllStatementsFromPubkeyRepo(invIri, invPubkey);
1020
        for (IRI typeIri : typesToLoadInto) {
1021
            loadNanopubToRepo(invIri, invContent, "type_" + Utils.createHash(typeIri));
1022
        }
1023
    }
1024

1025
    /**
1026
     * Reverse-order counterpart for the spaces repo: when a space-relevant nanopub
1027
     * is loaded and {@link #getInvalidatingStatements} captured already-loaded
1028
     * retractors of it, load each retractor's full content into the spaces repo so
1029
     * the materialiser's invalidation join (?invNp npx:invalidates ?np +
1030
     * ?invNp npa:hasLoadNumber ?ln in npa:graph) finds it regardless of the
1031
     * load order between target and retractor.
1032
     *
1033
     * <p>Source is the retractor's per-pubkey repo (the one shard unconditionally
1034
     * populated for every successfully-loaded nanopub). Passes an empty
1035
     * {@code spaceExtraction} list to {@link #loadToSpacesRepo}: by construction
1036
     * the retractor would already be in the spaces repo by its regular spaces-load
1037
     * task if it carried space-relevant extractions of its own.
1038
     * {@link #loadToSpacesRepo} is idempotent on {@code npa:hasLoadNumber}, so
1039
     * the double-load case is a no-op.
1040
     */
1041
    @GeneratedFlagForDependentElements
1042
    private static void loadInvalidatorIntoSpacesRepo(IRI invIri, String invPubkey, IRI thisNpId) {
1043
        List<Statement> invContent = fetchNanopubAllStatementsFromPubkeyRepo(invIri, invPubkey);
1044
        loadToSpacesRepo(invIri, invContent, Collections.emptyList());
1045
    }
1046

1047
    @GeneratedFlagForDependentElements
1048
    private static Set<IRI> readInvalidatorTypesFromMeta(IRI invIri, IRI thisNpId) {
1049
        Set<IRI> invTypes = new HashSet<>();
1050
        boolean success = false;
1051
        int retries = 0;
1052
        while (!success) {
1053
            invTypes.clear();
1054
            RepositoryConnection metaConn = TripleStore.get().getRepoConnection("meta");
1055
            try (metaConn) {
1056
                metaConn.begin(IsolationLevels.READ_COMMITTED);
1057
                for (Value v : Utils.getObjectsForPattern(metaConn, NPA.GRAPH, invIri, NPX.HAS_NANOPUB_TYPE)) {
1058
                    if (v instanceof IRI ti) {
1059
                        invTypes.add(ti);
1060
                    }
1061
                }
1062
                metaConn.commit();
1063
                success = true;
1064
            } catch (Exception ex) {
1065
                logger.warn("Failed to read types for invalidator <{}> (needed for target <{}>): {}", invIri, thisNpId, ex.getMessage(), ex);
1066
                if (metaConn.isActive()) {
1067
                    metaConn.rollback();
1068
                }
1069
            }
1070
            if (!success) {
1071
                retries++;
1072
                if (retries >= MAX_RETRIES) {
1073
                    throw new RuntimeException("Failed to read invalidator types for " + invIri + " after " + MAX_RETRIES + " retries");
1074
                }
1075
                long delay = computeBackoffMillis(retries);
1076
                logger.info("Retrying type-read for invalidator <{}> in {} ms (attempt {}/{})...", invIri, delay, retries, MAX_RETRIES);
1077
                try {
1078
                    Thread.sleep(delay);
1079
                } catch (InterruptedException x) {
1080
                    Thread.currentThread().interrupt();
1081
                }
1082
            }
1083
        }
1084
        return invTypes;
1085
    }
1086

1087
    /**
1088
     * Reads a nanopub's content back from its per-pubkey repo as a list of
1089
     * statements that mirrors what its original load produced into
1090
     * {@code allStatements}, minus the per-repo bookkeeping triples
1091
     * ({@code npa:hasLoadNumber}, {@code npa:hasLoadChecksum},
1092
     * {@code npa:hasLoadTimestamp}). {@link #loadNanopubToRepo} stamps those
1093
     * fresh on every destination repo, so they must be filtered out of the
1094
     * source set.
1095
     *
1096
     * <p>Fetched content:
1097
     * <ul>
1098
     *   <li>All triples in the nanopub's four named graphs, discovered via
1099
     *       {@code <npId> npa:hasGraph ?g} in {@code npa:graph}.</li>
1100
     *   <li>{@code (<npId>, ?p, ?o)} in {@code npa:graph}, excluding the per-repo
1101
     *       bookkeeping predicates above.</li>
1102
     *   <li>{@code (?inv, npx:invalidates, <npId>)} in {@code npa:graph}, plus
1103
     *       the matching {@code npa:hasValidSignatureForPublicKey[Hash]} triples
1104
     *       of each {@code ?inv}, so propagation carries the nanopub's full
1105
     *       invalidator history (which doesn't affect query results — see the
1106
     *       one-hop filter in {@code Utils#defaultQuery} — but keeps repos
1107
     *       consistent).</li>
1108
     *   <li>{@code (<npId>, ?p, ?o)} in {@code npa:networkGraph}.</li>
1109
     * </ul>
1110
     */
1111
    @GeneratedFlagForDependentElements
1112
    private static List<Statement> fetchNanopubAllStatementsFromPubkeyRepo(IRI npId, String pubkey) {
1113
        String repoName = "pubkey_" + Utils.createHash(pubkey);
1114
        boolean success = false;
1115
        int retries = 0;
1116
        List<Statement> result = new ArrayList<>();
1117
        while (!success) {
1118
            result.clear();
1119
            RepositoryConnection conn = TripleStore.get().getRepoConnection(repoName);
1120
            try (conn) {
1121
                // Append-only data + idempotent re-load downstream: READ_COMMITTED suffices.
1122
                conn.begin(IsolationLevels.READ_COMMITTED);
1123

1124
                List<IRI> npGraphs = new ArrayList<>();
1125
                try (RepositoryResult<Statement> r = conn.getStatements(npId, NPA.HAS_GRAPH, null, NPA.GRAPH)) {
1126
                    while (r.hasNext()) {
1127
                        Value o = r.next().getObject();
1128
                        if (o instanceof IRI iri) {
1129
                            npGraphs.add(iri);
1130
                        }
1131
                    }
1132
                }
1133

1134
                for (IRI g : npGraphs) {
1135
                    try (RepositoryResult<Statement> r = conn.getStatements(null, null, null, g)) {
1136
                        while (r.hasNext()) result.add(r.next());
1137
                    }
1138
                }
1139

1140
                try (RepositoryResult<Statement> r = conn.getStatements(npId, null, null, NPA.GRAPH)) {
1141
                    while (r.hasNext()) {
1142
                        Statement st = r.next();
1143
                        IRI p = st.getPredicate();
1144
                        if (p.equals(NPA.HAS_LOAD_NUMBER)
1145
                            || p.equals(NPA.HAS_LOAD_CHECKSUM)
1146
                            || p.equals(NPA.HAS_LOAD_TIMESTAMP)) {
1147
                            continue;
1148
                        }
1149
                        result.add(st);
1150
                    }
1151
                }
1152

1153
                Set<IRI> invalidators = new HashSet<>();
1154
                try (RepositoryResult<Statement> r = conn.getStatements(null, NPX.INVALIDATES, npId, NPA.GRAPH)) {
1155
                    while (r.hasNext()) {
1156
                        Statement st = r.next();
1157
                        result.add(st);
1158
                        if (st.getSubject() instanceof IRI invIri) {
1159
                            invalidators.add(invIri);
1160
                        }
1161
                    }
1162
                }
1163
                for (IRI invIri : invalidators) {
1164
                    try (RepositoryResult<Statement> r = conn.getStatements(invIri, NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY, null, NPA.GRAPH)) {
1165
                        while (r.hasNext()) result.add(r.next());
1166
                    }
1167
                    try (RepositoryResult<Statement> r = conn.getStatements(invIri, NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH, null, NPA.GRAPH)) {
1168
                        while (r.hasNext()) result.add(r.next());
1169
                    }
1170
                }
1171

1172
                try (RepositoryResult<Statement> r = conn.getStatements(npId, null, null, NPA.NETWORK_GRAPH)) {
1173
                    while (r.hasNext()) result.add(r.next());
1174
                }
1175

1176
                conn.commit();
1177
                success = true;
1178
            } catch (Exception ex) {
1179
                logger.warn("Failed to fetch content of nanopub <{}> from repo '{}': {}", npId, repoName, ex.getMessage(), ex);
1180
                if (conn.isActive()) {
1181
                    conn.rollback();
1182
                }
1183
            }
1184
            if (!success) {
1185
                retries++;
1186
                if (retries >= MAX_RETRIES) {
1187
                    throw new RuntimeException("Failed to fetch nanopub content from " + repoName + " for " + npId + " after " + MAX_RETRIES + " retries");
1188
                }
1189
                long delay = computeBackoffMillis(retries);
1190
                logger.info("Retrying content-fetch of <{}> from repo '{}' in {} ms (attempt {}/{})...", npId, repoName, delay, retries, MAX_RETRIES);
1191
                try {
1192
                    Thread.sleep(delay);
1193
                } catch (InterruptedException x) {
1194
                    Thread.currentThread().interrupt();
1195
                }
1196
            }
1197
        }
1198
        return result;
1199
    }
1200

1201
    @GeneratedFlagForDependentElements
1202
    private static RepositoryConnection loadStatements(String repoName, Statement... statements) {
1203
        RepositoryConnection conn = TripleStore.get().getRepoConnection(repoName);
1204
        // Basic isolation: we only append new statements
1205
        conn.begin(IsolationLevels.READ_COMMITTED);
1206
        for (Statement st : statements) {
1207
            conn.add(st);
1208
        }
1209
        return conn;
1210
    }
1211

1212
    @GeneratedFlagForDependentElements
1213
    static List<Statement> getInvalidatingStatements(IRI npId) {
1214
        List<Statement> invalidatingStatements = new ArrayList<>();
1215
        boolean success = false;
1216
        int retries = 0;
1217
        while (!success) {
1218
            RepositoryConnection conn = TripleStore.get().getRepoConnection("meta");
1219
            try (conn) {
1220
                // Basic isolation because here we only read append-only data.
1221
                conn.begin(IsolationLevels.READ_COMMITTED);
1222

1223
                TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * { graph <" + NPA.GRAPH + "> { " + "?np <" + NPX.INVALIDATES + "> <" + npId + "> ; <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY + "> ?pubkey . " + "} }").evaluate();
1224
                try (r) {
1225
                    while (r.hasNext()) {
1226
                        BindingSet b = r.next();
1227
                        invalidatingStatements.add(vf.createStatement((IRI) b.getBinding("np").getValue(), NPX.INVALIDATES, npId, NPA.GRAPH));
1228
                        invalidatingStatements.add(vf.createStatement((IRI) b.getBinding("np").getValue(), NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY, b.getBinding("pubkey").getValue(), NPA.GRAPH));
1229
                    }
1230
                }
1231
                conn.commit();
1232
                success = true;
1233
            } catch (Exception ex) {
1234
                logger.warn("Failed to query existing invalidators of <{}> from meta repo: {}", npId, ex.getMessage(), ex);
1235
                if (conn.isActive()) {
1236
                    conn.rollback();
1237
                }
1238
            }
1239
            if (!success) {
1240
                retries++;
1241
                if (retries >= MAX_RETRIES) {
1242
                    throw new RuntimeException("Failed to get invalidating statements for " + npId + " after " + MAX_RETRIES + " retries");
1243
                }
1244
                long delay = computeBackoffMillis(retries);
1245
                logger.info("Retrying invalidator-query for <{}> in {} ms (attempt {}/{})...", npId, delay, retries, MAX_RETRIES);
1246
                try {
1247
                    Thread.sleep(delay);
1248
                } catch (InterruptedException x) {
1249
                    Thread.currentThread().interrupt();
1250
                }
1251
            }
1252
        }
1253
        return invalidatingStatements;
1254
    }
1255

1256
    @GeneratedFlagForDependentElements
1257
    private static void loadNoteToRepo(Resource subj, String note) {
1258
        boolean success = false;
1259
        int retries = 0;
1260
        while (!success) {
1261
            RepositoryConnection conn = TripleStore.get().getAdminRepoConnection();
1262
            try (conn) {
1263
                List<Statement> statements = new ArrayList<>();
1264
                statements.add(vf.createStatement(subj, NPA.NOTE, vf.createLiteral(note), NPA.GRAPH));
1265
                conn.add(statements);
1266
                success = true;
1267
            } catch (Exception ex) {
1268
                logger.warn("Failed to write note \"{}\" to admin repo for <{}>: {}", note, subj, ex.getMessage(), ex);
1269
            }
1270
            if (!success) {
1271
                retries++;
1272
                if (retries >= MAX_RETRIES) {
1273
                    throw new RuntimeException("Failed to load note to repo for " + subj + " after " + MAX_RETRIES + " retries");
1274
                }
1275
                long delay = computeBackoffMillis(retries);
1276
                logger.info("Retrying note-write for <{}> in {} ms (attempt {}/{})...", subj, delay, retries, MAX_RETRIES);
1277
                try {
1278
                    Thread.sleep(delay);
1279
                } catch (InterruptedException x) {
1280
                    Thread.currentThread().interrupt();
1281
                }
1282
            }
1283
        }
1284
    }
1285

1286
    static boolean hasValidSignature(NanopubSignatureElement el) {
1287
        if (el == null) {
6!
1288
            logger.warn("Signature validation skipped: signature element is null (nanopub has no signature)");
×
1289
            return false;
×
1290
        }
1291
        try {
1292
            if (SignatureUtils.hasValidSignature(el) && el.getPublicKeyString() != null) {
18!
1293
                return true;
6✔
1294
            }
1295
            logger.warn("Signature invalid for <{}> (pubkey: {})",
12✔
1296
                    el.getUri(), el.getPublicKeyString() != null ? el.getPublicKeyString() : "none");
21!
1297
        } catch (GeneralSecurityException ex) {
3✔
1298
            logger.warn("Signature verification threw a security exception for <{}>: {}", el.getUri(), ex.getMessage(), ex);
57✔
1299
        }
3✔
1300
        return false;
6✔
1301
    }
1302

1303
    private static IRI getBaseTrustyUri(Value v) {
1304
        if (!(v instanceof IRI)) {
9!
1305
            return null;
×
1306
        }
1307
        String s = v.stringValue();
9✔
1308
        if (!s.matches(".*[^A-Za-z0-9\\-_]RA[A-Za-z0-9\\-_]{43}([^A-Za-z0-9\\\\-_].{0,43})?")) {
12✔
1309
            return null;
6✔
1310
        }
1311
        return vf.createIRI(s.replaceFirst("^(.*[^A-Za-z0-9\\-_]RA[A-Za-z0-9\\-_]{43})([^A-Za-z0-9\\\\-_].{0,43})?$", "$1"));
21✔
1312
    }
1313

1314
    // TODO: Move this to nanopub library:
1315
    private static boolean isIntroNanopub(Nanopub np) {
1316
        for (Statement st : np.getAssertion()) {
33✔
1317
            if (st.getPredicate().equals(NPX.DECLARED_BY)) {
15✔
1318
                return true;
6✔
1319
            }
1320
        }
3✔
1321
        return false;
6✔
1322
    }
1323

1324
    /**
1325
     * Check if a nanopub is already loaded in the admin graph.
1326
     *
1327
     * @param npId the nanopub ID
1328
     * @return true if the nanopub is loaded, false otherwise
1329
     */
1330
    @GeneratedFlagForDependentElements
1331
    static boolean isNanopubLoaded(String npId) {
1332
        boolean loaded = false;
1333
        RepositoryConnection conn = TripleStore.get().getRepoConnection("meta");
1334
        try (conn) {
1335
            if (Utils.getObjectForPattern(conn, NPA.GRAPH, vf.createIRI(npId), NPA.HAS_LOAD_NUMBER) != null) {
1336
                loaded = true;
1337
            }
1338
        } catch (Exception ex) {
1339
            logger.warn("Could not check load status of <{}>: {}", npId, ex.getMessage(), ex);
1340
        }
1341
        return loaded;
1342
    }
1343

1344
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
1345

1346
    // TODO remove the constants and use the ones from the nanopub library instead
1347

1348
    /**
1349
     * Template for the query that fetches the status of a repository.
1350
     */
1351
    // Template for .fetchRepoStatus
1352
    private static final String REPO_STATUS_QUERY_TEMPLATE = """
84✔
1353
            SELECT * { graph <%s> {
1354
              OPTIONAL { <%s> <%s> ?loadNumber . }
1355
              <%s> <%s> ?count ;
1356
                   <%s> ?checksum .
1357
            } }
1358
            """.formatted(NPA.GRAPH, "%s", NPA.HAS_LOAD_NUMBER, NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT, NPA.HAS_NANOPUB_CHECKSUM);
6✔
1359
}
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