Skip to content

Commit a8a9be1

Browse files
dzhelezovclaude
andcommitted
feat(harness/soak-ab): restart-count signal + crash-loop alert
PR #8 makes unknown-head / reorg-gap FATAL in stream mode (exit 75, EX_TEMPFAIL) with restart as the designed recovery. soak-b.service now: Restart=on-failure (covers 75) + an ExecStartPre that appends a UTC timestamp to $SOAK_B_RESTART_LOG on every (re)start (ReadWritePaths widened to cover it). ab-diff.mjs reads that log (restartStats, unit-tested) and surfaces restartCount / lastRestartAt / restartsLastHour in soak-ab-status.json, raising a crash-loop alert at >3 restarts/hour. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4bf4e01 commit a8a9be1

5 files changed

Lines changed: 124 additions & 2 deletions

File tree

harness/soak-ab/ab-diff.mjs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,34 @@ export function compareBucketHashes(aBuckets, bBuckets) {
7171
return { ok: mismatches.length === 0, mismatches, onlyA, onlyB };
7272
}
7373

74+
// Restart accounting from the systemd ExecStartPre log (one UTC-timestamp line per (re)start). In
75+
// stream mode a fatal unknown-head / reorg-gap exits 75 and systemd restarts — designed recovery, so
76+
// only the RATE matters: >3 restarts in the trailing hour is a crash-loop alert.
77+
export function restartStats(lines, nowMs = Date.now()) {
78+
const stamps = [];
79+
for (const line of lines) {
80+
const m = line.trim().match(/^(\d{4}-\d\d-\d\dT[\d:.]+Z)/);
81+
if (m) {
82+
stamps.push(Date.parse(m[1]));
83+
}
84+
}
85+
stamps.sort((x, y) => x - y);
86+
87+
const hourAgo = nowMs - 3_600_000;
88+
const restartsLastHour = stamps.filter((t) => t >= hourAgo).length;
89+
const lastRestartAt =
90+
stamps.length > 0
91+
? new Date(stamps[stamps.length - 1]).toISOString()
92+
: null;
93+
94+
return {
95+
restartCount: stamps.length,
96+
lastRestartAt,
97+
restartsLastHour,
98+
crashLoop: restartsLastHour > 3,
99+
};
100+
}
101+
74102
// ── psql adapters ──────────────────────────────────────────────────────────────────────────────
75103

76104
const SEP = '\x1f'; // unit separator — never appears in the hex/numeric fields we select
@@ -316,10 +344,41 @@ async function main() {
316344
? 'PASS'
317345
: 'PENDING';
318346

347+
// Soak B restart signal (from the systemd ExecStartPre log).
348+
const restartLog =
349+
process.env.RESTART_LOG ?? `${process.env.HOME ?? '.'}/soak-b-restarts.log`;
350+
let restarts = {
351+
restartCount: 0,
352+
lastRestartAt: null,
353+
restartsLastHour: 0,
354+
crashLoop: false,
355+
};
356+
try {
357+
restarts = restartStats(readFileSync(restartLog, 'utf8').split('\n'));
358+
} catch {
359+
// no restart log yet — Soak B not started, or first boot
360+
}
361+
362+
const alerts = [];
363+
if (restarts.crashLoop) {
364+
alerts.push(
365+
`crash-loop: ${restarts.restartsLastHour} restarts in the last hour (>3)`,
366+
);
367+
}
368+
if (verdict === 'FAIL') {
369+
alerts.push(
370+
'finalized-diff: an unexpected finalized-overlap divergence (see diffClasses)',
371+
);
372+
}
373+
319374
const status = {
320375
ts: new Date().toISOString(),
321376
chains: results.map((r) => r.chain),
322377
verdict,
378+
restartCount: restarts.restartCount,
379+
lastRestartAt: restarts.lastRestartAt,
380+
restartsLastHour: restarts.restartsLastHour,
381+
alerts,
323382
diffClasses: Object.fromEntries(results.map((r) => [r.chain, r.classes])),
324383
lagA: Object.fromEntries(results.map((r) => [r.chain, r.lagA ?? null])),
325384
lagB: Object.fromEntries(results.map((r) => [r.chain, r.lagB ?? null])),

harness/soak-ab/ab-diff.test.mjs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
checkpointMonotonic,
55
classifyTxDiff,
66
compareBucketHashes,
7+
restartStats,
78
} from './ab-diff.mjs';
89

910
test('classifyTxDiff: B missing A parent txs, all log-referenced → expected class (PASS)', () => {
@@ -67,3 +68,43 @@ test('compareBucketHashes: shared buckets must match; one-sided buckets are repo
6768
assert.equal(badRes.mismatches.length, 1);
6869
assert.equal(badRes.mismatches[0].bucket, '1');
6970
});
71+
72+
test('restartStats: counts restarts, reports last, flags crash-loop only above 3/hour', () => {
73+
const now = Date.parse('2026-07-03T12:00:00Z');
74+
// 2 restarts within the hour, 1 old → not a crash-loop
75+
const calm = restartStats(
76+
[
77+
'2026-07-03T09:00:00Z restart',
78+
'2026-07-03T11:30:00Z restart',
79+
'2026-07-03T11:55:00Z restart',
80+
'', // trailing blank line from the log split
81+
],
82+
now,
83+
);
84+
assert.equal(calm.restartCount, 3);
85+
assert.equal(calm.lastRestartAt, '2026-07-03T11:55:00.000Z');
86+
assert.equal(calm.restartsLastHour, 2);
87+
assert.equal(calm.crashLoop, false);
88+
89+
// 4 restarts within the hour → crash-loop
90+
const loop = restartStats(
91+
[
92+
'2026-07-03T11:10:00Z restart',
93+
'2026-07-03T11:25:00Z restart',
94+
'2026-07-03T11:40:00Z restart',
95+
'2026-07-03T11:59:00Z restart',
96+
],
97+
now,
98+
);
99+
assert.equal(loop.restartsLastHour, 4);
100+
assert.equal(loop.crashLoop, true);
101+
102+
// empty / never-started
103+
const none = restartStats([], now);
104+
assert.deepEqual(none, {
105+
restartCount: 0,
106+
lastRestartAt: null,
107+
restartsLastHour: 0,
108+
crashLoop: false,
109+
});
110+
});

harness/soak-ab/deploy-soak-b.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ PGADMIN_URL="${PGADMIN_URL:-postgres:///postgres}"
2525
MEM_HIGH="${SOAK_B_MEM_HIGH:-6G}"
2626
MEM_MAX="${SOAK_B_MEM_MAX:-8G}"
2727
UNIT_DIR="${SYSTEMD_DIR:-/etc/systemd/system}"
28+
RESTART_LOG="${SOAK_B_RESTART_LOG:-$HOME/soak-b-restarts.log}"
29+
RESTART_LOG_DIR="$(dirname "$RESTART_LOG")"
2830

2931
HERE="$(cd "$(dirname "$0")" && pwd)"
3032

@@ -89,12 +91,15 @@ chmod 600 "$ENVFILE"
8991
echo " wrote $ENVFILE (chmod 600)"
9092

9193
# ── 4. render + install the unit; enable but DO NOT start ──
94+
touch "$RESTART_LOG" 2>/dev/null || true
9295
RENDER="$(mktemp)"
9396
sed -e "s#@@WORKDIR@@#${WORKDIR}#g" \
9497
-e "s#@@ENVFILE@@#${ENVFILE}#g" \
9598
-e "s#@@PORT@@#${PORT}#g" \
9699
-e "s#@@MEM_HIGH@@#${MEM_HIGH}#g" \
97100
-e "s#@@MEM_MAX@@#${MEM_MAX}#g" \
101+
-e "s#@@RESTART_LOG@@#${RESTART_LOG}#g" \
102+
-e "s#@@RESTART_LOG_DIR@@#${RESTART_LOG_DIR}#g" \
98103
"$HERE/soak-b.service" > "$RENDER"
99104

100105
if [ -w "$UNIT_DIR" ] || [ "$(id -u)" = 0 ]; then
@@ -109,3 +114,4 @@ else
109114
fi
110115
rm -f "$RENDER"
111116
echo "▶ Soak B provisioned. Start manually; then run harness/soak-ab/ab-diff.mjs hourly."
117+
echo " restart log: $RESTART_LOG (ab-diff.mjs reads restartCount/lastRestartAt; >3/h = crash-loop alert)"

harness/soak-ab/soak-b.service

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,21 @@ Environment=CI=true
2626
# The soak indexes the SAME 3 chains as Soak A; the app's ponder.config.ts reads SOAK_CHAINS.
2727
Environment=SOAK_CHAINS=eth,base,arbitrum
2828

29+
# Restart counting is a first-class soak signal. Each (re)start appends a UTC timestamp to the
30+
# restart log; ab-diff.mjs reads it into restartCount / lastRestartAt and alerts on >3 restarts/hour
31+
# (crash-loop). In PORTAL_REALTIME=stream mode unknown-head and reorg-gap conditions are FATAL by
32+
# design and exit 75 (EX_TEMPFAIL) — Restart=on-failure makes that the intended restart-recovery,
33+
# NOT a soak failure (only the crash-loop RATE is an alert).
34+
Environment=SOAK_B_RESTART_LOG=@@RESTART_LOG@@
35+
ExecStartPre=/bin/sh -c 'echo "$(date -u +%%Y-%%m-%%dT%%H:%%M:%%SZ) restart" >> "$SOAK_B_RESTART_LOG"'
36+
2937
ExecStart=@@WORKDIR@@/node_modules/.bin/ponder start --schema soak_b --port @@PORT@@
3038

3139
# RSS cap — MemoryHigh throttles, MemoryMax is the hard kill ceiling (the soak asserts it holds).
3240
MemoryHigh=@@MEM_HIGH@@
3341
MemoryMax=@@MEM_MAX@@
3442

43+
# on-failure covers exit 75 (the designed stream-mode restart) and any crash/OOM/signal.
3544
Restart=on-failure
3645
RestartSec=10
3746
# 2 planned SIGKILL+restart drills mid-soak are exercised by the operator via `systemctl kill -s KILL`.
@@ -40,7 +49,7 @@ TimeoutStopSec=30
4049

4150
NoNewPrivileges=true
4251
ProtectSystem=strict
43-
ReadWritePaths=@@WORKDIR@@
52+
ReadWritePaths=@@WORKDIR@@ @@RESTART_LOG_DIR@@
4453

4554
[Install]
4655
WantedBy=multi-user.target

harness/validate/README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,14 @@ memory. It asserts: **logs** strict row-set + field identity (PRIMARY, must be 0
125125
identity (total_difficulty excluded); **transactions** are EXACTLY the expected class — B may be
126126
missing parent txs for realtime-ingested spans, each referenced by an A-side log; anything else FAILS;
127127
per-1000-block checkpoint hashes match; `_ponder_checkpoint` is monotonic. It writes
128-
`soak-ab-status.json` for the hourly monitor.
128+
`soak-ab-status.json` (`verdict`, `restartCount`, `lastRestartAt`, `restartsLastHour`, `alerts`,
129+
`diffClasses`, `lagA`/`lagB`, `counters`) for the hourly monitor.
130+
131+
**Restart signal** — in `PORTAL_REALTIME=stream` mode, unknown-head / reorg-gap are FATAL by design
132+
and exit **75 (EX_TEMPFAIL)**; `Restart=on-failure` makes that a designed restart-recovery, not a
133+
soak failure. The unit's `ExecStartPre` appends a UTC timestamp to `$SOAK_B_RESTART_LOG` on every
134+
(re)start; `ab-diff.mjs` reads it into `restartCount`/`lastRestartAt` and raises a **crash-loop**
135+
alert when restarts exceed 3/hour. Point `ab-diff.mjs` at the same log via `RESTART_LOG=…`.
129136

130137
**Guardrails** (`deploy-soak-b.sh`): DB is `euler_rt_b` and nothing else; port is never `:9547`; the
131138
`euler` prod DB is never touched; secrets are copied into a `chmod 600` env file, never printed/committed.

0 commit comments

Comments
 (0)