@agentplat/mesh-sim 0.3.0-alpha.1 → 0.3.0-alpha.2
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/README.md +73 -2
- package/dist/index.d.ts +119 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1817 -44
- package/dist/index.js.map +1 -1
- package/dist/reducer-scenario.d.ts +129 -0
- package/dist/reducer-scenario.d.ts.map +1 -0
- package/dist/reducer-scenario.js +774 -0
- package/dist/reducer-scenario.js.map +1 -0
- package/package.json +4 -4
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
import { canonicalizeMeshJsonBytes } from '@agentplat/mesh-protocol';
|
|
2
|
+
import { MESH_SIMULATION_FAULT_LIMITS, } from './index.js';
|
|
3
|
+
const reducerScenarioCanonicalLimits = Object.freeze({
|
|
4
|
+
maximumEnvelopeBytes: 64 * 1024 * 1024,
|
|
5
|
+
maximumPayloadBytes: 64 * 1024 * 1024,
|
|
6
|
+
maximumNestingDepth: 128,
|
|
7
|
+
maximumTotalObjectKeys: 2_000_000,
|
|
8
|
+
maximumObjectKeys: 1_000_000,
|
|
9
|
+
maximumTotalArrayItems: 2_000_000,
|
|
10
|
+
maximumArrayItems: 1_000_000,
|
|
11
|
+
maximumStringBytes: 1_048_576,
|
|
12
|
+
});
|
|
13
|
+
/**
|
|
14
|
+
* Runs a closed serialized schedule against a caller supplied production reducer
|
|
15
|
+
* dispatch table. Faults mutate only driver availability, links, clocks, or the
|
|
16
|
+
* event queue.
|
|
17
|
+
*/
|
|
18
|
+
export async function runMeshReducerScenario(inputConfig, inputRuntime) {
|
|
19
|
+
assertPlainRecord(inputRuntime, 'runtime');
|
|
20
|
+
assertExactKeys(inputRuntime, ['driverId', 'project', 'projectionId', 'reduce'], ['invariants']);
|
|
21
|
+
if (typeof inputRuntime.reduce !== 'function' ||
|
|
22
|
+
typeof inputRuntime.project !== 'function')
|
|
23
|
+
throw new TypeError('Invalid Mesh reducer scenario runtime');
|
|
24
|
+
const rawInvariants = inputRuntime.invariants ?? [];
|
|
25
|
+
assertDenseArray(rawInvariants, 'runtime invariants');
|
|
26
|
+
if (rawInvariants.length > 256)
|
|
27
|
+
throw new RangeError('Mesh reducer scenario invariant limit exceeded');
|
|
28
|
+
const invariants = Object.freeze(rawInvariants.map((invariant) => {
|
|
29
|
+
assertPlainRecord(invariant, 'runtime invariant');
|
|
30
|
+
assertExactKeys(invariant, ['evaluate', 'name']);
|
|
31
|
+
assertString(invariant.name, 'invariant name');
|
|
32
|
+
if (typeof invariant.evaluate !== 'function')
|
|
33
|
+
throw new TypeError('Invalid Mesh reducer scenario invariant');
|
|
34
|
+
return Object.freeze({
|
|
35
|
+
name: invariant.name,
|
|
36
|
+
evaluate: invariant.evaluate,
|
|
37
|
+
});
|
|
38
|
+
}));
|
|
39
|
+
const runtime = Object.freeze({
|
|
40
|
+
driverId: inputRuntime.driverId,
|
|
41
|
+
projectionId: inputRuntime.projectionId,
|
|
42
|
+
reduce: inputRuntime.reduce,
|
|
43
|
+
project: inputRuntime.project,
|
|
44
|
+
invariants,
|
|
45
|
+
});
|
|
46
|
+
const config = deepFreezeData(inputConfig);
|
|
47
|
+
validateScenario(config, runtime);
|
|
48
|
+
const runtimeDescriptor = Object.freeze({
|
|
49
|
+
driverId: runtime.driverId,
|
|
50
|
+
projectionId: runtime.projectionId,
|
|
51
|
+
invariantNames: Object.freeze(runtime.invariants.map(({ name }) => name)),
|
|
52
|
+
});
|
|
53
|
+
const configurationDigest = await digest({
|
|
54
|
+
config,
|
|
55
|
+
...runtimeDescriptor,
|
|
56
|
+
});
|
|
57
|
+
const faultPlanDigest = await digest(config.faultPlan);
|
|
58
|
+
const states = new Map(config.peers.map(({ peerId, state }) => [peerId, deepFreezeData(state)]));
|
|
59
|
+
const availability = new Map(config.peers.map(({ peerId }) => [peerId, true]));
|
|
60
|
+
const clockOffsets = new Map(config.peers.map(({ peerId }) => [peerId, 0]));
|
|
61
|
+
const links = new Map(config.links.map((link) => [linkKey(link.fromPeerId, link.toPeerId), link]));
|
|
62
|
+
const prngStates = new Map();
|
|
63
|
+
let insertionSequence = 0;
|
|
64
|
+
const queue = [];
|
|
65
|
+
for (const event of config.events)
|
|
66
|
+
queue.push(Object.freeze({
|
|
67
|
+
...event,
|
|
68
|
+
action: deepFreezeData(event.action),
|
|
69
|
+
insertionSequence: ++insertionSequence,
|
|
70
|
+
}));
|
|
71
|
+
for (const fault of config.faultPlan.faults) {
|
|
72
|
+
const frozenFault = deepFreezeData(fault);
|
|
73
|
+
const targetPeerId = faultTargetPeerId(frozenFault, config.peers);
|
|
74
|
+
const faultEventId = `fault:${frozenFault.faultId}`;
|
|
75
|
+
assertString(faultEventId, 'fault eventId');
|
|
76
|
+
queue.push(Object.freeze({
|
|
77
|
+
eventId: faultEventId,
|
|
78
|
+
targetPeerId,
|
|
79
|
+
logicalTime: frozenFault.logicalTime,
|
|
80
|
+
priority: frozenFault.priority,
|
|
81
|
+
action: Object.freeze({}),
|
|
82
|
+
insertionSequence: ++insertionSequence,
|
|
83
|
+
fault: frozenFault,
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
queue.sort(compareQueued);
|
|
87
|
+
const records = [];
|
|
88
|
+
const faultRecords = [];
|
|
89
|
+
let chainDigest = configurationDigest;
|
|
90
|
+
let logicalTime = 0;
|
|
91
|
+
let reducerCalls = 0;
|
|
92
|
+
let acceptedReducerCalls = 0;
|
|
93
|
+
let rejectedReducerCalls = 0;
|
|
94
|
+
let suppressedEvents = 0;
|
|
95
|
+
while (queue.length > 0) {
|
|
96
|
+
if (records.length >= config.limits.maximumEvents)
|
|
97
|
+
throw new RangeError('Mesh reducer scenario event limit exceeded');
|
|
98
|
+
const event = queue.shift();
|
|
99
|
+
if (event === undefined)
|
|
100
|
+
throw new TypeError('Mesh reducer scenario queue became inconsistent');
|
|
101
|
+
logicalTime = event.logicalTime;
|
|
102
|
+
const order = Object.freeze({
|
|
103
|
+
logicalTime,
|
|
104
|
+
priority: event.priority,
|
|
105
|
+
insertionSequence: event.insertionSequence,
|
|
106
|
+
});
|
|
107
|
+
let kind = 'reducer';
|
|
108
|
+
let accepted = false;
|
|
109
|
+
let rejectionCode;
|
|
110
|
+
let effects = Object.freeze([]);
|
|
111
|
+
let faultRecord;
|
|
112
|
+
if (event.fault !== undefined) {
|
|
113
|
+
kind = 'fault';
|
|
114
|
+
const applied = applyFault(event.fault, queue, availability, clockOffsets, links, config, () => ++insertionSequence);
|
|
115
|
+
suppressedEvents += applied.suppressedEvents ?? 0;
|
|
116
|
+
accepted = applied.applied;
|
|
117
|
+
faultRecord = Object.freeze({
|
|
118
|
+
faultId: event.fault.faultId,
|
|
119
|
+
kind: event.fault.kind,
|
|
120
|
+
applied: applied.applied,
|
|
121
|
+
affectedEventIds: Object.freeze(applied.affectedEventIds),
|
|
122
|
+
affectedLinkIds: Object.freeze(applied.affectedLinkIds ?? []),
|
|
123
|
+
});
|
|
124
|
+
faultRecords.push(faultRecord);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const sourcePeerId = event.sourcePeerId;
|
|
128
|
+
const unavailable = availability.get(event.targetPeerId) !== true;
|
|
129
|
+
const partitioned = sourcePeerId !== undefined &&
|
|
130
|
+
links.get(linkKey(sourcePeerId, event.targetPeerId))?.enabled !== true;
|
|
131
|
+
if (unavailable || partitioned) {
|
|
132
|
+
kind = 'suppressed';
|
|
133
|
+
accepted = false;
|
|
134
|
+
rejectionCode = unavailable
|
|
135
|
+
? 'simulation_peer_crashed'
|
|
136
|
+
: 'simulation_partitioned';
|
|
137
|
+
suppressedEvents += 1;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
const state = states.get(event.targetPeerId);
|
|
141
|
+
if (state === undefined)
|
|
142
|
+
throw new TypeError('Mesh reducer scenario peer state disappeared');
|
|
143
|
+
let decision;
|
|
144
|
+
try {
|
|
145
|
+
decision = runtime.reduce({
|
|
146
|
+
peerId: event.targetPeerId,
|
|
147
|
+
state,
|
|
148
|
+
action: event.action,
|
|
149
|
+
logicalTime,
|
|
150
|
+
clockOffset: clockOffsets.get(event.targetPeerId) ?? 0,
|
|
151
|
+
random(scope) {
|
|
152
|
+
const scoped = `${event.targetPeerId}:${scope}`;
|
|
153
|
+
let value = prngStates.get(scoped) ?? mixSeed(config.seed >>> 0, scoped);
|
|
154
|
+
value = xorshift32(value);
|
|
155
|
+
prngStates.set(scoped, value);
|
|
156
|
+
return value / 0x1_0000_0000;
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
throw new Error(`Mesh reducer scenario event ${event.eventId} threw: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
162
|
+
}
|
|
163
|
+
assertDecision(decision);
|
|
164
|
+
const frozenState = deepFreezeData(decision.state);
|
|
165
|
+
assertCanonicalWithin(frozenState, config.limits.maximumStateBytes, 'state');
|
|
166
|
+
states.set(event.targetPeerId, frozenState);
|
|
167
|
+
accepted = decision.accepted;
|
|
168
|
+
rejectionCode = decision.rejectionCode;
|
|
169
|
+
effects = deepFreezeData([...(decision.effects ?? [])]);
|
|
170
|
+
reducerCalls += 1;
|
|
171
|
+
if (accepted)
|
|
172
|
+
acceptedReducerCalls += 1;
|
|
173
|
+
else
|
|
174
|
+
rejectedReducerCalls += 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const peerStates = frozenRecord([...states]);
|
|
178
|
+
const projections = frozenRecord([...states].map(([peerId, state]) => [
|
|
179
|
+
peerId,
|
|
180
|
+
deepFreezeData(runtime.project(state)),
|
|
181
|
+
]));
|
|
182
|
+
for (const invariant of runtime.invariants ?? [])
|
|
183
|
+
invariant.evaluate({
|
|
184
|
+
eventId: event.eventId,
|
|
185
|
+
peerStates,
|
|
186
|
+
projections,
|
|
187
|
+
queuedEvents: queue.length,
|
|
188
|
+
});
|
|
189
|
+
const [actionDigest, effectsDigest, stateDigest, projectionDigest] = await Promise.all([
|
|
190
|
+
digest(event.fault ?? event.action),
|
|
191
|
+
digest(effects),
|
|
192
|
+
digest(peerStates),
|
|
193
|
+
digest(projections),
|
|
194
|
+
]);
|
|
195
|
+
const base = {
|
|
196
|
+
eventId: event.eventId,
|
|
197
|
+
peerId: event.targetPeerId,
|
|
198
|
+
order,
|
|
199
|
+
kind,
|
|
200
|
+
accepted,
|
|
201
|
+
...(rejectionCode === undefined ? {} : { rejectionCode }),
|
|
202
|
+
...(faultRecord === undefined
|
|
203
|
+
? {}
|
|
204
|
+
: {
|
|
205
|
+
faultId: faultRecord.faultId,
|
|
206
|
+
faultKind: faultRecord.kind,
|
|
207
|
+
}),
|
|
208
|
+
actionDigest,
|
|
209
|
+
effectsDigest,
|
|
210
|
+
stateDigest,
|
|
211
|
+
projectionDigest,
|
|
212
|
+
};
|
|
213
|
+
chainDigest = await digest({ previous: chainDigest, record: base });
|
|
214
|
+
records.push(Object.freeze({ ...base, chainDigest }));
|
|
215
|
+
queue.sort(compareQueued);
|
|
216
|
+
}
|
|
217
|
+
const peerStates = frozenRecord([...states]);
|
|
218
|
+
const projections = frozenRecord([...states].map(([peerId, state]) => [
|
|
219
|
+
peerId,
|
|
220
|
+
deepFreezeData(runtime.project(state)),
|
|
221
|
+
]));
|
|
222
|
+
return Object.freeze({
|
|
223
|
+
scenarioId: config.scenarioId,
|
|
224
|
+
seed: config.seed,
|
|
225
|
+
prngVersion: config.prngVersion,
|
|
226
|
+
configurationDigest,
|
|
227
|
+
faultPlanDigest,
|
|
228
|
+
chainDigest,
|
|
229
|
+
metrics: Object.freeze({
|
|
230
|
+
processedEvents: records.length,
|
|
231
|
+
reducerCalls,
|
|
232
|
+
acceptedReducerCalls,
|
|
233
|
+
rejectedReducerCalls,
|
|
234
|
+
faultEvents: faultRecords.length,
|
|
235
|
+
suppressedEvents,
|
|
236
|
+
finalLogicalTime: logicalTime,
|
|
237
|
+
}),
|
|
238
|
+
records: Object.freeze(records),
|
|
239
|
+
faults: Object.freeze(faultRecords),
|
|
240
|
+
peerStates,
|
|
241
|
+
projections,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
export async function replayMeshReducerScenario(config, runtime, expected) {
|
|
245
|
+
const actual = await runMeshReducerScenario(config, runtime);
|
|
246
|
+
const count = Math.max(expected.records.length, actual.records.length);
|
|
247
|
+
let firstDivergence;
|
|
248
|
+
for (let index = 0; index < count; index += 1) {
|
|
249
|
+
if (expected.records[index]?.chainDigest !==
|
|
250
|
+
actual.records[index]?.chainDigest) {
|
|
251
|
+
firstDivergence = index;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const matches = expected.configurationDigest === actual.configurationDigest &&
|
|
256
|
+
expected.chainDigest === actual.chainDigest;
|
|
257
|
+
return Object.freeze({
|
|
258
|
+
matches,
|
|
259
|
+
expectedChainDigest: expected.chainDigest,
|
|
260
|
+
actualChainDigest: actual.chainDigest,
|
|
261
|
+
...(matches || firstDivergence === undefined ? {} : { firstDivergence }),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
function validateScenario(config, runtime) {
|
|
265
|
+
assertPlainRecord(config, 'configuration');
|
|
266
|
+
assertExactKeys(config, [
|
|
267
|
+
'events',
|
|
268
|
+
'faultPlan',
|
|
269
|
+
'limits',
|
|
270
|
+
'links',
|
|
271
|
+
'peers',
|
|
272
|
+
'prngVersion',
|
|
273
|
+
'scenarioId',
|
|
274
|
+
'schemaVersion',
|
|
275
|
+
'seed',
|
|
276
|
+
]);
|
|
277
|
+
if (!config ||
|
|
278
|
+
config.schemaVersion !== 1 ||
|
|
279
|
+
!Number.isSafeInteger(config.seed) ||
|
|
280
|
+
config.prngVersion !== 'xorshift32-v1')
|
|
281
|
+
throw new TypeError('Invalid Mesh reducer scenario configuration');
|
|
282
|
+
assertString(config.scenarioId, 'scenarioId');
|
|
283
|
+
assertString(runtime.driverId, 'driverId');
|
|
284
|
+
assertString(runtime.projectionId, 'projectionId');
|
|
285
|
+
assertDenseArray(config.peers, 'peers');
|
|
286
|
+
assertDenseArray(config.events, 'events');
|
|
287
|
+
assertDenseArray(config.links, 'links');
|
|
288
|
+
assertPlainRecord(config.faultPlan, 'faultPlan');
|
|
289
|
+
assertExactKeys(config.faultPlan, ['faults', 'schemaVersion']);
|
|
290
|
+
assertDenseArray(config.faultPlan.faults, 'faults');
|
|
291
|
+
if (config.peers.length === 0 ||
|
|
292
|
+
config.peers.length > 256 ||
|
|
293
|
+
!config.faultPlan ||
|
|
294
|
+
config.faultPlan.schemaVersion !== 1 ||
|
|
295
|
+
config.faultPlan.faults.length > MESH_SIMULATION_FAULT_LIMITS.maximumFaults)
|
|
296
|
+
throw new TypeError('Invalid Mesh reducer scenario collections');
|
|
297
|
+
const peerIds = new Set();
|
|
298
|
+
for (const peer of config.peers) {
|
|
299
|
+
assertPlainRecord(peer, 'peer');
|
|
300
|
+
assertExactKeys(peer, ['peerId', 'state']);
|
|
301
|
+
assertString(peer.peerId, 'peerId');
|
|
302
|
+
if (peerIds.has(peer.peerId))
|
|
303
|
+
throw new TypeError('Duplicate Mesh reducer scenario peer');
|
|
304
|
+
peerIds.add(peer.peerId);
|
|
305
|
+
assertCanonicalWithin(deepFreezeData(peer.state), config.limits.maximumStateBytes, 'initial state');
|
|
306
|
+
}
|
|
307
|
+
assertPlainRecord(config.limits, 'limits');
|
|
308
|
+
assertExactKeys(config.limits, [
|
|
309
|
+
'maximumEvents',
|
|
310
|
+
'maximumLogicalTime',
|
|
311
|
+
'maximumQueuedEvents',
|
|
312
|
+
'maximumStateBytes',
|
|
313
|
+
]);
|
|
314
|
+
if (!Number.isSafeInteger(config.limits.maximumEvents) ||
|
|
315
|
+
config.limits.maximumEvents < 1 ||
|
|
316
|
+
!Number.isSafeInteger(config.limits.maximumLogicalTime) ||
|
|
317
|
+
config.limits.maximumLogicalTime < 0 ||
|
|
318
|
+
!Number.isSafeInteger(config.limits.maximumQueuedEvents) ||
|
|
319
|
+
config.limits.maximumQueuedEvents < 1 ||
|
|
320
|
+
!Number.isSafeInteger(config.limits.maximumStateBytes) ||
|
|
321
|
+
config.limits.maximumStateBytes < 1 ||
|
|
322
|
+
config.limits.maximumStateBytes > 16 * 1024 * 1024 ||
|
|
323
|
+
config.events.length + config.faultPlan.faults.length >
|
|
324
|
+
config.limits.maximumQueuedEvents)
|
|
325
|
+
throw new RangeError('Invalid Mesh reducer scenario limits');
|
|
326
|
+
const ids = new Set();
|
|
327
|
+
const eventsById = new Map();
|
|
328
|
+
for (const event of config.events) {
|
|
329
|
+
assertPlainRecord(event, 'event');
|
|
330
|
+
assertExactKeys(event, ['action', 'eventId', 'logicalTime', 'priority', 'targetPeerId'], ['scheduledAt', 'sourcePeerId']);
|
|
331
|
+
assertString(event.eventId, 'eventId');
|
|
332
|
+
if (ids.has(event.eventId) ||
|
|
333
|
+
event.eventId.startsWith('fault:') ||
|
|
334
|
+
!peerIds.has(event.targetPeerId) ||
|
|
335
|
+
(event.sourcePeerId !== undefined && !peerIds.has(event.sourcePeerId)) ||
|
|
336
|
+
(event.scheduledAt !== undefined &&
|
|
337
|
+
(event.sourcePeerId === undefined ||
|
|
338
|
+
!Number.isSafeInteger(event.scheduledAt) ||
|
|
339
|
+
event.scheduledAt < 0 ||
|
|
340
|
+
event.scheduledAt > event.logicalTime)) ||
|
|
341
|
+
!Number.isSafeInteger(event.logicalTime) ||
|
|
342
|
+
event.logicalTime < 0 ||
|
|
343
|
+
event.logicalTime > config.limits.maximumLogicalTime ||
|
|
344
|
+
!Number.isSafeInteger(event.priority))
|
|
345
|
+
throw new TypeError('Invalid Mesh reducer scenario event');
|
|
346
|
+
ids.add(event.eventId);
|
|
347
|
+
eventsById.set(event.eventId, event);
|
|
348
|
+
assertCanonicalWithin(event.action, 1_048_576, 'action');
|
|
349
|
+
}
|
|
350
|
+
for (const link of config.links) {
|
|
351
|
+
assertPlainRecord(link, 'link');
|
|
352
|
+
assertExactKeys(link, ['enabled', 'fromPeerId', 'latency', 'toPeerId']);
|
|
353
|
+
if (!peerIds.has(link.fromPeerId) ||
|
|
354
|
+
!peerIds.has(link.toPeerId) ||
|
|
355
|
+
link.fromPeerId === link.toPeerId ||
|
|
356
|
+
!Number.isSafeInteger(link.latency) ||
|
|
357
|
+
link.latency < 0 ||
|
|
358
|
+
typeof link.enabled !== 'boolean')
|
|
359
|
+
throw new TypeError('Invalid Mesh reducer scenario link');
|
|
360
|
+
}
|
|
361
|
+
const configuredLinks = new Set();
|
|
362
|
+
for (const link of config.links) {
|
|
363
|
+
const key = linkKey(link.fromPeerId, link.toPeerId);
|
|
364
|
+
if (configuredLinks.has(key))
|
|
365
|
+
throw new TypeError('Duplicate Mesh reducer scenario link');
|
|
366
|
+
configuredLinks.add(key);
|
|
367
|
+
}
|
|
368
|
+
const faultIds = new Set();
|
|
369
|
+
for (const fault of config.faultPlan.faults) {
|
|
370
|
+
validateFaultShape(fault);
|
|
371
|
+
assertString(fault.faultId, 'faultId');
|
|
372
|
+
if (faultIds.has(fault.faultId) ||
|
|
373
|
+
!Number.isSafeInteger(fault.logicalTime) ||
|
|
374
|
+
fault.logicalTime < 0 ||
|
|
375
|
+
fault.logicalTime > config.limits.maximumLogicalTime ||
|
|
376
|
+
!Number.isSafeInteger(fault.priority))
|
|
377
|
+
throw new TypeError('Invalid Mesh reducer scenario fault');
|
|
378
|
+
faultIds.add(fault.faultId);
|
|
379
|
+
validateFaultTargets(fault, peerIds, eventsById, configuredLinks, config.limits.maximumLogicalTime);
|
|
380
|
+
}
|
|
381
|
+
const invariantNames = new Set();
|
|
382
|
+
for (const invariant of runtime.invariants ?? []) {
|
|
383
|
+
assertString(invariant.name, 'invariant name');
|
|
384
|
+
if (invariantNames.has(invariant.name))
|
|
385
|
+
throw new TypeError('Duplicate Mesh reducer scenario invariant');
|
|
386
|
+
invariantNames.add(invariant.name);
|
|
387
|
+
}
|
|
388
|
+
assertCanonicalWithin(deepFreezeData(config), Number.MAX_SAFE_INTEGER, 'configuration');
|
|
389
|
+
}
|
|
390
|
+
function validateFaultTargets(fault, peerIds, eventsById, configuredLinks, maximumLogicalTime) {
|
|
391
|
+
if ('peerId' in fault && !peerIds.has(fault.peerId))
|
|
392
|
+
throw new TypeError('Mesh reducer scenario fault peer is unknown');
|
|
393
|
+
if ('deliveryEventId' in fault) {
|
|
394
|
+
const target = eventsById.get(fault.deliveryEventId);
|
|
395
|
+
if (target === undefined)
|
|
396
|
+
throw new TypeError('Mesh reducer scenario fault event is unknown');
|
|
397
|
+
if (target.sourcePeerId === undefined)
|
|
398
|
+
throw new TypeError('Mesh reducer scenario message fault target is not a delivery');
|
|
399
|
+
}
|
|
400
|
+
if ('copies' in fault &&
|
|
401
|
+
(!Number.isSafeInteger(fault.copies) ||
|
|
402
|
+
fault.copies < 1 ||
|
|
403
|
+
fault.copies > 16))
|
|
404
|
+
throw new RangeError('Mesh reducer scenario duplicate limit exceeded');
|
|
405
|
+
if (fault.kind === 'clock.offset' &&
|
|
406
|
+
(!Number.isSafeInteger(fault.offset) ||
|
|
407
|
+
Math.abs(fault.offset) > MESH_SIMULATION_FAULT_LIMITS.maximumClockOffset))
|
|
408
|
+
throw new RangeError('Mesh reducer scenario clock offset limit exceeded');
|
|
409
|
+
if ('delay' in fault &&
|
|
410
|
+
(!Number.isSafeInteger(fault.delay) ||
|
|
411
|
+
fault.delay < 1 ||
|
|
412
|
+
!Number.isSafeInteger(eventsById.get(fault.deliveryEventId).logicalTime + fault.delay) ||
|
|
413
|
+
eventsById.get(fault.deliveryEventId).logicalTime + fault.delay >
|
|
414
|
+
maximumLogicalTime))
|
|
415
|
+
throw new RangeError('Invalid Mesh reducer scenario delay');
|
|
416
|
+
if ('links' in fault) {
|
|
417
|
+
if (fault.links.length === 0)
|
|
418
|
+
throw new TypeError('Mesh reducer scenario partition is empty');
|
|
419
|
+
if (fault.links.length > MESH_SIMULATION_FAULT_LIMITS.maximumLinksPerFault)
|
|
420
|
+
throw new RangeError('Mesh reducer scenario partition link limit exceeded');
|
|
421
|
+
const links = new Set();
|
|
422
|
+
for (const link of fault.links)
|
|
423
|
+
if (!peerIds.has(link.fromPeerId) ||
|
|
424
|
+
!peerIds.has(link.toPeerId) ||
|
|
425
|
+
link.fromPeerId === link.toPeerId)
|
|
426
|
+
throw new TypeError('Mesh reducer scenario fault link is unknown');
|
|
427
|
+
else {
|
|
428
|
+
const key = linkKey(link.fromPeerId, link.toPeerId);
|
|
429
|
+
if (links.has(key))
|
|
430
|
+
throw new TypeError('Duplicate Mesh reducer scenario fault link');
|
|
431
|
+
if (!configuredLinks.has(key))
|
|
432
|
+
throw new TypeError('Mesh reducer scenario fault link is not configured');
|
|
433
|
+
links.add(key);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (fault.kind === 'message.reorder' &&
|
|
437
|
+
(!Number.isSafeInteger(fault.newLogicalTime) ||
|
|
438
|
+
!Number.isSafeInteger(fault.newPriority) ||
|
|
439
|
+
fault.newLogicalTime < fault.logicalTime ||
|
|
440
|
+
fault.newLogicalTime > maximumLogicalTime))
|
|
441
|
+
throw new RangeError('Invalid Mesh reducer scenario reorder');
|
|
442
|
+
}
|
|
443
|
+
function validateFaultShape(fault) {
|
|
444
|
+
assertPlainRecord(fault, 'fault');
|
|
445
|
+
const base = ['faultId', 'kind', 'logicalTime', 'priority'];
|
|
446
|
+
if (fault.kind === 'peer.crash' ||
|
|
447
|
+
fault.kind === 'peer.resume' ||
|
|
448
|
+
fault.kind === 'clock.offset') {
|
|
449
|
+
assertExactKeys(fault, [
|
|
450
|
+
...base,
|
|
451
|
+
'peerId',
|
|
452
|
+
...(fault.kind === 'clock.offset' ? ['offset'] : []),
|
|
453
|
+
]);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (fault.kind === 'message.drop') {
|
|
457
|
+
assertExactKeys(fault, [...base, 'deliveryEventId']);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (fault.kind === 'message.duplicate') {
|
|
461
|
+
assertExactKeys(fault, [...base, 'copies', 'deliveryEventId']);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (fault.kind === 'message.delay') {
|
|
465
|
+
assertExactKeys(fault, [...base, 'delay', 'deliveryEventId']);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (fault.kind === 'message.reorder') {
|
|
469
|
+
assertExactKeys(fault, [
|
|
470
|
+
...base,
|
|
471
|
+
'deliveryEventId',
|
|
472
|
+
'newLogicalTime',
|
|
473
|
+
'newPriority',
|
|
474
|
+
]);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (fault.kind === 'network.partition' || fault.kind === 'network.heal') {
|
|
478
|
+
assertExactKeys(fault, [...base, 'links']);
|
|
479
|
+
assertDenseArray(fault.links, 'fault links');
|
|
480
|
+
for (const link of fault.links) {
|
|
481
|
+
assertPlainRecord(link, 'fault link');
|
|
482
|
+
assertExactKeys(link, ['fromPeerId', 'toPeerId']);
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
throw new TypeError('Unsupported Mesh reducer scenario fault kind');
|
|
487
|
+
}
|
|
488
|
+
function applyFault(fault, queue, availability, clockOffsets, links, config, nextInsertionSequence) {
|
|
489
|
+
if (fault.kind === 'peer.crash') {
|
|
490
|
+
const changed = availability.get(fault.peerId) !== false;
|
|
491
|
+
availability.set(fault.peerId, false);
|
|
492
|
+
if (!changed)
|
|
493
|
+
return { applied: false, affectedEventIds: [] };
|
|
494
|
+
const affectedEventIds = [];
|
|
495
|
+
for (let index = queue.length - 1; index >= 0; index -= 1) {
|
|
496
|
+
const event = queue[index];
|
|
497
|
+
if (event.fault === undefined &&
|
|
498
|
+
event.sourcePeerId !== undefined &&
|
|
499
|
+
(event.scheduledAt ?? 0) <= fault.logicalTime &&
|
|
500
|
+
event.targetPeerId === fault.peerId) {
|
|
501
|
+
affectedEventIds.push(event.eventId);
|
|
502
|
+
queue.splice(index, 1);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
affectedEventIds.reverse();
|
|
506
|
+
return { applied: true, affectedEventIds };
|
|
507
|
+
}
|
|
508
|
+
if (fault.kind === 'peer.resume') {
|
|
509
|
+
const changed = availability.get(fault.peerId) !== true;
|
|
510
|
+
availability.set(fault.peerId, true);
|
|
511
|
+
return { applied: changed, affectedEventIds: [] };
|
|
512
|
+
}
|
|
513
|
+
if (fault.kind === 'clock.offset') {
|
|
514
|
+
const changed = clockOffsets.get(fault.peerId) !== fault.offset;
|
|
515
|
+
clockOffsets.set(fault.peerId, fault.offset);
|
|
516
|
+
return { applied: changed, affectedEventIds: [] };
|
|
517
|
+
}
|
|
518
|
+
if (fault.kind === 'network.partition' || fault.kind === 'network.heal') {
|
|
519
|
+
const enabled = fault.kind === 'network.heal';
|
|
520
|
+
const affectedEventIds = [];
|
|
521
|
+
const affectedLinkIds = [];
|
|
522
|
+
const requestedLinks = new Set(fault.links.map((target) => linkKey(target.fromPeerId, target.toPeerId)));
|
|
523
|
+
for (const target of fault.links) {
|
|
524
|
+
const key = linkKey(target.fromPeerId, target.toPeerId);
|
|
525
|
+
const link = links.get(key);
|
|
526
|
+
if (link !== undefined && link.enabled !== enabled) {
|
|
527
|
+
links.set(key, Object.freeze({ ...link, enabled }));
|
|
528
|
+
affectedLinkIds.push(key);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
let suppressedEvents = 0;
|
|
532
|
+
if (!enabled) {
|
|
533
|
+
const droppedEventIds = [];
|
|
534
|
+
for (let index = queue.length - 1; index >= 0; index -= 1) {
|
|
535
|
+
const event = queue[index];
|
|
536
|
+
if (event.fault === undefined &&
|
|
537
|
+
event.sourcePeerId !== undefined &&
|
|
538
|
+
(event.scheduledAt ?? 0) <= fault.logicalTime &&
|
|
539
|
+
requestedLinks.has(linkKey(event.sourcePeerId, event.targetPeerId))) {
|
|
540
|
+
droppedEventIds.push(event.eventId);
|
|
541
|
+
queue.splice(index, 1);
|
|
542
|
+
suppressedEvents += 1;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
affectedEventIds.push(...droppedEventIds.reverse());
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
applied: affectedEventIds.length > 0 || affectedLinkIds.length > 0,
|
|
549
|
+
affectedEventIds,
|
|
550
|
+
affectedLinkIds,
|
|
551
|
+
suppressedEvents,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (fault.kind !== 'message.drop' &&
|
|
555
|
+
fault.kind !== 'message.duplicate' &&
|
|
556
|
+
fault.kind !== 'message.delay' &&
|
|
557
|
+
fault.kind !== 'message.reorder')
|
|
558
|
+
throw new TypeError('Unsupported Mesh reducer scenario fault');
|
|
559
|
+
const index = queue.findIndex(({ eventId, fault: queuedFault, sourcePeerId, scheduledAt }) => queuedFault === undefined &&
|
|
560
|
+
sourcePeerId !== undefined &&
|
|
561
|
+
(scheduledAt ?? 0) <= fault.logicalTime &&
|
|
562
|
+
eventId === fault.deliveryEventId);
|
|
563
|
+
if (index < 0)
|
|
564
|
+
return { applied: false, affectedEventIds: [] };
|
|
565
|
+
const target = queue[index];
|
|
566
|
+
if (fault.kind === 'message.drop') {
|
|
567
|
+
queue.splice(index, 1);
|
|
568
|
+
return { applied: true, affectedEventIds: [target.eventId] };
|
|
569
|
+
}
|
|
570
|
+
if (fault.kind === 'message.duplicate') {
|
|
571
|
+
const affectedEventIds = [];
|
|
572
|
+
for (let copy = 1; copy <= fault.copies; copy += 1) {
|
|
573
|
+
if (queue.length >= config.limits.maximumQueuedEvents)
|
|
574
|
+
throw new RangeError('Mesh reducer scenario queue limit exceeded');
|
|
575
|
+
const eventId = `${target.eventId}:duplicate:${fault.faultId}:${copy}`;
|
|
576
|
+
assertString(eventId, 'duplicate eventId');
|
|
577
|
+
if (queue.some((event) => event.eventId === eventId))
|
|
578
|
+
throw new TypeError('Duplicate Mesh reducer scenario event');
|
|
579
|
+
queue.push(Object.freeze({
|
|
580
|
+
...target,
|
|
581
|
+
eventId,
|
|
582
|
+
insertionSequence: nextInsertionSequence(),
|
|
583
|
+
}));
|
|
584
|
+
affectedEventIds.push(eventId);
|
|
585
|
+
}
|
|
586
|
+
return { applied: true, affectedEventIds };
|
|
587
|
+
}
|
|
588
|
+
if (fault.kind === 'message.delay') {
|
|
589
|
+
const logicalTime = target.logicalTime + fault.delay;
|
|
590
|
+
if (logicalTime > config.limits.maximumLogicalTime)
|
|
591
|
+
throw new RangeError('Mesh reducer scenario delay exceeds time limit');
|
|
592
|
+
queue[index] = Object.freeze({ ...target, logicalTime });
|
|
593
|
+
return { applied: true, affectedEventIds: [target.eventId] };
|
|
594
|
+
}
|
|
595
|
+
if (fault.kind !== 'message.reorder')
|
|
596
|
+
throw new TypeError('Unsupported Mesh reducer scenario queue fault');
|
|
597
|
+
if (fault.newLogicalTime < fault.logicalTime ||
|
|
598
|
+
fault.newLogicalTime > config.limits.maximumLogicalTime)
|
|
599
|
+
throw new RangeError('Mesh reducer scenario reorder exceeds time limit');
|
|
600
|
+
queue[index] = Object.freeze({
|
|
601
|
+
...target,
|
|
602
|
+
logicalTime: fault.newLogicalTime,
|
|
603
|
+
priority: fault.newPriority,
|
|
604
|
+
});
|
|
605
|
+
return { applied: true, affectedEventIds: [target.eventId] };
|
|
606
|
+
}
|
|
607
|
+
function assertDecision(decision) {
|
|
608
|
+
if (!decision ||
|
|
609
|
+
typeof decision !== 'object' ||
|
|
610
|
+
typeof decision.accepted !== 'boolean' ||
|
|
611
|
+
(decision.rejectionCode !== undefined &&
|
|
612
|
+
typeof decision.rejectionCode !== 'string') ||
|
|
613
|
+
(decision.effects !== undefined && !Array.isArray(decision.effects)))
|
|
614
|
+
throw new TypeError('Invalid Mesh reducer scenario decision');
|
|
615
|
+
}
|
|
616
|
+
function compareQueued(left, right) {
|
|
617
|
+
return (left.logicalTime - right.logicalTime ||
|
|
618
|
+
left.priority - right.priority ||
|
|
619
|
+
left.insertionSequence - right.insertionSequence);
|
|
620
|
+
}
|
|
621
|
+
function faultTargetPeerId(fault, peers) {
|
|
622
|
+
const peerId = 'peerId' in fault
|
|
623
|
+
? fault.peerId
|
|
624
|
+
: 'links' in fault
|
|
625
|
+
? fault.links[0]?.fromPeerId
|
|
626
|
+
: peers[0]?.peerId;
|
|
627
|
+
if (peerId === undefined)
|
|
628
|
+
throw new TypeError('Mesh reducer scenario fault lacks a target');
|
|
629
|
+
return peerId;
|
|
630
|
+
}
|
|
631
|
+
function linkKey(fromPeerId, toPeerId) {
|
|
632
|
+
return JSON.stringify([fromPeerId, toPeerId]);
|
|
633
|
+
}
|
|
634
|
+
function xorshift32(input) {
|
|
635
|
+
let value = input || 0x6d2b79f5;
|
|
636
|
+
value ^= value << 13;
|
|
637
|
+
value ^= value >>> 17;
|
|
638
|
+
value ^= value << 5;
|
|
639
|
+
return value >>> 0;
|
|
640
|
+
}
|
|
641
|
+
function mixSeed(seed, scope) {
|
|
642
|
+
let value = seed ^ 0x811c9dc5;
|
|
643
|
+
for (let index = 0; index < scope.length; index += 1)
|
|
644
|
+
value = Math.imul(value ^ scope.charCodeAt(index), 0x01000193);
|
|
645
|
+
return value >>> 0 || 1;
|
|
646
|
+
}
|
|
647
|
+
function assertPlainRecord(value, name) {
|
|
648
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
649
|
+
throw new TypeError(`Mesh reducer scenario ${name} must be a record`);
|
|
650
|
+
const prototype = Object.getPrototypeOf(value);
|
|
651
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
652
|
+
if ((prototype !== Object.prototype && prototype !== null) ||
|
|
653
|
+
Object.getOwnPropertySymbols(value).length !== 0 ||
|
|
654
|
+
Object.values(descriptors).some((descriptor) => !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')))
|
|
655
|
+
throw new TypeError(`Mesh reducer scenario ${name} must contain plain data`);
|
|
656
|
+
}
|
|
657
|
+
function assertDenseArray(value, name) {
|
|
658
|
+
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype)
|
|
659
|
+
throw new TypeError(`Mesh reducer scenario ${name} must be an array`);
|
|
660
|
+
const names = Object.getOwnPropertyNames(value);
|
|
661
|
+
if (names.length !== value.length + 1)
|
|
662
|
+
throw new TypeError(`Mesh reducer scenario ${name} must be dense`);
|
|
663
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
664
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
665
|
+
if (descriptor === undefined ||
|
|
666
|
+
!descriptor.enumerable ||
|
|
667
|
+
!Object.hasOwn(descriptor, 'value'))
|
|
668
|
+
throw new TypeError(`Mesh reducer scenario ${name} must be dense`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function assertExactKeys(value, required, optional = []) {
|
|
672
|
+
const supported = new Set([...required, ...optional]);
|
|
673
|
+
const keys = Object.keys(value);
|
|
674
|
+
if (keys.some((key) => !supported.has(key)) ||
|
|
675
|
+
required.some((key) => !Object.hasOwn(value, key)))
|
|
676
|
+
throw new TypeError('Mesh reducer scenario value contains unsupported fields');
|
|
677
|
+
}
|
|
678
|
+
function assertString(value, name) {
|
|
679
|
+
if (typeof value !== 'string' ||
|
|
680
|
+
value.length === 0 ||
|
|
681
|
+
new TextEncoder().encode(value).byteLength > 768)
|
|
682
|
+
throw new TypeError(`Invalid Mesh reducer scenario ${name}`);
|
|
683
|
+
}
|
|
684
|
+
function assertCanonicalWithin(value, maximumBytes, name) {
|
|
685
|
+
const canonical = canonicalizeMeshJsonBytes(value, {
|
|
686
|
+
limits: reducerScenarioCanonicalLimits,
|
|
687
|
+
});
|
|
688
|
+
if (!canonical.ok)
|
|
689
|
+
throw new TypeError(`Mesh reducer scenario ${name} is not canonical`);
|
|
690
|
+
if (canonical.value.byteLength > maximumBytes)
|
|
691
|
+
throw new RangeError(`Mesh reducer scenario ${name} exceeds its byte limit`);
|
|
692
|
+
}
|
|
693
|
+
function deepFreezeData(value, context = {
|
|
694
|
+
active: new WeakSet(),
|
|
695
|
+
copies: new WeakMap(),
|
|
696
|
+
nodes: 0,
|
|
697
|
+
totalArrayItems: 0,
|
|
698
|
+
totalObjectKeys: 0,
|
|
699
|
+
}, depth = 0) {
|
|
700
|
+
if (value === null ||
|
|
701
|
+
typeof value === 'string' ||
|
|
702
|
+
typeof value === 'boolean' ||
|
|
703
|
+
(typeof value === 'number' && Number.isFinite(value)))
|
|
704
|
+
return value;
|
|
705
|
+
if (typeof value !== 'object')
|
|
706
|
+
throw new TypeError('Mesh reducer scenario values must contain data only');
|
|
707
|
+
if (depth > reducerScenarioCanonicalLimits.maximumNestingDepth ||
|
|
708
|
+
context.nodes >=
|
|
709
|
+
reducerScenarioCanonicalLimits.maximumTotalArrayItems +
|
|
710
|
+
reducerScenarioCanonicalLimits.maximumTotalObjectKeys ||
|
|
711
|
+
context.active.has(value))
|
|
712
|
+
throw new RangeError('Mesh reducer scenario value exceeds data limits');
|
|
713
|
+
const prior = context.copies.get(value);
|
|
714
|
+
if (prior !== undefined)
|
|
715
|
+
return prior;
|
|
716
|
+
context.active.add(value);
|
|
717
|
+
context.nodes += 1;
|
|
718
|
+
if (Array.isArray(value)) {
|
|
719
|
+
if (value.length > reducerScenarioCanonicalLimits.maximumArrayItems ||
|
|
720
|
+
context.totalArrayItems + value.length >
|
|
721
|
+
reducerScenarioCanonicalLimits.maximumTotalArrayItems)
|
|
722
|
+
throw new RangeError('Mesh reducer scenario array exceeds data limits');
|
|
723
|
+
context.totalArrayItems += value.length;
|
|
724
|
+
assertDenseArray(value, 'value array');
|
|
725
|
+
const result = [];
|
|
726
|
+
context.copies.set(value, result);
|
|
727
|
+
for (const entry of value) {
|
|
728
|
+
if (entry === undefined)
|
|
729
|
+
throw new TypeError('Mesh reducer scenario arrays cannot contain undefined');
|
|
730
|
+
result.push(deepFreezeData(entry, context, depth + 1));
|
|
731
|
+
}
|
|
732
|
+
context.active.delete(value);
|
|
733
|
+
const frozen = Object.freeze(result);
|
|
734
|
+
context.copies.set(value, frozen);
|
|
735
|
+
return frozen;
|
|
736
|
+
}
|
|
737
|
+
const propertyNames = Object.getOwnPropertyNames(value);
|
|
738
|
+
if (propertyNames.length > reducerScenarioCanonicalLimits.maximumObjectKeys ||
|
|
739
|
+
context.totalObjectKeys + propertyNames.length >
|
|
740
|
+
reducerScenarioCanonicalLimits.maximumTotalObjectKeys)
|
|
741
|
+
throw new RangeError('Mesh reducer scenario object exceeds data limits');
|
|
742
|
+
context.totalObjectKeys += propertyNames.length;
|
|
743
|
+
assertPlainRecord(value, 'value');
|
|
744
|
+
const result = Object.create(null);
|
|
745
|
+
context.copies.set(value, result);
|
|
746
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
747
|
+
for (const [key, descriptor] of Object.entries(descriptors)) {
|
|
748
|
+
const entry = descriptor.value;
|
|
749
|
+
if (entry !== undefined)
|
|
750
|
+
result[key] = deepFreezeData(entry, context, depth + 1);
|
|
751
|
+
}
|
|
752
|
+
context.active.delete(value);
|
|
753
|
+
const frozen = Object.freeze(result);
|
|
754
|
+
context.copies.set(value, frozen);
|
|
755
|
+
return frozen;
|
|
756
|
+
}
|
|
757
|
+
async function digest(value) {
|
|
758
|
+
const canonical = canonicalizeMeshJsonBytes(value, {
|
|
759
|
+
limits: reducerScenarioCanonicalLimits,
|
|
760
|
+
});
|
|
761
|
+
if (!canonical.ok)
|
|
762
|
+
throw new TypeError(`Mesh reducer scenario value is not canonical: ${JSON.stringify(canonical.issues)}`);
|
|
763
|
+
const bytes = canonical.value;
|
|
764
|
+
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
765
|
+
const result = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', source));
|
|
766
|
+
return [...result].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
767
|
+
}
|
|
768
|
+
function frozenRecord(entries) {
|
|
769
|
+
const record = Object.create(null);
|
|
770
|
+
for (const [key, value] of entries)
|
|
771
|
+
record[key] = value;
|
|
772
|
+
return Object.freeze(record);
|
|
773
|
+
}
|
|
774
|
+
//# sourceMappingURL=reducer-scenario.js.map
|