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

knowledgepixels / nanopub-query / 24982836847

27 Apr 2026 07:45AM UTC coverage: 56.577% (-0.4%) from 56.94%
24982836847

push

github

web-flow
Merge pull request #88 from knowledgepixels/feature/62-rename-plan-to-design

docs: rename plan-space-repositories.md to design-space-repositories.md

404 of 804 branches covered (50.25%)

Branch coverage included in aggregate %.

1153 of 1948 relevant lines covered (59.19%)

9.03 hits per line

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

24.64
src/main/java/com/knowledgepixels/query/TrustStateLoader.java
1
package com.knowledgepixels.query;
2

3
import java.io.IOException;
4
import java.net.URLEncoder;
5
import java.nio.charset.StandardCharsets;
6
import java.util.ArrayList;
7
import java.util.List;
8
import java.util.Optional;
9

10
import org.apache.http.client.methods.HttpGet;
11
import org.apache.http.impl.client.CloseableHttpClient;
12
import org.apache.http.impl.client.HttpClientBuilder;
13
import org.apache.http.util.EntityUtils;
14
import org.eclipse.rdf4j.common.transaction.IsolationLevels;
15
import org.eclipse.rdf4j.model.IRI;
16
import org.eclipse.rdf4j.model.ValueFactory;
17
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
18
import org.eclipse.rdf4j.model.vocabulary.RDF;
19
import org.eclipse.rdf4j.model.vocabulary.XSD;
20
import org.eclipse.rdf4j.query.QueryLanguage;
21
import org.eclipse.rdf4j.query.TupleQueryResult;
22
import org.eclipse.rdf4j.repository.RepositoryConnection;
23
import org.nanopub.vocabulary.NPA;
24
import org.slf4j.Logger;
25
import org.slf4j.LoggerFactory;
26

27
import com.google.common.hash.Hashing;
28
import com.knowledgepixels.query.vocabulary.NPAA;
29
import com.knowledgepixels.query.vocabulary.NPAT;
30

31
/**
32
 * Materializes a registry trust state into the local {@code trust} repository
33
 * when a hash change is detected.
34
 *
35
 * <p>Detection happens in {@link JellyNanopubLoader} (which polls the registry
36
 * every ~2 s anyway and reads {@code Nanopub-Registry-Trust-State-Hash}). This
37
 * class does the rest: fetch {@code /trust-state/<hash>.json}, parse the
38
 * envelope, materialize the snapshot into a named graph, and swap the current
39
 * pointer — all in one serializable transaction.
40
 *
41
 * <p>See {@code doc/design-trust-state-repos.md} for the full design.
42
 */
43
public class TrustStateLoader {
44

45
    private static final Logger log = LoggerFactory.getLogger(TrustStateLoader.class);
9✔
46

47
    /** Local name of the repository that holds all mirrored trust states. */
48
    static final String TRUST_REPO = "trust";
49

50
    /** Default number of historical trust states retained locally. Matches the registry's own snapshot retention. */
51
    static final int DEFAULT_LOCAL_RETENTION = 100;
52

53
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
54

55
    // Local extensions to the upstream NPA vocabulary (terms used only on the
56
    // consumer side). Defined here rather than in a vocab class because they're
57
    // strictly internal to the trust-state mirroring code.
58
    private static final IRI NPA_TRUST_STATE = vf.createIRI(NPA.NAMESPACE, "TrustState");
15✔
59
    private static final IRI NPA_ACCOUNT_STATE = vf.createIRI(NPA.NAMESPACE, "AccountState");
15✔
60
    private static final IRI NPA_HAS_TRUST_STATE_HASH = vf.createIRI(NPA.NAMESPACE, "hasTrustStateHash");
15✔
61
    private static final IRI NPA_HAS_TRUST_STATE_COUNTER = vf.createIRI(NPA.NAMESPACE, "hasTrustStateCounter");
15✔
62
    private static final IRI NPA_HAS_CREATED_AT = vf.createIRI(NPA.NAMESPACE, "hasCreatedAt");
15✔
63
    private static final IRI NPA_HAS_CURRENT_TRUST_STATE = vf.createIRI(NPA.NAMESPACE, "hasCurrentTrustState");
15✔
64
    private static final IRI NPA_AGENT = vf.createIRI(NPA.NAMESPACE, "agent");
15✔
65
    private static final IRI NPA_PUBKEY = vf.createIRI(NPA.NAMESPACE, "pubkey");
15✔
66
    private static final IRI NPA_TRUST_STATUS = vf.createIRI(NPA.NAMESPACE, "trustStatus");
15✔
67
    private static final IRI NPA_DEPTH = vf.createIRI(NPA.NAMESPACE, "depth");
15✔
68
    private static final IRI NPA_PATH_COUNT = vf.createIRI(NPA.NAMESPACE, "pathCount");
15✔
69
    private static final IRI NPA_RATIO = vf.createIRI(NPA.NAMESPACE, "ratio");
15✔
70
    private static final IRI NPA_QUOTA = vf.createIRI(NPA.NAMESPACE, "quota");
15✔
71

72
    private static final CloseableHttpClient httpClient =
3✔
73
            HttpClientBuilder.create().setDefaultRequestConfig(Utils.getHttpRequestConfig()).build();
15✔
74

75
    private TrustStateLoader() {
76
    }  // no instances
77

78
    /**
79
     * Seeds {@link TrustStateRegistry} from the current-state pointer persisted
80
     * in the {@code trust} repo. Intended to run once on startup, before the
81
     * periodic poll begins — so if the pointer already matches the registry's
82
     * advertised hash, the first poll is a no-op rather than a redundant
83
     * re-materialization.
84
     *
85
     * <p>Safe to call on a fresh deployment (the trust repo may not even exist
86
     * yet — auto-created, found empty, seeded nothing). Any failure is logged
87
     * at INFO; bootstrap falls through and the first poll materializes from
88
     * scratch.
89
     */
90
    public static void bootstrap() {
91
        if (!FeatureFlags.trustStateEnabled()) return;
×
92
        try (RepositoryConnection conn = TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
93
            String query = String.format("""
×
94
                    SELECT ?s WHERE {
95
                      GRAPH <%s> {
96
                        <%s> <%s> ?s .
97
                      }
98
                    } LIMIT 1
99
                    """,
100
                    NPA.GRAPH, NPA.THIS_REPO, NPA_HAS_CURRENT_TRUST_STATE);
101
            try (TupleQueryResult result =
×
102
                         conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
103
                if (!result.hasNext()) {
×
104
                    log.info("Trust state bootstrap: no current-state pointer yet");
×
105
                    return;
×
106
                }
107
                IRI ptr = (IRI) result.next().getValue("s");
×
108
                String iri = ptr.stringValue();
×
109
                if (!iri.startsWith(NPAT.NAMESPACE)) {
×
110
                    log.warn("Trust state bootstrap: unexpected pointer IRI {}", iri);
×
111
                    return;
×
112
                }
113
                String hash = iri.substring(NPAT.NAMESPACE.length());
×
114
                if (hash.isEmpty()) {
×
115
                    log.warn("Trust state bootstrap: pointer IRI has empty hash suffix");
×
116
                    return;
×
117
                }
118
                TrustStateRegistry.get().setCurrentHash(hash);
×
119
                log.info("Trust state bootstrap: seeded current hash {}", hash);
×
120
            }
×
121
        } catch (Exception ex) {
×
122
            log.info("Trust state bootstrap: failed to read pointer: {}", ex.toString());
×
123
        }
×
124
    }
×
125

126
    /**
127
     * Called when registry-poll metadata is fetched. Compares the hash to the
128
     * locally-tracked one and, if different, fetches the snapshot and
129
     * materializes it into the {@code trust} repo.
130
     *
131
     * <p>Safe to call with a null/empty hash (older registries don't expose
132
     * trust state) — silently no-op in that case.
133
     *
134
     * @param newTrustStateHash the {@code trustStateHash} reported by the
135
     *                          registry, or null if the registry doesn't
136
     *                          expose one
137
     */
138
    public static void maybeUpdate(String newTrustStateHash) {
139
        if (!FeatureFlags.trustStateEnabled()) return;
6!
140
        if (newTrustStateHash == null || newTrustStateHash.isEmpty()) return;
18✔
141
        String current = TrustStateRegistry.get().getCurrentHash().orElse(null);
18✔
142
        if (newTrustStateHash.equals(current)) return;
15✔
143

144
        log.info("Trust state hash change detected: {} -> {}",
9✔
145
                current == null ? "(none)" : current, newTrustStateHash);
15!
146

147
        Optional<TrustStateSnapshot> snapshotOpt = fetchSnapshot(newTrustStateHash);
9✔
148
        if (snapshotOpt.isEmpty()) return;
12!
149
        TrustStateSnapshot snapshot = snapshotOpt.get();
×
150

151
        // Integrity check: the URL hash must match what's in the body.
152
        if (!newTrustStateHash.equals(snapshot.trustStateHash())) {
×
153
            log.warn("Trust state envelope hash mismatch: URL was {}, body says {}",
×
154
                    newTrustStateHash, snapshot.trustStateHash());
×
155
            return;
×
156
        }
157

158
        try {
159
            materialize(snapshot);
×
160
            TrustStateRegistry.get().setCurrentHash(snapshot.trustStateHash());
×
161
            log.info("Materialized trust state {} (counter={}, accounts={})",
×
162
                    snapshot.trustStateHash(), snapshot.trustStateCounter(),
×
163
                    snapshot.accounts().size());
×
164
        } catch (Exception ex) {
×
165
            log.warn("Failed to materialize trust state {}: {}",
×
166
                    snapshot.trustStateHash(), ex.toString(), ex);
×
167
        }
×
168
    }
×
169

170
    /**
171
     * Fetches and parses the snapshot for the given trust state hash from the
172
     * registry. Returns {@link Optional#empty()} on 404 (the registry has
173
     * pruned this hash) or on any I/O / parse error (logged at INFO).
174
     *
175
     * @param trustStateHash the hash to fetch
176
     * @return the parsed snapshot, or empty if unavailable
177
     */
178
    static Optional<TrustStateSnapshot> fetchSnapshot(String trustStateHash) {
179
        String url = JellyNanopubLoader.registryUrl
9✔
180
                + "trust-state/" + URLEncoder.encode(trustStateHash, StandardCharsets.UTF_8) + ".json";
9✔
181
        try (var response = httpClient.execute(new HttpGet(url))) {
21✔
182
            int status = response.getStatusLine().getStatusCode();
12✔
183
            if (status == 404) {
9!
184
                log.info("Trust state snapshot {} returned 404 (pruned by registry); skipping",
12✔
185
                        trustStateHash);
186
                EntityUtils.consumeQuietly(response.getEntity());
9✔
187
                return Optional.empty();
12✔
188
            }
189
            if (status < 200 || status >= 300) {
×
190
                log.info("Trust state snapshot {} returned HTTP {} ({}); skipping",
×
191
                        trustStateHash, status, response.getStatusLine().getReasonPhrase());
×
192
                EntityUtils.consumeQuietly(response.getEntity());
×
193
                return Optional.empty();
×
194
            }
195
            String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
×
196
            return Optional.of(TrustStateSnapshot.parse(body));
×
197
        } catch (IOException ex) {
12!
198
            log.info("Failed to fetch trust state snapshot {}: {}", trustStateHash, ex.toString());
×
199
            return Optional.empty();
×
200
        } catch (IllegalArgumentException ex) {
×
201
            log.info("Failed to parse trust state snapshot {}: {}", trustStateHash, ex.toString());
×
202
            return Optional.empty();
×
203
        }
204
    }
205

206
    /**
207
     * Writes the snapshot's account-state triples into the trust state's named
208
     * graph, writes cross-state metadata into {@code npa:graph}, and swaps the
209
     * current-state pointer — all in one serializable transaction. Idempotent
210
     * on the same hash (re-running just rewrites the same triples).
211
     *
212
     * @param snapshot the snapshot to materialize
213
     */
214
    static void materialize(TrustStateSnapshot snapshot) {
215
        IRI trustStateIri = NPAT.forHash(snapshot.trustStateHash());
×
216

217
        try (RepositoryConnection conn =
218
                     TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
219
            conn.begin(IsolationLevels.SERIALIZABLE);
×
220

221
            // 1. Account-state triples in the trust state's named graph.
222
            // depth / pathCount / ratio / quota may be null (e.g. for status=skipped
223
            // accounts, which were rejected by trust calculation and so don't carry
224
            // these stats). Only emit a triple when the field is present.
225
            for (TrustStateSnapshot.AccountEntry a : snapshot.accounts()) {
×
226
                IRI accountStateIri =
×
227
                        NPAA.forHash(accountStateHash(snapshot.trustStateHash(), a));
×
228
                conn.add(accountStateIri, RDF.TYPE, NPA_ACCOUNT_STATE, trustStateIri);
×
229
                conn.add(accountStateIri, NPA_AGENT,
×
230
                        vf.createIRI(a.agent()), trustStateIri);
×
231
                conn.add(accountStateIri, NPA_PUBKEY,
×
232
                        vf.createLiteral(a.pubkey()), trustStateIri);
×
233
                conn.add(accountStateIri, NPA_TRUST_STATUS,
×
234
                        vf.createIRI(NPA.NAMESPACE, a.status()), trustStateIri);
×
235
                if (a.depth() != null) {
×
236
                    conn.add(accountStateIri, NPA_DEPTH,
×
237
                            vf.createLiteral(a.depth()), trustStateIri);
×
238
                }
239
                if (a.pathCount() != null) {
×
240
                    conn.add(accountStateIri, NPA_PATH_COUNT,
×
241
                            vf.createLiteral(a.pathCount()), trustStateIri);
×
242
                }
243
                if (a.ratio() != null) {
×
244
                    conn.add(accountStateIri, NPA_RATIO,
×
245
                            vf.createLiteral(a.ratio()), trustStateIri);
×
246
                }
247
                if (a.quota() != null) {
×
248
                    conn.add(accountStateIri, NPA_QUOTA,
×
249
                            vf.createLiteral(a.quota()), trustStateIri);
×
250
                }
251
            }
×
252

253
            // 2. Cross-state metadata in npa:graph
254
            conn.add(trustStateIri, RDF.TYPE, NPA_TRUST_STATE, NPA.GRAPH);
×
255
            conn.add(trustStateIri, NPA_HAS_TRUST_STATE_HASH,
×
256
                    vf.createLiteral(snapshot.trustStateHash()), NPA.GRAPH);
×
257
            conn.add(trustStateIri, NPA_HAS_TRUST_STATE_COUNTER,
×
258
                    vf.createLiteral(snapshot.trustStateCounter()), NPA.GRAPH);
×
259
            conn.add(trustStateIri, NPA_HAS_CREATED_AT,
×
260
                    vf.createLiteral(snapshot.createdAt().toString(), XSD.DATETIME),
×
261
                    NPA.GRAPH);
262

263
            // 3. Atomic pointer swap
264
            conn.remove(NPA.THIS_REPO, NPA_HAS_CURRENT_TRUST_STATE, null, NPA.GRAPH);
×
265
            conn.add(NPA.THIS_REPO, NPA_HAS_CURRENT_TRUST_STATE, trustStateIri, NPA.GRAPH);
×
266

267
            // 4. Prune any historical trust states beyond the retention window
268
            int pruned = pruneOldStates(conn);
×
269
            if (pruned > 0) {
×
270
                log.info("Pruned {} trust state(s) beyond retention", pruned);
×
271
            }
272

273
            conn.commit();
×
274
        }
275
    }
×
276

277
    /**
278
     * Removes trust states beyond the retention window: their named-graph
279
     * contents are dropped and their metadata triples in {@code npa:graph}
280
     * are removed. Must be called inside an open transaction on the
281
     * {@code trust} repo. Returns the number of states pruned.
282
     */
283
    private static int pruneOldStates(RepositoryConnection conn) {
284
        int retention = effectiveRetention();
×
285
        // ORDER BY DESC counter, then OFFSET retention → those beyond the keep window.
286
        String query = String.format("""
×
287
                PREFIX npa: <%s>
288
                SELECT ?s WHERE {
289
                  GRAPH <%s> {
290
                    ?s a <%s> ; <%s> ?c .
291
                  }
292
                } ORDER BY DESC(?c) OFFSET %d
293
                """,
294
                NPA.NAMESPACE, NPA.GRAPH, NPA_TRUST_STATE, NPA_HAS_TRUST_STATE_COUNTER, retention);
×
295

296
        List<IRI> toPrune = new ArrayList<>();
×
297
        try (TupleQueryResult result = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
298
            while (result.hasNext()) {
×
299
                toPrune.add((IRI) result.next().getValue("s"));
×
300
            }
301
        }
302
        for (IRI old : toPrune) {
×
303
            conn.clear(old);                          // drop the named graph's triples
×
304
            conn.remove(old, null, null, NPA.GRAPH);  // drop its metadata in npa:graph
×
305
        }
×
306
        return toPrune.size();
×
307
    }
308

309
    /**
310
     * Reads {@code TRUST_STATE_LOCAL_RETENTION} from the environment, falling
311
     * back to {@link #DEFAULT_LOCAL_RETENTION}. Values below 1 are coerced
312
     * back to the default with a warning (the plan rejects retention=0).
313
     */
314
    static int effectiveRetention() {
315
        int n = Utils.getEnvInt("TRUST_STATE_LOCAL_RETENTION", DEFAULT_LOCAL_RETENTION);
12✔
316
        if (n < 1) {
9!
317
            log.warn("TRUST_STATE_LOCAL_RETENTION={} is invalid (must be >= 1); using default {}",
×
318
                    n, DEFAULT_LOCAL_RETENTION);
×
319
            return DEFAULT_LOCAL_RETENTION;
×
320
        }
321
        return n;
6✔
322
    }
323

324
    /**
325
     * Computes the account-state hash for a single entry within a snapshot.
326
     * SHA-256 over {@code trustStateHash + "|" + pubkey + "|" + agent}; the
327
     * trustStateHash is part of the input so the same {@code (pubkey, agent)}
328
     * pair in two snapshots produces two different account-state IRIs.
329
     */
330
    static String accountStateHash(String trustStateHash, TrustStateSnapshot.AccountEntry a) {
331
        String composite = trustStateHash + "|" + a.pubkey() + "|" + a.agent();
21✔
332
        return Hashing.sha256().hashString(composite, StandardCharsets.UTF_8).toString();
18✔
333
    }
334

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