instar 1.3.339 → 1.3.340
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +166 -10
- package/dist/commands/server.js.map +1 -1
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +17 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/AutonomousSessions.d.ts +17 -2
- package/dist/core/AutonomousSessions.d.ts.map +1 -1
- package/dist/core/AutonomousSessions.js +30 -4
- package/dist/core/AutonomousSessions.js.map +1 -1
- package/dist/core/CoherenceJournal.d.ts +326 -0
- package/dist/core/CoherenceJournal.d.ts.map +1 -0
- package/dist/core/CoherenceJournal.js +999 -0
- package/dist/core/CoherenceJournal.js.map +1 -0
- package/dist/core/CoherenceJournalReader.d.ts +158 -0
- package/dist/core/CoherenceJournalReader.d.ts.map +1 -0
- package/dist/core/CoherenceJournalReader.js +450 -0
- package/dist/core/CoherenceJournalReader.js.map +1 -0
- package/dist/core/JournalSyncApplier.d.ts +272 -0
- package/dist/core/JournalSyncApplier.d.ts.map +1 -0
- package/dist/core/JournalSyncApplier.js +835 -0
- package/dist/core/JournalSyncApplier.js.map +1 -0
- package/dist/core/StateManager.d.ts +41 -0
- package/dist/core/StateManager.d.ts.map +1 -1
- package/dist/core/StateManager.js +100 -0
- package/dist/core/StateManager.js.map +1 -1
- package/dist/core/types.d.ts +35 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/messaging/TelegramAdapter.d.ts +14 -0
- package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
- package/dist/messaging/TelegramAdapter.js +16 -1
- package/dist/messaging/TelegramAdapter.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +7 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +54 -3
- package/dist/server/routes.js.map +1 -1
- package/package.json +2 -2
- package/scripts/lint-cas-emit-placement.js +87 -0
- package/scripts/lint-journal-actuation-ban.js +63 -0
- package/scripts/lint-state-registry.js +313 -0
- package/src/data/builtin-manifest.json +46 -46
- package/src/data/state-coherence-registry.json +880 -0
- package/src/scaffold/templates.ts +7 -0
- package/upgrades/1.3.340.md +132 -0
- package/upgrades/coherence-journal-p1-1.eli16.md +11 -0
- package/upgrades/coherence-journal-p1-2.eli16.md +11 -0
- package/upgrades/coherence-journal-p1-3.eli16.md +13 -0
- package/upgrades/side-effects/coherence-journal-p1-1.md +68 -0
- package/upgrades/side-effects/coherence-journal-p1-2.md +54 -0
- package/upgrades/side-effects/coherence-journal-p1-3.md +49 -0
- package/upgrades/1.3.339.md +0 -48
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JournalSyncApplier — the RECEIVE (and own-stream SERVE) side of coherence
|
|
3
|
+
* journal replication (P1.3 of multi-machine coherence).
|
|
4
|
+
*
|
|
5
|
+
* Spec: docs/specs/COHERENCE-JOURNAL-SPEC.md §3.4 (replication trust model —
|
|
6
|
+
* ALL seven rules), §3.1 (stream/meta layout), §5 (trust model).
|
|
7
|
+
*
|
|
8
|
+
* This class is PURE and MESH-INDEPENDENT. It owns disk state only; another
|
|
9
|
+
* workstream wires the `journal-sync` MeshRpc verb, the `session-status`
|
|
10
|
+
* advert, and the delta-request loop on top of it. It NEVER imports MeshRpc,
|
|
11
|
+
* the server, or the mesh dispatcher — it is the engine those call.
|
|
12
|
+
*
|
|
13
|
+
* Replica layout (§3.1): peer copies of machine M's stream of kind K live at
|
|
14
|
+
* <stateDir>/state/coherence-journal/peers/<safeMachineId(M)>.<kind>.jsonl
|
|
15
|
+
* with one sidecar
|
|
16
|
+
* <stateDir>/state/coherence-journal/peers/<safeMachineId(M)>.meta.json
|
|
17
|
+
* holding the stream-set incarnation token (top-level, mirroring the writer's
|
|
18
|
+
* own meta so `CoherenceJournalReader.readIncarnation` parses it) plus per-kind
|
|
19
|
+
* apply bookkeeping (lastHeldSeq, status, suspect/flap counters, gap sentinels)
|
|
20
|
+
* and the bounded quarantine record.
|
|
21
|
+
*
|
|
22
|
+
* The receive-side §4.1 durability rule lives HERE: a replica append fsyncs
|
|
23
|
+
* BEFORE the entries are reported `applied`, so a journal-sync ack is an
|
|
24
|
+
* ack-after-durable-commit. Author-side durability is the writer's separate
|
|
25
|
+
* best-effort flush window — this is the receiver's contract.
|
|
26
|
+
*
|
|
27
|
+
* Trust model enforced (§3.4):
|
|
28
|
+
* 1. FIRST-HOP SENDER BINDING — every entry's `machine` must === the
|
|
29
|
+
* authenticated sender; the target file derives from the sender, NEVER a
|
|
30
|
+
* payload field. Forged entries are rejected + counted, never appended.
|
|
31
|
+
* 2. SCHEMA-VALIDATED APPLY — per entry: parseable + size-cap, seq exactly
|
|
32
|
+
* lastHeldSeq+1, ts parses, kind known-or-ignorable, data passes the kind's
|
|
33
|
+
* typed schema. ANY failure marks the stream `suspect` and STOPS the batch
|
|
34
|
+
* at the last valid line. `suspect` self-clears after K=20 consecutive
|
|
35
|
+
* valid in-order applies.
|
|
36
|
+
* 3. INCARNATION FENCING — a known stream arriving with a NEW incarnation
|
|
37
|
+
* quarantines the old replica (rename aside, ≤2 kept per stream), starts
|
|
38
|
+
* fresh, and surfaces a coalesced divergence signal (per-machine 10min
|
|
39
|
+
* window); >3 flips in the window → status `reset-flapping`, surfaced once.
|
|
40
|
+
* 4. TRUNCATION SIGNALS — a batch may carry `oldestRetainedSeq`; when
|
|
41
|
+
* lastHeldSeq+1 < oldestRetainedSeq the receiver records a gap sentinel in
|
|
42
|
+
* the replica meta (NOT a fake journal line), fast-forwards lastHeldSeq to
|
|
43
|
+
* oldestRetainedSeq-1, and marks the stream `gapped` until the next clean
|
|
44
|
+
* apply. (Gap re-request PACING is the caller's job; this exposes status.)
|
|
45
|
+
*
|
|
46
|
+
* All replica writes go through the injected guardWrite seam (same seam as the
|
|
47
|
+
* writer) and O_APPEND single-line writes. The applier is tolerant + bounded
|
|
48
|
+
* everywhere and NEVER throws into its caller; degradation is surfaced via
|
|
49
|
+
* counters and per-stream status.
|
|
50
|
+
*/
|
|
51
|
+
import crypto from 'node:crypto';
|
|
52
|
+
import fs from 'node:fs';
|
|
53
|
+
import path from 'node:path';
|
|
54
|
+
import { JOURNAL_KINDS, sanitizeMachineId, readTailTolerant, } from './CoherenceJournal.js';
|
|
55
|
+
import { SafeFsExecutor } from './SafeFsExecutor.js';
|
|
56
|
+
// ---- tunables (§3.4) -------------------------------------------------------
|
|
57
|
+
/** Per-entry size cap on the receive side (§3.4 rule 2). Mirrors the writer's
|
|
58
|
+
* DEFAULT_MAX_ENTRY_BYTES (source of truth: CoherenceJournal.ts). */
|
|
59
|
+
export const APPLIER_MAX_ENTRY_BYTES = 8 * 1024;
|
|
60
|
+
/** Consecutive valid in-order applies that self-clear `suspect` (§3.4 rule 2). */
|
|
61
|
+
export const SUSPECT_CLEAR_THRESHOLD = 20;
|
|
62
|
+
/** Quarantined replica files kept per stream before evicting the oldest (rule 3). */
|
|
63
|
+
export const MAX_QUARANTINE_PER_STREAM = 2;
|
|
64
|
+
/** Incarnation-flap coalescing window per machine (rule 3). */
|
|
65
|
+
export const FLAP_WINDOW_MS = 10 * 60 * 1000;
|
|
66
|
+
/** Flips within the window past this → `reset-flapping`, surfaced once (rule 3). */
|
|
67
|
+
export const FLAP_RESET_THRESHOLD = 3;
|
|
68
|
+
/** Default serve-batch size cap (§3.4 rule 5 — journalSyncMaxBatchBytes). */
|
|
69
|
+
export const DEFAULT_MAX_BATCH_BYTES = 262144;
|
|
70
|
+
/** Reverse-tail read ceiling when reading own stream to serve. */
|
|
71
|
+
const SERVE_READ_BYTE_CEILING = 64 * 1024 * 1024;
|
|
72
|
+
function realFs() {
|
|
73
|
+
return {
|
|
74
|
+
openSync: fs.openSync,
|
|
75
|
+
writeSync: fs.writeSync,
|
|
76
|
+
fdatasyncSync: fs.fdatasyncSync,
|
|
77
|
+
closeSync: fs.closeSync,
|
|
78
|
+
existsSync: fs.existsSync,
|
|
79
|
+
statSync: fs.statSync,
|
|
80
|
+
renameSync: fs.renameSync,
|
|
81
|
+
writeFileSync: fs.writeFileSync,
|
|
82
|
+
readFileSync: fs.readFileSync,
|
|
83
|
+
readdirSync: fs.readdirSync,
|
|
84
|
+
truncateSync: fs.truncateSync,
|
|
85
|
+
mkdirSync: fs.mkdirSync,
|
|
86
|
+
readSync: fs.readSync,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function escapeRegExp(s) {
|
|
90
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
91
|
+
}
|
|
92
|
+
export class JournalSyncApplier {
|
|
93
|
+
stateDir;
|
|
94
|
+
guardWrite;
|
|
95
|
+
now;
|
|
96
|
+
logger;
|
|
97
|
+
io;
|
|
98
|
+
maxEntryBytes;
|
|
99
|
+
/** In-memory meta cache per peer machine (keyed by RAW machineId). */
|
|
100
|
+
metaCache = new Map();
|
|
101
|
+
degradation = {
|
|
102
|
+
applied: 0,
|
|
103
|
+
forgedEntries: 0,
|
|
104
|
+
duplicates: 0,
|
|
105
|
+
invalidEntries: 0,
|
|
106
|
+
suspectMarks: 0,
|
|
107
|
+
quarantines: 0,
|
|
108
|
+
gapsRecorded: 0,
|
|
109
|
+
guardSkips: 0,
|
|
110
|
+
appendErrors: 0,
|
|
111
|
+
signals: 0,
|
|
112
|
+
};
|
|
113
|
+
loggedClasses = new Set();
|
|
114
|
+
constructor(config) {
|
|
115
|
+
this.stateDir = config.stateDir;
|
|
116
|
+
this.guardWrite = config.guardWrite;
|
|
117
|
+
this.now = config.now ?? (() => new Date());
|
|
118
|
+
this.logger = config.logger;
|
|
119
|
+
this.io = config.fsImpl ?? realFs();
|
|
120
|
+
this.maxEntryBytes = config.maxEntryBytes ?? APPLIER_MAX_ENTRY_BYTES;
|
|
121
|
+
}
|
|
122
|
+
getDegradation() {
|
|
123
|
+
return { ...this.degradation };
|
|
124
|
+
}
|
|
125
|
+
// ---- advert state (for delta requests) ---------------------------------
|
|
126
|
+
/**
|
|
127
|
+
* What this machine holds per peer stream — `{ machineId → { kind →
|
|
128
|
+
* { incarnation, lastSeq } } }` — so the caller can compare against a peer's
|
|
129
|
+
* advert and request only the missing ranges (§3.4 rule 5). Sourced from the
|
|
130
|
+
* persisted peer meta (incarnation + per-kind lastHeldSeq).
|
|
131
|
+
*/
|
|
132
|
+
getAdvertState() {
|
|
133
|
+
const out = {};
|
|
134
|
+
for (const machineId of this.discoverPeerMachines()) {
|
|
135
|
+
const meta = this.loadMeta(machineId);
|
|
136
|
+
const perKind = {};
|
|
137
|
+
for (const kind of JOURNAL_KINDS) {
|
|
138
|
+
const ks = meta.kinds[kind];
|
|
139
|
+
// Surface a stream only if we hold something for it (lastHeldSeq > 0)
|
|
140
|
+
// OR it has bookkeeping (a gap/suspect). A bare zero is "nothing held".
|
|
141
|
+
if (ks && (ks.lastHeldSeq > 0 || ks.status !== 'current' || ks.gaps.length > 0)) {
|
|
142
|
+
perKind[kind] = { incarnation: meta.incarnation, lastSeq: ks.lastHeldSeq };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (Object.keys(perKind).length > 0)
|
|
146
|
+
out[machineId] = perKind;
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
/** Per-stream replication status (§3.4 rule 4 — caller drives gap pacing). */
|
|
151
|
+
getStreamStatus() {
|
|
152
|
+
const out = {};
|
|
153
|
+
for (const machineId of this.discoverPeerMachines()) {
|
|
154
|
+
const meta = this.loadMeta(machineId);
|
|
155
|
+
if (meta.resetFlapping) {
|
|
156
|
+
// A reset-flapping machine surfaces that status across all its kinds.
|
|
157
|
+
for (const kind of JOURNAL_KINDS) {
|
|
158
|
+
if (meta.kinds[kind])
|
|
159
|
+
out[`${machineId}.${kind}`] = 'reset-flapping';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const kind of JOURNAL_KINDS) {
|
|
163
|
+
const ks = meta.kinds[kind];
|
|
164
|
+
if (!ks)
|
|
165
|
+
continue;
|
|
166
|
+
const key = `${machineId}.${kind}`;
|
|
167
|
+
if (out[key])
|
|
168
|
+
continue; // reset-flapping already wins
|
|
169
|
+
out[key] = ks.status;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
// ---- apply (the RECEIVE side, §3.4 rules 1-4) --------------------------
|
|
175
|
+
/**
|
|
176
|
+
* Apply an inbound batch from `senderMachineId` (the AUTHENTICATED envelope
|
|
177
|
+
* identity). Implements §3.4 rules 1-4. Never throws; the result is fully
|
|
178
|
+
* observable. The target replica files derive from `senderMachineId` only —
|
|
179
|
+
* NEVER from any payload field.
|
|
180
|
+
*/
|
|
181
|
+
apply(senderMachineId, batch) {
|
|
182
|
+
const result = {
|
|
183
|
+
applied: 0,
|
|
184
|
+
forgedEntries: 0,
|
|
185
|
+
duplicates: 0,
|
|
186
|
+
invalidEntries: 0,
|
|
187
|
+
suspectStreams: 0,
|
|
188
|
+
quarantined: 0,
|
|
189
|
+
gapsRecorded: 0,
|
|
190
|
+
guardSkips: 0,
|
|
191
|
+
signals: [],
|
|
192
|
+
statuses: {},
|
|
193
|
+
};
|
|
194
|
+
try {
|
|
195
|
+
if (!senderMachineId || typeof senderMachineId !== 'string')
|
|
196
|
+
return result;
|
|
197
|
+
const meta = this.loadMeta(senderMachineId);
|
|
198
|
+
for (const stream of batch || []) {
|
|
199
|
+
try {
|
|
200
|
+
this.applyStream(senderMachineId, meta, stream, result);
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
// A single bad stream slice must never poison the rest of the batch.
|
|
204
|
+
this.log('apply-stream', `[journal-sync] applyStream failed for ${stream?.kind}: ${e?.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Persist the (possibly mutated) meta once per apply call.
|
|
208
|
+
this.persistMeta(senderMachineId, meta);
|
|
209
|
+
// Final per-stream statuses for the caller.
|
|
210
|
+
for (const kind of JOURNAL_KINDS) {
|
|
211
|
+
const ks = meta.kinds[kind];
|
|
212
|
+
if (ks) {
|
|
213
|
+
result.statuses[`${senderMachineId}.${kind}`] = meta.resetFlapping ? 'reset-flapping' : ks.status;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
this.log('apply', `[journal-sync] apply failed (swallowed): ${e?.message}`);
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
applyStream(senderMachineId, meta, stream, result) {
|
|
223
|
+
if (!stream || !JOURNAL_KINDS.includes(stream.kind)) {
|
|
224
|
+
// Unknown / ignorable kind: nothing applied, no poisoning (forward-compat).
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const kind = stream.kind;
|
|
228
|
+
const incarnation = typeof stream.incarnation === 'string' ? stream.incarnation : '';
|
|
229
|
+
// First batch ever for this peer: adopt its incarnation (no quarantine —
|
|
230
|
+
// there is no old history to fence against).
|
|
231
|
+
if (!meta.incarnation) {
|
|
232
|
+
meta.incarnation = incarnation || this.mintIncarnation();
|
|
233
|
+
}
|
|
234
|
+
// §3.4 rule 3 — INCARNATION FENCING. A KNOWN stream arriving with a NEW
|
|
235
|
+
// incarnation quarantines the old replica + starts fresh + signals.
|
|
236
|
+
if (incarnation && incarnation !== meta.incarnation) {
|
|
237
|
+
this.handleIncarnationFlip(senderMachineId, meta, kind, incarnation, result);
|
|
238
|
+
}
|
|
239
|
+
const ks = this.ensureKind(meta, kind);
|
|
240
|
+
// §3.4 rule 4 — TRUNCATION SIGNAL. If our next-needed seq fell below what
|
|
241
|
+
// the peer still retains, record a gap sentinel + fast-forward + mark gapped.
|
|
242
|
+
if (typeof stream.oldestRetainedSeq === 'number' && Number.isFinite(stream.oldestRetainedSeq)) {
|
|
243
|
+
const nextNeeded = ks.lastHeldSeq + 1;
|
|
244
|
+
if (nextNeeded < stream.oldestRetainedSeq) {
|
|
245
|
+
ks.gaps.push({
|
|
246
|
+
fromSeq: nextNeeded,
|
|
247
|
+
toSeq: stream.oldestRetainedSeq - 1,
|
|
248
|
+
recordedAt: this.now().toISOString(),
|
|
249
|
+
});
|
|
250
|
+
// Bound the gap record (No Unbounded Loops) — keep the most recent few.
|
|
251
|
+
if (ks.gaps.length > 16)
|
|
252
|
+
ks.gaps.splice(0, ks.gaps.length - 16);
|
|
253
|
+
ks.lastHeldSeq = stream.oldestRetainedSeq - 1;
|
|
254
|
+
ks.status = 'gapped';
|
|
255
|
+
ks.consecutiveValid = 0;
|
|
256
|
+
this.degradation.gapsRecorded++;
|
|
257
|
+
result.gapsRecorded++;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// §3.4 rules 1 + 2 — validate every entry IN ORDER, append durably, stop at
|
|
261
|
+
// the first failure (suspect), skip duplicates silently.
|
|
262
|
+
const toAppend = [];
|
|
263
|
+
let stopped = false;
|
|
264
|
+
for (const entry of stream.entries || []) {
|
|
265
|
+
const verdict = this.validateEntry(senderMachineId, kind, entry, ks.lastHeldSeq + toAppend.length);
|
|
266
|
+
if (verdict === 'forged') {
|
|
267
|
+
this.degradation.forgedEntries++;
|
|
268
|
+
result.forgedEntries++;
|
|
269
|
+
// Rule 1: a forged entry is rejected + counted. It is NOT a torn write
|
|
270
|
+
// (it's a trust violation), so it does NOT mark the stream suspect; we
|
|
271
|
+
// simply do not append it and stop the batch (cannot trust ordering past it).
|
|
272
|
+
stopped = true;
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
if (verdict === 'duplicate') {
|
|
276
|
+
this.degradation.duplicates++;
|
|
277
|
+
result.duplicates++;
|
|
278
|
+
continue; // silent drop, keep scanning (rule 2)
|
|
279
|
+
}
|
|
280
|
+
if (verdict === 'invalid') {
|
|
281
|
+
this.degradation.invalidEntries++;
|
|
282
|
+
result.invalidEntries++;
|
|
283
|
+
// Rule 2: ANY failure marks suspect + STOPS the batch at the last valid.
|
|
284
|
+
this.markSuspect(ks);
|
|
285
|
+
this.degradation.suspectMarks++;
|
|
286
|
+
result.suspectStreams++;
|
|
287
|
+
stopped = true;
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
// valid + in order → queue for durable append.
|
|
291
|
+
toAppend.push(JSON.stringify(entry));
|
|
292
|
+
}
|
|
293
|
+
if (toAppend.length === 0) {
|
|
294
|
+
return; // nothing to durably write (all dup/forged/invalid, or empty)
|
|
295
|
+
}
|
|
296
|
+
const appendedCount = this.durablyAppend(senderMachineId, kind, toAppend, result);
|
|
297
|
+
if (appendedCount > 0) {
|
|
298
|
+
ks.lastHeldSeq += appendedCount;
|
|
299
|
+
// §3.4 rule 2 — `suspect` self-clears after K consecutive valid in-order
|
|
300
|
+
// applies. A clean apply that advances the stream out of `gapped` returns
|
|
301
|
+
// it to `current`/`behind` honestly.
|
|
302
|
+
if (ks.status === 'suspect') {
|
|
303
|
+
ks.consecutiveValid += appendedCount;
|
|
304
|
+
if (ks.consecutiveValid >= SUSPECT_CLEAR_THRESHOLD) {
|
|
305
|
+
ks.status = 'current';
|
|
306
|
+
ks.consecutiveValid = 0;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else if (ks.status === 'gapped') {
|
|
310
|
+
// A clean apply after a gap returns the stream to current (the hole is
|
|
311
|
+
// recorded in meta; the read API reports it honestly via the sentinel).
|
|
312
|
+
ks.status = 'current';
|
|
313
|
+
ks.consecutiveValid = 0;
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
ks.consecutiveValid += appendedCount;
|
|
317
|
+
ks.status = 'current';
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// If the batch stopped short (invalid/forged) the stream is "behind" the
|
|
321
|
+
// peer's advert from the caller's POV — but suspect/quarantine status wins.
|
|
322
|
+
if (stopped && ks.status === 'current') {
|
|
323
|
+
ks.status = 'behind';
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Per-entry verdict (§3.4 rules 1 + 2). `expectedSeq` is lastHeldSeq + count
|
|
328
|
+
* already queued this batch, so seq must be exactly contiguous.
|
|
329
|
+
*/
|
|
330
|
+
validateEntry(senderMachineId, kind, entry, lastHeldSeq) {
|
|
331
|
+
if (!entry || typeof entry !== 'object')
|
|
332
|
+
return 'invalid';
|
|
333
|
+
// Rule 1: FIRST-HOP SENDER BINDING. entry.machine must === the sender.
|
|
334
|
+
if (typeof entry.machine !== 'string' || entry.machine !== senderMachineId) {
|
|
335
|
+
return 'forged';
|
|
336
|
+
}
|
|
337
|
+
// Size cap (parseable ≤ cap). The line we'd write is the serialized entry.
|
|
338
|
+
let line;
|
|
339
|
+
try {
|
|
340
|
+
line = JSON.stringify(entry);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return 'invalid';
|
|
344
|
+
}
|
|
345
|
+
if (Buffer.byteLength(line, 'utf-8') > this.maxEntryBytes)
|
|
346
|
+
return 'invalid';
|
|
347
|
+
// kind binding — the entry must belong to the stream slice it arrived in.
|
|
348
|
+
if (entry.kind !== kind)
|
|
349
|
+
return 'invalid';
|
|
350
|
+
// seq must be a finite integer; <= lastHeld is a duplicate (silent drop),
|
|
351
|
+
// != lastHeld+1 (a forward gap) is invalid (stop the batch).
|
|
352
|
+
const seq = entry.seq;
|
|
353
|
+
if (typeof seq !== 'number' || !Number.isFinite(seq) || Math.floor(seq) !== seq)
|
|
354
|
+
return 'invalid';
|
|
355
|
+
if (seq <= lastHeldSeq)
|
|
356
|
+
return 'duplicate';
|
|
357
|
+
if (seq !== lastHeldSeq + 1)
|
|
358
|
+
return 'invalid';
|
|
359
|
+
// ts must parse.
|
|
360
|
+
if (typeof entry.ts !== 'string' || Number.isNaN(Date.parse(entry.ts)))
|
|
361
|
+
return 'invalid';
|
|
362
|
+
// topic, when present, must be a finite number.
|
|
363
|
+
if (entry.topic !== undefined && (typeof entry.topic !== 'number' || !Number.isFinite(entry.topic))) {
|
|
364
|
+
return 'invalid';
|
|
365
|
+
}
|
|
366
|
+
// data must pass the kind's typed schema.
|
|
367
|
+
if (!this.validateData(kind, entry.data))
|
|
368
|
+
return 'invalid';
|
|
369
|
+
return 'valid';
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* §3.2 typed schema validation — MIRRORED from CoherenceJournal.validate()
|
|
373
|
+
* (source of truth: src/core/CoherenceJournal.ts). The applier validates
|
|
374
|
+
* STRICTLY (reject on unknown/extra fields too) so a peer's buggy/forged
|
|
375
|
+
* writer cannot smuggle free text past the receiver. Returns true if the
|
|
376
|
+
* data is a well-formed instance of the kind's schema.
|
|
377
|
+
*/
|
|
378
|
+
validateData(kind, data) {
|
|
379
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
380
|
+
return false;
|
|
381
|
+
const raw = data;
|
|
382
|
+
const keys = Object.keys(raw);
|
|
383
|
+
if (kind === 'topic-placement') {
|
|
384
|
+
const reasons = ['user-move', 'placed', 'failover', 'released', 'quota-block-move'];
|
|
385
|
+
if (typeof raw.owner !== 'string' || !raw.owner)
|
|
386
|
+
return false;
|
|
387
|
+
if (typeof raw.epoch !== 'number' || !Number.isFinite(raw.epoch))
|
|
388
|
+
return false;
|
|
389
|
+
if (typeof raw.reason !== 'string' || !reasons.includes(raw.reason))
|
|
390
|
+
return false;
|
|
391
|
+
if (raw.prevOwner !== undefined && typeof raw.prevOwner !== 'string')
|
|
392
|
+
return false;
|
|
393
|
+
const known = ['owner', 'epoch', 'reason', 'prevOwner'];
|
|
394
|
+
return keys.every((k) => known.includes(k));
|
|
395
|
+
}
|
|
396
|
+
if (kind === 'session-lifecycle') {
|
|
397
|
+
const statuses = ['created', 'completed', 'killed', 'reaped', 'failed'];
|
|
398
|
+
if (typeof raw.sessionId !== 'string' || !raw.sessionId)
|
|
399
|
+
return false;
|
|
400
|
+
if (typeof raw.status !== 'string' || !statuses.includes(raw.status))
|
|
401
|
+
return false;
|
|
402
|
+
if (raw.reapReason !== undefined && typeof raw.reapReason !== 'string')
|
|
403
|
+
return false;
|
|
404
|
+
if (raw.reapLogRef !== undefined && typeof raw.reapLogRef !== 'string')
|
|
405
|
+
return false;
|
|
406
|
+
const known = ['sessionId', 'status', 'reapReason', 'reapLogRef'];
|
|
407
|
+
return keys.every((k) => known.includes(k));
|
|
408
|
+
}
|
|
409
|
+
if (kind === 'autonomous-run') {
|
|
410
|
+
const actions = ['started', 'stopped'];
|
|
411
|
+
if (typeof raw.action !== 'string' || !actions.includes(raw.action))
|
|
412
|
+
return false;
|
|
413
|
+
if (typeof raw.runId !== 'string' || !raw.runId)
|
|
414
|
+
return false;
|
|
415
|
+
if (!Array.isArray(raw.artifactPaths))
|
|
416
|
+
return false;
|
|
417
|
+
if (!raw.artifactPaths.every((p) => typeof p === 'string'))
|
|
418
|
+
return false;
|
|
419
|
+
const known = ['action', 'runId', 'artifactPaths'];
|
|
420
|
+
return keys.every((k) => known.includes(k));
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
// ---- incarnation fencing (§3.4 rule 3) ---------------------------------
|
|
425
|
+
handleIncarnationFlip(senderMachineId, meta, kind, newIncarnation, result) {
|
|
426
|
+
const oldIncarnation = meta.incarnation;
|
|
427
|
+
// Quarantine every existing replica file for this peer (rename aside),
|
|
428
|
+
// bounded at MAX_QUARANTINE_PER_STREAM per stream (oldest evicted).
|
|
429
|
+
for (const k of JOURNAL_KINDS) {
|
|
430
|
+
this.quarantineReplica(senderMachineId, k);
|
|
431
|
+
}
|
|
432
|
+
this.degradation.quarantines++;
|
|
433
|
+
result.quarantined++;
|
|
434
|
+
// Reset ALL per-kind apply state — a fresh incarnation is a fresh history.
|
|
435
|
+
meta.incarnation = newIncarnation;
|
|
436
|
+
meta.kinds = {};
|
|
437
|
+
// Record the flip in the coalescing window and decide flapping.
|
|
438
|
+
const nowMs = this.now().getTime();
|
|
439
|
+
meta.flipsMs.push(nowMs);
|
|
440
|
+
meta.flipsMs = meta.flipsMs.filter((t) => nowMs - t <= FLAP_WINDOW_MS);
|
|
441
|
+
if (meta.flipsMs.length > FLAP_RESET_THRESHOLD) {
|
|
442
|
+
// Past the threshold → reset-flapping, surfaced ONCE.
|
|
443
|
+
if (!meta.resetFlapping) {
|
|
444
|
+
meta.resetFlapping = true;
|
|
445
|
+
this.log(`flap-${senderMachineId}`, `[journal-sync] peer ${senderMachineId} is reset-flapping (${meta.flipsMs.length} incarnation flips in window)`);
|
|
446
|
+
}
|
|
447
|
+
// No per-flip divergence signal once flapping — coalesced to the one above.
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
// Coalesce the divergence signal per machine within the window: emit one
|
|
451
|
+
// signal per flip up to the threshold (these are the "loud" ones), but
|
|
452
|
+
// only the FIRST flip in a fresh window emits to the caller — subsequent
|
|
453
|
+
// ones within the window are folded (coalesced) into the lifetime count.
|
|
454
|
+
const signal = {
|
|
455
|
+
machineId: senderMachineId,
|
|
456
|
+
kind,
|
|
457
|
+
oldIncarnation,
|
|
458
|
+
newIncarnation,
|
|
459
|
+
at: this.now().toISOString(),
|
|
460
|
+
};
|
|
461
|
+
// Coalescing: only surface to the caller if this is the first flip in the
|
|
462
|
+
// current window (flipsMs has exactly 1 entry after the filter above).
|
|
463
|
+
if (meta.flipsMs.length === 1) {
|
|
464
|
+
result.signals.push(signal);
|
|
465
|
+
this.degradation.signals++;
|
|
466
|
+
this.log(`diverge-${senderMachineId}`, `[journal-sync] divergence: peer ${senderMachineId} new incarnation ${newIncarnation.slice(0, 8)} (old ${oldIncarnation.slice(0, 8)})`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Rename a peer replica file aside as `<file>.quarantine.<stamp>`, keeping at
|
|
472
|
+
* most MAX_QUARANTINE_PER_STREAM per stream (oldest evicted). Bounded disk.
|
|
473
|
+
*/
|
|
474
|
+
quarantineReplica(senderMachineId, kind) {
|
|
475
|
+
const file = this.replicaFilePath(senderMachineId, kind);
|
|
476
|
+
if (!this.io.existsSync(file))
|
|
477
|
+
return;
|
|
478
|
+
const stamp = this.now().getTime();
|
|
479
|
+
const safe = sanitizeMachineId(senderMachineId);
|
|
480
|
+
const target = path.join(this.peersDirPath(), `${safe}.${kind}.quarantine.${stamp}.jsonl`);
|
|
481
|
+
try {
|
|
482
|
+
if (this.guardWrite)
|
|
483
|
+
this.guardWrite(target);
|
|
484
|
+
this.io.renameSync(file, target);
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
this.log('quarantine', `[journal-sync] quarantine rename failed for ${kind}: ${e?.message}`);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
this.pruneQuarantine(senderMachineId, kind);
|
|
491
|
+
}
|
|
492
|
+
pruneQuarantine(senderMachineId, kind) {
|
|
493
|
+
const safe = sanitizeMachineId(senderMachineId);
|
|
494
|
+
const re = new RegExp(`^${escapeRegExp(`${safe}.${kind}.quarantine.`)}(\\d+)\\.jsonl$`);
|
|
495
|
+
let names;
|
|
496
|
+
try {
|
|
497
|
+
names = this.io.readdirSync(this.peersDirPath());
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const matched = [];
|
|
503
|
+
for (const n of names) {
|
|
504
|
+
const m = re.exec(n);
|
|
505
|
+
if (m)
|
|
506
|
+
matched.push({ name: n, stamp: Number(m[1]) });
|
|
507
|
+
}
|
|
508
|
+
if (matched.length <= MAX_QUARANTINE_PER_STREAM)
|
|
509
|
+
return;
|
|
510
|
+
matched.sort((a, b) => a.stamp - b.stamp); // oldest first
|
|
511
|
+
const drop = matched.slice(0, matched.length - MAX_QUARANTINE_PER_STREAM);
|
|
512
|
+
for (const d of drop) {
|
|
513
|
+
const p = path.join(this.peersDirPath(), d.name);
|
|
514
|
+
try {
|
|
515
|
+
// Receiver-managed peer replica eviction — through the destructive-fs
|
|
516
|
+
// funnel (mirrors CoherenceJournal.pruneArchives, the source of truth
|
|
517
|
+
// for journal file deletion).
|
|
518
|
+
SafeFsExecutor.safeRmSync(p, { force: true, operation: 'journal-sync:prune-quarantine' });
|
|
519
|
+
}
|
|
520
|
+
catch (e) {
|
|
521
|
+
this.log('quarantine-prune', `[journal-sync] quarantine prune failed for ${d.name}: ${e?.message}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// ---- durable append (§4.1 — ack-after-fsync) ---------------------------
|
|
526
|
+
/**
|
|
527
|
+
* Append validated lines to the peer replica file with O_APPEND single-line
|
|
528
|
+
* writes, then fdatasync BEFORE returning the applied count (§4.1 — the
|
|
529
|
+
* receiver's durability-before-ack rule lives HERE). Returns the number of
|
|
530
|
+
* lines durably committed (0 if the guard refused or the open/write failed
|
|
531
|
+
* before any byte landed). Never throws.
|
|
532
|
+
*/
|
|
533
|
+
durablyAppend(senderMachineId, kind, lines, result) {
|
|
534
|
+
const file = this.replicaFilePath(senderMachineId, kind);
|
|
535
|
+
this.ensurePeersDir();
|
|
536
|
+
// Injected standby guard — throwing skips the batch + counts (never thrown).
|
|
537
|
+
try {
|
|
538
|
+
if (this.guardWrite)
|
|
539
|
+
this.guardWrite(file);
|
|
540
|
+
}
|
|
541
|
+
catch (e) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
542
|
+
this.degradation.guardSkips++;
|
|
543
|
+
result.guardSkips++;
|
|
544
|
+
this.log('guard', `[journal-sync] guardWrite refused ${kind}: ${e?.message}`);
|
|
545
|
+
return 0;
|
|
546
|
+
}
|
|
547
|
+
let fd = null;
|
|
548
|
+
let bytesWritten = false;
|
|
549
|
+
let committed = 0;
|
|
550
|
+
try {
|
|
551
|
+
fd = this.io.openSync(file, 'a');
|
|
552
|
+
for (const line of lines) {
|
|
553
|
+
const buf = Buffer.from(line + '\n', 'utf-8');
|
|
554
|
+
this.io.writeSync(fd, buf, 0, buf.length);
|
|
555
|
+
bytesWritten = true;
|
|
556
|
+
committed++;
|
|
557
|
+
}
|
|
558
|
+
// §4.1 — fsync BEFORE we report applied. This is the ack-after-durable
|
|
559
|
+
// boundary: nothing past this line treats the entries as committed until
|
|
560
|
+
// fdatasync has returned.
|
|
561
|
+
this.io.fdatasyncSync(fd);
|
|
562
|
+
}
|
|
563
|
+
catch (e) {
|
|
564
|
+
this.degradation.appendErrors++;
|
|
565
|
+
this.log('append', `[journal-sync] replica append/fsync failed for ${kind}: ${e?.message}`);
|
|
566
|
+
if (!bytesWritten) {
|
|
567
|
+
committed = 0; // nothing landed — caller advances nothing.
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
// Bytes landed but fsync did not confirm. We cannot prove durability,
|
|
571
|
+
// so we do NOT report them applied (ack-after-durable-commit). A torn
|
|
572
|
+
// tail is repaired by the tolerant reader; the caller will re-request.
|
|
573
|
+
committed = 0;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
finally {
|
|
577
|
+
if (fd !== null) {
|
|
578
|
+
try {
|
|
579
|
+
this.io.closeSync(fd);
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
/* best-effort */
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (committed > 0) {
|
|
587
|
+
this.degradation.applied += committed;
|
|
588
|
+
result.applied += committed;
|
|
589
|
+
}
|
|
590
|
+
return committed;
|
|
591
|
+
}
|
|
592
|
+
// ---- SERVE side (§3.4 rule 5/7 — own stream only, durably-flushed only) -
|
|
593
|
+
/**
|
|
594
|
+
* Build a delta batch from THIS machine's OWN stream for a peer that is
|
|
595
|
+
* behind (§3.4 rule 5). Reads the FILE (never any queue) so only
|
|
596
|
+
* durably-flushed entries are served — a §3.4-rule-3 corollary the writer
|
|
597
|
+
* guarantees by advertising only flushed seqs. First-hop only: this serves
|
|
598
|
+
* exactly one own stream of one kind.
|
|
599
|
+
*
|
|
600
|
+
* @param kind which own stream to serve.
|
|
601
|
+
* @param fromSeq serve entries with seq > fromSeq (the peer's lastSeq).
|
|
602
|
+
* @param maxBatchBytes total serialized byte cap (default DEFAULT_MAX_BATCH_BYTES).
|
|
603
|
+
* @param ownMachineId this machine's id (the producer of the served stream).
|
|
604
|
+
*/
|
|
605
|
+
buildServeBatch(kind, fromSeq, ownMachineId, maxBatchBytes = DEFAULT_MAX_BATCH_BYTES) {
|
|
606
|
+
const out = {
|
|
607
|
+
kind,
|
|
608
|
+
incarnation: this.readOwnIncarnation(ownMachineId),
|
|
609
|
+
entries: [],
|
|
610
|
+
};
|
|
611
|
+
if (!JOURNAL_KINDS.includes(kind))
|
|
612
|
+
return out;
|
|
613
|
+
// Read own current file + archives newest→oldest, collect entries with
|
|
614
|
+
// seq > fromSeq, then return them ascending and byte-capped.
|
|
615
|
+
const files = this.ownStreamFilesNewestFirst(ownMachineId, kind);
|
|
616
|
+
const collected = [];
|
|
617
|
+
// Track the smallest seq we can still serve (for oldestRetainedSeq honesty).
|
|
618
|
+
let minServableSeq = Number.POSITIVE_INFINITY;
|
|
619
|
+
for (const f of files) {
|
|
620
|
+
const read = readTailTolerant(this.io, f, Number.MAX_SAFE_INTEGER, SERVE_READ_BYTE_CEILING);
|
|
621
|
+
for (const e of read.entries) {
|
|
622
|
+
if (typeof e.seq !== 'number')
|
|
623
|
+
continue;
|
|
624
|
+
if (e.seq < minServableSeq)
|
|
625
|
+
minServableSeq = e.seq;
|
|
626
|
+
if (e.seq > fromSeq)
|
|
627
|
+
collected.push(e);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// Ascending by seq (the apply side requires contiguous in-order).
|
|
631
|
+
collected.sort((a, b) => a.seq - b.seq);
|
|
632
|
+
// Byte-cap mid-batch: include entries until adding the next would exceed
|
|
633
|
+
// maxBatchBytes. At least one entry is always included if any exists and it
|
|
634
|
+
// fits; an entry that alone exceeds the cap is still included (so a single
|
|
635
|
+
// over-cap line is served rather than the stream stalling forever).
|
|
636
|
+
let bytes = 0;
|
|
637
|
+
for (const e of collected) {
|
|
638
|
+
const lineBytes = Buffer.byteLength(JSON.stringify(e), 'utf-8') + 1; // +newline
|
|
639
|
+
if (out.entries.length > 0 && bytes + lineBytes > maxBatchBytes)
|
|
640
|
+
break;
|
|
641
|
+
out.entries.push(e);
|
|
642
|
+
bytes += lineBytes;
|
|
643
|
+
}
|
|
644
|
+
// §3.4 rule 4 — include oldestRetainedSeq when fromSeq has rotated out: the
|
|
645
|
+
// peer asked from `fromSeq` but the oldest seq we can still serve is higher,
|
|
646
|
+
// so signal the truncation. Only set it when there IS a hole below what we hold.
|
|
647
|
+
if (Number.isFinite(minServableSeq) && minServableSeq > fromSeq + 1) {
|
|
648
|
+
out.oldestRetainedSeq = minServableSeq;
|
|
649
|
+
}
|
|
650
|
+
return out;
|
|
651
|
+
}
|
|
652
|
+
// ---- meta (load / persist / cache) -------------------------------------
|
|
653
|
+
ensureKind(meta, kind) {
|
|
654
|
+
let ks = meta.kinds[kind];
|
|
655
|
+
if (!ks) {
|
|
656
|
+
ks = { lastHeldSeq: 0, status: 'current', consecutiveValid: 0, gaps: [] };
|
|
657
|
+
meta.kinds[kind] = ks;
|
|
658
|
+
}
|
|
659
|
+
return ks;
|
|
660
|
+
}
|
|
661
|
+
markSuspect(ks) {
|
|
662
|
+
ks.status = 'suspect';
|
|
663
|
+
ks.consecutiveValid = 0;
|
|
664
|
+
}
|
|
665
|
+
loadMeta(machineId) {
|
|
666
|
+
const cached = this.metaCache.get(machineId);
|
|
667
|
+
if (cached)
|
|
668
|
+
return cached;
|
|
669
|
+
const metaPath = this.metaPath(machineId);
|
|
670
|
+
let meta = { incarnation: '', kinds: {}, flipsMs: [], resetFlapping: false };
|
|
671
|
+
if (this.io.existsSync(metaPath)) {
|
|
672
|
+
try {
|
|
673
|
+
const raw = this.io.readFileSync(metaPath, 'utf-8');
|
|
674
|
+
const obj = JSON.parse(raw);
|
|
675
|
+
if (obj && typeof obj === 'object') {
|
|
676
|
+
meta = {
|
|
677
|
+
incarnation: typeof obj.incarnation === 'string' ? obj.incarnation : '',
|
|
678
|
+
kinds: this.normalizeKinds(obj.kinds),
|
|
679
|
+
flipsMs: Array.isArray(obj.flipsMs) ? obj.flipsMs.filter((n) => typeof n === 'number') : [],
|
|
680
|
+
resetFlapping: obj.resetFlapping === true,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
// malformed meta → start clean (tolerant).
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
this.metaCache.set(machineId, meta);
|
|
689
|
+
return meta;
|
|
690
|
+
}
|
|
691
|
+
normalizeKinds(raw) {
|
|
692
|
+
const out = {};
|
|
693
|
+
if (!raw || typeof raw !== 'object')
|
|
694
|
+
return out;
|
|
695
|
+
const r = raw;
|
|
696
|
+
const validStatus = ['current', 'behind', 'gapped', 'suspect', 'reset-flapping'];
|
|
697
|
+
for (const kind of JOURNAL_KINDS) {
|
|
698
|
+
const ks = r[kind];
|
|
699
|
+
if (!ks || typeof ks !== 'object')
|
|
700
|
+
continue;
|
|
701
|
+
out[kind] = {
|
|
702
|
+
lastHeldSeq: typeof ks.lastHeldSeq === 'number' && Number.isFinite(ks.lastHeldSeq) ? ks.lastHeldSeq : 0,
|
|
703
|
+
status: validStatus.includes(ks.status) ? ks.status : 'current',
|
|
704
|
+
consecutiveValid: typeof ks.consecutiveValid === 'number' ? ks.consecutiveValid : 0,
|
|
705
|
+
gaps: Array.isArray(ks.gaps)
|
|
706
|
+
? ks.gaps.filter((g) => !!g && typeof g === 'object' && typeof g.fromSeq === 'number')
|
|
707
|
+
: [],
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
return out;
|
|
711
|
+
}
|
|
712
|
+
persistMeta(machineId, meta) {
|
|
713
|
+
this.ensurePeersDir();
|
|
714
|
+
const metaPath = this.metaPath(machineId);
|
|
715
|
+
const tmp = metaPath + '.tmp';
|
|
716
|
+
try {
|
|
717
|
+
if (this.guardWrite)
|
|
718
|
+
this.guardWrite(metaPath);
|
|
719
|
+
this.io.writeFileSync(tmp, JSON.stringify(meta, null, 2), { mode: 0o644 });
|
|
720
|
+
this.io.renameSync(tmp, metaPath);
|
|
721
|
+
}
|
|
722
|
+
catch (e) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
723
|
+
this.log('persist-meta', `[journal-sync] persistMeta failed for ${machineId}: ${e?.message}`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
mintIncarnation() {
|
|
727
|
+
return crypto.randomBytes(12).toString('hex');
|
|
728
|
+
}
|
|
729
|
+
// ---- peer / own discovery ----------------------------------------------
|
|
730
|
+
/** Discover peer machine ids from the peers/ meta sidecars + replica files. */
|
|
731
|
+
discoverPeerMachines() {
|
|
732
|
+
const dir = this.peersDirPath();
|
|
733
|
+
let names;
|
|
734
|
+
try {
|
|
735
|
+
names = this.io.readdirSync(dir);
|
|
736
|
+
}
|
|
737
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
738
|
+
return [];
|
|
739
|
+
}
|
|
740
|
+
const ids = new Set();
|
|
741
|
+
// Pull machineIds from `<machineId>.meta.json` first (authoritative set).
|
|
742
|
+
for (const n of names) {
|
|
743
|
+
const m = /^(.+)\.meta\.json$/.exec(n);
|
|
744
|
+
if (m) {
|
|
745
|
+
ids.add(this.unsanitizeBestEffort(m[1]));
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
// Also pull from replica files for machines without a meta yet.
|
|
749
|
+
for (const kind of JOURNAL_KINDS) {
|
|
750
|
+
const k = escapeRegExp(kind);
|
|
751
|
+
const re = new RegExp(`^(.+)\\.${k}(?:\\.(?:\\d+|quarantine\\.\\d+))?\\.jsonl$`);
|
|
752
|
+
for (const n of names) {
|
|
753
|
+
const m = re.exec(n);
|
|
754
|
+
if (m)
|
|
755
|
+
ids.add(this.unsanitizeBestEffort(m[1]));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return [...ids];
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* The peer file grammar uses the SANITIZED machine id as the literal. The raw
|
|
762
|
+
* id is only needed for first-hop binding (which uses the sender id directly,
|
|
763
|
+
* not a derived one) — for advert/status keys we surface the on-disk literal
|
|
764
|
+
* (already sanitized + injective), so reverse-mapping is unnecessary and we
|
|
765
|
+
* keep the literal as the machine key. (sanitizeMachineId is injective, so
|
|
766
|
+
* the literal is a faithful, stable handle.)
|
|
767
|
+
*/
|
|
768
|
+
unsanitizeBestEffort(safeLiteral) {
|
|
769
|
+
return safeLiteral;
|
|
770
|
+
}
|
|
771
|
+
/** Own current + archive stream files (newest-first) for serve reads. */
|
|
772
|
+
ownStreamFilesNewestFirst(ownMachineId, kind) {
|
|
773
|
+
const safe = sanitizeMachineId(ownMachineId);
|
|
774
|
+
const dir = this.dirPath();
|
|
775
|
+
const current = path.join(dir, `${safe}.${kind}.jsonl`);
|
|
776
|
+
const out = [];
|
|
777
|
+
if (this.io.existsSync(current))
|
|
778
|
+
out.push(current);
|
|
779
|
+
let names;
|
|
780
|
+
try {
|
|
781
|
+
names = this.io.readdirSync(dir);
|
|
782
|
+
}
|
|
783
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
const re = new RegExp(`^${escapeRegExp(`${safe}.${kind}.`)}(\\d+)\\.jsonl$`);
|
|
787
|
+
const archives = [];
|
|
788
|
+
for (const n of names) {
|
|
789
|
+
const m = re.exec(n);
|
|
790
|
+
if (m)
|
|
791
|
+
archives.push({ file: path.join(dir, n), stamp: Number(m[1]) });
|
|
792
|
+
}
|
|
793
|
+
archives.sort((a, b) => b.stamp - a.stamp); // newest first
|
|
794
|
+
return [...out, ...archives.map((a) => a.file)];
|
|
795
|
+
}
|
|
796
|
+
readOwnIncarnation(ownMachineId) {
|
|
797
|
+
const safe = sanitizeMachineId(ownMachineId);
|
|
798
|
+
const metaPath = path.join(this.dirPath(), `${safe}.meta.json`);
|
|
799
|
+
try {
|
|
800
|
+
if (!this.io.existsSync(metaPath))
|
|
801
|
+
return '';
|
|
802
|
+
const raw = this.io.readFileSync(metaPath, 'utf-8');
|
|
803
|
+
const obj = JSON.parse(raw);
|
|
804
|
+
return typeof obj?.incarnation === 'string' ? obj.incarnation : '';
|
|
805
|
+
}
|
|
806
|
+
catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
|
|
807
|
+
return '';
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// ---- paths -------------------------------------------------------------
|
|
811
|
+
dirPath() {
|
|
812
|
+
return path.join(this.stateDir, 'state', 'coherence-journal');
|
|
813
|
+
}
|
|
814
|
+
peersDirPath() {
|
|
815
|
+
return path.join(this.dirPath(), 'peers');
|
|
816
|
+
}
|
|
817
|
+
replicaFilePath(senderMachineId, kind) {
|
|
818
|
+
return path.join(this.peersDirPath(), `${sanitizeMachineId(senderMachineId)}.${kind}.jsonl`);
|
|
819
|
+
}
|
|
820
|
+
metaPath(machineId) {
|
|
821
|
+
return path.join(this.peersDirPath(), `${sanitizeMachineId(machineId)}.meta.json`);
|
|
822
|
+
}
|
|
823
|
+
ensurePeersDir() {
|
|
824
|
+
const p = this.peersDirPath();
|
|
825
|
+
if (!this.io.existsSync(p))
|
|
826
|
+
this.io.mkdirSync(p, { recursive: true });
|
|
827
|
+
}
|
|
828
|
+
log(cls, msg) {
|
|
829
|
+
if (this.loggedClasses.has(cls))
|
|
830
|
+
return;
|
|
831
|
+
this.loggedClasses.add(cls);
|
|
832
|
+
this.logger?.(msg);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
//# sourceMappingURL=JournalSyncApplier.js.map
|