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

knowledgepixels / nanopub-query / 27336340710

11 Jun 2026 09:09AM UTC coverage: 59.604% (-0.3%) from 59.878%
27336340710

push

github

ashleycaselli
refactor(logging): replace 'log' with 'logger' for consistency across classes and improve logs in general

480 of 896 branches covered (53.57%)

Branch coverage included in aggregate %.

1416 of 2285 relevant lines covered (61.97%)

9.25 hits per line

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

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

3
import com.google.common.hash.Hashing;
4
import com.knowledgepixels.query.vocabulary.NPAA;
5
import com.knowledgepixels.query.vocabulary.NPAT;
6
import org.apache.http.client.methods.HttpGet;
7
import org.apache.http.impl.client.CloseableHttpClient;
8
import org.apache.http.impl.client.HttpClientBuilder;
9
import org.apache.http.util.EntityUtils;
10
import org.eclipse.rdf4j.common.transaction.IsolationLevels;
11
import org.eclipse.rdf4j.model.IRI;
12
import org.eclipse.rdf4j.model.ValueFactory;
13
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
14
import org.eclipse.rdf4j.model.vocabulary.FOAF;
15
import org.eclipse.rdf4j.model.vocabulary.RDF;
16
import org.eclipse.rdf4j.model.vocabulary.XSD;
17
import org.eclipse.rdf4j.query.QueryLanguage;
18
import org.eclipse.rdf4j.query.TupleQueryResult;
19
import org.eclipse.rdf4j.repository.RepositoryConnection;
20
import org.nanopub.vocabulary.NPA;
21
import org.slf4j.Logger;
22
import org.slf4j.LoggerFactory;
23

24
import java.io.IOException;
25
import java.net.URLEncoder;
26
import java.nio.charset.StandardCharsets;
27
import java.util.*;
28

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

43
    private static final Logger logger = LoggerFactory.getLogger(TrustStateLoader.class);
9✔
44

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

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

55
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
56

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

74
    private static final CloseableHttpClient httpClient =
75
            HttpClientBuilder.create().setDefaultRequestConfig(Utils.getHttpRequestConfig()).build();
15✔
76

77
    private TrustStateLoader() {
78
    }  // no instances
79

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

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

154
        logger.info("Trust state hash change detected: {} -> {}",
9✔
155
                current == null ? "(none)" : current, newTrustStateHash);
15!
156

157
        Optional<TrustStateSnapshot> snapshotOpt = fetchSnapshot(newTrustStateHash);
9✔
158
        if (snapshotOpt.isEmpty()) {
9!
159
            return;
3✔
160
        }
161
        TrustStateSnapshot snapshot = snapshotOpt.get();
×
162

163
        // Integrity check: the URL hash must match what's in the body.
164
        if (!newTrustStateHash.equals(snapshot.trustStateHash())) {
×
165
            logger.warn("Trust state envelope hash mismatch: URL was {}, body says {}",
×
166
                    newTrustStateHash, snapshot.trustStateHash());
×
167
            return;
×
168
        }
169

170
        try {
171
            materialize(snapshot);
×
172
            TrustStateRegistry.get().setCurrentHash(snapshot.trustStateHash());
×
173
            logger.info("Materialized trust state {} (counter={}, accounts={})",
×
174
                    snapshot.trustStateHash(), snapshot.trustStateCounter(),
×
175
                    snapshot.accounts().size());
×
176
        } catch (Exception ex) {
×
177
            logger.warn("Failed to materialize trust state {}: {}",
×
178
                    snapshot.trustStateHash(), ex, ex);
×
179
        }
×
180
    }
×
181

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

218
    /**
219
     * Writes the snapshot's account-state triples into the trust state's named
220
     * graph, writes cross-state metadata into {@code npa:graph}, and swaps the
221
     * current-state pointer — all in one serializable transaction. Idempotent
222
     * on the same hash (re-running just rewrites the same triples).
223
     *
224
     * @param snapshot the snapshot to materialize
225
     */
226
    static void materialize(TrustStateSnapshot snapshot) {
227
        IRI trustStateIri = NPAT.forHash(snapshot.trustStateHash());
×
228

229
        try (RepositoryConnection conn =
230
                     TripleStore.get().getRepoConnection(TRUST_REPO)) {
×
231
            conn.begin(IsolationLevels.SERIALIZABLE);
×
232

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

265
            // 1b. Canonical foaf:name per agent. The registry stamps each account
266
            // row with the foaf:name + dct:created of its declaring intro
267
            // (see nanopub-registry#113). Per-agent canonical name is whichever
268
            // approved row has the highest ratio (ties → MIN(name) lex). Emitted
269
            // once per agent in the trust-state graph so consumers can read
270
            // ?agent foaf:name ?n directly without a SERVICE join to /repo/full.
271
            for (Map.Entry<String, String> e : resolveCanonicalNames(snapshot).entrySet()) {
×
272
                conn.add(vf.createIRI(e.getKey()), FOAF.NAME,
×
273
                        vf.createLiteral(e.getValue()), trustStateIri);
×
274
            }
×
275

276
            // 2. Cross-state metadata in npa:graph
277
            conn.add(trustStateIri, RDF.TYPE, NPA_TRUST_STATE, NPA.GRAPH);
×
278
            conn.add(trustStateIri, NPA_HAS_TRUST_STATE_HASH,
×
279
                    vf.createLiteral(snapshot.trustStateHash()), NPA.GRAPH);
×
280
            conn.add(trustStateIri, NPA_HAS_TRUST_STATE_COUNTER,
×
281
                    vf.createLiteral(snapshot.trustStateCounter()), NPA.GRAPH);
×
282
            conn.add(trustStateIri, NPA_HAS_CREATED_AT,
×
283
                    vf.createLiteral(snapshot.createdAt().toString(), XSD.DATETIME),
×
284
                    NPA.GRAPH);
285

286
            // 3. Atomic pointer swap
287
            conn.remove(NPA.THIS_REPO, NPA_HAS_CURRENT_TRUST_STATE, null, NPA.GRAPH);
×
288
            conn.add(NPA.THIS_REPO, NPA_HAS_CURRENT_TRUST_STATE, trustStateIri, NPA.GRAPH);
×
289

290
            // 4. Prune any historical trust states beyond the retention window
291
            int pruned = pruneOldStates(conn);
×
292
            if (pruned > 0) {
×
293
                logger.info("Pruned {} trust state(s) beyond retention", pruned);
×
294
            }
295

296
            conn.commit();
×
297
        }
298
    }
×
299

300
    /**
301
     * Removes trust states beyond the retention window: their named-graph
302
     * contents are dropped and their metadata triples in {@code npa:graph}
303
     * are removed. Must be called inside an open transaction on the
304
     * {@code trust} repo. Returns the number of states pruned.
305
     */
306
    private static int pruneOldStates(RepositoryConnection conn) {
307
        int retention = effectiveRetention();
×
308
        // ORDER BY DESC counter, then OFFSET retention → those beyond the keep window.
309
        String query = String.format("""
×
310
                        PREFIX npa: <%s>
311
                        SELECT ?s WHERE {
312
                          GRAPH <%s> {
313
                            ?s a <%s> ; <%s> ?c .
314
                          }
315
                        } ORDER BY DESC(?c) OFFSET %d
316
                        """,
317
                NPA.NAMESPACE, NPA.GRAPH, NPA_TRUST_STATE, NPA_HAS_TRUST_STATE_COUNTER, retention);
×
318

319
        List<IRI> toPrune = new ArrayList<>();
×
320
        try (TupleQueryResult result = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate()) {
×
321
            while (result.hasNext()) {
×
322
                toPrune.add((IRI) result.next().getValue("s"));
×
323
            }
324
        }
325
        for (IRI old : toPrune) {
×
326
            conn.clear(old);                          // drop the named graph's triples
×
327
            conn.remove(old, null, null, NPA.GRAPH);  // drop its metadata in npa:graph
×
328
        }
×
329
        return toPrune.size();
×
330
    }
331

332
    /**
333
     * Reads {@code TRUST_STATE_LOCAL_RETENTION} from the environment, falling
334
     * back to {@link #DEFAULT_LOCAL_RETENTION}. Values below 1 are coerced
335
     * back to the default with a warning (the plan rejects retention=0).
336
     */
337
    static int effectiveRetention() {
338
        int n = Utils.getEnvInt("TRUST_STATE_LOCAL_RETENTION", DEFAULT_LOCAL_RETENTION);
12✔
339
        if (n < 1) {
9!
340
            logger.warn("TRUST_STATE_LOCAL_RETENTION={} is invalid (must be >= 1); using default {}",
×
341
                    n, DEFAULT_LOCAL_RETENTION);
×
342
            return DEFAULT_LOCAL_RETENTION;
×
343
        }
344
        return n;
6✔
345
    }
346

347
    /**
348
     * Computes the account-state hash for a single entry within a snapshot.
349
     * SHA-256 over {@code trustStateHash + "|" + pubkey + "|" + agent}; the
350
     * trustStateHash is part of the input so the same {@code (pubkey, agent)}
351
     * pair in two snapshots produces two different account-state IRIs.
352
     */
353
    static String accountStateHash(String trustStateHash, TrustStateSnapshot.AccountEntry a) {
354
        String composite = trustStateHash + "|" + a.pubkey() + "|" + a.agent();
21✔
355
        return Hashing.sha256().hashString(composite, StandardCharsets.UTF_8).toString();
18✔
356
    }
357

358
    /**
359
     * Trust-approved status set: rows with one of these {@code npa:trustStatus} values
360
     * are eligible to contribute the canonical agent name. Matches the set used by
361
     * {@code AuthorityResolver.mirrorTrustState}.
362
     */
363
    private static final Set<String> APPROVED_STATUSES = Set.of("loaded", "toLoad");
15✔
364

365
    /**
366
     * Per-agent canonical name resolution. Returns a map from agent IRI to its
367
     * canonical {@code foaf:name} literal, derived from the snapshot's per-account
368
     * {@code name} field.
369
     *
370
     * <p>Policy: among an agent's account rows whose {@code status} is approved
371
     * ({@code loaded} or {@code toLoad}) and whose {@code ratio} and {@code name}
372
     * are both non-null, pick the row with the highest {@code ratio}. Ties break
373
     * on lex-min {@code name} for determinism across rebuilds. Agents with no
374
     * qualifying row are simply absent from the result map (no name emitted).
375
     *
376
     * <p>Per-{@code (agent, pubkey)} resolution (the latest declaring intro
377
     * supplies that row's {@code name}) lives in the registry; this layer only
378
     * folds across keys.
379
     */
380
    static Map<String, String> resolveCanonicalNames(TrustStateSnapshot snapshot) {
381
        Map<String, TrustStateSnapshot.AccountEntry> chosen = new HashMap<>();
12✔
382
        for (TrustStateSnapshot.AccountEntry a : snapshot.accounts()) {
33✔
383
            if (!APPROVED_STATUSES.contains(a.status())) {
15✔
384
                continue;
3✔
385
            }
386
            if (a.name() == null || a.ratio() == null) {
18!
387
                continue;
×
388
            }
389
            TrustStateSnapshot.AccountEntry incumbent = chosen.get(a.agent());
18✔
390
            if (incumbent == null
9✔
391
                || a.ratio() > incumbent.ratio()
24✔
392
                || (a.ratio().equals(incumbent.ratio())
18!
393
                    && a.name().compareTo(incumbent.name()) < 0)) {
15!
394
                chosen.put(a.agent(), a);
18✔
395
            }
396
        }
3✔
397
        Map<String, String> result = new HashMap<>(chosen.size());
18✔
398
        for (Map.Entry<String, TrustStateSnapshot.AccountEntry> e : chosen.entrySet()) {
33✔
399
            result.put(e.getKey(), e.getValue().name());
30✔
400
        }
3✔
401
        return result;
6✔
402
    }
403

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