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

knowledgepixels / nanopub-registry / 24087317420

07 Apr 2026 02:39PM UTC coverage: 32.924% (+1.1%) from 31.828%
24087317420

push

github

web-flow
Merge pull request #94 from knowledgepixels/feat/type-coverage-enforcement

feat: enforce type coverage restrictions across all loading paths

242 of 796 branches covered (30.4%)

Branch coverage included in aggregate %.

750 of 2217 relevant lines covered (33.83%)

5.73 hits per line

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

12.65
src/main/java/com/knowledgepixels/registry/Task.java
1
package com.knowledgepixels.registry;
2

3
import com.knowledgepixels.registry.db.IndexInitializer;
4
import com.mongodb.client.ClientSession;
5
import com.mongodb.client.FindIterable;
6
import com.mongodb.client.MongoCollection;
7
import com.mongodb.client.MongoCursor;
8
import net.trustyuri.TrustyUriUtils;
9
import org.apache.commons.lang.Validate;
10
import org.bson.Document;
11
import org.eclipse.rdf4j.model.IRI;
12
import org.eclipse.rdf4j.model.Statement;
13
import org.nanopub.Nanopub;
14
import org.nanopub.extra.index.IndexUtils;
15
import org.nanopub.extra.index.NanopubIndex;
16
import org.nanopub.extra.security.KeyDeclaration;
17
import org.nanopub.extra.setting.IntroNanopub;
18
import org.nanopub.extra.setting.NanopubSetting;
19
import org.slf4j.Logger;
20
import org.slf4j.LoggerFactory;
21

22
import java.io.Serializable;
23
import java.time.ZonedDateTime;
24
import java.util.*;
25
import java.util.concurrent.atomic.AtomicLong;
26

27
import static com.knowledgepixels.registry.EntryStatus.*;
28
import static com.knowledgepixels.registry.NanopubLoader.*;
29
import static com.knowledgepixels.registry.RegistryDB.*;
30
import static com.knowledgepixels.registry.ServerStatus.*;
31
import static com.mongodb.client.model.Filters.eq;
32
import static com.mongodb.client.model.Sorts.*;
33

34
public enum Task implements Serializable {
6✔
35

36
    INIT_DB {
33✔
37
        public void run(ClientSession s, Document taskDoc) {
38
            setServerStatus(s, launching);
9✔
39

40
            increaseStateCounter(s);
6✔
41
            if (RegistryDB.isInitialized(s)) {
9!
42
                throw new RuntimeException("DB already initialized");
×
43
            }
44
            setValue(s, Collection.SERVER_INFO.toString(), "setupId", Math.abs(Utils.getRandom().nextLong()));
27✔
45
            setValue(s, Collection.SERVER_INFO.toString(), "testInstance", "true".equals(System.getenv("REGISTRY_TEST_INSTANCE")));
30✔
46
            schedule(s, LOAD_CONFIG);
9✔
47
        }
3✔
48

49
    },
50

51
    LOAD_CONFIG {
33✔
52
        public void run(ClientSession s, Document taskDoc) {
53
            if (getServerStatus(s) != launching) {
12!
54
                throw new IllegalTaskStatusException("Illegal status for this task: " + getServerStatus(s));
×
55
            }
56

57
            if (System.getenv("REGISTRY_COVERAGE_TYPES") != null) {
9!
58
                setValue(s, Collection.SERVER_INFO.toString(), "coverageTypes", System.getenv("REGISTRY_COVERAGE_TYPES"));
×
59
            }
60
            if (System.getenv("REGISTRY_COVERAGE_AGENTS") != null) {
9!
61
                setValue(s, Collection.SERVER_INFO.toString(), "coverageAgents", System.getenv("REGISTRY_COVERAGE_AGENTS"));
×
62
            }
63
            schedule(s, LOAD_SETTING);
9✔
64
        }
3✔
65

66
    },
67

68
    LOAD_SETTING {
33✔
69
        public void run(ClientSession s, Document taskDoc) throws Exception {
70
            if (getServerStatus(s) != launching) {
12!
71
                throw new IllegalTaskStatusException("Illegal status for this task: " + getServerStatus(s));
×
72
            }
73

74
            NanopubSetting settingNp = Utils.getSetting();
6✔
75
            String settingId = TrustyUriUtils.getArtifactCode(settingNp.getNanopub().getUri().stringValue());
18✔
76
            setValue(s, Collection.SETTING.toString(), "original", settingId);
18✔
77
            setValue(s, Collection.SETTING.toString(), "current", settingId);
18✔
78
            loadNanopub(s, settingNp.getNanopub());
15✔
79
            List<Document> bootstrapServices = new ArrayList<>();
12✔
80
            for (IRI i : settingNp.getBootstrapServices()) {
33✔
81
                bootstrapServices.add(new Document("_id", i.stringValue()));
27✔
82
            }
3✔
83
            // potentially currently hardcoded in the nanopub lib
84
            setValue(s, Collection.SETTING.toString(), "bootstrap-services", bootstrapServices);
18✔
85

86
            if (!"false".equals(System.getenv("REGISTRY_PERFORM_FULL_LOAD"))) {
15!
87
                schedule(s, LOAD_FULL.withDelay(60 * 1000));
15✔
88
            }
89

90
            setServerStatus(s, coreLoading);
9✔
91
            schedule(s, INIT_COLLECTIONS);
9✔
92
        }
3✔
93

94
    },
95

96
    INIT_COLLECTIONS {
33✔
97

98
        // DB read from:
99
        // DB write to:  trustPaths, endorsements, accounts
100
        // This state is periodically executed
101

102
        public void run(ClientSession s, Document taskDoc) throws Exception {
103
            if (getServerStatus(s) != coreLoading && getServerStatus(s) != updating) {
×
104
                throw new IllegalTaskStatusException("Illegal status for this task: " + getServerStatus(s));
×
105
            }
106

107
            IndexInitializer.initLoadingCollections(s);
×
108

109
            // since this may take long, we start with postfix "_loading"
110
            // and only at completion it's changed to trustPath, endorsements, accounts
111
            insert(s, "trustPaths_loading",
×
112
                    new Document("_id", "$")
113
                            .append("sorthash", "")
×
114
                            .append("agent", "$")
×
115
                            .append("pubkey", "$")
×
116
                            .append("depth", 0)
×
117
                            .append("ratio", 1.0d)
×
118
                            .append("type", "extended")
×
119
            );
120

121
            NanopubIndex agentIndex = IndexUtils.castToIndex(NanopubLoader.retrieveNanopub(s, Utils.getSetting().getAgentIntroCollection().stringValue()));
×
122
            loadNanopub(s, agentIndex);
×
123
            for (IRI el : agentIndex.getElements()) {
×
124
                String declarationAc = TrustyUriUtils.getArtifactCode(el.stringValue());
×
125
                Validate.notNull(declarationAc);
×
126

127
                insert(s, "endorsements_loading",
×
128
                        new Document("agent", "$")
129
                                .append("pubkey", "$")
×
130
                                .append("endorsedNanopub", declarationAc)
×
131
                                .append("source", getValue(s, Collection.SETTING.toString(), "current").toString())
×
132
                                .append("status", toRetrieve.getValue())
×
133

134
                );
135

136
            }
×
137
            insert(s, "accounts_loading",
×
138
                    new Document("agent", "$")
139
                            .append("pubkey", "$")
×
140
                            .append("status", visited.getValue())
×
141
                            .append("depth", 0)
×
142
            );
143

144
            log.info("Starting iteration at depth 0");
×
145
            schedule(s, LOAD_DECLARATIONS.with("depth", 1));
×
146
        }
×
147

148
        // At the end of this task, the base agent is initialized:
149
        // ------------------------------------------------------------
150
        //
151
        //              $$$$ ----endorses----> [intro]
152
        //              base                (to-retrieve)
153
        //              $$$$
154
        //            (visited)
155
        //
156
        //              [0] trust path
157
        //
158
        // ------------------------------------------------------------
159
        // Only one endorses-link to an introduction is shown here,
160
        // but there are typically several.
161

162
    },
163

164
    LOAD_DECLARATIONS {
33✔
165

166
        // In general, we have at this point accounts with
167
        // endorsement links to unvisited agent introductions:
168
        // ------------------------------------------------------------
169
        //
170
        //         o      ----endorses----> [intro]
171
        //    --> /#\  /o\___            (to-retrieve)
172
        //        / \  \_/^^^
173
        //         (visited)
174
        //
175
        //    ========[X] trust path
176
        //
177
        // ------------------------------------------------------------
178

179
        // DB read from: endorsements, trustEdges, accounts
180
        // DB write to:  endorsements, trustEdges, accounts
181

182
        public void run(ClientSession s, Document taskDoc) {
183

184
            int depth = taskDoc.getInteger("depth");
×
185

186
            if (has(s, "endorsements_loading", new Document("status", toRetrieve.getValue()))) {
×
187
                Document d = getOne(s, "endorsements_loading",
×
188
                        new DbEntryWrapper(toRetrieve).getDocument());
×
189

190
                IntroNanopub agentIntro = getAgentIntro(s, d.getString("endorsedNanopub"));
×
191
                if (agentIntro != null) {
×
192
                    String agentId = agentIntro.getUser().stringValue();
×
193

194
                    for (KeyDeclaration kd : agentIntro.getKeyDeclarations()) {
×
195
                        String sourceAgent = d.getString("agent");
×
196
                        Validate.notNull(sourceAgent);
×
197
                        String sourcePubkey = d.getString("pubkey");
×
198
                        Validate.notNull(sourcePubkey);
×
199
                        String sourceAc = d.getString("source");
×
200
                        Validate.notNull(sourceAc);
×
201
                        String agentPubkey = Utils.getHash(kd.getPublicKeyString());
×
202
                        Validate.notNull(agentPubkey);
×
203
                        Document trustEdge = new Document("fromAgent", sourceAgent)
×
204
                                .append("fromPubkey", sourcePubkey)
×
205
                                .append("toAgent", agentId)
×
206
                                .append("toPubkey", agentPubkey)
×
207
                                .append("source", sourceAc);
×
208
                        if (!has(s, "trustEdges", trustEdge)) {
×
209
                            boolean invalidated = has(s, "invalidations", new Document("invalidatedNp", sourceAc).append("invalidatingPubkey", sourcePubkey));
×
210
                            insert(s, "trustEdges", trustEdge.append("invalidated", invalidated));
×
211
                        }
212

213
                        Document agent = new Document("agent", agentId).append("pubkey", agentPubkey);
×
214
                        if (!has(s, "accounts_loading", agent)) {
×
215
                            insert(s, "accounts_loading", agent.append("status", seen.getValue()).append("depth", depth));
×
216
                        }
217
                    }
×
218

219
                    set(s, "endorsements_loading", d.append("status", retrieved.getValue()));
×
220
                } else {
×
221
                    set(s, "endorsements_loading", d.append("status", discarded.getValue()));
×
222
                }
223

224
                schedule(s, LOAD_DECLARATIONS.with("depth", depth));
×
225

226
            } else {
×
227
                schedule(s, EXPAND_TRUST_PATHS.with("depth", depth));
×
228
            }
229
        }
×
230

231
        // At the end of this step, the key declarations in the agent
232
        // introductions are loaded and the corresponding trust edges
233
        // established:
234
        // ------------------------------------------------------------
235
        //
236
        //        o      ----endorses----> [intro]
237
        //   --> /#\  /o\___                o
238
        //       / \  \_/^^^ ---trusts---> /#\  /o\___
239
        //        (visited)                / \  \_/^^^
240
        //                                   (seen)
241
        //
242
        //   ========[X] trust path
243
        //
244
        // ------------------------------------------------------------
245
        // Only one trust edge per introduction is shown here, but
246
        // there can be several.
247

248
    },
249

250
    EXPAND_TRUST_PATHS {
33✔
251

252
        // DB read from: accounts, trustPaths, trustEdges
253
        // DB write to:  accounts, trustPaths
254

255
        public void run(ClientSession s, Document taskDoc) {
256

257
            int depth = taskDoc.getInteger("depth");
×
258

259
            Document d = getOne(s, "accounts_loading",
×
260
                    new Document("status", visited.getValue())
×
261
                            .append("depth", depth - 1)
×
262
            );
263

264
            if (d != null) {
×
265

266
                String agentId = d.getString("agent");
×
267
                Validate.notNull(agentId);
×
268
                String pubkeyHash = d.getString("pubkey");
×
269
                Validate.notNull(pubkeyHash);
×
270

271
                Document trustPath = collection("trustPaths_loading").find(s,
×
272
                        new Document("agent", agentId).append("pubkey", pubkeyHash).append("type", "extended").append("depth", depth - 1)
×
273
                ).sort(orderBy(descending("ratio"), ascending("sorthash"))).first();
×
274

275
                if (trustPath == null) {
×
276
                    // Check it again in next iteration:
277
                    set(s, "accounts_loading", d.append("depth", depth));
×
278
                } else {
279
                    // Only first matching trust path is considered
280

281
                    Map<String, Document> newPaths = new HashMap<>();
×
282
                    Map<String, Set<String>> pubkeySets = new HashMap<>();
×
283
                    String currentSetting = getValue(s, Collection.SETTING.toString(), "current").toString();
×
284

285
                    MongoCursor<Document> edgeCursor = get(s, "trustEdges",
×
286
                            new Document("fromAgent", agentId)
287
                                    .append("fromPubkey", pubkeyHash)
×
288
                                    .append("invalidated", false)
×
289
                    );
290
                    while (edgeCursor.hasNext()) {
×
291
                        Document e = edgeCursor.next();
×
292

293
                        String agent = e.getString("toAgent");
×
294
                        Validate.notNull(agent);
×
295
                        String pubkey = e.getString("toPubkey");
×
296
                        Validate.notNull(pubkey);
×
297
                        String pathId = trustPath.getString("_id") + " " + agent + "|" + pubkey;
×
298
                        newPaths.put(pathId,
×
299
                                new Document("_id", pathId)
300
                                        .append("sorthash", Utils.getHash(currentSetting + " " + pathId))
×
301
                                        .append("agent", agent)
×
302
                                        .append("pubkey", pubkey)
×
303
                                        .append("depth", depth)
×
304
                                        .append("type", "extended")
×
305
                        );
306
                        if (!pubkeySets.containsKey(agent)) pubkeySets.put(agent, new HashSet<>());
×
307
                        pubkeySets.get(agent).add(pubkey);
×
308
                    }
×
309
                    for (String pathId : newPaths.keySet()) {
×
310
                        Document pd = newPaths.get(pathId);
×
311
                        // first divide by agents; then for each agent, divide by number of pubkeys:
312
                        double newRatio = (trustPath.getDouble("ratio") * 0.9) / pubkeySets.size() / pubkeySets.get(pd.getString("agent")).size();
×
313
                        insert(s, "trustPaths_loading", pd.append("ratio", newRatio));
×
314
                    }
×
315
                    set(s, "trustPaths_loading", trustPath.append("type", "primary"));
×
316
                    set(s, "accounts_loading", d.append("status", expanded.getValue()));
×
317
                }
318
                schedule(s, EXPAND_TRUST_PATHS.with("depth", depth));
×
319

320
            } else {
×
321

322
                schedule(s, LOAD_CORE.with("depth", depth).append("load-count", 0));
×
323

324
            }
325

326
        }
×
327

328
        // At the end of this step, trust paths are updated to include
329
        // the new accounts:
330
        // ------------------------------------------------------------
331
        //
332
        //         o      ----endorses----> [intro]
333
        //    --> /#\  /o\___                o
334
        //        / \  \_/^^^ ---trusts---> /#\  /o\___
335
        //        (expanded)                / \  \_/^^^
336
        //                                    (seen)
337
        //
338
        //    ========[X]=====================[X+1] trust path
339
        //
340
        // ------------------------------------------------------------
341
        // Only one trust path is shown here, but they branch out if
342
        // several trust edges are present.
343

344
    },
345

346
    LOAD_CORE {
33✔
347

348
        // From here on, we refocus on the head of the trust paths:
349
        // ------------------------------------------------------------
350
        //
351
        //         o
352
        //    --> /#\  /o\___
353
        //        / \  \_/^^^
354
        //          (seen)
355
        //
356
        //    ========[X] trust path
357
        //
358
        // ------------------------------------------------------------
359

360
        // DB read from: accounts, trustPaths, endorsements, lists
361
        // DB write to:  accounts, endorsements, lists
362

363
        public void run(ClientSession s, Document taskDoc) {
364

365
            int depth = taskDoc.getInteger("depth");
×
366
            int loadCount = taskDoc.getInteger("load-count");
×
367

368
            Document agentAccount = getOne(s, "accounts_loading",
×
369
                    new Document("depth", depth).append("status", seen.getValue()));
×
370
            final String agentId;
371
            final String pubkeyHash;
372
            final Document trustPath;
373
            if (agentAccount != null) {
×
374
                agentId = agentAccount.getString("agent");
×
375
                Validate.notNull(agentId);
×
376
                pubkeyHash = agentAccount.getString("pubkey");
×
377
                Validate.notNull(pubkeyHash);
×
378
                trustPath = getOne(s, "trustPaths_loading",
×
379
                        new Document("depth", depth)
×
380
                                .append("agent", agentId)
×
381
                                .append("pubkey", pubkeyHash)
×
382
                );
383
            } else {
384
                agentId = null;
×
385
                pubkeyHash = null;
×
386
                trustPath = null;
×
387
            }
388

389
            if (agentAccount == null) {
×
390
                schedule(s, FINISH_ITERATION.with("depth", depth).append("load-count", loadCount));
×
391
            } else if (trustPath == null) {
×
392
                // Account was seen but has no trust path at this depth; skip it
393
                set(s, "accounts_loading", agentAccount.append("status", skipped.getValue()));
×
394
                schedule(s, LOAD_CORE.with("depth", depth).append("load-count", loadCount));
×
395
            } else if (trustPath.getDouble("ratio") < MIN_TRUST_PATH_RATIO) {
×
396
                set(s, "accounts_loading", agentAccount.append("status", skipped.getValue()));
×
397
                Document d = new Document("pubkey", pubkeyHash).append("type", INTRO_TYPE_HASH);
×
398
                if (!has(s, "lists", d)) {
×
399
                    insert(s, "lists", d.append("status", encountered.getValue()));
×
400
                }
401
                schedule(s, LOAD_CORE.with("depth", depth).append("load-count", loadCount + 1));
×
402
            } else {
×
403
                // TODO check intro limit
404
                Document introList = new Document()
×
405
                        .append("pubkey", pubkeyHash)
×
406
                        .append("type", INTRO_TYPE_HASH)
×
407
                        .append("status", loading.getValue());
×
408
                if (!has(s, "lists", new Document("pubkey", pubkeyHash).append("type", INTRO_TYPE_HASH))) {
×
409
                    insert(s, "lists", introList);
×
410
                }
411

412
                // No checksum skip in LOAD_CORE: the endorsement extraction logic (below) needs to
413
                // see every nanopub to populate endorsements_loading, which is rebuilt from scratch each UPDATE.
414
                try (var stream = NanopubLoader.retrieveNanopubsFromPeers(INTRO_TYPE_HASH, pubkeyHash)) {
×
415
                    NanopubLoader.loadStreamInParallel(stream, np -> {
×
416
                        try (ClientSession ws = RegistryDB.getClient().startSession()) {
×
417
                            loadNanopub(ws, np, pubkeyHash, INTRO_TYPE);
×
418
                        }
419
                    });
×
420
                }
421

422
                set(s, "lists", introList.append("status", loaded.getValue()));
×
423

424
                // TODO check endorsement limit
425
                Document endorseList = new Document()
×
426
                        .append("pubkey", pubkeyHash)
×
427
                        .append("type", ENDORSE_TYPE_HASH)
×
428
                        .append("status", loading.getValue());
×
429
                if (!has(s, "lists", new Document("pubkey", pubkeyHash).append("type", ENDORSE_TYPE_HASH))) {
×
430
                    insert(s, "lists", endorseList);
×
431
                }
432

433
                try (var stream = NanopubLoader.retrieveNanopubsFromPeers(ENDORSE_TYPE_HASH, pubkeyHash)) {
×
434
                    stream.forEach(m -> {
×
435
                        if (!m.isSuccess())
×
436
                            throw new AbortingTaskException("Failed to download nanopub; aborting task...");
×
437
                        Nanopub nanopub = m.getNanopub();
×
438
                        loadNanopub(s, nanopub, pubkeyHash, ENDORSE_TYPE);
×
439
                        String sourceNpId = TrustyUriUtils.getArtifactCode(nanopub.getUri().stringValue());
×
440
                        Validate.notNull(sourceNpId);
×
441
                        for (Statement st : nanopub.getAssertion()) {
×
442
                            if (!st.getPredicate().equals(Utils.APPROVES_OF)) continue;
×
443
                            if (!(st.getObject() instanceof IRI)) continue;
×
444
                            if (!agentId.equals(st.getSubject().stringValue())) continue;
×
445
                            String objStr = st.getObject().stringValue();
×
446
                            if (!TrustyUriUtils.isPotentialTrustyUri(objStr)) continue;
×
447
                            String endorsedNpId = TrustyUriUtils.getArtifactCode(objStr);
×
448
                            Validate.notNull(endorsedNpId);
×
449
                            Document endorsement = new Document("agent", agentId)
×
450
                                    .append("pubkey", pubkeyHash)
×
451
                                    .append("endorsedNanopub", endorsedNpId)
×
452
                                    .append("source", sourceNpId);
×
453
                            if (!has(s, "endorsements_loading", endorsement)) {
×
454
                                insert(s, "endorsements_loading",
×
455
                                        endorsement.append("status", toRetrieve.getValue()));
×
456
                            }
457
                        }
×
458
                    });
×
459
                }
460

461
                set(s, "lists", endorseList.append("status", loaded.getValue()));
×
462

463
                Document df = new Document("pubkey", pubkeyHash).append("type", "$");
×
464
                if (!has(s, "lists", df)) insert(s, "lists",
×
465
                        df.append("status", encountered.getValue()));
×
466

467
                set(s, "accounts_loading", agentAccount.append("status", visited.getValue()));
×
468

469
                schedule(s, LOAD_CORE.with("depth", depth).append("load-count", loadCount + 1));
×
470
            }
471

472
        }
×
473

474
        // At the end of this step, we have added new endorsement
475
        // links to yet-to-retrieve agent introductions:
476
        // ------------------------------------------------------------
477
        //
478
        //         o      ----endorses----> [intro]
479
        //    --> /#\  /o\___            (to-retrieve)
480
        //        / \  \_/^^^
481
        //         (visited)
482
        //
483
        //    ========[X] trust path
484
        //
485
        // ------------------------------------------------------------
486
        // Only one endorsement is shown here, but there are typically
487
        // several.
488

489
    },
490

491
    FINISH_ITERATION {
33✔
492
        public void run(ClientSession s, Document taskDoc) {
493

494
            int depth = taskDoc.getInteger("depth");
×
495
            int loadCount = taskDoc.getInteger("load-count");
×
496

497
            if (loadCount == 0) {
×
498
                log.info("No new cores loaded; finishing iteration");
×
499
                schedule(s, CALCULATE_TRUST_SCORES);
×
500
            } else if (depth == MAX_TRUST_PATH_DEPTH) {
×
501
                log.info("Maximum depth reached: {}", depth);
×
502
                schedule(s, CALCULATE_TRUST_SCORES);
×
503
            } else {
504
                log.info("Progressing iteration at depth {}", depth + 1);
×
505
                schedule(s, LOAD_DECLARATIONS.with("depth", depth + 1));
×
506
            }
507

508
        }
×
509

510
    },
511

512
    CALCULATE_TRUST_SCORES {
33✔
513

514
        // DB read from: accounts, trustPaths
515
        // DB write to:  accounts
516

517
        public void run(ClientSession s, Document taskDoc) {
518

519
            Document d = getOne(s, "accounts_loading", new Document("status", expanded.getValue()));
×
520

521
            if (d == null) {
×
522
                schedule(s, AGGREGATE_AGENTS);
×
523
            } else {
524
                double ratio = 0.0;
×
525
                Map<String, Boolean> seenPathElements = new HashMap<>();
×
526
                int pathCount = 0;
×
527
                MongoCursor<Document> trustPaths = collection("trustPaths_loading").find(s,
×
528
                        new Document("agent", d.get("agent").toString()).append("pubkey", d.get("pubkey").toString())
×
529
                ).sort(orderBy(ascending("depth"), descending("ratio"), ascending("sorthash"))).cursor();
×
530
                while (trustPaths.hasNext()) {
×
531
                    Document trustPath = trustPaths.next();
×
532
                    ratio += trustPath.getDouble("ratio");
×
533
                    boolean independentPath = true;
×
534
                    String[] pathElements = trustPath.getString("_id").split(" ");
×
535
                    // Iterate over path elements, ignoring first (root) and last (this agent/pubkey):
536
                    for (int i = 1; i < pathElements.length - 1; i++) {
×
537
                        String p = pathElements[i];
×
538
                        if (seenPathElements.containsKey(p)) {
×
539
                            independentPath = false;
×
540
                            break;
×
541
                        }
542
                        seenPathElements.put(p, true);
×
543
                    }
544
                    if (independentPath) pathCount += 1;
×
545
                }
×
546
                double rawQuota = GLOBAL_QUOTA * ratio;
×
547
                int quota = (int) rawQuota;
×
548
                if (rawQuota < MIN_USER_QUOTA) {
×
549
                    quota = MIN_USER_QUOTA;
×
550
                } else if (rawQuota > MAX_USER_QUOTA) {
×
551
                    quota = MAX_USER_QUOTA;
×
552
                }
553
                set(s, "accounts_loading",
×
554
                        d.append("status", processed.getValue())
×
555
                                .append("ratio", ratio)
×
556
                                .append("pathCount", pathCount)
×
557
                                .append("quota", quota)
×
558
                );
559
                schedule(s, CALCULATE_TRUST_SCORES);
×
560
            }
561

562
        }
×
563

564
    },
565

566
    AGGREGATE_AGENTS {
33✔
567

568
        // DB read from: accounts, agents
569
        // DB write to:  accounts, agents
570

571
        public void run(ClientSession s, Document taskDoc) {
572

573
            Document a = getOne(s, "accounts_loading", new Document("status", processed.getValue()));
×
574
            if (a == null) {
×
575
                schedule(s, ASSIGN_PUBKEYS);
×
576
            } else {
577
                Document agentId = new Document("agent", a.get("agent").toString()).append("status", processed.getValue());
×
578
                int count = 0;
×
579
                int pathCountSum = 0;
×
580
                double totalRatio = 0.0d;
×
581
                MongoCursor<Document> agentAccounts = collection("accounts_loading").find(s, agentId).cursor();
×
582
                while (agentAccounts.hasNext()) {
×
583
                    Document d = agentAccounts.next();
×
584
                    count++;
×
585
                    pathCountSum += d.getInteger("pathCount");
×
586
                    totalRatio += d.getDouble("ratio");
×
587
                }
×
588
                collection("accounts_loading").updateMany(s, agentId, new Document("$set",
×
589
                        new DbEntryWrapper(aggregated).getDocument()));
×
590
                insert(s, "agents_loading",
×
591
                        agentId.append("accountCount", count)
×
592
                                .append("avgPathCount", (double) pathCountSum / count)
×
593
                                .append("totalRatio", totalRatio)
×
594
                );
595
                schedule(s, AGGREGATE_AGENTS);
×
596
            }
597

598
        }
×
599

600
    },
601

602
    ASSIGN_PUBKEYS {
33✔
603

604
        // DB read from: accounts
605
        // DB write to:  accounts
606

607
        public void run(ClientSession s, Document taskDoc) {
608

609
            Document a = getOne(s, "accounts_loading", new DbEntryWrapper(aggregated).getDocument());
×
610
            if (a == null) {
×
611
                schedule(s, DETERMINE_UPDATES);
×
612
            } else {
613
                Document pubkeyId = new Document("pubkey", a.get("pubkey").toString());
×
614
                if (collection("accounts_loading").countDocuments(s, pubkeyId) == 1) {
×
615
                    collection("accounts_loading").updateMany(s, pubkeyId,
×
616
                            new Document("$set", new DbEntryWrapper(approved).getDocument()));
×
617
                } else {
618
                    // TODO At the moment all get marked as 'contested'; implement more nuanced algorithm
619
                    collection("accounts_loading").updateMany(s, pubkeyId, new Document("$set",
×
620
                            new DbEntryWrapper(contested).getDocument()));
×
621
                }
622
                schedule(s, ASSIGN_PUBKEYS);
×
623
            }
624

625
        }
×
626

627
    },
628

629
    DETERMINE_UPDATES {
33✔
630

631
        // DB read from: accounts
632
        // DB write to:  accounts
633

634
        public void run(ClientSession s, Document taskDoc) {
635

636
            // TODO Handle contested accounts properly:
637
            for (Document d : collection("accounts_loading").find(
×
638
                    new DbEntryWrapper(approved).getDocument())) {
×
639
                // TODO Consider quota too:
640
                Document accountId = new Document("agent", d.get("agent").toString()).append("pubkey", d.get("pubkey").toString());
×
641
                if (collection(Collection.ACCOUNTS.toString()) == null || !has(s, Collection.ACCOUNTS.toString(),
×
642
                        accountId.append("status", loaded.getValue()))) {
×
643
                    set(s, "accounts_loading", d.append("status", toLoad.getValue()));
×
644
                } else {
645
                    set(s, "accounts_loading", d.append("status", loaded.getValue()));
×
646
                }
647
            }
×
648
            schedule(s, FINALIZE_TRUST_STATE);
×
649

650
        }
×
651

652
    },
653

654
    FINALIZE_TRUST_STATE {
33✔
655
        // We do this is a separate task/transaction, because if we do it at the beginning of RELEASE_DATA, that task hangs and cannot
656
        // properly re-run (as some renaming outside of transactions will have taken place).
657
        public void run(ClientSession s, Document taskDoc) {
658
            String newTrustStateHash = RegistryDB.calculateTrustStateHash(s);
×
659
            String previousTrustStateHash = (String) getValue(s, Collection.SERVER_INFO.toString(), "trustStateHash");  // may be null
×
660
            setValue(s, Collection.SERVER_INFO.toString(), "lastTrustStateUpdate", ZonedDateTime.now().toString());
×
661

662
            schedule(s, RELEASE_DATA.with("newTrustStateHash", newTrustStateHash).append("previousTrustStateHash", previousTrustStateHash));
×
663
        }
×
664

665
    },
666

667
    RELEASE_DATA {
33✔
668
        public void run(ClientSession s, Document taskDoc) {
669
            ServerStatus status = getServerStatus(s);
×
670

671
            String newTrustStateHash = taskDoc.get("newTrustStateHash").toString();
×
672
            String previousTrustStateHash = taskDoc.getString("previousTrustStateHash");  // may be null
×
673

674
            // Renaming collections is run outside of a transaction, but is idempotent operation, so can safely be retried if task fails:
675
            rename("accounts_loading", Collection.ACCOUNTS.toString());
×
676
            rename("trustPaths_loading", "trustPaths");
×
677
            rename("agents_loading", Collection.AGENTS.toString());
×
678
            rename("endorsements_loading", "endorsements");
×
679

680
            if (previousTrustStateHash == null || !previousTrustStateHash.equals(newTrustStateHash)) {
×
681
                increaseStateCounter(s);
×
682
                setValue(s, Collection.SERVER_INFO.toString(), "trustStateHash", newTrustStateHash);
×
683
                insert(s, "debug_trustPaths", new Document()
×
684
                        .append("trustStateTxt", DebugPage.getTrustPathsTxt(s))
×
685
                        .append("trustStateHash", newTrustStateHash)
×
686
                        .append("trustStateCounter", getValue(s, Collection.SERVER_INFO.toString(), "trustStateCounter"))
×
687
                );
688
            }
689

690
            if (status == coreLoading) {
×
691
                setServerStatus(s, coreReady);
×
692
            } else {
693
                setServerStatus(s, ready);
×
694
            }
695

696
            // Run update after 1h:
697
            schedule(s, UPDATE.withDelay(60 * 60 * 1000));
×
698
        }
×
699

700
    },
701

702
    UPDATE {
33✔
703
        public void run(ClientSession s, Document taskDoc) {
704
            ServerStatus status = getServerStatus(s);
×
705
            if (status == ready || status == coreReady) {
×
706
                setServerStatus(s, updating);
×
707
                schedule(s, INIT_COLLECTIONS);
×
708
            } else {
709
                log.info("Postponing update; currently in status {}", status);
×
710
                schedule(s, UPDATE.withDelay(10 * 60 * 1000));
×
711
            }
712

713
        }
×
714

715
    },
716

717
    LOAD_FULL {
33✔
718
        public void run(ClientSession s, Document taskDoc) {
719
            if ("false".equals(System.getenv("REGISTRY_PERFORM_FULL_LOAD"))) return;
15!
720

721
            ServerStatus status = getServerStatus(s);
9✔
722
            if (status != coreReady && status != ready && status != updating) {
27!
723
                log.info("Server currently not ready; checking again later");
9✔
724
                schedule(s, LOAD_FULL.withDelay(60 * 1000));
15✔
725
                return;
3✔
726
            }
727

728
            Document a = getOne(s, Collection.ACCOUNTS.toString(), new DbEntryWrapper(toLoad).getDocument());
×
729
            if (a == null) {
×
730
                log.info("Nothing to load");
×
731
                if (status == coreReady) {
×
732
                    log.info("Full load finished");
×
733
                    setServerStatus(s, ready);
×
734
                }
735
                log.info("Scheduling optional loading checks");
×
736
                schedule(s, RUN_OPTIONAL_LOAD.withDelay(100));
×
737
            } else {
738
                final String ph = a.getString("pubkey");
×
739
                if (!ph.equals("$")) {
×
740
                    long startTime = System.nanoTime();
×
741
                    AtomicLong totalLoaded = new AtomicLong(0);
×
742

743
                    // Load per covered type (or "$" if no restriction) with checksum skip-ahead
744
                    for (String typeHash : getLoadTypeHashes(s, ph)) {
×
745
                        String checksums = buildChecksumFallbacks(s, ph, typeHash);
×
746
                        try (var stream = NanopubLoader.retrieveNanopubsFromPeers(typeHash, ph, checksums)) {
×
747
                            NanopubLoader.loadStreamInParallel(stream, np -> {
×
748
                                if (!CoverageFilter.isCovered(np)) return;
×
749
                                try (ClientSession ws = RegistryDB.getClient().startSession()) {
×
750
                                    loadNanopub(ws, np, ph, "$");
×
751
                                    totalLoaded.incrementAndGet();
×
752
                                }
753
                            });
×
754
                        }
755
                    }
×
756

757
                    double timeSeconds = (System.nanoTime() - startTime) * 1e-9;
×
758
                    log.info("Loaded {} nanopubs in {}s, {} np/s",
×
759
                            totalLoaded.get(), timeSeconds, String.format("%.2f", totalLoaded.get() / timeSeconds));
×
760
                }
761

762
                Document l = getOne(s, "lists", new Document().append("pubkey", ph).append("type", "$"));
×
763
                if (l != null) set(s, "lists", l.append("status", loaded.getValue()));
×
764
                set(s, Collection.ACCOUNTS.toString(), a.append("status", loaded.getValue()));
×
765

766
                schedule(s, LOAD_FULL.withDelay(100));
×
767
            }
768
        }
×
769

770
        @Override
771
        public boolean runAsTransaction() {
772
            // TODO Make this a transaction once we connect to other Nanopub Registry instances:
773
            return false;
×
774
        }
775

776
    },
777

778
    RUN_OPTIONAL_LOAD {
33✔
779

780
        private static final int BATCH_SIZE = Integer.parseInt(
15✔
781
                Utils.getEnv("REGISTRY_OPTIONAL_LOAD_BATCH_SIZE", "100"));
3✔
782

783
        public void run(ClientSession s, Document taskDoc) {
784
            AtomicLong totalLoaded = new AtomicLong(0);
×
785

786
            // Phase 1: Process encountered intro lists (core loading)
787
            while (totalLoaded.get() < BATCH_SIZE) {
×
788
                Document di = getOne(s, "lists", new Document("type", INTRO_TYPE_HASH).append("status", encountered.getValue()));
×
789
                if (di == null) break;
×
790

791
                final String pubkeyHash = di.getString("pubkey");
×
792
                Validate.notNull(pubkeyHash);
×
793
                log.info("Optional core loading: {}", pubkeyHash);
×
794

795
                String introChecksums = buildChecksumFallbacks(s, pubkeyHash, INTRO_TYPE_HASH);
×
796
                try (var stream = NanopubLoader.retrieveNanopubsFromPeers(INTRO_TYPE_HASH, pubkeyHash, introChecksums)) {
×
797
                    NanopubLoader.loadStreamInParallel(stream, np -> {
×
798
                        try (ClientSession ws = RegistryDB.getClient().startSession()) {
×
799
                            loadNanopub(ws, np, pubkeyHash, INTRO_TYPE);
×
800
                            totalLoaded.incrementAndGet();
×
801
                        }
802
                    });
×
803
                }
804
                set(s, "lists", di.append("status", loaded.getValue()));
×
805

806
                String endorseChecksums = buildChecksumFallbacks(s, pubkeyHash, ENDORSE_TYPE_HASH);
×
807
                try (var stream = NanopubLoader.retrieveNanopubsFromPeers(ENDORSE_TYPE_HASH, pubkeyHash, endorseChecksums)) {
×
808
                    NanopubLoader.loadStreamInParallel(stream, np -> {
×
809
                        try (ClientSession ws = RegistryDB.getClient().startSession()) {
×
810
                            loadNanopub(ws, np, pubkeyHash, ENDORSE_TYPE);
×
811
                            totalLoaded.incrementAndGet();
×
812
                        }
813
                    });
×
814
                }
815

816
                Document de = new Document("pubkey", pubkeyHash).append("type", ENDORSE_TYPE_HASH);
×
817
                if (has(s, "lists", de)) {
×
818
                    set(s, "lists", de.append("status", loaded.getValue()));
×
819
                } else {
820
                    insert(s, "lists", de.append("status", loaded.getValue()));
×
821
                }
822

823
                Document df = new Document("pubkey", pubkeyHash).append("type", "$");
×
824
                if (!has(s, "lists", df)) insert(s, "lists", df.append("status", encountered.getValue()));
×
825
            }
×
826

827
            // Phase 2: Process encountered full lists (if budget remains)
828
            while (totalLoaded.get() < BATCH_SIZE) {
×
829
                Document df = getOne(s, "lists", new Document("type", "$").append("status", encountered.getValue()));
×
830
                if (df == null) break;
×
831

832
                final String pubkeyHash = df.getString("pubkey");
×
833
                log.info("Optional full loading: {}", pubkeyHash);
×
834

835
                // Load per covered type (or "$" if no restriction) with checksum skip-ahead
836
                for (String typeHash : getLoadTypeHashes(s, pubkeyHash)) {
×
837
                    String checksums = buildChecksumFallbacks(s, pubkeyHash, typeHash);
×
838
                    try (var stream = NanopubLoader.retrieveNanopubsFromPeers(typeHash, pubkeyHash, checksums)) {
×
839
                        NanopubLoader.loadStreamInParallel(stream, np -> {
×
840
                            if (!CoverageFilter.isCovered(np)) return;
×
841
                            try (ClientSession ws = RegistryDB.getClient().startSession()) {
×
842
                                loadNanopub(ws, np, pubkeyHash, "$");
×
843
                                totalLoaded.incrementAndGet();
×
844
                            }
845
                        });
×
846
                    }
847
                }
×
848

849
                set(s, "lists", df.append("status", loaded.getValue()));
×
850
            }
×
851

852
            if (totalLoaded.get() > 0) {
×
853
                log.info("Optional load batch completed: {} nanopubs across multiple pubkeys", totalLoaded.get());
×
854
            }
855

856
            if (prioritizeAllPubkeys()) {
×
857
                // Check if there are more pubkeys waiting to be processed
858
                boolean moreWork = has(s, "lists", new Document("type", INTRO_TYPE_HASH).append("status", encountered.getValue()))
×
859
                        || has(s, "lists", new Document("type", "$").append("status", encountered.getValue()));
×
860
                if (moreWork) {
×
861
                    // Continue processing without a full CHECK_NEW cycle in between.
862
                    // CHECK_NEW will run naturally once all encountered lists are processed.
863
                    schedule(s, RUN_OPTIONAL_LOAD.withDelay(10));
×
864
                } else {
865
                    schedule(s, CHECK_NEW.withDelay(500));
×
866
                }
867
            } else {
×
868
                // Throttled: yield to CHECK_NEW after each batch to prioritize approved pubkeys
869
                schedule(s, CHECK_NEW.withDelay(500));
×
870
            }
871
        }
×
872

873
    },
874

875
    CHECK_NEW {
33✔
876
        public void run(ClientSession s, Document taskDoc) {
877
            RegistryPeerConnector.checkPeers(s);
×
878
            // Keep legacy connection during transition period:
879
            LegacyConnector.checkForNewNanopubs(s);
×
880
            // TODO Somehow throttle the loading of such potentially non-approved nanopubs
881

882
            schedule(s, LOAD_FULL.withDelay(100));
×
883
        }
×
884

885
        @Override
886
        public boolean runAsTransaction() {
887
            // Peer sync includes long-running streaming fetches that would exceed
888
            // MongoDB's transaction timeout; each operation is individually safe.
889
            return false;
×
890
        }
891

892
    };
893

894
    private static final Logger log = LoggerFactory.getLogger(Task.class);
9✔
895

896
    public abstract void run(ClientSession s, Document taskDoc) throws Exception;
897

898
    public boolean runAsTransaction() {
899
        return true;
×
900
    }
901

902
    Document asDocument() {
903
        return withDelay(0L);
12✔
904
    }
905

906
    private Document withDelay(long delay) {
907
        // TODO Rename "not-before" to "notBefore" for consistency with other field names
908
        return new Document()
15✔
909
                .append("not-before", System.currentTimeMillis() + delay)
21✔
910
                .append("action", name());
6✔
911
    }
912

913
    private Document with(String key, Object value) {
914
        return asDocument().append(key, value);
×
915
    }
916

917
    private static boolean prioritizeAllPubkeys() {
918
        return "true".equals(System.getenv("REGISTRY_PRIORITIZE_ALL_PUBKEYS"));
×
919
    }
920

921
    /**
922
     * Returns the type hashes to load for a given pubkey. When coverage is unrestricted,
923
     * returns just "$" (all types in one request). When restricted, returns each covered
924
     * type hash for per-type fetching with checksum skip-ahead.
925
     *
926
     * TODO: Fetching "$" from peers with type restrictions will only return their covered
927
     * types, not all types. To get full coverage, we'd need to fetch per-type from such peers.
928
     * Additionally, checksum-based skip-ahead won't work correctly against such peers, because
929
     * their "$" list has different checksums due to the differing type subset. This means full
930
     * re-downloads on every cycle. Per-type fetching would solve both issues.
931
     */
932
    private static java.util.List<String> getLoadTypeHashes(ClientSession s, String pubkeyHash) {
933
        if (CoverageFilter.coversAllTypes()) {
×
934
            return java.util.List.of("$");
×
935
        }
936
        return java.util.List.copyOf(CoverageFilter.getCoveredTypeHashes());
×
937
    }
938

939
    // TODO Move these to setting:
940
    private static final int MAX_TRUST_PATH_DEPTH = 10;
941
    private static final double MIN_TRUST_PATH_RATIO = 0.00000001;
942
    //private static final double MIN_TRUST_PATH_RATIO = 0.01; // For testing
943
    private static final int GLOBAL_QUOTA = 100000000;
944
    private static final int MIN_USER_QUOTA = 100;
945
    private static final int MAX_USER_QUOTA = 10000;
946

947
    private static MongoCollection<Document> tasksCollection = collection(Collection.TASKS.toString());
15✔
948

949
    private static volatile String currentTaskName;
950
    private static volatile long currentTaskStartTime;
951

952
    public static String getCurrentTaskName() {
953
        return currentTaskName;
×
954
    }
955

956
    public static long getCurrentTaskStartTime() {
957
        return currentTaskStartTime;
×
958
    }
959

960
    /**
961
     * The super important base entry point!
962
     */
963
    static void runTasks() {
964
        try (ClientSession s = RegistryDB.getClient().startSession()) {
×
965
            if (!RegistryDB.isInitialized(s)) {
×
966
                schedule(s, INIT_DB); // does not yet execute, only schedules
×
967
            }
968

969
            while (true) {
970
                FindIterable<Document> taskResult = tasksCollection.find(s).sort(ascending("not-before"));
×
971
                Document taskDoc = taskResult.first();
×
972
                long sleepTime = 10;
×
973
                if (taskDoc != null && taskDoc.getLong("not-before") < System.currentTimeMillis()) {
×
974
                    Task task = valueOf(taskDoc.getString("action"));
×
975
                    log.info("Running task: {}", task.name());
×
976
                    if (task.runAsTransaction()) {
×
977
                        try {
978
                            s.startTransaction();
×
979
                            log.info("Transaction started");
×
980
                            runTask(task, taskDoc);
×
981
                            s.commitTransaction();
×
982
                            log.info("Transaction committed");
×
983
                        } catch (Exception ex) {
×
984
                            log.info("Aborting transaction", ex);
×
985
                            abortTransaction(s, ex.getMessage());
×
986
                            log.info("Transaction aborted");
×
987
                            sleepTime = 1000;
×
988
                        } finally {
989
                            cleanTransactionWithRetry(s);
×
990
                        }
×
991
                    } else {
992
                        try {
993
                            runTask(task, taskDoc);
×
994
                        } catch (Exception ex) {
×
995
                            log.info("Transaction failed", ex);
×
996
                        }
×
997
                    }
998
                }
999
                try {
1000
                    Thread.sleep(sleepTime);
×
1001
                } catch (InterruptedException ex) {
×
1002
                    // ignore
1003
                }
×
1004
            }
×
1005
        }
1006
    }
1007

1008
    static void runTask(Task task, Document taskDoc) throws Exception {
1009
        try (ClientSession s = RegistryDB.getClient().startSession()) {
9✔
1010
            log.info("Executing task: {}", task.name());
15✔
1011
            currentTaskName = task.name();
9✔
1012
            currentTaskStartTime = System.currentTimeMillis();
6✔
1013
            task.run(s, taskDoc);
12✔
1014
            tasksCollection.deleteOne(s, eq("_id", taskDoc.get("_id")));
27✔
1015
            log.info("Task {} completed and removed from queue.", task.name());
15✔
1016
        } finally {
1017
            currentTaskName = null;
6✔
1018
        }
1019
    }
3✔
1020

1021
    public static void abortTransaction(ClientSession mongoSession, String message) {
1022
        boolean successful = false;
×
1023
        while (!successful) {
×
1024
            try {
1025
                if (mongoSession.hasActiveTransaction()) {
×
1026
                    mongoSession.abortTransaction();
×
1027
                }
1028
                successful = true;
×
1029
            } catch (Exception ex) {
×
1030
                log.info("Aborting transaction failed. ", ex);
×
1031
                try {
1032
                    Thread.sleep(1000);
×
1033
                } catch (InterruptedException iex) {
×
1034
                    // ignore
1035
                }
×
1036
            }
×
1037
        }
1038
    }
×
1039

1040
    public synchronized static void cleanTransactionWithRetry(ClientSession mongoSession) {
1041
        boolean successful = false;
×
1042
        while (!successful) {
×
1043
            try {
1044
                if (mongoSession.hasActiveTransaction()) {
×
1045
                    mongoSession.abortTransaction();
×
1046
                }
1047
                successful = true;
×
1048
            } catch (Exception ex) {
×
1049
                log.info("Cleaning transaction failed. ", ex);
×
1050
                try {
1051
                    Thread.sleep(1000);
×
1052
                } catch (InterruptedException iex) {
×
1053
                    // ignore
1054
                }
×
1055
            }
×
1056
        }
1057
    }
×
1058

1059
    private static IntroNanopub getAgentIntro(ClientSession mongoSession, String nanopubId) {
1060
        IntroNanopub agentIntro = new IntroNanopub(NanopubLoader.retrieveNanopub(mongoSession, nanopubId));
×
1061
        if (agentIntro.getUser() == null) return null;
×
1062
        loadNanopub(mongoSession, agentIntro.getNanopub());
×
1063
        return agentIntro;
×
1064
    }
1065

1066
    private static void setServerStatus(ClientSession mongoSession, ServerStatus status) {
1067
        setValue(mongoSession, Collection.SERVER_INFO.toString(), "status", status.toString());
21✔
1068
    }
3✔
1069

1070
    private static ServerStatus getServerStatus(ClientSession mongoSession) {
1071
        Object status = getValue(mongoSession, Collection.SERVER_INFO.toString(), "status");
18✔
1072
        if (status == null) {
6!
1073
            throw new RuntimeException("Illegal DB state: serverInfo status unavailable");
×
1074
        }
1075
        return ServerStatus.valueOf(status.toString());
12✔
1076
    }
1077

1078
    private static void schedule(ClientSession mongoSession, Task task) {
1079
        schedule(mongoSession, task.asDocument());
12✔
1080
    }
3✔
1081

1082
    private static void schedule(ClientSession mongoSession, Document taskDoc) {
1083
        log.info("Scheduling task: {}", taskDoc.getString("action"));
18✔
1084
        tasksCollection.insertOne(mongoSession, taskDoc);
12✔
1085
    }
3✔
1086

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