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

knowledgepixels / nanopub-query / 30630997299

31 Jul 2026 12:32PM UTC coverage: 59.0% (+0.5%) from 58.504%
30630997299

push

github

web-flow
Merge pull request #148 from knowledgepixels/feat/loader-sync-lag-metric-and-alerts

feat(metrics): add loader sync-lag gauge and ship alerting rules

627 of 1202 branches covered (52.16%)

Branch coverage included in aggregate %.

1828 of 2959 relevant lines covered (61.78%)

9.5 hits per line

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

94.02
src/main/java/com/knowledgepixels/query/MetricsCollector.java
1
package com.knowledgepixels.query;
2

3
import io.micrometer.core.instrument.Gauge;
4
import io.micrometer.core.instrument.MeterRegistry;
5

6
import java.util.Map;
7
import java.util.Set;
8
import java.util.concurrent.ConcurrentHashMap;
9
import java.util.concurrent.atomic.AtomicInteger;
10
import java.util.concurrent.atomic.AtomicLong;
11

12
/**
13
 * Class to collect metrics for performance analysis.
14
 */
15
public final class MetricsCollector {
16

17
    private final AtomicInteger loadCounter = new AtomicInteger(0);
18✔
18
    private final AtomicInteger typeRepositoriesCounter = new AtomicInteger(0);
18✔
19
    private final AtomicInteger pubkeyRepositoriesCounter = new AtomicInteger(0);
18✔
20
    private final AtomicInteger fullRepositoriesCounter = new AtomicInteger(0);
18✔
21

22
    /**
23
     * Value behind {@code registry.loader.sync_lag_nanopubs}. Refreshed on the
24
     * {@code updateMetrics} tick rather than inside the gauge lambda: the gauge is
25
     * evaluated on the scrape path, which runs on a Vert.x event loop, and
26
     * {@link NanopubLoader#getLoadedNanopubCount()} can fall through to a SPARQL
27
     * query on a cold cache. The repo-name counters above are populated the same
28
     * way and for the same reason.
29
     */
30
    private final AtomicLong syncLagNanopubs = new AtomicLong(UNKNOWN_LAG);
18✔
31

32
    /**
33
     * Sentinel for {@code registry.loader.sync_lag_nanopubs} when either side of the
34
     * subtraction is unavailable — before the first registry poll, or if the
35
     * forwarded count is unparseable. Distinct from {@code 0}, which asserts the
36
     * instance is genuinely in sync. Real lags are clamped at zero so this value
37
     * can never arise from arithmetic.
38
     */
39
    private static final long UNKNOWN_LAG = -1L;
40

41
    private final Map<StatusController.State, AtomicInteger> statusStates = new ConcurrentHashMap<>();
15✔
42

43
    /**
44
     * Creates new metrics collector object.
45
     *
46
     * @param meterRegistry The registry instance
47
     */
48
    public MetricsCollector(MeterRegistry meterRegistry) {
6✔
49
        // Numeric metrics
50
        Gauge.builder("registry.load.counter", loadCounter, AtomicInteger::get).register(meterRegistry);
24✔
51
        Gauge.builder("registry.type.repositories.counter", typeRepositoriesCounter, AtomicInteger::get).register(meterRegistry);
24✔
52
        Gauge.builder("registry.pubkey.repositories.counter", pubkeyRepositoriesCounter, AtomicInteger::get).register(meterRegistry);
24✔
53
        Gauge.builder("registry.full.repositories.counter", fullRepositoriesCounter, AtomicInteger::get).register(meterRegistry);
24✔
54

55
        // Circuit-breaker observability: expose both the raw counter and a boolean
56
        // "breaker active" flag. The boolean is redundant with counter >= threshold
57
        // but much cleaner to visualise in Grafana (the counter can saturate well
58
        // above the threshold during a sustained outage, which makes a single
59
        // "is the breaker tripped?" alert awkward to express over the raw value).
60
        Gauge.builder("registry.loader.consecutive_batch_failures",
12✔
61
                        () -> (double) JellyNanopubLoader.consecutiveBatchFailures)
12✔
62
                .description("Consecutive loadUpdates batches that threw an exception before succeeding")
6✔
63
                .register(meterRegistry);
6✔
64
        Gauge.builder("registry.loader.breaker_active",
12✔
65
                        () -> JellyNanopubLoader.consecutiveBatchFailures >= JellyNanopubLoader.BREAKER_THRESHOLD ? 1.0 : 0.0)
18!
66
                .description("1 if the loader circuit breaker is tripped (consecutive failures >= threshold), 0 otherwise")
6✔
67
                .register(meterRegistry);
6✔
68
        // Liveness signal that works without log access: seconds since the last
69
        // non-exceptional loadUpdates return. Counts both "loaded a batch" and
70
        // "caught up, nothing to do" as progress. An instance whose value climbs
71
        // unbounded while peers stay low is stuck on something the other
72
        // gauges don't capture.
73
        Gauge.builder("registry.loader.last_successful_batch_age_seconds",
12✔
74
                        () -> {
75
                            long t = JellyNanopubLoader.lastSuccessfulBatchAtMs;
6✔
76
                            if (t == 0L) return 0.0;    // not started yet
21!
77
                            return (System.currentTimeMillis() - t) / 1000.0;
×
78
                        })
79
                .description("Seconds since the last non-exceptional loadUpdates return (idle or loading)")
6✔
80
                .register(meterRegistry);
6✔
81
        // How far behind its own registry this instance is. The gauges above all
82
        // describe the loader's *internal* health; this one is the outcome an
83
        // operator actually cares about, and it is absolute rather than relative —
84
        // unlike the monitor's cross-instance checksum comparison, it still fires
85
        // when every instance stalls at once (incident 2026-07-31).
86
        //
87
        // Pair it with last_successful_batch_age_seconds when alerting. The registry
88
        // side of the subtraction is the count forwarded by the most recent poll, so
89
        // if polling itself is what broke, both counts freeze together and the lag
90
        // reads a falsely reassuring 0. Neither signal covers the other's blind spot.
91
        Gauge.builder("registry.loader.sync_lag_nanopubs", syncLagNanopubs, AtomicLong::get)
18✔
92
                .description("Nanopubs this instance is behind its registry; -1 when either count is unknown")
6✔
93
                .register(meterRegistry);
6✔
94

95
        // Shard-reconciliation observability (issue #139). Both read volatile
96
        // counters kept by ShardReconciler — no SPARQL on the scrape path. Any
97
        // non-zero repaired value means the backend acknowledged a shard write
98
        // that was not durable, and deserves an alert.
99
        Gauge.builder("registry.reconciler.nanopubs_checked_total",
12✔
100
                        () -> (double) ShardReconciler.checkedNanopubCount)
12✔
101
                .description("Nanopubs whose shard fan-out was verified by the reconciliation sweep since process start")
6✔
102
                .register(meterRegistry);
6✔
103
        Gauge.builder("registry.reconciler.shards_repaired_total",
12✔
104
                        () -> (double) ShardReconciler.repairedShardCount)
12✔
105
                .description("Missing shard repos detected and re-loaded by the reconciliation sweep since process start")
6✔
106
                .register(meterRegistry);
6✔
107
        Gauge.builder("registry.reconciler.shards_relost_total",
12✔
108
                        () -> (double) ShardReconciler.relostShardCount)
12✔
109
                .description("Shards that a previous sweep verified present and that later vanished (backend revoked readable state, issue #142)")
6✔
110
                .register(meterRegistry);
6✔
111

112
        // Status label metrics
113
        for (final var status : StatusController.State.values()) {
48✔
114
            AtomicInteger stateGauge = new AtomicInteger(0);
15✔
115
            statusStates.put(status, stateGauge);
18✔
116
            Gauge.builder("registry.server.status", stateGauge, AtomicInteger::get)
15✔
117
                    .description("Server status (1 if current)")
9✔
118
                    .tag("status", status.name())
9✔
119
                    .register(meterRegistry);
6✔
120
        }
121

122
        // Spaces / AuthorityResolver gauges. These read volatile fields kept
123
        // by AuthorityResolver — no SPARQL on the scrape path. Each lambda
124
        // re-fetches the singleton to match the lazy-init pattern used by
125
        // the rest of the codebase.
126
        Gauge.builder("registry.spaces.subjects.admin_ris",
12✔
127
                        () -> (double) AuthorityResolver.get().getLastSubjectTotals().adminRIs())
18✔
128
                .description("Distinct admin gen:RoleInstantiation subjects in the current space-state graph (last build/cycle observation)")
6✔
129
                .register(meterRegistry);
6✔
130
        Gauge.builder("registry.spaces.subjects.attachment_ras",
12✔
131
                        () -> (double) AuthorityResolver.get().getLastSubjectTotals().attachmentRAs())
18✔
132
                .description("Distinct gen:RoleAssignment subjects in the current space-state graph (last build/cycle observation)")
6✔
133
                .register(meterRegistry);
6✔
134
        Gauge.builder("registry.spaces.subjects.non_admin_ris",
12✔
135
                        () -> (double) AuthorityResolver.get().getLastSubjectTotals().nonAdminRIs())
18✔
136
                .description("Distinct non-admin gen:RoleInstantiation subjects in the current space-state graph (last build/cycle observation)")
6✔
137
                .register(meterRegistry);
6✔
138
        Gauge.builder("registry.spaces.delta.last_inserted_triples",
12✔
139
                        () -> (double) AuthorityResolver.get().getLastInsertedTriplesTotal())
15✔
140
                .description("Total inserted triples across all five tiers in the most recent full build or incremental cycle")
6✔
141
                .register(meterRegistry);
6✔
142
        Gauge.builder("registry.spaces.rebuild.last_duration_seconds",
12✔
143
                        () -> AuthorityResolver.get().getLastFullBuildDurationMs() / 1000.0)
21✔
144
                .description("Wall-clock duration of the most recent full space-state build")
6✔
145
                .register(meterRegistry);
6✔
146
        Gauge.builder("registry.spaces.cycle.last_duration_seconds",
12✔
147
                        () -> AuthorityResolver.get().getLastIncrementalCycleDurationMs() / 1000.0)
21✔
148
                .description("Wall-clock duration of the most recent incremental space-state cycle that did work")
6✔
149
                .register(meterRegistry);
6✔
150
        Gauge.builder("registry.spaces.processed_up_to_lag",
12✔
151
                        () -> (double) AuthorityResolver.get().getLastProcessedUpToLag())
15✔
152
                .description("currentLoadCounter - processedUpTo observed at the start of the most recent incremental cycle (0 after a full build)")
6✔
153
                .register(meterRegistry);
6✔
154
    }
3✔
155

156
    /**
157
     * Updates the metrics based on the current state of the system.
158
     */
159
    public void updateMetrics() {
160
        // Update numeric metrics
161
        loadCounter.set((int) StatusController.get().getState().loadCounter);
21✔
162
        // Request repository names once, to avoid multiple calls
163
        var repoNames = TripleStore.get().getRepositoryNames();
9✔
164
        if (repoNames == null) {
6!
165
            repoNames = Set.of();
×
166
        }
167
        typeRepositoriesCounter.set(
12✔
168
                (int) repoNames
169
                        .stream()
6✔
170
                        .filter(repo -> repo.startsWith("type_"))
15✔
171
                        .count()
6✔
172
        );
173
        pubkeyRepositoriesCounter.set(
12✔
174
                (int) repoNames
175
                        .stream()
6✔
176
                        .filter(repo -> repo.startsWith("pubkey_"))
15✔
177
                        .count()
6✔
178
        );
179
        fullRepositoriesCounter.set(repoNames.size());
15✔
180
        syncLagNanopubs.set(computeSyncLag());
12✔
181

182
        // Update status gauge
183
        final var currentStatus = StatusController.get().getState().state;
12✔
184
        for (final var status : StatusController.State.values()) {
48✔
185
            statusStates.get(status).set(status.equals(currentStatus) ? 1 : 0);
33!
186
        }
187
    }
3✔
188

189
    /**
190
     * Nanopubs this instance is behind its registry, or {@link #UNKNOWN_LAG} if either
191
     * count is unavailable.
192
     *
193
     * <p>Clamped at zero: the loaded count is bumped as each nanopub lands while the
194
     * registry count only refreshes once per poll, so the loaded side can legitimately
195
     * run ahead for a tick. Reporting that as negative would collide with the
196
     * unknown sentinel.
197
     *
198
     * @return the lag in nanopubs, clamped to zero, or {@link #UNKNOWN_LAG}
199
     */
200
    static long computeSyncLag() {
201
        String registryCount = JellyNanopubLoader.lastNanopubCount;
6✔
202
        Long loaded = NanopubLoader.getLoadedNanopubCount();
6✔
203
        if (registryCount == null || loaded == null) {
12!
204
            return UNKNOWN_LAG;
6✔
205
        }
206
        try {
207
            return Math.max(0L, Long.parseLong(registryCount.trim()) - loaded);
27✔
208
        } catch (NumberFormatException ex) {
3✔
209
            return UNKNOWN_LAG;
6✔
210
        }
211
    }
212
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc