@rulvar/store-conformance 1.47.0 → 1.48.0
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/index.d.ts +218 -1
- package/dist/index.js +705 -12
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -61,6 +61,223 @@ interface FencedTranscriptsFixture {
|
|
|
61
61
|
}
|
|
62
62
|
declare function fencedTranscriptsConformance(mk: StoreFactory<FencedTranscriptsFixture>): ConformanceSuite;
|
|
63
63
|
//#endregion
|
|
64
|
+
//#region src/multi-process-soak.d.ts
|
|
65
|
+
/** Accepted-mutation surfaces of the soaked run (serial-history members). */
|
|
66
|
+
type SoakAcceptSurface = "marker" | "append" | "meta" | "blob-put" | "blob-delete";
|
|
67
|
+
/** Surfaces of the stale-probe sweep; every one must reject typed. */
|
|
68
|
+
type SoakProbeSurface = "append" | "meta" | "blob-put" | "blob-delete" | "run-delete" | "renew" | "cross-run" | "release";
|
|
69
|
+
/** One JSONL line of a writer's report file (`w` is the writer index). */
|
|
70
|
+
type SoakEvent = {
|
|
71
|
+
t: "grant";
|
|
72
|
+
w: number;
|
|
73
|
+
epoch: number;
|
|
74
|
+
} | {
|
|
75
|
+
t: "accept";
|
|
76
|
+
w: number;
|
|
77
|
+
surface: SoakAcceptSurface;
|
|
78
|
+
epoch: number;
|
|
79
|
+
counter: number;
|
|
80
|
+
nonce: string;
|
|
81
|
+
seq?: number;
|
|
82
|
+
ref?: string;
|
|
83
|
+
} | {
|
|
84
|
+
t: "victim";
|
|
85
|
+
w: number;
|
|
86
|
+
epoch: number;
|
|
87
|
+
vid: string;
|
|
88
|
+
} | {
|
|
89
|
+
t: "stale-reject";
|
|
90
|
+
w: number;
|
|
91
|
+
surface: SoakProbeSurface;
|
|
92
|
+
epoch: number;
|
|
93
|
+
} | {
|
|
94
|
+
t: "stale-accept";
|
|
95
|
+
w: number;
|
|
96
|
+
surface: string;
|
|
97
|
+
epoch: number;
|
|
98
|
+
} | {
|
|
99
|
+
t: "live-cross-reject";
|
|
100
|
+
w: number;
|
|
101
|
+
epoch: number;
|
|
102
|
+
} | {
|
|
103
|
+
t: "fence-kick";
|
|
104
|
+
w: number;
|
|
105
|
+
surface: string;
|
|
106
|
+
epoch: number;
|
|
107
|
+
} | {
|
|
108
|
+
t: "busy";
|
|
109
|
+
w: number;
|
|
110
|
+
surface: string;
|
|
111
|
+
} | {
|
|
112
|
+
t: "renewed";
|
|
113
|
+
w: number;
|
|
114
|
+
epoch: number;
|
|
115
|
+
} | {
|
|
116
|
+
t: "released";
|
|
117
|
+
w: number;
|
|
118
|
+
epoch: number;
|
|
119
|
+
} | {
|
|
120
|
+
t: "stall";
|
|
121
|
+
w: number;
|
|
122
|
+
epoch: number;
|
|
123
|
+
} | {
|
|
124
|
+
t: "victim-abandoned";
|
|
125
|
+
w: number;
|
|
126
|
+
vid: string;
|
|
127
|
+
surface: string;
|
|
128
|
+
why: string;
|
|
129
|
+
} | {
|
|
130
|
+
t: "error";
|
|
131
|
+
w: number;
|
|
132
|
+
surface: string;
|
|
133
|
+
message: string;
|
|
134
|
+
} | {
|
|
135
|
+
t: "fatal";
|
|
136
|
+
w: number;
|
|
137
|
+
message: string;
|
|
138
|
+
} | {
|
|
139
|
+
t: "done";
|
|
140
|
+
w: number;
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* The per-writer contract, serialized as JSON into the
|
|
144
|
+
* `RULVAR_SOAK_CONFIG` environment variable of each spawned writer.
|
|
145
|
+
*/
|
|
146
|
+
interface SoakWriterConfig {
|
|
147
|
+
/** Store location the writer script constructs its store over. */
|
|
148
|
+
storePath: string;
|
|
149
|
+
/** The soaked run id every writer competes for. */
|
|
150
|
+
runId: string;
|
|
151
|
+
/** This writer's index (0-based; also its report identity). */
|
|
152
|
+
writer: number;
|
|
153
|
+
/** Lease ttl the writer's store MUST be constructed with. */
|
|
154
|
+
ttlMs: number;
|
|
155
|
+
/** Deterministic PRNG seed (writers derive per-index streams). */
|
|
156
|
+
seed: number;
|
|
157
|
+
/** JSONL report file this writer appends its events to. */
|
|
158
|
+
reportPath: string;
|
|
159
|
+
/** The storm ends when this file exists. */
|
|
160
|
+
stopPath: string;
|
|
161
|
+
}
|
|
162
|
+
/** Consumer hooks for {@link runSoakWriter}. */
|
|
163
|
+
interface SoakWriterHooks {
|
|
164
|
+
/**
|
|
165
|
+
* Classifies a thrown store error as transient contention worth an
|
|
166
|
+
* in-place retry (for `SqliteStore`, the driver's SQLITE_BUSY under
|
|
167
|
+
* `BEGIN IMMEDIATE`). Typed `LeaseHeldError` and
|
|
168
|
+
* `JournalOrderViolation` are classified by the protocol itself and
|
|
169
|
+
* never reach this hook. Default: nothing is retryable.
|
|
170
|
+
*/
|
|
171
|
+
retryable?: (thrown: unknown) => boolean;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Minimum activity the storm must reach before the referee stops it:
|
|
175
|
+
* run-until-quorum makes the soak adaptive (a slow CI machine storms
|
|
176
|
+
* longer, it never asserts on thin coverage).
|
|
177
|
+
*/
|
|
178
|
+
interface SoakQuorum {
|
|
179
|
+
/** Distinct fencing epochs granted (each one is a takeover). */
|
|
180
|
+
epochs: number;
|
|
181
|
+
/** Typed rejections observed by stale probe sweeps, all surfaces. */
|
|
182
|
+
staleRejects: number;
|
|
183
|
+
/** Accepted journal appends (markers included). */
|
|
184
|
+
appends: number;
|
|
185
|
+
/** Accepted meta writes. */
|
|
186
|
+
metaWrites: number;
|
|
187
|
+
/** Accepted transcript blob puts. */
|
|
188
|
+
blobPuts: number;
|
|
189
|
+
/** Accepted transcript blob deletes. */
|
|
190
|
+
blobDeletes: number;
|
|
191
|
+
/** Full fenced-deletion cycles on side runs. */
|
|
192
|
+
victimCycles: number;
|
|
193
|
+
/** Typed rejections of a live lease guarding a foreign run. */
|
|
194
|
+
liveCrossRejects: number;
|
|
195
|
+
}
|
|
196
|
+
/** Default quorum: a few seconds of storm on a developer machine. */
|
|
197
|
+
declare const DEFAULT_SOAK_QUORUM: SoakQuorum;
|
|
198
|
+
/** Activity counters derived from the merged report events. */
|
|
199
|
+
interface SoakActivity {
|
|
200
|
+
epochs: number;
|
|
201
|
+
staleRejects: number;
|
|
202
|
+
appends: number;
|
|
203
|
+
metaWrites: number;
|
|
204
|
+
blobPuts: number;
|
|
205
|
+
blobDeletes: number;
|
|
206
|
+
victimCycles: number;
|
|
207
|
+
liveCrossRejects: number;
|
|
208
|
+
busyRetries: number;
|
|
209
|
+
}
|
|
210
|
+
interface MultiProcessSoakOptions {
|
|
211
|
+
/**
|
|
212
|
+
* Absolute path of the consumer's writer script. It must construct
|
|
213
|
+
* the store over `soakWriterConfigFromEnv().storePath` (bare, no
|
|
214
|
+
* retry wrapper: concurrent boot is part of the promise under test),
|
|
215
|
+
* call {@link runSoakWriter}, and exit 0.
|
|
216
|
+
*/
|
|
217
|
+
writerScript: string;
|
|
218
|
+
/** Scratch directory for the store file, reports, and stop file. */
|
|
219
|
+
dir: string;
|
|
220
|
+
/**
|
|
221
|
+
* Opens the referee's own fixture over the SAME store location once
|
|
222
|
+
* the storm has ended, for state verification.
|
|
223
|
+
*/
|
|
224
|
+
openStore: (storePath: string) => Promise<FencedTranscriptsFixture> | FencedTranscriptsFixture;
|
|
225
|
+
/** Closes what {@link openStore} opened. */
|
|
226
|
+
closeStore?: (fixture: FencedTranscriptsFixture) => void | Promise<void>;
|
|
227
|
+
/** Store location; default `join(dir, 'soak.db')`. */
|
|
228
|
+
storePath?: string;
|
|
229
|
+
/** Concurrent writer processes; default 3. */
|
|
230
|
+
writers?: number;
|
|
231
|
+
/** Lease ttl for the storm; default 250 ms (short = many takeovers). */
|
|
232
|
+
ttlMs?: number;
|
|
233
|
+
/** PRNG seed; default 1. */
|
|
234
|
+
seed?: number;
|
|
235
|
+
/** Activity quorum overrides; see {@link DEFAULT_SOAK_QUORUM}. */
|
|
236
|
+
quorum?: Partial<SoakQuorum>;
|
|
237
|
+
/** Hard wall-clock cap on the storm; default 60000 ms. */
|
|
238
|
+
capMs?: number;
|
|
239
|
+
/** Extra environment for the writer processes. */
|
|
240
|
+
env?: Record<string, string>;
|
|
241
|
+
/** Extra `node` arguments placed before the writer script. */
|
|
242
|
+
execArgv?: string[];
|
|
243
|
+
}
|
|
244
|
+
/** What a green soak returns (the storm's observed coverage). */
|
|
245
|
+
interface MultiProcessSoakResult {
|
|
246
|
+
activity: SoakActivity;
|
|
247
|
+
stormMs: number;
|
|
248
|
+
journalEntries: number;
|
|
249
|
+
events: SoakEvent[];
|
|
250
|
+
}
|
|
251
|
+
/** Reads the writer contract a referee serialized into the child env. */
|
|
252
|
+
declare function soakWriterConfigFromEnv(env?: Record<string, string | undefined>): SoakWriterConfig;
|
|
253
|
+
/**
|
|
254
|
+
* The writer protocol: run it in a spawned process against the
|
|
255
|
+
* consumer-constructed store pair. Appends every observation to the
|
|
256
|
+
* report file; protocol-level anomalies (a stale acceptance, an
|
|
257
|
+
* unexpected error class) are logged as events for the referee, never
|
|
258
|
+
* thrown, so one writer's finding cannot vanish with its process.
|
|
259
|
+
*/
|
|
260
|
+
declare function runSoakWriter(fixture: FencedTranscriptsFixture, config: SoakWriterConfig, hooks?: SoakWriterHooks): Promise<void>;
|
|
261
|
+
/** Parses one report file, tolerating a torn trailing line. */
|
|
262
|
+
declare function parseSoakReport(path: string): SoakEvent[];
|
|
263
|
+
/** Derives the activity counters the quorum is judged against. */
|
|
264
|
+
declare function countSoakActivity(events: readonly SoakEvent[]): SoakActivity;
|
|
265
|
+
/**
|
|
266
|
+
* The pure referee: rebuilds the serial history from the merged report
|
|
267
|
+
* events and diffs it against the actual post-storm store state.
|
|
268
|
+
* Returns every violation as a descriptive string; an empty array means
|
|
269
|
+
* the fencing promise held for the whole storm.
|
|
270
|
+
*/
|
|
271
|
+
declare function verifySoakHistory(fixture: FencedTranscriptsFixture, events: readonly SoakEvent[], runId: string): Promise<string[]>;
|
|
272
|
+
/**
|
|
273
|
+
* Spawns the writer processes, stops the storm at quorum (or at the
|
|
274
|
+
* hard cap), verifies the serial history against the store, and throws
|
|
275
|
+
* one Error naming every violation. The returned result is the storm's
|
|
276
|
+
* observed coverage; assert on it if the caller wants a floor beyond
|
|
277
|
+
* the quorum.
|
|
278
|
+
*/
|
|
279
|
+
declare function runMultiProcessSoak(options: MultiProcessSoakOptions): Promise<MultiProcessSoakResult>;
|
|
280
|
+
//#endregion
|
|
64
281
|
//#region src/fixtures/golden-fold.d.ts
|
|
65
282
|
/**
|
|
66
283
|
* seq 0 agent spawn (running; abandoned by seq 6)
|
|
@@ -85,4 +302,4 @@ declare function foldStateSha256(entries: readonly JournalEntry[]): string;
|
|
|
85
302
|
/** The reference hash; computed once from the kernel fold and frozen. */
|
|
86
303
|
declare const GOLDEN_FOLD_STATE_SHA256 = "81e6ccff549fb3e6c1de4d34ba65b912162eba6f66403b5d5f23a3e1ec69243c";
|
|
87
304
|
//#endregion
|
|
88
|
-
export { type ConformanceCheck, type ConformanceSuite, type FencedTranscriptsFixture, GOLDEN_FOLD_JOURNAL, GOLDEN_FOLD_STATE_SHA256, type StoreFactory, type TestRegistrar, fencedTranscriptsConformance, fencedWritesConformance, foldStateSha256, journalStoreConformance, leasableStoreConformance, makeSuite, materializeFoldState, registerConformance, stableStringify };
|
|
305
|
+
export { type ConformanceCheck, type ConformanceSuite, DEFAULT_SOAK_QUORUM, type FencedTranscriptsFixture, GOLDEN_FOLD_JOURNAL, GOLDEN_FOLD_STATE_SHA256, type MultiProcessSoakOptions, type MultiProcessSoakResult, type SoakAcceptSurface, type SoakActivity, type SoakEvent, type SoakProbeSurface, type SoakQuorum, type SoakWriterConfig, type SoakWriterHooks, type StoreFactory, type TestRegistrar, countSoakActivity, fencedTranscriptsConformance, fencedWritesConformance, foldStateSha256, journalStoreConformance, leasableStoreConformance, makeSuite, materializeFoldState, parseSoakReport, registerConformance, runMultiProcessSoak, runSoakWriter, soakWriterConfigFromEnv, stableStringify, verifySoakHistory };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { LeaseHeldError, Replayer, ResolutionFold, agentScope, buildDeriverRegistry, deriveContentKey, dispositionHook } from "@rulvar/core";
|
|
1
|
+
import { JournalOrderViolation, LeaseHeldError, Replayer, ResolutionFold, agentScope, buildDeriverRegistry, deriveContentKey, dispositionHook } from "@rulvar/core";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
3
6
|
//#region src/types.ts
|
|
4
7
|
/** Registers the suite as one `describe` block with one `it` per check. */
|
|
5
8
|
function registerConformance(suite, api) {
|
|
@@ -628,7 +631,7 @@ function leaseEntry(seq) {
|
|
|
628
631
|
startedAt: (/* @__PURE__ */ new Date(17e11 + seq * 1e3)).toISOString()
|
|
629
632
|
};
|
|
630
633
|
}
|
|
631
|
-
function sleep(ms) {
|
|
634
|
+
function sleep$1(ms) {
|
|
632
635
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
633
636
|
}
|
|
634
637
|
async function mustReject(operation) {
|
|
@@ -692,13 +695,13 @@ function leasableStoreConformance(mk, options) {
|
|
|
692
695
|
const cadence = Math.floor(ttlMs / 3);
|
|
693
696
|
const deadline = Date.now() + Math.ceil(ttlMs * 1.5);
|
|
694
697
|
while (Date.now() < deadline) {
|
|
695
|
-
await sleep(cadence);
|
|
698
|
+
await sleep$1(cadence);
|
|
696
699
|
await store.renew(held);
|
|
697
700
|
ensure(await mustReject(() => store.acquire(RUN$2, "owner-b")) instanceof LeaseHeldError, "lease-ttl-and-renew-cadence", "a lease renewed at ttl/3 must stay held past the original ttl");
|
|
698
701
|
}
|
|
699
702
|
await store.release(held);
|
|
700
703
|
const abandoned = await store.acquire("expiring-run", "owner-a");
|
|
701
|
-
await sleep(Math.ceil(ttlMs * 1.2));
|
|
704
|
+
await sleep$1(Math.ceil(ttlMs * 1.2));
|
|
702
705
|
ensure((await store.acquire("expiring-run", "owner-b")).epoch > abandoned.epoch, "lease-ttl-and-renew-cadence", "reclaiming an expired lease must advance the fencing epoch");
|
|
703
706
|
}
|
|
704
707
|
});
|
|
@@ -744,7 +747,7 @@ function meta(runId, status, segments) {
|
|
|
744
747
|
updatedAt: `at-${status}-${String(segments)}`
|
|
745
748
|
};
|
|
746
749
|
}
|
|
747
|
-
async function readMeta(store, runId) {
|
|
750
|
+
async function readMeta$1(store, runId) {
|
|
748
751
|
const lookup = store;
|
|
749
752
|
if (typeof lookup.getMeta === "function") return lookup.getMeta(runId);
|
|
750
753
|
return (await store.listRuns()).find((m) => m.runId === runId);
|
|
@@ -778,7 +781,7 @@ function fencedWritesConformance(mk) {
|
|
|
778
781
|
const second = await store.acquire(RUN$1, "owner-b");
|
|
779
782
|
await store.putMeta(meta(RUN$1, "running", 3), second);
|
|
780
783
|
await mustRejectFenced$1("fenced-meta-stale-rejected", "a stale putMeta", () => store.putMeta(meta(RUN$1, "cancelled", 2), first));
|
|
781
|
-
const current = await readMeta(store, RUN$1);
|
|
784
|
+
const current = await readMeta$1(store, RUN$1);
|
|
782
785
|
ensure(current !== void 0 && current.status === "running" && current.segments === 3, "fenced-meta-stale-rejected", `the successor meta (status running, segments 3) must survive a stale putMeta unchanged, got ${JSON.stringify(current)}`);
|
|
783
786
|
}
|
|
784
787
|
},
|
|
@@ -794,9 +797,9 @@ function fencedWritesConformance(mk) {
|
|
|
794
797
|
const second = await store.acquire(RUN$1, "owner-b");
|
|
795
798
|
await mustRejectFenced$1("fenced-delete-stale-rejected", "a stale delete", () => store.delete(RUN$1, first));
|
|
796
799
|
ensure((await store.load(RUN$1)).length === 1, "fenced-delete-stale-rejected", "the journal must survive a stale delete unchanged");
|
|
797
|
-
ensure(await readMeta(store, RUN$1) !== void 0, "fenced-delete-stale-rejected", "the meta row must survive a stale delete unchanged");
|
|
800
|
+
ensure(await readMeta$1(store, RUN$1) !== void 0, "fenced-delete-stale-rejected", "the meta row must survive a stale delete unchanged");
|
|
798
801
|
await store.delete(RUN$1, second);
|
|
799
|
-
ensure((await store.load(RUN$1)).length === 0 && await readMeta(store, RUN$1) === void 0, "fenced-delete-stale-rejected", "a delete under the current lease must remove the journal and the meta row");
|
|
802
|
+
ensure((await store.load(RUN$1)).length === 0 && await readMeta$1(store, RUN$1) === void 0, "fenced-delete-stale-rejected", "a delete under the current lease must remove the journal and the meta row");
|
|
800
803
|
}
|
|
801
804
|
},
|
|
802
805
|
{
|
|
@@ -810,7 +813,7 @@ function fencedWritesConformance(mk) {
|
|
|
810
813
|
await mustRejectFenced$1("fenced-run-match", "a cross-run putMeta", () => store.putMeta(meta(RUN$1, "cancelled", 1), foreign));
|
|
811
814
|
await mustRejectFenced$1("fenced-run-match", "a cross-run append", () => store.append(RUN$1, fencedEntry(1), foreign));
|
|
812
815
|
await mustRejectFenced$1("fenced-run-match", "a cross-run delete", () => store.delete(RUN$1, foreign));
|
|
813
|
-
const current = await readMeta(store, RUN$1);
|
|
816
|
+
const current = await readMeta$1(store, RUN$1);
|
|
814
817
|
ensure(current !== void 0 && current.status === "running", "fenced-run-match", "the run guarded by nobody must survive every cross-run mutation attempt");
|
|
815
818
|
ensure((await store.load(RUN$1)).length === 1, "fenced-run-match", "a cross-run append must never become visible");
|
|
816
819
|
}
|
|
@@ -822,10 +825,10 @@ function fencedWritesConformance(mk) {
|
|
|
822
825
|
const store = await mk();
|
|
823
826
|
await store.putMeta(meta(RUN$1, "running", 1));
|
|
824
827
|
await store.append(RUN$1, fencedEntry(0));
|
|
825
|
-
const current = await readMeta(store, RUN$1);
|
|
828
|
+
const current = await readMeta$1(store, RUN$1);
|
|
826
829
|
ensure(current !== void 0 && current.status === "running", "fenced-unleased-passthrough", "an unleased putMeta must keep working");
|
|
827
830
|
await store.delete(RUN$1);
|
|
828
|
-
ensure((await store.load(RUN$1)).length === 0 && await readMeta(store, RUN$1) === void 0, "fenced-unleased-passthrough", "an unleased delete must keep working");
|
|
831
|
+
ensure((await store.load(RUN$1)).length === 0 && await readMeta$1(store, RUN$1) === void 0, "fenced-unleased-passthrough", "an unleased delete must keep working");
|
|
829
832
|
}
|
|
830
833
|
}
|
|
831
834
|
]);
|
|
@@ -943,4 +946,694 @@ function fencedTranscriptsConformance(mk) {
|
|
|
943
946
|
]);
|
|
944
947
|
}
|
|
945
948
|
//#endregion
|
|
946
|
-
|
|
949
|
+
//#region src/multi-process-soak.ts
|
|
950
|
+
/**
|
|
951
|
+
* The adversarial multi-process soak (the fenced run state RFC, phase
|
|
952
|
+
* 3's last open item): real OS processes storm one store file through
|
|
953
|
+
* EVERY write surface the `fencedWrites` capability names (journal
|
|
954
|
+
* append, meta write, transcript blob put and delete, fenced run
|
|
955
|
+
* deletion, renew, release), with injected stalls past the lease ttl so
|
|
956
|
+
* takeovers happen while superseded holders are still alive and
|
|
957
|
+
* probing. The harness then rebuilds the ONE serial history the fencing
|
|
958
|
+
* epochs promise (accepted mutations ordered by `(epoch, counter)`) and
|
|
959
|
+
* diffs it against the actual store state: any stale acceptance, lost
|
|
960
|
+
* accepted write, epoch inversion, or divergent final byte is a
|
|
961
|
+
* violation.
|
|
962
|
+
*
|
|
963
|
+
* Three pieces, split so the child side never needs the referee:
|
|
964
|
+
* - {@link runSoakWriter} is the writer protocol one child process runs
|
|
965
|
+
* against the consumer-constructed store (the consumer's writer
|
|
966
|
+
* script is a few lines: construct the store over
|
|
967
|
+
* `soakWriterConfigFromEnv().storePath`, call `runSoakWriter`, exit).
|
|
968
|
+
* Constructing the store bare, concurrently with every other writer,
|
|
969
|
+
* is deliberately part of the exercise: a store whose boot dies under
|
|
970
|
+
* concurrent construction fails the soak in the referee's exit-code
|
|
971
|
+
* check before any fencing is probed.
|
|
972
|
+
* - {@link verifySoakHistory} is the pure referee: given the merged
|
|
973
|
+
* report events and a fixture opened AFTER the storm, it returns
|
|
974
|
+
* every violation as a descriptive string (empty array = the promise
|
|
975
|
+
* held).
|
|
976
|
+
* - {@link runMultiProcessSoak} orchestrates: spawns the writers, stops
|
|
977
|
+
* the storm once the activity quorum is met (so slow machines run
|
|
978
|
+
* longer instead of asserting on thin coverage), waits for clean
|
|
979
|
+
* exits, verifies, and throws one Error naming every violation.
|
|
980
|
+
*
|
|
981
|
+
* The probe sweep defeats the A5 monotonic-seq mask on purpose: a stale
|
|
982
|
+
* append is attempted with a FRESH tail seq (re-loading the journal
|
|
983
|
+
* first), so the fencing check is the only thing standing between the
|
|
984
|
+
* write and the journal.
|
|
985
|
+
*/
|
|
986
|
+
/** Default quorum: a few seconds of storm on a developer machine. */
|
|
987
|
+
const DEFAULT_SOAK_QUORUM = {
|
|
988
|
+
epochs: 4,
|
|
989
|
+
staleRejects: 16,
|
|
990
|
+
appends: 8,
|
|
991
|
+
metaWrites: 5,
|
|
992
|
+
blobPuts: 5,
|
|
993
|
+
blobDeletes: 1,
|
|
994
|
+
victimCycles: 1,
|
|
995
|
+
liveCrossRejects: 1
|
|
996
|
+
};
|
|
997
|
+
const SOAK_CONFIG_ENV = "RULVAR_SOAK_CONFIG";
|
|
998
|
+
/** Reads the writer contract a referee serialized into the child env. */
|
|
999
|
+
function soakWriterConfigFromEnv(env = process.env) {
|
|
1000
|
+
const raw = env[SOAK_CONFIG_ENV];
|
|
1001
|
+
if (raw === void 0) throw new Error(`store-conformance multi-process-soak: the writer expects its config in ${SOAK_CONFIG_ENV}`);
|
|
1002
|
+
return JSON.parse(raw);
|
|
1003
|
+
}
|
|
1004
|
+
const wallClock = Date.now.bind(globalThis);
|
|
1005
|
+
/** Deterministic PRNG (mulberry32): the soak never uses Math.random. */
|
|
1006
|
+
function prng(seed) {
|
|
1007
|
+
let a = seed >>> 0;
|
|
1008
|
+
return () => {
|
|
1009
|
+
a |= 0;
|
|
1010
|
+
a = a + 1831565813 | 0;
|
|
1011
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
1012
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
1013
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1017
|
+
function soakEntry(seq, nonce) {
|
|
1018
|
+
return {
|
|
1019
|
+
hashVersion: 2,
|
|
1020
|
+
seq,
|
|
1021
|
+
scope: "",
|
|
1022
|
+
key: `soak-${nonce}`,
|
|
1023
|
+
ordinal: 0,
|
|
1024
|
+
kind: "step",
|
|
1025
|
+
status: "ok",
|
|
1026
|
+
value: { soak: { nonce } },
|
|
1027
|
+
spanId: "soak-span",
|
|
1028
|
+
startedAt: (/* @__PURE__ */ new Date(17e11 + seq * 1e3)).toISOString()
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
const soakBlob = (nonce) => new TextEncoder().encode(JSON.stringify({ soak: { nonce } }));
|
|
1032
|
+
const nonceOfBlob = (bytes) => {
|
|
1033
|
+
if (bytes === null) return;
|
|
1034
|
+
try {
|
|
1035
|
+
return JSON.parse(new TextDecoder().decode(bytes)).soak?.nonce;
|
|
1036
|
+
} catch {
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
const nonceOfEntry = (entry) => entry.value?.soak?.nonce;
|
|
1041
|
+
async function readMeta(journal, runId) {
|
|
1042
|
+
const lookup = journal;
|
|
1043
|
+
if (typeof lookup.getMeta === "function") return lookup.getMeta(runId);
|
|
1044
|
+
return (await journal.listRuns()).find((m) => m.runId === runId);
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* The writer protocol: run it in a spawned process against the
|
|
1048
|
+
* consumer-constructed store pair. Appends every observation to the
|
|
1049
|
+
* report file; protocol-level anomalies (a stale acceptance, an
|
|
1050
|
+
* unexpected error class) are logged as events for the referee, never
|
|
1051
|
+
* thrown, so one writer's finding cannot vanish with its process.
|
|
1052
|
+
*/
|
|
1053
|
+
async function runSoakWriter(fixture, config, hooks = {}) {
|
|
1054
|
+
const { journal, transcripts } = fixture;
|
|
1055
|
+
const retryable = hooks.retryable ?? (() => false);
|
|
1056
|
+
const owner = `soak-w${config.writer}`;
|
|
1057
|
+
const rnd = prng(config.seed * 1000003 + config.writer * 7919);
|
|
1058
|
+
const log = (event) => {
|
|
1059
|
+
appendFileSync(config.reportPath, `${JSON.stringify(event)}\n`);
|
|
1060
|
+
};
|
|
1061
|
+
const stopRequested = () => existsSync(config.stopPath);
|
|
1062
|
+
const tailSeq = async () => {
|
|
1063
|
+
let max = 0;
|
|
1064
|
+
for (const entry of await journal.load(config.runId)) if (Number.isFinite(entry.seq) && entry.seq > max) max = entry.seq;
|
|
1065
|
+
return max;
|
|
1066
|
+
};
|
|
1067
|
+
const attempt = async (surface, op) => {
|
|
1068
|
+
for (let round = 0; round < 500; round += 1) try {
|
|
1069
|
+
await op();
|
|
1070
|
+
return "ok";
|
|
1071
|
+
} catch (thrown) {
|
|
1072
|
+
if (thrown instanceof LeaseHeldError) return "lease";
|
|
1073
|
+
if (thrown instanceof JournalOrderViolation) return "order";
|
|
1074
|
+
if (retryable(thrown)) {
|
|
1075
|
+
log({
|
|
1076
|
+
t: "busy",
|
|
1077
|
+
w: config.writer,
|
|
1078
|
+
surface
|
|
1079
|
+
});
|
|
1080
|
+
await sleep(1 + Math.floor(rnd() * 3));
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
log({
|
|
1084
|
+
t: "error",
|
|
1085
|
+
w: config.writer,
|
|
1086
|
+
surface,
|
|
1087
|
+
message: String(thrown)
|
|
1088
|
+
});
|
|
1089
|
+
throw thrown;
|
|
1090
|
+
}
|
|
1091
|
+
log({
|
|
1092
|
+
t: "error",
|
|
1093
|
+
w: config.writer,
|
|
1094
|
+
surface,
|
|
1095
|
+
message: "transient retries exhausted"
|
|
1096
|
+
});
|
|
1097
|
+
throw new Error(`soak writer: transient retries exhausted on ${surface}`);
|
|
1098
|
+
};
|
|
1099
|
+
const staleSweep = async (deadLease) => {
|
|
1100
|
+
const probes = [
|
|
1101
|
+
["append", async () => {
|
|
1102
|
+
const tail = await tailSeq();
|
|
1103
|
+
await journal.append(config.runId, soakEntry(tail + 1, `stale-w${config.writer}-e${deadLease.epoch}`), deadLease);
|
|
1104
|
+
}],
|
|
1105
|
+
["meta", () => journal.putMeta({
|
|
1106
|
+
runId: config.runId,
|
|
1107
|
+
status: "cancelled",
|
|
1108
|
+
updatedAt: `stale-w${config.writer}`
|
|
1109
|
+
}, deadLease)],
|
|
1110
|
+
["blob-put", () => transcripts.put(`${config.runId}/blob-0`, soakBlob(`stale-w${config.writer}-e${deadLease.epoch}`), deadLease)],
|
|
1111
|
+
["blob-delete", () => transcripts.delete(`${config.runId}/blob-0`, deadLease)],
|
|
1112
|
+
["run-delete", () => journal.delete(config.runId, deadLease)],
|
|
1113
|
+
["renew", () => journal.renew(deadLease)],
|
|
1114
|
+
["cross-run", () => transcripts.put("soak-other/blob", soakBlob(`cross-w${config.writer}`), deadLease)],
|
|
1115
|
+
["release", () => journal.release(deadLease)]
|
|
1116
|
+
];
|
|
1117
|
+
for (const [surface, op] of probes) {
|
|
1118
|
+
let outcome;
|
|
1119
|
+
try {
|
|
1120
|
+
outcome = await attempt(`stale-${surface}`, op);
|
|
1121
|
+
} catch {
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
if (outcome === "ok") log({
|
|
1125
|
+
t: "stale-accept",
|
|
1126
|
+
w: config.writer,
|
|
1127
|
+
surface,
|
|
1128
|
+
epoch: deadLease.epoch
|
|
1129
|
+
});
|
|
1130
|
+
else if (outcome === "lease") log({
|
|
1131
|
+
t: "stale-reject",
|
|
1132
|
+
w: config.writer,
|
|
1133
|
+
surface,
|
|
1134
|
+
epoch: deadLease.epoch
|
|
1135
|
+
});
|
|
1136
|
+
else log({
|
|
1137
|
+
t: "error",
|
|
1138
|
+
w: config.writer,
|
|
1139
|
+
surface: `stale-${surface}`,
|
|
1140
|
+
message: "JournalOrderViolation where the fence should reject first"
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
let victimSeq = 0;
|
|
1145
|
+
const victimCycle = async (epoch) => {
|
|
1146
|
+
const vid = `victim-w${config.writer}-e${epoch}-${victimSeq}`;
|
|
1147
|
+
victimSeq += 1;
|
|
1148
|
+
let vlease;
|
|
1149
|
+
const acquired = await attempt("victim-acquire", async () => {
|
|
1150
|
+
vlease = await journal.acquire(vid, owner);
|
|
1151
|
+
});
|
|
1152
|
+
if (acquired !== "ok" || vlease === void 0) {
|
|
1153
|
+
log({
|
|
1154
|
+
t: "victim-abandoned",
|
|
1155
|
+
w: config.writer,
|
|
1156
|
+
vid,
|
|
1157
|
+
surface: "victim-acquire",
|
|
1158
|
+
why: acquired
|
|
1159
|
+
});
|
|
1160
|
+
return acquired;
|
|
1161
|
+
}
|
|
1162
|
+
const lease = vlease;
|
|
1163
|
+
const steps = [
|
|
1164
|
+
["victim-append", () => journal.append(vid, soakEntry(1, `${vid}-entry`), lease)],
|
|
1165
|
+
["victim-meta", () => journal.putMeta({
|
|
1166
|
+
runId: vid,
|
|
1167
|
+
status: "running",
|
|
1168
|
+
updatedAt: `soak-${vid}`
|
|
1169
|
+
}, lease)],
|
|
1170
|
+
["victim-blob", () => transcripts.put(`${vid}/blob`, soakBlob(vid), lease)],
|
|
1171
|
+
["victim-blob-delete", () => transcripts.delete(`${vid}/blob`, lease)],
|
|
1172
|
+
["victim-delete", () => journal.delete(vid, lease)]
|
|
1173
|
+
];
|
|
1174
|
+
for (const [surface, op] of steps) {
|
|
1175
|
+
const outcome = await attempt(surface, op);
|
|
1176
|
+
if (outcome !== "ok") {
|
|
1177
|
+
log({
|
|
1178
|
+
t: "victim-abandoned",
|
|
1179
|
+
w: config.writer,
|
|
1180
|
+
vid,
|
|
1181
|
+
surface,
|
|
1182
|
+
why: outcome
|
|
1183
|
+
});
|
|
1184
|
+
return outcome;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
log({
|
|
1188
|
+
t: "victim",
|
|
1189
|
+
w: config.writer,
|
|
1190
|
+
epoch,
|
|
1191
|
+
vid
|
|
1192
|
+
});
|
|
1193
|
+
return "ok";
|
|
1194
|
+
};
|
|
1195
|
+
const tenure = async () => {
|
|
1196
|
+
let lease;
|
|
1197
|
+
try {
|
|
1198
|
+
lease = await journal.acquire(config.runId, owner);
|
|
1199
|
+
} catch (thrown) {
|
|
1200
|
+
if (thrown instanceof LeaseHeldError || retryable(thrown)) {
|
|
1201
|
+
await sleep(3 + Math.floor(rnd() * 12));
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
log({
|
|
1205
|
+
t: "error",
|
|
1206
|
+
w: config.writer,
|
|
1207
|
+
surface: "acquire",
|
|
1208
|
+
message: String(thrown)
|
|
1209
|
+
});
|
|
1210
|
+
throw thrown;
|
|
1211
|
+
}
|
|
1212
|
+
const epoch = lease.epoch;
|
|
1213
|
+
log({
|
|
1214
|
+
t: "grant",
|
|
1215
|
+
w: config.writer,
|
|
1216
|
+
epoch
|
|
1217
|
+
});
|
|
1218
|
+
let tail = await tailSeq();
|
|
1219
|
+
let counter = 0;
|
|
1220
|
+
let alive = true;
|
|
1221
|
+
const nonce = () => `w${config.writer}-e${epoch}-c${counter}`;
|
|
1222
|
+
const accepted = (surface, extra = {}) => {
|
|
1223
|
+
log({
|
|
1224
|
+
t: "accept",
|
|
1225
|
+
w: config.writer,
|
|
1226
|
+
surface,
|
|
1227
|
+
epoch,
|
|
1228
|
+
counter,
|
|
1229
|
+
nonce: nonce(),
|
|
1230
|
+
...extra
|
|
1231
|
+
});
|
|
1232
|
+
counter += 1;
|
|
1233
|
+
};
|
|
1234
|
+
{
|
|
1235
|
+
const seq = tail + 1;
|
|
1236
|
+
const outcome = await attempt("marker", () => journal.append(config.runId, soakEntry(seq, nonce()), lease));
|
|
1237
|
+
if (outcome === "ok") {
|
|
1238
|
+
tail = seq;
|
|
1239
|
+
accepted("marker", { seq });
|
|
1240
|
+
} else if (outcome === "lease") {
|
|
1241
|
+
log({
|
|
1242
|
+
t: "fence-kick",
|
|
1243
|
+
w: config.writer,
|
|
1244
|
+
surface: "marker",
|
|
1245
|
+
epoch
|
|
1246
|
+
});
|
|
1247
|
+
await staleSweep(lease);
|
|
1248
|
+
return;
|
|
1249
|
+
} else {
|
|
1250
|
+
log({
|
|
1251
|
+
t: "error",
|
|
1252
|
+
w: config.writer,
|
|
1253
|
+
surface: "marker",
|
|
1254
|
+
message: "JournalOrderViolation under a live lease"
|
|
1255
|
+
});
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
const ops = 3 + Math.floor(rnd() * 6);
|
|
1260
|
+
for (let k = 0; k < ops && alive && !stopRequested(); k += 1) {
|
|
1261
|
+
const dice = rnd();
|
|
1262
|
+
if (dice < .3) {
|
|
1263
|
+
const seq = tail + 1;
|
|
1264
|
+
const outcome = await attempt("append", () => journal.append(config.runId, soakEntry(seq, nonce()), lease));
|
|
1265
|
+
if (outcome === "ok") {
|
|
1266
|
+
tail = seq;
|
|
1267
|
+
accepted("append", { seq });
|
|
1268
|
+
} else if (outcome === "lease") {
|
|
1269
|
+
log({
|
|
1270
|
+
t: "fence-kick",
|
|
1271
|
+
w: config.writer,
|
|
1272
|
+
surface: "append",
|
|
1273
|
+
epoch
|
|
1274
|
+
});
|
|
1275
|
+
alive = false;
|
|
1276
|
+
} else {
|
|
1277
|
+
log({
|
|
1278
|
+
t: "error",
|
|
1279
|
+
w: config.writer,
|
|
1280
|
+
surface: "append",
|
|
1281
|
+
message: "JournalOrderViolation under a live lease"
|
|
1282
|
+
});
|
|
1283
|
+
alive = false;
|
|
1284
|
+
}
|
|
1285
|
+
} else if (dice < .5) {
|
|
1286
|
+
const meta = {
|
|
1287
|
+
runId: config.runId,
|
|
1288
|
+
status: "running",
|
|
1289
|
+
updatedAt: `soak-${nonce()}`,
|
|
1290
|
+
soak: { nonce: nonce() }
|
|
1291
|
+
};
|
|
1292
|
+
if (await attempt("meta", () => journal.putMeta(meta, lease)) === "ok") accepted("meta");
|
|
1293
|
+
else {
|
|
1294
|
+
log({
|
|
1295
|
+
t: "fence-kick",
|
|
1296
|
+
w: config.writer,
|
|
1297
|
+
surface: "meta",
|
|
1298
|
+
epoch
|
|
1299
|
+
});
|
|
1300
|
+
alive = false;
|
|
1301
|
+
}
|
|
1302
|
+
} else if (dice < .65) {
|
|
1303
|
+
const ref = `${config.runId}/blob-${Math.floor(rnd() * 4)}`;
|
|
1304
|
+
if (await attempt("blob-put", () => transcripts.put(ref, soakBlob(nonce()), lease)) === "ok") accepted("blob-put", { ref });
|
|
1305
|
+
else {
|
|
1306
|
+
log({
|
|
1307
|
+
t: "fence-kick",
|
|
1308
|
+
w: config.writer,
|
|
1309
|
+
surface: "blob-put",
|
|
1310
|
+
epoch
|
|
1311
|
+
});
|
|
1312
|
+
alive = false;
|
|
1313
|
+
}
|
|
1314
|
+
} else if (dice < .75) {
|
|
1315
|
+
const ref = `${config.runId}/blob-${Math.floor(rnd() * 4)}`;
|
|
1316
|
+
if (await attempt("blob-delete", () => transcripts.delete(ref, lease)) === "ok") accepted("blob-delete", { ref });
|
|
1317
|
+
else {
|
|
1318
|
+
log({
|
|
1319
|
+
t: "fence-kick",
|
|
1320
|
+
w: config.writer,
|
|
1321
|
+
surface: "blob-delete",
|
|
1322
|
+
epoch
|
|
1323
|
+
});
|
|
1324
|
+
alive = false;
|
|
1325
|
+
}
|
|
1326
|
+
} else if (dice < .85) {
|
|
1327
|
+
if (await victimCycle(epoch) === "lease") {
|
|
1328
|
+
log({
|
|
1329
|
+
t: "fence-kick",
|
|
1330
|
+
w: config.writer,
|
|
1331
|
+
surface: "victim",
|
|
1332
|
+
epoch
|
|
1333
|
+
});
|
|
1334
|
+
alive = false;
|
|
1335
|
+
}
|
|
1336
|
+
} else if (dice < .93) if (await attempt("renew", () => journal.renew(lease)) === "ok") log({
|
|
1337
|
+
t: "renewed",
|
|
1338
|
+
w: config.writer,
|
|
1339
|
+
epoch
|
|
1340
|
+
});
|
|
1341
|
+
else {
|
|
1342
|
+
log({
|
|
1343
|
+
t: "fence-kick",
|
|
1344
|
+
w: config.writer,
|
|
1345
|
+
surface: "renew",
|
|
1346
|
+
epoch
|
|
1347
|
+
});
|
|
1348
|
+
alive = false;
|
|
1349
|
+
}
|
|
1350
|
+
else {
|
|
1351
|
+
const outcome = await attempt("cross-run-live", () => transcripts.put("soak-other/blob", soakBlob(nonce()), lease));
|
|
1352
|
+
if (outcome === "lease") log({
|
|
1353
|
+
t: "live-cross-reject",
|
|
1354
|
+
w: config.writer,
|
|
1355
|
+
epoch
|
|
1356
|
+
});
|
|
1357
|
+
else if (outcome === "ok") log({
|
|
1358
|
+
t: "stale-accept",
|
|
1359
|
+
w: config.writer,
|
|
1360
|
+
surface: "cross-run-live",
|
|
1361
|
+
epoch
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
if (!alive) {
|
|
1366
|
+
await staleSweep(lease);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
if (rnd() < .5) {
|
|
1370
|
+
log({
|
|
1371
|
+
t: "stall",
|
|
1372
|
+
w: config.writer,
|
|
1373
|
+
epoch
|
|
1374
|
+
});
|
|
1375
|
+
await sleep(Math.ceil(config.ttlMs * 1.4));
|
|
1376
|
+
await staleSweep(lease);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
if (await attempt("release", () => journal.release(lease)) === "ok") log({
|
|
1380
|
+
t: "released",
|
|
1381
|
+
w: config.writer,
|
|
1382
|
+
epoch
|
|
1383
|
+
});
|
|
1384
|
+
else {
|
|
1385
|
+
log({
|
|
1386
|
+
t: "fence-kick",
|
|
1387
|
+
w: config.writer,
|
|
1388
|
+
surface: "release",
|
|
1389
|
+
epoch
|
|
1390
|
+
});
|
|
1391
|
+
await staleSweep(lease);
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
try {
|
|
1395
|
+
while (!stopRequested()) await tenure();
|
|
1396
|
+
log({
|
|
1397
|
+
t: "done",
|
|
1398
|
+
w: config.writer
|
|
1399
|
+
});
|
|
1400
|
+
} catch (thrown) {
|
|
1401
|
+
log({
|
|
1402
|
+
t: "fatal",
|
|
1403
|
+
w: config.writer,
|
|
1404
|
+
message: String(thrown)
|
|
1405
|
+
});
|
|
1406
|
+
throw thrown;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
/** Parses one report file, tolerating a torn trailing line. */
|
|
1410
|
+
function parseSoakReport(path) {
|
|
1411
|
+
if (!existsSync(path)) return [];
|
|
1412
|
+
const events = [];
|
|
1413
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
1414
|
+
if (line.trim() === "") continue;
|
|
1415
|
+
try {
|
|
1416
|
+
events.push(JSON.parse(line));
|
|
1417
|
+
} catch {}
|
|
1418
|
+
}
|
|
1419
|
+
return events;
|
|
1420
|
+
}
|
|
1421
|
+
/** Derives the activity counters the quorum is judged against. */
|
|
1422
|
+
function countSoakActivity(events) {
|
|
1423
|
+
const epochs = /* @__PURE__ */ new Set();
|
|
1424
|
+
const activity = {
|
|
1425
|
+
epochs: 0,
|
|
1426
|
+
staleRejects: 0,
|
|
1427
|
+
appends: 0,
|
|
1428
|
+
metaWrites: 0,
|
|
1429
|
+
blobPuts: 0,
|
|
1430
|
+
blobDeletes: 0,
|
|
1431
|
+
victimCycles: 0,
|
|
1432
|
+
liveCrossRejects: 0,
|
|
1433
|
+
busyRetries: 0
|
|
1434
|
+
};
|
|
1435
|
+
for (const event of events) switch (event.t) {
|
|
1436
|
+
case "grant":
|
|
1437
|
+
epochs.add(event.epoch);
|
|
1438
|
+
break;
|
|
1439
|
+
case "stale-reject":
|
|
1440
|
+
activity.staleRejects += 1;
|
|
1441
|
+
break;
|
|
1442
|
+
case "accept":
|
|
1443
|
+
if (event.surface === "marker" || event.surface === "append") activity.appends += 1;
|
|
1444
|
+
else if (event.surface === "meta") activity.metaWrites += 1;
|
|
1445
|
+
else if (event.surface === "blob-put") activity.blobPuts += 1;
|
|
1446
|
+
else activity.blobDeletes += 1;
|
|
1447
|
+
break;
|
|
1448
|
+
case "victim":
|
|
1449
|
+
activity.victimCycles += 1;
|
|
1450
|
+
break;
|
|
1451
|
+
case "live-cross-reject":
|
|
1452
|
+
activity.liveCrossRejects += 1;
|
|
1453
|
+
break;
|
|
1454
|
+
case "busy":
|
|
1455
|
+
activity.busyRetries += 1;
|
|
1456
|
+
break;
|
|
1457
|
+
default: break;
|
|
1458
|
+
}
|
|
1459
|
+
activity.epochs = epochs.size;
|
|
1460
|
+
return activity;
|
|
1461
|
+
}
|
|
1462
|
+
function quorumMet(activity, quorum) {
|
|
1463
|
+
return activity.epochs >= quorum.epochs && activity.staleRejects >= quorum.staleRejects && activity.appends >= quorum.appends && activity.metaWrites >= quorum.metaWrites && activity.blobPuts >= quorum.blobPuts && activity.blobDeletes >= quorum.blobDeletes && activity.victimCycles >= quorum.victimCycles && activity.liveCrossRejects >= quorum.liveCrossRejects;
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* The pure referee: rebuilds the serial history from the merged report
|
|
1467
|
+
* events and diffs it against the actual post-storm store state.
|
|
1468
|
+
* Returns every violation as a descriptive string; an empty array means
|
|
1469
|
+
* the fencing promise held for the whole storm.
|
|
1470
|
+
*/
|
|
1471
|
+
async function verifySoakHistory(fixture, events, runId) {
|
|
1472
|
+
const violations = [];
|
|
1473
|
+
for (const event of events) if (event.t === "stale-accept") violations.push(`writer ${event.w}: STALE ACCEPT on ${event.surface} under epoch ${event.epoch}`);
|
|
1474
|
+
else if (event.t === "error") violations.push(`writer ${event.w}: unexpected error on ${event.surface}: ${event.message}`);
|
|
1475
|
+
else if (event.t === "fatal") violations.push(`writer ${event.w}: fatal: ${event.message}`);
|
|
1476
|
+
const grantee = /* @__PURE__ */ new Map();
|
|
1477
|
+
const lastGrant = /* @__PURE__ */ new Map();
|
|
1478
|
+
for (const event of events) {
|
|
1479
|
+
if (event.t !== "grant") continue;
|
|
1480
|
+
if (grantee.has(event.epoch)) violations.push(`epoch ${event.epoch} granted twice (writers ${String(grantee.get(event.epoch))} and ${event.w})`);
|
|
1481
|
+
grantee.set(event.epoch, event.w);
|
|
1482
|
+
const prior = lastGrant.get(event.w) ?? 0;
|
|
1483
|
+
if (event.epoch <= prior) violations.push(`writer ${event.w}: grant epochs not increasing (${prior} then ${event.epoch})`);
|
|
1484
|
+
lastGrant.set(event.w, event.epoch);
|
|
1485
|
+
}
|
|
1486
|
+
const accepts = events.filter((event) => event.t === "accept");
|
|
1487
|
+
for (const event of accepts) if (grantee.get(event.epoch) !== event.w) violations.push(`writer ${event.w} accepted a mutation under epoch ${event.epoch} granted to ${String(grantee.get(event.epoch))}`);
|
|
1488
|
+
const serial = [...accepts].sort((a, b) => a.epoch - b.epoch || a.counter - b.counter);
|
|
1489
|
+
const positions = /* @__PURE__ */ new Set();
|
|
1490
|
+
for (const event of serial) {
|
|
1491
|
+
const position = `${event.epoch}:${event.counter}`;
|
|
1492
|
+
if (positions.has(position)) violations.push(`duplicate serial position ${position}`);
|
|
1493
|
+
positions.add(position);
|
|
1494
|
+
}
|
|
1495
|
+
const journal = await fixture.journal.load(runId);
|
|
1496
|
+
const expectedAppends = serial.filter((event) => event.surface === "marker" || event.surface === "append");
|
|
1497
|
+
if (journal.length !== expectedAppends.length) violations.push(`journal has ${journal.length} entries, the serial history accepted ${expectedAppends.length}`);
|
|
1498
|
+
let lastEpoch = 0;
|
|
1499
|
+
const comparable = Math.min(journal.length, expectedAppends.length);
|
|
1500
|
+
for (let i = 0; i < comparable; i += 1) {
|
|
1501
|
+
const actual = journal[i];
|
|
1502
|
+
const expected = expectedAppends[i];
|
|
1503
|
+
if (nonceOfEntry(actual) !== expected.nonce) {
|
|
1504
|
+
violations.push(`journal[${i}] carries nonce ${String(nonceOfEntry(actual))}, expected ${expected.nonce}`);
|
|
1505
|
+
break;
|
|
1506
|
+
}
|
|
1507
|
+
if (actual.seq !== i + 1) violations.push(`journal[${i}] seq ${actual.seq} breaks the contiguous 1..N order`);
|
|
1508
|
+
if (expected.seq !== i + 1) violations.push(`accepted append ${expected.nonce} logged seq ${String(expected.seq)}, expected ${i + 1}`);
|
|
1509
|
+
if (expected.epoch < lastEpoch) violations.push(`epoch inversion along the journal at index ${i}: ${expected.epoch} after ${lastEpoch}`);
|
|
1510
|
+
lastEpoch = Math.max(lastEpoch, expected.epoch);
|
|
1511
|
+
}
|
|
1512
|
+
const nonceOfMeta = (meta) => meta?.soak?.nonce;
|
|
1513
|
+
const metaAccepts = serial.filter((event) => event.surface === "meta");
|
|
1514
|
+
if (metaAccepts.length > 0) {
|
|
1515
|
+
const got = nonceOfMeta(await readMeta(fixture.journal, runId));
|
|
1516
|
+
const want = metaAccepts[metaAccepts.length - 1].nonce;
|
|
1517
|
+
if (got !== want) violations.push(`final meta nonce ${String(got)} is not the last accepted ${want}`);
|
|
1518
|
+
}
|
|
1519
|
+
const expectedBlobs = /* @__PURE__ */ new Map();
|
|
1520
|
+
for (const event of serial) if (event.surface === "blob-put" && event.ref !== void 0) expectedBlobs.set(event.ref, event.nonce);
|
|
1521
|
+
else if (event.surface === "blob-delete" && event.ref !== void 0) expectedBlobs.delete(event.ref);
|
|
1522
|
+
const listed = await fixture.transcripts.list(runId);
|
|
1523
|
+
const expectedRefs = [...expectedBlobs.keys()].sort();
|
|
1524
|
+
if (JSON.stringify(listed) !== JSON.stringify(expectedRefs)) violations.push(`blob list ${JSON.stringify(listed)} diverges from the serial history ${JSON.stringify(expectedRefs)}`);
|
|
1525
|
+
for (const [ref, want] of expectedBlobs) {
|
|
1526
|
+
const got = nonceOfBlob(await fixture.transcripts.get(ref));
|
|
1527
|
+
if (got !== want) violations.push(`blob ${ref} carries nonce ${String(got)}, expected ${want}`);
|
|
1528
|
+
}
|
|
1529
|
+
const foreign = await fixture.transcripts.list("soak-other");
|
|
1530
|
+
if (foreign.length !== 0) violations.push(`cross-run writes leaked into soak-other: ${JSON.stringify(foreign)}`);
|
|
1531
|
+
for (const event of events) {
|
|
1532
|
+
if (event.t !== "victim") continue;
|
|
1533
|
+
const entries = await fixture.journal.load(event.vid);
|
|
1534
|
+
const meta = await readMeta(fixture.journal, event.vid);
|
|
1535
|
+
const blobs = await fixture.transcripts.list(event.vid);
|
|
1536
|
+
if (entries.length !== 0 || meta !== void 0 || blobs.length !== 0) violations.push(`victim ${event.vid} not fully deleted: ${entries.length} entries, meta ${JSON.stringify(meta)}, ${blobs.length} blobs`);
|
|
1537
|
+
}
|
|
1538
|
+
return violations;
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Spawns the writer processes, stops the storm at quorum (or at the
|
|
1542
|
+
* hard cap), verifies the serial history against the store, and throws
|
|
1543
|
+
* one Error naming every violation. The returned result is the storm's
|
|
1544
|
+
* observed coverage; assert on it if the caller wants a floor beyond
|
|
1545
|
+
* the quorum.
|
|
1546
|
+
*/
|
|
1547
|
+
async function runMultiProcessSoak(options) {
|
|
1548
|
+
const writers = options.writers ?? 3;
|
|
1549
|
+
const ttlMs = options.ttlMs ?? 250;
|
|
1550
|
+
const seed = options.seed ?? 1;
|
|
1551
|
+
const capMs = options.capMs ?? 6e4;
|
|
1552
|
+
const quorum = {
|
|
1553
|
+
...DEFAULT_SOAK_QUORUM,
|
|
1554
|
+
...options.quorum
|
|
1555
|
+
};
|
|
1556
|
+
const storePath = options.storePath ?? join(options.dir, "soak.db");
|
|
1557
|
+
const stopPath = join(options.dir, "soak-stop");
|
|
1558
|
+
const runId = "soak-run";
|
|
1559
|
+
const reportPaths = Array.from({ length: writers }, (_, i) => join(options.dir, `soak-report-${i}.jsonl`));
|
|
1560
|
+
const children = reportPaths.map((reportPath, i) => {
|
|
1561
|
+
const config = {
|
|
1562
|
+
storePath,
|
|
1563
|
+
runId,
|
|
1564
|
+
writer: i,
|
|
1565
|
+
ttlMs,
|
|
1566
|
+
seed,
|
|
1567
|
+
reportPath,
|
|
1568
|
+
stopPath
|
|
1569
|
+
};
|
|
1570
|
+
const child = spawn(process.execPath, [...options.execArgv ?? [], options.writerScript], {
|
|
1571
|
+
env: {
|
|
1572
|
+
...process.env,
|
|
1573
|
+
...options.env,
|
|
1574
|
+
[SOAK_CONFIG_ENV]: JSON.stringify(config)
|
|
1575
|
+
},
|
|
1576
|
+
stdio: [
|
|
1577
|
+
"ignore",
|
|
1578
|
+
"ignore",
|
|
1579
|
+
"pipe"
|
|
1580
|
+
]
|
|
1581
|
+
});
|
|
1582
|
+
let stderr = "";
|
|
1583
|
+
child.stderr.on("data", (chunk) => {
|
|
1584
|
+
stderr += String(chunk);
|
|
1585
|
+
});
|
|
1586
|
+
return {
|
|
1587
|
+
child,
|
|
1588
|
+
exit: new Promise((resolve) => {
|
|
1589
|
+
child.on("exit", (code) => resolve(code));
|
|
1590
|
+
}),
|
|
1591
|
+
stderrOf: () => stderr
|
|
1592
|
+
};
|
|
1593
|
+
});
|
|
1594
|
+
const startedAt = wallClock();
|
|
1595
|
+
let capped = false;
|
|
1596
|
+
for (;;) {
|
|
1597
|
+
await sleep(100);
|
|
1598
|
+
if (quorumMet(countSoakActivity(reportPaths.flatMap((path) => parseSoakReport(path))), quorum)) break;
|
|
1599
|
+
if (wallClock() - startedAt > capMs) {
|
|
1600
|
+
capped = true;
|
|
1601
|
+
break;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
writeFileSync(stopPath, "");
|
|
1605
|
+
const exitDeadline = wallClock() + 3e4;
|
|
1606
|
+
const codes = await Promise.all(children.map(async ({ child, exit }) => {
|
|
1607
|
+
const timeout = Math.max(1, exitDeadline - wallClock());
|
|
1608
|
+
const code = await Promise.race([exit, sleep(timeout).then(() => "hung")]);
|
|
1609
|
+
if (code === "hung") {
|
|
1610
|
+
child.kill("SIGKILL");
|
|
1611
|
+
return "hung";
|
|
1612
|
+
}
|
|
1613
|
+
return code;
|
|
1614
|
+
}));
|
|
1615
|
+
const stormMs = wallClock() - startedAt;
|
|
1616
|
+
const events = reportPaths.flatMap((path) => parseSoakReport(path));
|
|
1617
|
+
const activity = countSoakActivity(events);
|
|
1618
|
+
const violations = [];
|
|
1619
|
+
for (const [i, code] of codes.entries()) if (code === "hung") violations.push(`writer ${i} did not exit after the stop signal (killed)`);
|
|
1620
|
+
else if (code !== 0) violations.push(`writer ${i} exited with code ${String(code)}: ${children[i]?.stderrOf().slice(0, 2e3) ?? ""}`);
|
|
1621
|
+
if (capped) violations.push(`activity quorum not reached inside ${capMs} ms: ${JSON.stringify(activity)} versus ` + JSON.stringify(quorum));
|
|
1622
|
+
const fixture = await options.openStore(storePath);
|
|
1623
|
+
let journalEntries = 0;
|
|
1624
|
+
try {
|
|
1625
|
+
violations.push(...await verifySoakHistory(fixture, events, runId));
|
|
1626
|
+
journalEntries = (await fixture.journal.load(runId)).length;
|
|
1627
|
+
} finally {
|
|
1628
|
+
await options.closeStore?.(fixture);
|
|
1629
|
+
}
|
|
1630
|
+
if (violations.length > 0) throw new Error(`store-conformance multi-process-soak: ${violations.length} violation(s)\n - ` + violations.join("\n - "));
|
|
1631
|
+
return {
|
|
1632
|
+
activity,
|
|
1633
|
+
stormMs,
|
|
1634
|
+
journalEntries,
|
|
1635
|
+
events
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
//#endregion
|
|
1639
|
+
export { DEFAULT_SOAK_QUORUM, GOLDEN_FOLD_JOURNAL, GOLDEN_FOLD_STATE_SHA256, countSoakActivity, fencedTranscriptsConformance, fencedWritesConformance, foldStateSha256, journalStoreConformance, leasableStoreConformance, makeSuite, materializeFoldState, parseSoakReport, registerConformance, runMultiProcessSoak, runSoakWriter, soakWriterConfigFromEnv, stableStringify, verifySoakHistory };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rulvar/store-conformance",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.48.0",
|
|
4
4
|
"description": "Rulvar executable store conformance kit (DEF-4).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"access": "public"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@rulvar/core": "1.
|
|
25
|
+
"@rulvar/core": "1.48.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^22.20.0",
|