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

knowledgepixels / nanopub-query / 26211688342

21 May 2026 07:22AM UTC coverage: 58.112% (+0.09%) from 58.023%
26211688342

push

github

web-flow
Merge pull request #105 from knowledgepixels/fix/narrow-spaces-load-rule

Narrow spaces-repo load gate to space-relevant nanopubs and their invalidators

476 of 906 branches covered (52.54%)

Branch coverage included in aggregate %.

1322 of 2188 relevant lines covered (60.42%)

9.28 hits per line

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

71.05
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

36
import java.security.GeneralSecurityException;
37
import java.util.*;
38
import java.util.concurrent.ExecutionException;
39
import java.util.concurrent.Executors;
40
import java.util.concurrent.Future;
41
import java.util.concurrent.ThreadLocalRandom;
42
import java.util.concurrent.ThreadPoolExecutor;
43
import java.util.function.Consumer;
44

45
/**
46
 * Utility class for loading nanopublications into the database.
47
 */
48
public class NanopubLoader {
49

50
    private static HttpClient httpClient;
51
    private static final ThreadPoolExecutor loadingPool = (ThreadPoolExecutor) Executors.newFixedThreadPool(4);
12✔
52

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

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

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

100
    /**
101
     * Returns the sleep delay in ms for the given 1-indexed retry attempt. Delay
102
     * is {@link #BACKOFF_BASE_MS}{@code [attempt-1]} perturbed by ±50 % uniform
103
     * jitter, clamped to be non-negative.
104
     *
105
     * @param attempt 1-indexed retry attempt number
106
     * @return the computed sleep delay in ms
107
     */
108
    static long computeBackoffMillis(int attempt) {
109
        long base = BACKOFF_BASE_MS[Math.min(attempt - 1, BACKOFF_BASE_MS.length - 1)];
×
110
        long jitter = ThreadLocalRandom.current().nextLong(base + 1) - base / 2;
×
111
        return Math.max(0L, base + jitter);
×
112
    }
113
    private Nanopub np;
114
    private NanopubSignatureElement el = null;
9✔
115
    private List<Statement> metaStatements = new ArrayList<>();
15✔
116
    private List<Statement> nanopubStatements = new ArrayList<>();
15✔
117
    private List<Statement> literalStatements = new ArrayList<>();
15✔
118
    private List<Statement> invalidateStatements = new ArrayList<>();
15✔
119
    private List<Statement> textStatements, allStatements, invalidatingStatements;
120
    private List<Statement> spaceExtractionStatements = new ArrayList<>();
15✔
121
    private Calendar timestamp = null;
9✔
122
    private Statement pubkeyStatement, pubkeyStatementX;
123
    private List<String> notes = new ArrayList<>();
15✔
124
    private boolean aborted = false;
9✔
125
    private static final Logger log = LoggerFactory.getLogger(NanopubLoader.class);
9✔
126

127

128
    NanopubLoader(Nanopub np, long counter) {
6✔
129
        this.np = np;
9✔
130
        if (counter >= 0) {
12✔
131
            log.info("Loading {}: {}", counter, np.getUri());
24✔
132
        } else {
133
            log.info("Loading: {}", np.getUri());
15✔
134
        }
135

136
        // TODO Ensure proper synchronization and DB rollbacks
137

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

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

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

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

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

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

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

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

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

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

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

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

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

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

335
        metaStatements.addAll(invalidateStatements);
18✔
336

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

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

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

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

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

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

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

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

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

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

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

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

456
            for (Statement st : invalidateStatements) {
457
                runTask.accept(() -> loadInvalidateStatements(np, el.getPublicKeyString(), st, pubkeyStatement, pubkeyStatementX, allStatements));
×
458
            }
459

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

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

498
            // Now submit and wait for the "meta" task after all other tasks have completed successfully
499
            Future<?> metaTask = loadingPool.submit(() -> loadNanopubToRepo(np.getUri(), metaStatements, "meta"));
×
500
            try {
501
                metaTask.get();
502
            } catch (ExecutionException | InterruptedException ex) {
503
                throw new RuntimeException("Error in nanopub loading thread (meta task)", ex.getCause());
504
            }
505
        }
506
    }
507

508
    private static Long lastUpdateOfLatestRepo = null;
6✔
509
    private static long THIRTY_DAYS = 1000L * 60 * 60 * 24 * 30;
6✔
510
    private static long ONE_HOUR = 1000L * 60 * 60;
6✔
511

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

566
    @GeneratedFlagForDependentElements
567
    private static void loadNanopubToRepo(IRI npId, List<Statement> statements, String repoName) {
568
        boolean success = false;
569
        int retries = 0;
570
        while (!success) {
571
            RepositoryConnection conn = TripleStore.get().getRepoConnection(repoName);
572
            long newCountForCache = -1;
573
            String newChecksumForCache = null;
574
            try (conn) {
575
                // Serializable, because write skew would cause the chain of hashes to be broken.
576
                // The inserts must be done serially.
577
                conn.begin(IsolationLevels.SERIALIZABLE);
578
                var repoStatus = fetchRepoStatus(conn, npId);
579
                if (repoStatus.isLoaded) {
580
                    log.info("Already loaded: {}", npId);
581
                } else {
582
                    String newChecksum = NanopubUtils.updateXorChecksum(npId, repoStatus.checksum);
583
                    conn.remove(NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT, null, NPA.GRAPH);
584
                    conn.remove(NPA.THIS_REPO, NPA.HAS_NANOPUB_CHECKSUM, null, NPA.GRAPH);
585
                    conn.add(NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT, vf.createLiteral(repoStatus.count + 1), NPA.GRAPH);
586
                    // @ADMIN-TRIPLE-TABLE@ REPO, npa:hasNanopubCount, NANOPUB_COUNT, npa:graph, admin, number of nanopubs loaded
587
                    conn.add(NPA.THIS_REPO, NPA.HAS_NANOPUB_CHECKSUM, vf.createLiteral(newChecksum), NPA.GRAPH);
588
                    // @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)
589
                    conn.add(npId, NPA.HAS_LOAD_NUMBER, vf.createLiteral(repoStatus.count), NPA.GRAPH);
590
                    // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasLoadNumber, LOAD_NUMBER, npa:graph, admin, the sequential number at which this NANOPUB was loaded
591
                    conn.add(npId, NPA.HAS_LOAD_CHECKSUM, vf.createLiteral(newChecksum), NPA.GRAPH);
592
                    // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasLoadChecksum, LOAD_CHECKSUM, npa:graph, admin, the checksum of all loaded nanopubs after loading the given NANOPUB
593
                    conn.add(npId, NPA.HAS_LOAD_TIMESTAMP, vf.createLiteral(new Date()), NPA.GRAPH);
594
                    // @ADMIN-TRIPLE-TABLE@ NANOPUB, npa:hasLoadTimestamp, LOAD_TIMESTAMP, npa:graph, admin, the time point at which this NANOPUB was loaded
595
                    conn.add(statements);
596
                    if ("meta".equals(repoName)) {
597
                        newCountForCache = repoStatus.count + 1;
598
                        newChecksumForCache = newChecksum;
599
                    }
600
                }
601
                conn.commit();
602
                if (newCountForCache >= 0) {
603
                    loadedNanopubCount = newCountForCache;
604
                }
605
                if (newChecksumForCache != null) {
606
                    loadedNanopubChecksum = newChecksumForCache;
607
                }
608
                success = true;
609
            } catch (Exception ex) {
610
                log.warn("Could not load nanopub {} to repo {}.", npId, repoName, ex);
611
                if (conn.isActive()) conn.rollback();
612
            }
613
            if (!success) {
614
                retries++;
615
                if (retries >= MAX_RETRIES) {
616
                    throw new RuntimeException("Failed to load nanopub " + npId + " to repo " + repoName + " after " + MAX_RETRIES + " retries");
617
                }
618
                long delay = computeBackoffMillis(retries);
619
                log.info("Retrying in {} ms for nanopub {} in repo {} (attempt {}/{})...", delay, npId, repoName, retries, MAX_RETRIES);
620
                try {
621
                    Thread.sleep(delay);
622
                } catch (InterruptedException x) {
623
                    Thread.currentThread().interrupt();
624
                }
625
            }
626
        }
627
    }
628

629
    /**
630
     * Writes the raw nanopub statements (all four graphs) into the {@code spaces}
631
     * repo alongside the pre-computed extraction statements (which target
632
     * {@code npa:spacesGraph}). Stamps the load-number on the nanopub IRI and bumps
633
     * {@code npa:thisRepo npa:currentLoadCounter} in {@code npa:graph}, all within
634
     * one serializable transaction.
635
     *
636
     * <p>Idempotent: if the nanopub already has a {@code npa:hasLoadNumber} stamp in
637
     * {@code npa:graph} of the {@code spaces} repo, this is a no-op.
638
     *
639
     * @param npId            nanopub IRI
640
     * @param nanopubTriples  raw nanopub statements (all four graphs + meta)
641
     * @param spaceExtraction summary triples destined for {@code npa:spacesGraph}
642
     */
643
    @GeneratedFlagForDependentElements
644
    private static void loadToSpacesRepo(IRI npId, List<Statement> nanopubTriples,
645
                                         List<Statement> spaceExtraction) {
646
        boolean success = false;
647
        int retries = 0;
648
        while (!success) {
649
            RepositoryConnection conn = TripleStore.get().getRepoConnection("spaces");
650
            try (conn) {
651
                conn.begin(IsolationLevels.SERIALIZABLE);
652
                // Idempotency: skip if this nanopub is already stamped in this repo.
653
                if (Utils.getObjectForPattern(conn, NPA.GRAPH, npId, NPA.HAS_LOAD_NUMBER) != null) {
654
                    conn.commit();
655
                    success = true;
656
                    continue;
657
                }
658
                long newCounter = fetchSpacesLoadCounter(conn) + 1;
659
                conn.remove(NPA.THIS_REPO,
660
                        com.knowledgepixels.query.vocabulary.SpacesVocab.CURRENT_LOAD_COUNTER,
661
                        null, NPA.GRAPH);
662
                conn.add(NPA.THIS_REPO,
663
                        com.knowledgepixels.query.vocabulary.SpacesVocab.CURRENT_LOAD_COUNTER,
664
                        vf.createLiteral(newCounter), NPA.GRAPH);
665
                conn.add(npId, NPA.HAS_LOAD_NUMBER, vf.createLiteral(newCounter), NPA.GRAPH);
666
                conn.add(nanopubTriples);
667
                conn.add(spaceExtraction);
668
                conn.commit();
669
                success = true;
670
            } catch (Exception ex) {
671
                log.warn("Could not load nanopub {} to spaces repo.", npId, ex);
672
                if (conn.isActive()) conn.rollback();
673
            }
674
            if (!success) {
675
                retries++;
676
                if (retries >= MAX_RETRIES) {
677
                    throw new RuntimeException("Failed to load nanopub " + npId + " to spaces repo after " + MAX_RETRIES + " retries");
678
                }
679
                long delay = computeBackoffMillis(retries);
680
                log.info("Retrying in {} ms for nanopub {} in spaces repo (attempt {}/{})...", delay, npId, retries, MAX_RETRIES);
681
                try {
682
                    Thread.sleep(delay);
683
                } catch (InterruptedException x) {
684
                    Thread.currentThread().interrupt();
685
                }
686
            }
687
        }
688
    }
689

690
    /**
691
     * Returns the cumulative count of nanopubs ever loaded into the {@code meta}
692
     * repo, or {@code null} if the value cannot be determined (e.g. the store
693
     * hasn't been initialised yet). Reads the persisted {@code npa:hasNanopubCount}
694
     * triple on first call and caches it in {@link #loadedNanopubCount};
695
     * subsequent fresh loads update the cache in-place.
696
     */
697
    public static Long getLoadedNanopubCount() {
698
        Long v = loadedNanopubCount;
6✔
699
        if (v != null) return v;
12✔
700
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection("meta")) {
12✔
701
            Value val = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT);
×
702
            if (val != null) {
×
703
                v = Long.parseLong(val.stringValue());
×
704
                loadedNanopubCount = v;
×
705
                return v;
×
706
            }
707
        } catch (NumberFormatException ex) {
×
708
            log.warn("Invalid npa:hasNanopubCount literal in meta repo", ex);
×
709
        } catch (Exception ex) {
3✔
710
            log.warn("Could not read npa:hasNanopubCount from meta repo", ex);
12✔
711
        }
×
712
        return null;
6✔
713
    }
714

715
    /**
716
     * Returns the order-independent XOR checksum (Base64-encoded) of trusty URIs
717
     * of all nanopubs ever loaded into the {@code meta} repo, or {@code null} if
718
     * the value cannot be determined (e.g. the store hasn't been initialised
719
     * yet). Reads the persisted {@code npa:hasNanopubChecksum} triple on first
720
     * call and caches it in {@link #loadedNanopubChecksum}; subsequent fresh
721
     * loads update the cache in-place alongside {@link #loadedNanopubCount}.
722
     */
723
    public static String getLoadedNanopubChecksum() {
724
        String v = loadedNanopubChecksum;
6✔
725
        if (v != null) return v;
12✔
726
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection("meta")) {
12✔
727
            Value val = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO, NPA.HAS_NANOPUB_CHECKSUM);
×
728
            if (val != null) {
×
729
                v = val.stringValue();
×
730
                loadedNanopubChecksum = v;
×
731
                return v;
×
732
            }
733
        } catch (Exception ex) {
3!
734
            log.warn("Could not read npa:hasNanopubChecksum from meta repo", ex);
12✔
735
        }
×
736
        return null;
6✔
737
    }
738

739
    private static long fetchSpacesLoadCounter(RepositoryConnection conn) {
740
        Value v = Utils.getObjectForPattern(conn, NPA.GRAPH, NPA.THIS_REPO,
×
741
                com.knowledgepixels.query.vocabulary.SpacesVocab.CURRENT_LOAD_COUNTER);
742
        if (v == null) return 0;
×
743
        try {
744
            return Long.parseLong(v.stringValue());
×
745
        } catch (NumberFormatException ex) {
×
746
            log.warn("Invalid npa:currentLoadCounter literal in spaces repo: {}", v);
×
747
            return 0;
×
748
        }
749
    }
750

751
    private record RepoStatus(boolean isLoaded, long count, String checksum) {
×
752
    }
753

754
    /**
755
     * To execute before loading a nanopub: check if the nanopub is already loaded and what is the
756
     * current load counter and checksum. This effectively batches three queries into one.
757
     * This method must be called from within a transaction.
758
     *
759
     * @param conn repo connection
760
     * @param npId nanopub ID
761
     * @return the current status
762
     */
763
    @GeneratedFlagForDependentElements
764
    private static RepoStatus fetchRepoStatus(RepositoryConnection conn, IRI npId) {
765
        var result = conn.prepareTupleQuery(QueryLanguage.SPARQL, REPO_STATUS_QUERY_TEMPLATE.formatted(npId)).evaluate();
766
        try (result) {
767
            if (!result.hasNext()) {
768
                // This may happen if the repo was created, but is completely empty.
769
                return new RepoStatus(false, 0, NanopubUtils.INIT_CHECKSUM);
770
            }
771
            var row = result.next();
772
            return new RepoStatus(row.hasBinding("loadNumber"), Long.parseLong(row.getBinding("count").getValue().stringValue()), row.getBinding("checksum").getValue().stringValue());
773
        }
774
    }
775

776
    @GeneratedFlagForDependentElements
777
    private static void loadInvalidateStatements(Nanopub thisNp, String thisPubkey, Statement invalidateStatement, Statement pubkeyStatement, Statement pubkeyStatementX, List<Statement> thisAllStatements) {
778
        boolean success = false;
779
        int retries = 0;
780
        List<IRI> typesToLoadFullInto = new ArrayList<>();
781
        boolean targetIsSpaceRelevant = false;
782
        while (!success) {
783
            typesToLoadFullInto.clear();
784
            targetIsSpaceRelevant = false;
785
            List<RepositoryConnection> connections = new ArrayList<>();
786
            RepositoryConnection metaConn = TripleStore.get().getRepoConnection("meta");
787
            try {
788
                IRI invalidatedNpId = (IRI) invalidateStatement.getObject();
789
                // Basic isolation because here we only read append-only data.
790
                metaConn.begin(IsolationLevels.READ_COMMITTED);
791

792
                Value pubkeyValue = Utils.getObjectForPattern(metaConn, NPA.GRAPH, invalidatedNpId, NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY);
793
                if (pubkeyValue != null) {
794
                    String pubkey = pubkeyValue.stringValue();
795

796
                    if (!pubkey.equals(thisPubkey)) {
797
                        //log.info("Adding invalidation expressed in " + thisNp.getUri() + " also to repo for pubkey " + pubkey);
798
                        connections.add(loadStatements("pubkey_" + Utils.createHash(pubkey), invalidateStatement, pubkeyStatement, pubkeyStatementX));
799
//                                                connections.add(loadStatements("text-pubkey_" + Utils.createHash(pubkey), invalidateStatement, pubkeyStatement));
800
                    }
801

802
                    Set<IRI> thisNpTypes = NanopubUtils.getTypes(thisNp);
803
                    for (Value v : Utils.getObjectsForPattern(metaConn, NPA.GRAPH, invalidatedNpId, NPX.HAS_NANOPUB_TYPE)) {
804
                        if (v instanceof IRI typeIri) {
805
                            if (!thisNpTypes.contains(typeIri)) {
806
                                // Defer until after the meta-read commits — full load goes
807
                                // through loadNanopubToRepo, which has its own transaction
808
                                // and retry loop (see post-loop block below).
809
                                typesToLoadFullInto.add(typeIri);
810
                            }
811
                            if (SpacesExtractor.TRIGGER_TYPES.contains(typeIri)) {
812
                                // Target carries a space-relevant type — propagate the
813
                                // retractor into the spaces repo too (deferred, same
814
                                // reason as above).
815
                                targetIsSpaceRelevant = true;
816
                            }
817
                        }
818
                    }
819

820
//                                        for (Value v : Utils.getObjectsForPattern(metaConn, NPA.GRAPH, invalidatedNpId, DCTERMS.CREATOR)) {
821
//                                                IRI creatorIri = (IRI) v;
822
//                                                if (!SimpleCreatorPattern.getCreators(thisNp).contains(creatorIri)) {
823
//                                                        //log.info("Adding invalidation expressed in " + thisNp.getUri() + " also to repo for user " + creatorIri);
824
//                                                        connections.add(loadStatements("user_" + Utils.createHash(creatorIri), invalidateStatement, pubkeyStatement));
825
//                                                        connections.add(loadStatements("text-user_" + Utils.createHash(creatorIri), invalidateStatement, pubkeyStatement));
826
//                                                }
827
//                                        }
828
                }
829

830
                metaConn.commit();
831
                // TODO handle case that some commits succeed and some fail
832
                for (RepositoryConnection c : connections) c.commit();
833
                success = true;
834
            } catch (Exception ex) {
835
                log.warn("Could not load invalidate statements for {}.", thisNp.getUri(), ex);
836
                if (metaConn.isActive()) metaConn.rollback();
837
                for (RepositoryConnection c : connections) {
838
                    if (c.isActive()) c.rollback();
839
                }
840
            } finally {
841
                metaConn.close();
842
                for (RepositoryConnection c : connections) c.close();
843
            }
844
            if (!success) {
845
                retries++;
846
                if (retries >= MAX_RETRIES) {
847
                    throw new RuntimeException("Failed to load invalidate statements for " + thisNp.getUri() + " after " + MAX_RETRIES + " retries");
848
                }
849
                long delay = computeBackoffMillis(retries);
850
                log.info("Retrying in {} ms for invalidate statements of {} (attempt {}/{})...", delay, thisNp.getUri(), retries, MAX_RETRIES);
851
                try {
852
                    Thread.sleep(delay);
853
                } catch (InterruptedException x) {
854
                    Thread.currentThread().interrupt();
855
                }
856
            }
857
        }
858
        // Mirror the Registries' behaviour: index a retraction under the types of the
859
        // nanopub it invalidates, even when the retractor itself doesn't carry those
860
        // types. Load the full retracting nanopub (not just the npx:invalidates marker)
861
        // so a query against a type repo can fetch the retractor's own assertion /
862
        // provenance / pubinfo, not only the join handle.
863
        // loadNanopubToRepo is idempotent (early-exit on npa:hasLoadNumber) and runs
864
        // its own SERIALIZABLE transaction + retry loop, so it's safe to call here.
865
        for (IRI typeIri : typesToLoadFullInto) {
866
            loadNanopubToRepo(thisNp.getUri(), thisAllStatements, "type_" + Utils.createHash(typeIri));
867
        }
868
        // Same rationale for the spaces repo: when the invalidated nanopub is itself
869
        // space-relevant, the retractor needs to land in the spaces repo so the
870
        // materialiser's invalidation join (?invNp npx:invalidates ?np +
871
        // ?invNp npa:hasLoadNumber ?ln in npa:graph) finds it. spaceExtractionStatements
872
        // is empty here — the retractor is not space-relevant by itself, otherwise it
873
        // would have already been loaded to spaces by the regular spaces-load task.
874
        if (targetIsSpaceRelevant && FeatureFlags.spacesEnabled()) {
875
            loadToSpacesRepo(thisNp.getUri(), thisAllStatements, Collections.emptyList());
876
        }
877
    }
878

879
    /**
880
     * Extracts a map from invalidator IRI to that invalidator's pubkey literal
881
     * out of an {@code invalidatingStatements} list as produced by
882
     * {@link #getInvalidatingStatements}. The list interleaves
883
     * {@code (?inv, npx:invalidates, ?np)} and
884
     * {@code (?inv, npa:hasValidSignatureForPublicKey, ?pubkey)} triples per
885
     * invalidator; this helper picks out only the pubkey-binding triples.
886
     */
887
    private static Map<IRI, String> collectInvalidatorPubkeys(List<Statement> invalidatingStatements) {
888
        Map<IRI, String> result = new LinkedHashMap<>();
×
889
        for (Statement st : invalidatingStatements) {
×
890
            if (st.getPredicate().equals(NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY)
×
891
                    && st.getSubject() instanceof IRI invIri) {
×
892
                result.put(invIri, st.getObject().stringValue());
×
893
            }
894
        }
×
895
        return result;
×
896
    }
897

898
    /**
899
     * Reverse-order counterpart of the forward-order full-content propagation in
900
     * {@link #loadInvalidateStatements}: when this nanopub had already-loaded
901
     * retractors at the time of its own load (captured by
902
     * {@link #getInvalidatingStatements}), load each retractor's full content
903
     * into this nanopub's per-type repos — restricted to those types the
904
     * retractor doesn't itself carry (the retractor's own load already populated
905
     * the type repos it covers).
906
     *
907
     * <p>Source is the retractor's per-pubkey repo, which is the one shard
908
     * unconditionally populated for every successfully-loaded nanopub. The
909
     * retractor's types are read from the meta repo so we can skip type repos
910
     * the retractor's own regular load already populated.
911
     */
912
    @GeneratedFlagForDependentElements
913
    private static void loadInvalidatorIntoTypeRepos(IRI invIri, String invPubkey, IRI thisNpId, Set<IRI> thisNpTypes) {
914
        Set<IRI> invTypes = readInvalidatorTypesFromMeta(invIri, thisNpId);
915

916
        List<IRI> typesToLoadInto = new ArrayList<>();
917
        for (IRI typeIri : thisNpTypes) {
918
            // Match the regular per-type load loop's exclusion of locally-minted IRIs.
919
            if (typeIri.stringValue().startsWith(thisNpId.stringValue())) continue;
920
            if (!typeIri.stringValue().matches("https?://.*")) continue;
921
            if (!invTypes.contains(typeIri)) typesToLoadInto.add(typeIri);
922
        }
923
        if (typesToLoadInto.isEmpty()) return;
924

925
        List<Statement> invContent = fetchNanopubAllStatementsFromPubkeyRepo(invIri, invPubkey);
926
        for (IRI typeIri : typesToLoadInto) {
927
            loadNanopubToRepo(invIri, invContent, "type_" + Utils.createHash(typeIri));
928
        }
929
    }
930

931
    /**
932
     * Reverse-order counterpart for the spaces repo: when a space-relevant nanopub
933
     * is loaded and {@link #getInvalidatingStatements} captured already-loaded
934
     * retractors of it, load each retractor's full content into the spaces repo so
935
     * the materialiser's invalidation join (?invNp npx:invalidates ?np +
936
     * ?invNp npa:hasLoadNumber ?ln in npa:graph) finds it regardless of the
937
     * load order between target and retractor.
938
     *
939
     * <p>Source is the retractor's per-pubkey repo (the one shard unconditionally
940
     * populated for every successfully-loaded nanopub). Passes an empty
941
     * {@code spaceExtraction} list to {@link #loadToSpacesRepo}: by construction
942
     * the retractor would already be in the spaces repo by its regular spaces-load
943
     * task if it carried space-relevant extractions of its own.
944
     * {@link #loadToSpacesRepo} is idempotent on {@code npa:hasLoadNumber}, so
945
     * the double-load case is a no-op.
946
     */
947
    @GeneratedFlagForDependentElements
948
    private static void loadInvalidatorIntoSpacesRepo(IRI invIri, String invPubkey, IRI thisNpId) {
949
        List<Statement> invContent = fetchNanopubAllStatementsFromPubkeyRepo(invIri, invPubkey);
950
        loadToSpacesRepo(invIri, invContent, Collections.emptyList());
951
    }
952

953
    @GeneratedFlagForDependentElements
954
    private static Set<IRI> readInvalidatorTypesFromMeta(IRI invIri, IRI thisNpId) {
955
        Set<IRI> invTypes = new HashSet<>();
956
        boolean success = false;
957
        int retries = 0;
958
        while (!success) {
959
            invTypes.clear();
960
            RepositoryConnection metaConn = TripleStore.get().getRepoConnection("meta");
961
            try (metaConn) {
962
                metaConn.begin(IsolationLevels.READ_COMMITTED);
963
                for (Value v : Utils.getObjectsForPattern(metaConn, NPA.GRAPH, invIri, NPX.HAS_NANOPUB_TYPE)) {
964
                    if (v instanceof IRI ti) invTypes.add(ti);
965
                }
966
                metaConn.commit();
967
                success = true;
968
            } catch (Exception ex) {
969
                log.warn("Could not read invalidator types for {} (target nanopub {}).", invIri, thisNpId, ex);
970
                if (metaConn.isActive()) metaConn.rollback();
971
            }
972
            if (!success) {
973
                retries++;
974
                if (retries >= MAX_RETRIES) {
975
                    throw new RuntimeException("Failed to read invalidator types for " + invIri + " after " + MAX_RETRIES + " retries");
976
                }
977
                long delay = computeBackoffMillis(retries);
978
                log.info("Retrying in {} ms for invalidator types of {} (attempt {}/{})...", delay, invIri, retries, MAX_RETRIES);
979
                try {
980
                    Thread.sleep(delay);
981
                } catch (InterruptedException x) {
982
                    Thread.currentThread().interrupt();
983
                }
984
            }
985
        }
986
        return invTypes;
987
    }
988

989
    /**
990
     * Reads a nanopub's content back from its per-pubkey repo as a list of
991
     * statements that mirrors what its original load produced into
992
     * {@code allStatements}, minus the per-repo bookkeeping triples
993
     * ({@code npa:hasLoadNumber}, {@code npa:hasLoadChecksum},
994
     * {@code npa:hasLoadTimestamp}). {@link #loadNanopubToRepo} stamps those
995
     * fresh on every destination repo, so they must be filtered out of the
996
     * source set.
997
     *
998
     * <p>Fetched content:
999
     * <ul>
1000
     *   <li>All triples in the nanopub's four named graphs, discovered via
1001
     *       {@code <npId> npa:hasGraph ?g} in {@code npa:graph}.</li>
1002
     *   <li>{@code (<npId>, ?p, ?o)} in {@code npa:graph}, excluding the per-repo
1003
     *       bookkeeping predicates above.</li>
1004
     *   <li>{@code (?inv, npx:invalidates, <npId>)} in {@code npa:graph}, plus
1005
     *       the matching {@code npa:hasValidSignatureForPublicKey[Hash]} triples
1006
     *       of each {@code ?inv}, so propagation carries the nanopub's full
1007
     *       invalidator history (which doesn't affect query results — see the
1008
     *       one-hop filter in {@code Utils#defaultQuery} — but keeps repos
1009
     *       consistent).</li>
1010
     *   <li>{@code (<npId>, ?p, ?o)} in {@code npa:networkGraph}.</li>
1011
     * </ul>
1012
     */
1013
    @GeneratedFlagForDependentElements
1014
    private static List<Statement> fetchNanopubAllStatementsFromPubkeyRepo(IRI npId, String pubkey) {
1015
        String repoName = "pubkey_" + Utils.createHash(pubkey);
1016
        boolean success = false;
1017
        int retries = 0;
1018
        List<Statement> result = new ArrayList<>();
1019
        while (!success) {
1020
            result.clear();
1021
            RepositoryConnection conn = TripleStore.get().getRepoConnection(repoName);
1022
            try (conn) {
1023
                // Append-only data + idempotent re-load downstream: READ_COMMITTED suffices.
1024
                conn.begin(IsolationLevels.READ_COMMITTED);
1025

1026
                List<IRI> npGraphs = new ArrayList<>();
1027
                try (RepositoryResult<Statement> r = conn.getStatements(npId, NPA.HAS_GRAPH, null, NPA.GRAPH)) {
1028
                    while (r.hasNext()) {
1029
                        Value o = r.next().getObject();
1030
                        if (o instanceof IRI iri) npGraphs.add(iri);
1031
                    }
1032
                }
1033

1034
                for (IRI g : npGraphs) {
1035
                    try (RepositoryResult<Statement> r = conn.getStatements(null, null, null, g)) {
1036
                        while (r.hasNext()) result.add(r.next());
1037
                    }
1038
                }
1039

1040
                try (RepositoryResult<Statement> r = conn.getStatements(npId, null, null, NPA.GRAPH)) {
1041
                    while (r.hasNext()) {
1042
                        Statement st = r.next();
1043
                        IRI p = st.getPredicate();
1044
                        if (p.equals(NPA.HAS_LOAD_NUMBER)
1045
                                || p.equals(NPA.HAS_LOAD_CHECKSUM)
1046
                                || p.equals(NPA.HAS_LOAD_TIMESTAMP)) {
1047
                            continue;
1048
                        }
1049
                        result.add(st);
1050
                    }
1051
                }
1052

1053
                Set<IRI> invalidators = new HashSet<>();
1054
                try (RepositoryResult<Statement> r = conn.getStatements(null, NPX.INVALIDATES, npId, NPA.GRAPH)) {
1055
                    while (r.hasNext()) {
1056
                        Statement st = r.next();
1057
                        result.add(st);
1058
                        if (st.getSubject() instanceof IRI invIri) invalidators.add(invIri);
1059
                    }
1060
                }
1061
                for (IRI invIri : invalidators) {
1062
                    try (RepositoryResult<Statement> r = conn.getStatements(invIri, NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY, null, NPA.GRAPH)) {
1063
                        while (r.hasNext()) result.add(r.next());
1064
                    }
1065
                    try (RepositoryResult<Statement> r = conn.getStatements(invIri, NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY_HASH, null, NPA.GRAPH)) {
1066
                        while (r.hasNext()) result.add(r.next());
1067
                    }
1068
                }
1069

1070
                try (RepositoryResult<Statement> r = conn.getStatements(npId, null, null, NPA.NETWORK_GRAPH)) {
1071
                    while (r.hasNext()) result.add(r.next());
1072
                }
1073

1074
                conn.commit();
1075
                success = true;
1076
            } catch (Exception ex) {
1077
                log.warn("Could not fetch nanopub content from {} for {}.", repoName, npId, ex);
1078
                if (conn.isActive()) conn.rollback();
1079
            }
1080
            if (!success) {
1081
                retries++;
1082
                if (retries >= MAX_RETRIES) {
1083
                    throw new RuntimeException("Failed to fetch nanopub content from " + repoName + " for " + npId + " after " + MAX_RETRIES + " retries");
1084
                }
1085
                long delay = computeBackoffMillis(retries);
1086
                log.info("Retrying in {} ms for fetching nanopub content of {} from {} (attempt {}/{})...", delay, npId, repoName, retries, MAX_RETRIES);
1087
                try {
1088
                    Thread.sleep(delay);
1089
                } catch (InterruptedException x) {
1090
                    Thread.currentThread().interrupt();
1091
                }
1092
            }
1093
        }
1094
        return result;
1095
    }
1096

1097
    @GeneratedFlagForDependentElements
1098
    private static RepositoryConnection loadStatements(String repoName, Statement... statements) {
1099
        RepositoryConnection conn = TripleStore.get().getRepoConnection(repoName);
1100
        // Basic isolation: we only append new statements
1101
        conn.begin(IsolationLevels.READ_COMMITTED);
1102
        for (Statement st : statements) {
1103
            conn.add(st);
1104
        }
1105
        return conn;
1106
    }
1107

1108
    @GeneratedFlagForDependentElements
1109
    static List<Statement> getInvalidatingStatements(IRI npId) {
1110
        List<Statement> invalidatingStatements = new ArrayList<>();
1111
        boolean success = false;
1112
        int retries = 0;
1113
        while (!success) {
1114
            RepositoryConnection conn = TripleStore.get().getRepoConnection("meta");
1115
            try (conn) {
1116
                // Basic isolation because here we only read append-only data.
1117
                conn.begin(IsolationLevels.READ_COMMITTED);
1118

1119
                TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, "SELECT * { graph <" + NPA.GRAPH + "> { " + "?np <" + NPX.INVALIDATES + "> <" + npId + "> ; <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY + "> ?pubkey . " + "} }").evaluate();
1120
                try (r) {
1121
                    while (r.hasNext()) {
1122
                        BindingSet b = r.next();
1123
                        invalidatingStatements.add(vf.createStatement((IRI) b.getBinding("np").getValue(), NPX.INVALIDATES, npId, NPA.GRAPH));
1124
                        invalidatingStatements.add(vf.createStatement((IRI) b.getBinding("np").getValue(), NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY, b.getBinding("pubkey").getValue(), NPA.GRAPH));
1125
                    }
1126
                }
1127
                conn.commit();
1128
                success = true;
1129
            } catch (Exception ex) {
1130
                log.warn("Could not load invalidating statements for {}.", npId, ex);
1131
                if (conn.isActive()) conn.rollback();
1132
            }
1133
            if (!success) {
1134
                retries++;
1135
                if (retries >= MAX_RETRIES) {
1136
                    throw new RuntimeException("Failed to get invalidating statements for " + npId + " after " + MAX_RETRIES + " retries");
1137
                }
1138
                long delay = computeBackoffMillis(retries);
1139
                log.info("Retrying in {} ms for invalidating statements of {} (attempt {}/{})...", delay, npId, retries, MAX_RETRIES);
1140
                try {
1141
                    Thread.sleep(delay);
1142
                } catch (InterruptedException x) {
1143
                    Thread.currentThread().interrupt();
1144
                }
1145
            }
1146
        }
1147
        return invalidatingStatements;
1148
    }
1149

1150
    @GeneratedFlagForDependentElements
1151
    private static void loadNoteToRepo(Resource subj, String note) {
1152
        boolean success = false;
1153
        int retries = 0;
1154
        while (!success) {
1155
            RepositoryConnection conn = TripleStore.get().getAdminRepoConnection();
1156
            try (conn) {
1157
                List<Statement> statements = new ArrayList<>();
1158
                statements.add(vf.createStatement(subj, NPA.NOTE, vf.createLiteral(note), NPA.GRAPH));
1159
                conn.add(statements);
1160
                success = true;
1161
            } catch (Exception ex) {
1162
                log.warn("Could not load note to repo for {}.", subj, ex);
1163
            }
1164
            if (!success) {
1165
                retries++;
1166
                if (retries >= MAX_RETRIES) {
1167
                    throw new RuntimeException("Failed to load note to repo for " + subj + " after " + MAX_RETRIES + " retries");
1168
                }
1169
                long delay = computeBackoffMillis(retries);
1170
                log.info("Retrying in {} ms for note on {} (attempt {}/{})...", delay, subj, retries, MAX_RETRIES);
1171
                try {
1172
                    Thread.sleep(delay);
1173
                } catch (InterruptedException x) {
1174
                    Thread.currentThread().interrupt();
1175
                }
1176
            }
1177
        }
1178
    }
1179

1180
    static boolean hasValidSignature(NanopubSignatureElement el) {
1181
        if (el == null) {
6!
1182
            log.warn("Signature validation: signature element is null");
×
1183
            return false;
×
1184
        }
1185
        try {
1186
            if (SignatureUtils.hasValidSignature(el) && el.getPublicKeyString() != null) {
18!
1187
                return true;
6✔
1188
            }
1189
            log.warn("Signature validation returned false for {} (pubkey present: {})",
12✔
1190
                    el.getUri(), el.getPublicKeyString() != null);
21!
1191
        } catch (GeneralSecurityException ex) {
3✔
1192
            log.warn("Signature validation failed for signature element {}", el.getUri(), ex);
18✔
1193
        }
3✔
1194
        return false;
6✔
1195
    }
1196

1197
    private static IRI getBaseTrustyUri(Value v) {
1198
        if (!(v instanceof IRI)) return null;
9!
1199
        String s = v.stringValue();
9✔
1200
        if (!s.matches(".*[^A-Za-z0-9\\-_]RA[A-Za-z0-9\\-_]{43}([^A-Za-z0-9\\\\-_].{0,43})?")) {
12✔
1201
            return null;
6✔
1202
        }
1203
        return vf.createIRI(s.replaceFirst("^(.*[^A-Za-z0-9\\-_]RA[A-Za-z0-9\\-_]{43})([^A-Za-z0-9\\\\-_].{0,43})?$", "$1"));
21✔
1204
    }
1205

1206
    // TODO: Move this to nanopub library:
1207
    private static boolean isIntroNanopub(Nanopub np) {
1208
        for (Statement st : np.getAssertion()) {
33✔
1209
            if (st.getPredicate().equals(NPX.DECLARED_BY)) return true;
21✔
1210
        }
3✔
1211
        return false;
6✔
1212
    }
1213

1214
    /**
1215
     * Check if a nanopub is already loaded in the admin graph.
1216
     *
1217
     * @param npId the nanopub ID
1218
     * @return true if the nanopub is loaded, false otherwise
1219
     */
1220
    @GeneratedFlagForDependentElements
1221
    static boolean isNanopubLoaded(String npId) {
1222
        boolean loaded = false;
1223
        RepositoryConnection conn = TripleStore.get().getRepoConnection("meta");
1224
        try (conn) {
1225
            if (Utils.getObjectForPattern(conn, NPA.GRAPH, vf.createIRI(npId), NPA.HAS_LOAD_NUMBER) != null) {
1226
                loaded = true;
1227
            }
1228
        } catch (Exception ex) {
1229
            log.warn("Could not check whether nanopub is loaded.", ex);
1230
        }
1231
        return loaded;
1232
    }
1233

1234
    private static ValueFactory vf = SimpleValueFactory.getInstance();
6✔
1235

1236
    // TODO remove the constants and use the ones from the nanopub library instead
1237

1238
    /**
1239
     * Template for the query that fetches the status of a repository.
1240
     */
1241
    // Template for .fetchRepoStatus
1242
    private static final String REPO_STATUS_QUERY_TEMPLATE = """
84✔
1243
            SELECT * { graph <%s> {
1244
              OPTIONAL { <%s> <%s> ?loadNumber . }
1245
              <%s> <%s> ?count ;
1246
                   <%s> ?checksum .
1247
            } }
1248
            """.formatted(NPA.GRAPH, "%s", NPA.HAS_LOAD_NUMBER, NPA.THIS_REPO, NPA.HAS_NANOPUB_COUNT, NPA.HAS_NANOPUB_CHECKSUM);
6✔
1249
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc