@optimystic/db-core 0.24.2 → 0.25.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/src/blocks/helpers.d.ts +5 -0
- package/dist/src/blocks/helpers.d.ts.map +1 -1
- package/dist/src/blocks/helpers.js +12 -0
- package/dist/src/blocks/helpers.js.map +1 -1
- package/dist/src/cluster/membership.d.ts +7 -0
- package/dist/src/cluster/membership.d.ts.map +1 -1
- package/dist/src/cluster/membership.js +12 -8
- package/dist/src/cluster/membership.js.map +1 -1
- package/dist/src/cluster/structs.d.ts +27 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/collection/action.d.ts +14 -0
- package/dist/src/collection/action.d.ts.map +1 -1
- package/dist/src/collection/action.js +16 -1
- package/dist/src/collection/action.js.map +1 -1
- package/dist/src/collection/collection.d.ts +273 -4
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +427 -30
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +16 -1
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +19 -0
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/network/i-repo.d.ts +11 -1
- package/dist/src/network/i-repo.d.ts.map +1 -1
- package/dist/src/network/stale-failure.d.ts +21 -0
- package/dist/src/network/stale-failure.d.ts.map +1 -1
- package/dist/src/network/stale-failure.js +22 -0
- package/dist/src/network/stale-failure.js.map +1 -1
- package/dist/src/network/struct.d.ts +66 -21
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts +22 -0
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +44 -5
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +20 -0
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +256 -113
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/operations-hash.d.ts +1 -1
- package/dist/src/transaction/operations-hash.js +1 -1
- package/dist/src/transaction/transaction.d.ts +4 -2
- package/dist/src/transaction/transaction.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +21 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +122 -29
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +11 -5
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +16 -8
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transform/cache-source.d.ts +13 -0
- package/dist/src/transform/cache-source.d.ts.map +1 -1
- package/dist/src/transform/cache-source.js +18 -0
- package/dist/src/transform/cache-source.js.map +1 -1
- package/dist/src/transform/digest.d.ts +18 -0
- package/dist/src/transform/digest.d.ts.map +1 -0
- package/dist/src/transform/digest.js +65 -0
- package/dist/src/transform/digest.js.map +1 -0
- package/dist/src/transform/index.d.ts +1 -0
- package/dist/src/transform/index.d.ts.map +1 -1
- package/dist/src/transform/index.js +1 -0
- package/dist/src/transform/index.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +16 -0
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +40 -1
- package/dist/src/transform/tracker.js.map +1 -1
- package/dist/src/utility/canonical-json.d.ts +11 -0
- package/dist/src/utility/canonical-json.d.ts.map +1 -0
- package/dist/src/utility/canonical-json.js +15 -0
- package/dist/src/utility/canonical-json.js.map +1 -0
- package/dist/src/utility/lru-map.d.ts +2 -0
- package/dist/src/utility/lru-map.d.ts.map +1 -1
- package/dist/src/utility/lru-map.js +4 -0
- package/dist/src/utility/lru-map.js.map +1 -1
- package/package.json +2 -1
- package/src/blocks/helpers.ts +26 -13
- package/src/cluster/membership.ts +87 -85
- package/src/cluster/structs.ts +28 -1
- package/src/collection/action.ts +17 -0
- package/src/collection/collection.ts +1129 -688
- package/src/collections/tree/tree.ts +341 -320
- package/src/index.ts +24 -23
- package/src/network/i-repo.ts +59 -46
- package/src/network/stale-failure.ts +67 -43
- package/src/network/struct.ts +332 -270
- package/src/testing/test-transactor.ts +680 -638
- package/src/transaction/coordinator.ts +1266 -1110
- package/src/transaction/operations-hash.ts +1 -1
- package/src/transaction/transaction.ts +4 -2
- package/src/transactor/network-transactor.ts +123 -32
- package/src/transactor/transactor-source.ts +17 -9
- package/src/transform/cache-source.ts +20 -0
- package/src/transform/digest.ts +72 -0
- package/src/transform/index.ts +1 -0
- package/src/transform/tracker.ts +42 -1
- package/src/utility/canonical-json.ts +16 -0
- package/src/utility/lru-map.ts +5 -0
|
@@ -1,638 +1,680 @@
|
|
|
1
|
-
import type { ITransactor, GetBlockResults, ActionBlocks, BlockActionStatus, PendResult, CommitResult, PendRequest, BlockId, CommitRequest, BlockGets, IBlock, ActionId, ActionTransforms, Transform, Transforms, ClusterNomineesResult, CollectionId } from "../index.js";
|
|
2
|
-
import { ensuredMap } from "../utility/ensured.js";
|
|
3
|
-
import { Latches } from "../utility/latches.js";
|
|
4
|
-
import { applyTransform, blockIdsForTransforms, transformForBlockId, emptyTransforms, concatTransform, transformsFromTransform } from "../transform/index.js";
|
|
5
|
-
import { Tree } from "../collections/tree/tree.js";
|
|
6
|
-
import type { TreeReplaceAction } from "../collections/tree/struct.js";
|
|
7
|
-
|
|
8
|
-
type RevisionNumber = number;
|
|
9
|
-
|
|
10
|
-
type BlockState = {
|
|
11
|
-
/** The current materialized block at each revision */
|
|
12
|
-
materializedBlocks: Map<RevisionNumber, IBlock>;
|
|
13
|
-
/** The latest revision number */
|
|
14
|
-
latestRev: RevisionNumber;
|
|
15
|
-
/** The action that created each revision */
|
|
16
|
-
revisionActions: Map<RevisionNumber, ActionId>;
|
|
17
|
-
/** Currently pending actions */
|
|
18
|
-
pendingActions: Map<ActionId, Transform>;
|
|
19
|
-
/** Committed actions */
|
|
20
|
-
committedActions: Map<ActionId, Transform>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Simple in-memory transactor for testing that maintains materialized blocks for every revision
|
|
24
|
-
export class TestTransactor implements ITransactor {
|
|
25
|
-
private blocks = new Map<BlockId, BlockState>();
|
|
26
|
-
available = true;
|
|
27
|
-
private getLocks = new Map<BlockId, Promise<() => void>>(); // Track lock releases
|
|
28
|
-
|
|
29
|
-
constructor() {}
|
|
30
|
-
|
|
31
|
-
async get(blockGets: BlockGets): Promise<GetBlockResults> {
|
|
32
|
-
this.checkAvailable();
|
|
33
|
-
const results: GetBlockResults = {};
|
|
34
|
-
const uniqueBlockIds = [...new Set(blockGets.blockIds)].sort(); // Ensure consistent lock order if needed, though get is read-only
|
|
35
|
-
const releases: (() => void)[] = [];
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
// Acquire locks for all requested blocks to ensure consistent read
|
|
39
|
-
for (const blockId of uniqueBlockIds) {
|
|
40
|
-
const lockId = `TestTransactor.commit:${blockId}`; // Use the same lock as commit
|
|
41
|
-
// Wait for any existing lock promise to resolve before acquiring the next
|
|
42
|
-
let release = await this.getLocks.get(lockId);
|
|
43
|
-
if (release) await Promise.resolve(release); // Ensure previous lock released if overlapping calls happen
|
|
44
|
-
|
|
45
|
-
const releasePromise = Latches.acquire(lockId);
|
|
46
|
-
this.getLocks.set(blockId, releasePromise.then(r => () => {
|
|
47
|
-
r();
|
|
48
|
-
this.getLocks.delete(blockId); // Clean up map entry after release
|
|
49
|
-
}));
|
|
50
|
-
release = await releasePromise;
|
|
51
|
-
releases.push(release);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// --- Start of Critical Section (Read) ---
|
|
55
|
-
for (const blockId of blockGets.blockIds) {
|
|
56
|
-
const blockState = this.blocks.get(blockId);
|
|
57
|
-
if (!blockState) {
|
|
58
|
-
// Block doesn't exist yet
|
|
59
|
-
results[blockId] = {
|
|
60
|
-
block: undefined,
|
|
61
|
-
state: { latest: undefined, pendings: [] }
|
|
62
|
-
};
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Get the appropriate materialized block based on context
|
|
67
|
-
let block: IBlock | undefined;
|
|
68
|
-
// The revision `block` was actually materialized at, reported as
|
|
69
|
-
// GetBlockResult.
|
|
70
|
-
// revision-pinned read of a block committed further since the pin makes them differ,
|
|
71
|
-
// and there the pinned value is what the reader observed (see the field's doc).
|
|
72
|
-
let materializedRev: number | undefined;
|
|
73
|
-
if (blockGets.context?.actionId !== undefined) {
|
|
74
|
-
// If requesting a specific action, apply pending transform if it exists
|
|
75
|
-
const pendingTransform = blockState.pendingActions.get(blockGets.context.actionId);
|
|
76
|
-
if (pendingTransform) {
|
|
77
|
-
// Read latest committed block as base for pending transform
|
|
78
|
-
const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
|
|
79
|
-
block = applyTransformSafe(baseBlock, pendingTransform);
|
|
80
|
-
// A pending carries no revision of its own — report the committed base it was
|
|
81
|
-
// applied over. Absent when there was no base (a pending-only insert).
|
|
82
|
-
if (baseBlock) materializedRev = blockState.latestRev;
|
|
83
|
-
} else {
|
|
84
|
-
// Action not pending, maybe committed? Or maybe invalid actionId for context.
|
|
85
|
-
// For simplicity, return undefined block if specific pending action not found.
|
|
86
|
-
// A more complex impl might check committedActions history.
|
|
87
|
-
block = undefined;
|
|
88
|
-
}
|
|
89
|
-
} else if (blockGets.context?.committed) {
|
|
90
|
-
// Check context.committed for matching pending actions — mirrors coordinator
|
|
91
|
-
// behavior: context.committed proves the action succeeded, so pending blocks
|
|
92
|
-
// for that action should be served.
|
|
93
|
-
for (const { actionId: cId } of blockGets.context.committed) {
|
|
94
|
-
const pendingTransform = blockState.pendingActions.get(cId);
|
|
95
|
-
if (pendingTransform) {
|
|
96
|
-
const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
|
|
97
|
-
block = applyTransformSafe(baseBlock, pendingTransform);
|
|
98
|
-
if (baseBlock) materializedRev = blockState.latestRev;
|
|
99
|
-
break;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
// Fall through to standard resolution if no pending match
|
|
103
|
-
if (block === undefined) {
|
|
104
|
-
if (blockGets.context.rev !== undefined) {
|
|
105
|
-
const found = latestMaterializedAt(blockState, blockGets.context.rev);
|
|
106
|
-
block = structuredClone(found?.block);
|
|
107
|
-
materializedRev = found?.rev;
|
|
108
|
-
} else {
|
|
109
|
-
block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
|
|
110
|
-
if (block) materializedRev = blockState.latestRev;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
} else if (blockGets.context?.rev !== undefined) {
|
|
114
|
-
// Return the materialized block at the highest revision ≤ requested
|
|
115
|
-
const found = latestMaterializedAt(blockState, blockGets.context.rev);
|
|
116
|
-
block = structuredClone(found?.block);
|
|
117
|
-
materializedRev = found?.rev;
|
|
118
|
-
} else {
|
|
119
|
-
// Otherwise return latest materialized block
|
|
120
|
-
block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
|
|
121
|
-
if (block) materializedRev = blockState.latestRev;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const actionId = blockState.revisionActions.get(blockState.latestRev);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
} :
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
existing
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
/**
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
)
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
* `
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
* the
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
1
|
+
import type { ITransactor, GetBlockResults, ActionBlocks, BlockActionStatus, PendResult, CommitResult, PendRequest, BlockId, CommitRequest, BlockGets, IBlock, ActionId, ActionTransforms, Transform, Transforms, ClusterNomineesResult, CollectionId } from "../index.js";
|
|
2
|
+
import { ensuredMap } from "../utility/ensured.js";
|
|
3
|
+
import { Latches } from "../utility/latches.js";
|
|
4
|
+
import { applyTransform, blockIdsForTransforms, transformForBlockId, emptyTransforms, concatTransform, transformsFromTransform } from "../transform/index.js";
|
|
5
|
+
import { Tree } from "../collections/tree/tree.js";
|
|
6
|
+
import type { TreeReplaceAction } from "../collections/tree/struct.js";
|
|
7
|
+
|
|
8
|
+
type RevisionNumber = number;
|
|
9
|
+
|
|
10
|
+
type BlockState = {
|
|
11
|
+
/** The current materialized block at each revision */
|
|
12
|
+
materializedBlocks: Map<RevisionNumber, IBlock>;
|
|
13
|
+
/** The latest revision number */
|
|
14
|
+
latestRev: RevisionNumber;
|
|
15
|
+
/** The action that created each revision */
|
|
16
|
+
revisionActions: Map<RevisionNumber, ActionId>;
|
|
17
|
+
/** Currently pending actions */
|
|
18
|
+
pendingActions: Map<ActionId, Transform>;
|
|
19
|
+
/** Committed actions */
|
|
20
|
+
committedActions: Map<ActionId, Transform>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Simple in-memory transactor for testing that maintains materialized blocks for every revision
|
|
24
|
+
export class TestTransactor implements ITransactor {
|
|
25
|
+
private blocks = new Map<BlockId, BlockState>();
|
|
26
|
+
available = true;
|
|
27
|
+
private getLocks = new Map<BlockId, Promise<() => void>>(); // Track lock releases
|
|
28
|
+
|
|
29
|
+
constructor() {}
|
|
30
|
+
|
|
31
|
+
async get(blockGets: BlockGets): Promise<GetBlockResults> {
|
|
32
|
+
this.checkAvailable();
|
|
33
|
+
const results: GetBlockResults = {};
|
|
34
|
+
const uniqueBlockIds = [...new Set(blockGets.blockIds)].sort(); // Ensure consistent lock order if needed, though get is read-only
|
|
35
|
+
const releases: (() => void)[] = [];
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// Acquire locks for all requested blocks to ensure consistent read
|
|
39
|
+
for (const blockId of uniqueBlockIds) {
|
|
40
|
+
const lockId = `TestTransactor.commit:${blockId}`; // Use the same lock as commit
|
|
41
|
+
// Wait for any existing lock promise to resolve before acquiring the next
|
|
42
|
+
let release = await this.getLocks.get(lockId);
|
|
43
|
+
if (release) await Promise.resolve(release); // Ensure previous lock released if overlapping calls happen
|
|
44
|
+
|
|
45
|
+
const releasePromise = Latches.acquire(lockId);
|
|
46
|
+
this.getLocks.set(blockId, releasePromise.then(r => () => {
|
|
47
|
+
r();
|
|
48
|
+
this.getLocks.delete(blockId); // Clean up map entry after release
|
|
49
|
+
}));
|
|
50
|
+
release = await releasePromise;
|
|
51
|
+
releases.push(release);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- Start of Critical Section (Read) ---
|
|
55
|
+
for (const blockId of blockGets.blockIds) {
|
|
56
|
+
const blockState = this.blocks.get(blockId);
|
|
57
|
+
if (!blockState) {
|
|
58
|
+
// Block doesn't exist yet
|
|
59
|
+
results[blockId] = {
|
|
60
|
+
block: undefined,
|
|
61
|
+
state: { latest: undefined, pendings: [] }
|
|
62
|
+
};
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Get the appropriate materialized block based on context
|
|
67
|
+
let block: IBlock | undefined;
|
|
68
|
+
// The revision `block` was actually materialized at, reported (with its action id) as
|
|
69
|
+
// GetBlockResult.materialized. Equals `latestRev` on every unpinned path; only a
|
|
70
|
+
// revision-pinned read of a block committed further since the pin makes them differ,
|
|
71
|
+
// and there the pinned value is what the reader observed (see the field's doc).
|
|
72
|
+
let materializedRev: number | undefined;
|
|
73
|
+
if (blockGets.context?.actionId !== undefined) {
|
|
74
|
+
// If requesting a specific action, apply pending transform if it exists
|
|
75
|
+
const pendingTransform = blockState.pendingActions.get(blockGets.context.actionId);
|
|
76
|
+
if (pendingTransform) {
|
|
77
|
+
// Read latest committed block as base for pending transform
|
|
78
|
+
const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
|
|
79
|
+
block = applyTransformSafe(baseBlock, pendingTransform);
|
|
80
|
+
// A pending carries no revision of its own — report the committed base it was
|
|
81
|
+
// applied over. Absent when there was no base (a pending-only insert).
|
|
82
|
+
if (baseBlock) materializedRev = blockState.latestRev;
|
|
83
|
+
} else {
|
|
84
|
+
// Action not pending, maybe committed? Or maybe invalid actionId for context.
|
|
85
|
+
// For simplicity, return undefined block if specific pending action not found.
|
|
86
|
+
// A more complex impl might check committedActions history.
|
|
87
|
+
block = undefined;
|
|
88
|
+
}
|
|
89
|
+
} else if (blockGets.context?.committed) {
|
|
90
|
+
// Check context.committed for matching pending actions — mirrors coordinator
|
|
91
|
+
// behavior: context.committed proves the action succeeded, so pending blocks
|
|
92
|
+
// for that action should be served.
|
|
93
|
+
for (const { actionId: cId } of blockGets.context.committed) {
|
|
94
|
+
const pendingTransform = blockState.pendingActions.get(cId);
|
|
95
|
+
if (pendingTransform) {
|
|
96
|
+
const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
|
|
97
|
+
block = applyTransformSafe(baseBlock, pendingTransform);
|
|
98
|
+
if (baseBlock) materializedRev = blockState.latestRev;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Fall through to standard resolution if no pending match
|
|
103
|
+
if (block === undefined) {
|
|
104
|
+
if (blockGets.context.rev !== undefined) {
|
|
105
|
+
const found = latestMaterializedAt(blockState, blockGets.context.rev);
|
|
106
|
+
block = structuredClone(found?.block);
|
|
107
|
+
materializedRev = found?.rev;
|
|
108
|
+
} else {
|
|
109
|
+
block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
|
|
110
|
+
if (block) materializedRev = blockState.latestRev;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} else if (blockGets.context?.rev !== undefined) {
|
|
114
|
+
// Return the materialized block at the highest revision ≤ requested
|
|
115
|
+
const found = latestMaterializedAt(blockState, blockGets.context.rev);
|
|
116
|
+
block = structuredClone(found?.block);
|
|
117
|
+
materializedRev = found?.rev;
|
|
118
|
+
} else {
|
|
119
|
+
// Otherwise return latest materialized block
|
|
120
|
+
block = structuredClone(blockState.materializedBlocks.get(blockState.latestRev));
|
|
121
|
+
if (block) materializedRev = blockState.latestRev;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
const actionId = blockState.revisionActions.get(blockState.latestRev);
|
|
126
|
+
// The materialized revision travels WITH its action id (one field, so the pair can
|
|
127
|
+
// never disagree) — the same `(rev, actionId)` the commit recorded for it.
|
|
128
|
+
const materializedActionId = materializedRev !== undefined
|
|
129
|
+
? blockState.revisionActions.get(materializedRev) : undefined;
|
|
130
|
+
results[blockId] = {
|
|
131
|
+
block,
|
|
132
|
+
...(materializedRev !== undefined && materializedActionId !== undefined
|
|
133
|
+
? { materialized: { rev: materializedRev, actionId: materializedActionId } } : {}),
|
|
134
|
+
state: {
|
|
135
|
+
latest: actionId !== undefined ? {
|
|
136
|
+
rev: blockState.latestRev,
|
|
137
|
+
actionId
|
|
138
|
+
} : undefined,
|
|
139
|
+
pendings: Array.from(blockState.pendingActions.keys())
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// --- End of Critical Section (Read) ---
|
|
144
|
+
|
|
145
|
+
} finally {
|
|
146
|
+
// Release locks in reverse order
|
|
147
|
+
releases.reverse().forEach(release => release());
|
|
148
|
+
}
|
|
149
|
+
return results;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async getStatus(actionRefs: ActionBlocks[]): Promise<BlockActionStatus[]> {
|
|
153
|
+
return actionRefs.map(ref => ({
|
|
154
|
+
...ref,
|
|
155
|
+
statuses: ref.blockIds.map(blockId => {
|
|
156
|
+
const blockState = this.blocks.get(blockId);
|
|
157
|
+
if (!blockState) return 'aborted';
|
|
158
|
+
return blockState.pendingActions.has(ref.actionId) ? 'pending'
|
|
159
|
+
: Array.from(blockState.revisionActions.values()).some(actionId => actionId === ref.actionId) ? 'committed'
|
|
160
|
+
: 'aborted';
|
|
161
|
+
})
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async pend(request: PendRequest): Promise<PendResult> {
|
|
166
|
+
this.checkAvailable();
|
|
167
|
+
const { actionId, transforms, policy, rev } = request;
|
|
168
|
+
const blockIds = blockIdsForTransforms(transforms);
|
|
169
|
+
const conflictingPendings: { blockId: BlockId, actionId: ActionId }[] = [];
|
|
170
|
+
const missing: ActionTransforms[] = [];
|
|
171
|
+
|
|
172
|
+
// Check for conflicts (pending or committed based on rev/insert)
|
|
173
|
+
for (const blockId of blockIds) {
|
|
174
|
+
const blockState = this.blocks.get(blockId);
|
|
175
|
+
const blockTransform = transformForBlockId(transforms, blockId);
|
|
176
|
+
if (!blockTransform) continue; // Should not happen
|
|
177
|
+
|
|
178
|
+
if (blockState) {
|
|
179
|
+
// Check for existing pending actions
|
|
180
|
+
if (blockState.pendingActions.size > 0) {
|
|
181
|
+
blockState.pendingActions.forEach((_, pendingActionId) => {
|
|
182
|
+
conflictingPendings.push({ blockId, actionId: pendingActionId });
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check for conflicting committed revisions (if rev specified or it's an insert)
|
|
187
|
+
if (rev !== undefined || blockTransform.insert) {
|
|
188
|
+
const checkRev = rev ?? 0; // Check from revision 0 if it's an insert
|
|
189
|
+
if (blockState.latestRev >= checkRev) {
|
|
190
|
+
// Collect conflicting committed actions
|
|
191
|
+
const missingForBlock = new Map<ActionId, { rev: number, transform: Transform }>();
|
|
192
|
+
for (let r = checkRev as number; r <= blockState.latestRev; r++) {
|
|
193
|
+
const committedActionId = blockState.revisionActions.get(r);
|
|
194
|
+
if (committedActionId !== undefined) {
|
|
195
|
+
const committedTransform = blockState.committedActions.get(committedActionId);
|
|
196
|
+
if (committedTransform) {
|
|
197
|
+
missingForBlock.set(committedActionId, { rev: r, transform: committedTransform });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Add collected missing transforms for this block to the main missing list
|
|
203
|
+
for (const [mActionId, data] of missingForBlock.entries()) {
|
|
204
|
+
let existing = missing.find(m => m.actionId === mActionId);
|
|
205
|
+
if (!existing) {
|
|
206
|
+
existing = { actionId: mActionId, rev: data.rev, transforms: emptyTransforms() };
|
|
207
|
+
missing.push(existing);
|
|
208
|
+
}
|
|
209
|
+
existing.rev = Math.max(existing.rev ?? 0, data.rev);
|
|
210
|
+
existing.transforms = concatTransform(existing.transforms, blockId, data.transform);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Handle failure due to committed conflicts first.
|
|
218
|
+
// `conflict: true` on the three optimistic-concurrency returns below mirrors what
|
|
219
|
+
// StorageRepo.pend now emits, so consumers of this test transactor see the real shape.
|
|
220
|
+
if (missing.length > 0) {
|
|
221
|
+
return {
|
|
222
|
+
success: false,
|
|
223
|
+
conflict: true,
|
|
224
|
+
missing
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Handle failure/retry due to pending conflicts
|
|
229
|
+
if (conflictingPendings.length > 0) {
|
|
230
|
+
if (policy === 'f') {
|
|
231
|
+
return { success: false, conflict: true, pending: conflictingPendings };
|
|
232
|
+
} else if (policy === 'r') {
|
|
233
|
+
// Simulate fetching pending transforms for 'r' policy
|
|
234
|
+
const pendingWithTransforms = conflictingPendings
|
|
235
|
+
.map(({ blockId: pBlockId, actionId: pActionId }) => {
|
|
236
|
+
const pBlockState = this.blocks.get(pBlockId);
|
|
237
|
+
const pTransform = pBlockState?.pendingActions.get(pActionId)
|
|
238
|
+
?? pBlockState?.committedActions.get(pActionId); // Might have been committed since check
|
|
239
|
+
if (pTransform) {
|
|
240
|
+
return { blockId: pBlockId, actionId: pActionId, transform: pTransform };
|
|
241
|
+
}
|
|
242
|
+
return null; // Handle case where it disappeared (cancelled?)
|
|
243
|
+
})
|
|
244
|
+
.filter(p => p !== null) as { blockId: BlockId, actionId: ActionId, transform: Transform }[];
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
success: false,
|
|
248
|
+
conflict: true,
|
|
249
|
+
pending: pendingWithTransforms
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
// Policy 'w' allows proceeding despite pending transactions
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// No fatal conflicts found, proceed to pend
|
|
256
|
+
for (const blockId of blockIds) {
|
|
257
|
+
const blockTransform = transformForBlockId(transforms, blockId);
|
|
258
|
+
if (blockTransform) {
|
|
259
|
+
const blockState = ensuredMap(this.blocks, blockId, () => newBlockState());
|
|
260
|
+
blockState.pendingActions.set(actionId, blockTransform);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Return success, include pending list as per StorageRepo behavior
|
|
265
|
+
return {
|
|
266
|
+
success: true,
|
|
267
|
+
pending: conflictingPendings,
|
|
268
|
+
blockIds
|
|
269
|
+
} as PendResult;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async cancel(actionRef: ActionBlocks): Promise<void> {
|
|
273
|
+
this.checkAvailable();
|
|
274
|
+
for (const blockId of actionRef.blockIds) {
|
|
275
|
+
const blockState = this.blocks.get(blockId);
|
|
276
|
+
if (blockState) {
|
|
277
|
+
blockState.pendingActions.delete(actionRef.actionId);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async commit(request: CommitRequest): Promise<CommitResult> {
|
|
283
|
+
this.checkAvailable();
|
|
284
|
+
const { actionId, rev, blockIds } = request;
|
|
285
|
+
const uniqueBlockIds = [...new Set(blockIds)].sort();
|
|
286
|
+
const releases: (() => void)[] = [];
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
// Simulate acquiring locks sequentially like StorageRepo
|
|
290
|
+
for (const id of uniqueBlockIds) {
|
|
291
|
+
const lockId = `TestTransactor.commit:${id}`;
|
|
292
|
+
const release = await Latches.acquire(lockId);
|
|
293
|
+
releases.push(release);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// --- Start of Critical Section (Simulated) ---
|
|
297
|
+
|
|
298
|
+
// Check for stale revisions
|
|
299
|
+
const staleBlocks = blockIds.filter(blockId => {
|
|
300
|
+
const blockState = this.blocks.get(blockId);
|
|
301
|
+
return blockState && blockState.latestRev >= rev;
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
if (staleBlocks.length > 0) {
|
|
305
|
+
// Collect missing actions for stale blocks
|
|
306
|
+
const missingByAction = new Map<ActionId, Transforms>();
|
|
307
|
+
for (const blockId of staleBlocks) {
|
|
308
|
+
const blockState = this.blocks.get(blockId)!;
|
|
309
|
+
for (let r = rev; r <= blockState.latestRev; r++) {
|
|
310
|
+
const committedActionId = blockState.revisionActions.get(r);
|
|
311
|
+
if (committedActionId) {
|
|
312
|
+
const transform = blockState.committedActions.get(committedActionId);
|
|
313
|
+
if (transform) {
|
|
314
|
+
const existing = missingByAction.get(committedActionId) ?? emptyTransforms();
|
|
315
|
+
missingByAction.set(committedActionId, concatTransform(existing, blockId, transform));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const missing: ActionTransforms[] = Array.from(missingByAction.entries()).map(([actionId, transforms]) => ({
|
|
322
|
+
actionId,
|
|
323
|
+
rev: Array.from(this.blocks.values())
|
|
324
|
+
.flatMap(bs => Array.from(bs.revisionActions.entries()))
|
|
325
|
+
.find(([, aId]) => aId === actionId)?.[0] ?? rev,
|
|
326
|
+
transforms
|
|
327
|
+
}));
|
|
328
|
+
return { success: false, missing };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Verify all blocks have the pending action
|
|
332
|
+
for (const blockId of blockIds) {
|
|
333
|
+
const blockState = this.blocks.get(blockId);
|
|
334
|
+
if (!blockState || !blockState.pendingActions.has(actionId)) {
|
|
335
|
+
return {
|
|
336
|
+
success: false,
|
|
337
|
+
reason: `Action ${actionId} not found or not pending for block ${blockId}`
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Commit the action for each block
|
|
343
|
+
for (const blockId of blockIds) {
|
|
344
|
+
const blockState = this.blocks.get(blockId)!;
|
|
345
|
+
const transform = blockState.pendingActions.get(actionId)!;
|
|
346
|
+
|
|
347
|
+
// Get base block to apply transform to
|
|
348
|
+
const baseBlock = blockState.materializedBlocks.get(blockState.latestRev);
|
|
349
|
+
|
|
350
|
+
let newBlock: IBlock | undefined;
|
|
351
|
+
if (!baseBlock) {
|
|
352
|
+
if (!transform.insert) {
|
|
353
|
+
throw new Error(`Commit Error: Action ${actionId} has no insert for new block ${blockId}`);
|
|
354
|
+
}
|
|
355
|
+
newBlock = structuredClone(transform.insert);
|
|
356
|
+
} else {
|
|
357
|
+
newBlock = applyTransformSafe(baseBlock, transform);
|
|
358
|
+
if (!newBlock && !transform.delete) {
|
|
359
|
+
throw new Error(`Commit Error: Action ${actionId} resulted in undefined block but had no delete flag for block ${blockId}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (newBlock) {
|
|
364
|
+
blockState.materializedBlocks.set(rev, newBlock);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Update block state
|
|
368
|
+
blockState.latestRev = rev;
|
|
369
|
+
blockState.revisionActions.set(rev, actionId);
|
|
370
|
+
blockState.committedActions.set(actionId, transform);
|
|
371
|
+
blockState.pendingActions.delete(actionId);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// --- End of Critical Section (Simulated) ---
|
|
375
|
+
|
|
376
|
+
return { success: true };
|
|
377
|
+
|
|
378
|
+
} finally {
|
|
379
|
+
// Release locks in reverse order
|
|
380
|
+
releases.reverse().forEach(release => release());
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Helper methods for testing
|
|
385
|
+
reset() {
|
|
386
|
+
this.blocks.clear();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
getPendingActions(): Map<ActionId, ActionTransforms> {
|
|
390
|
+
const allPending = new Map<ActionId, ActionTransforms>();
|
|
391
|
+
for (const [blockId, blockState] of this.blocks.entries()) {
|
|
392
|
+
for (const [actionId, transform] of blockState.pendingActions) {
|
|
393
|
+
const existing = allPending.get(actionId);
|
|
394
|
+
if (!existing) {
|
|
395
|
+
allPending.set(actionId, { actionId, transforms: transformsFromTransform(transform, blockId) });
|
|
396
|
+
} else {
|
|
397
|
+
existing.transforms = concatTransform(existing.transforms, blockId, transform);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return allPending;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
getCommittedActions(): Map<ActionId, ActionTransforms> {
|
|
405
|
+
const allCommitted = new Map<ActionId, ActionTransforms>();
|
|
406
|
+
for (const [blockId, blockState] of this.blocks.entries()) {
|
|
407
|
+
for (const [rev, actionId] of blockState.revisionActions) {
|
|
408
|
+
const transform = blockState.committedActions.get(actionId);
|
|
409
|
+
if (transform) {
|
|
410
|
+
const existing = allCommitted.get(actionId);
|
|
411
|
+
if (!existing) {
|
|
412
|
+
allCommitted.set(actionId, {
|
|
413
|
+
actionId,
|
|
414
|
+
rev,
|
|
415
|
+
transforms: transformsFromTransform(transform, blockId)
|
|
416
|
+
});
|
|
417
|
+
} else {
|
|
418
|
+
existing.transforms = concatTransform(existing.transforms, blockId, transform);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return allCommitted;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
setAvailable(available: boolean) {
|
|
427
|
+
this.available = available;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
checkAvailable() {
|
|
431
|
+
if (!this.available) {
|
|
432
|
+
throw new Error('Transactor is not available');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Optional method for querying cluster nominees (used in GATHER phase for multi-collection transactions) */
|
|
437
|
+
queryClusterNominees?: (blockId: BlockId) => Promise<ClusterNomineesResult>;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Base for the intercepting wrappers below: forwards every {@link ITransactor} member to `inner`,
|
|
442
|
+
* so a wrapper overrides only the one call it intercepts and cannot silently drop the rest.
|
|
443
|
+
*
|
|
444
|
+
* `queryClusterNominees` is forwarded through a GETTER rather than a method, because the coordinator
|
|
445
|
+
* treats its ABSENCE as "no supercluster" (see `gatherPhase`). A wrapper that always defined it
|
|
446
|
+
* would push every wrapped multi-collection test onto the GATHER path even when the inner
|
|
447
|
+
* transactor never opted in; a wrapper that omits it (as these did before) does the opposite —
|
|
448
|
+
* a test that sets `inner.queryClusterNominees` and then wraps would skip GATHER and pass
|
|
449
|
+
* vacuously. The getter reproduces the inner transactor's own answer either way.
|
|
450
|
+
*/
|
|
451
|
+
export abstract class DelegatingTransactor implements ITransactor {
|
|
452
|
+
protected constructor(protected readonly inner: TestTransactor) {}
|
|
453
|
+
|
|
454
|
+
get(b: BlockGets): Promise<GetBlockResults> { return this.inner.get(b); }
|
|
455
|
+
getStatus(a: ActionBlocks[]): Promise<BlockActionStatus[]> { return this.inner.getStatus(a); }
|
|
456
|
+
pend(r: PendRequest): Promise<PendResult> { return this.inner.pend(r); }
|
|
457
|
+
cancel(a: ActionBlocks): Promise<void> { return this.inner.cancel(a); }
|
|
458
|
+
commit(r: CommitRequest): Promise<CommitResult> { return this.inner.commit(r); }
|
|
459
|
+
|
|
460
|
+
get queryClusterNominees(): ((blockId: BlockId) => Promise<ClusterNomineesResult>) | undefined {
|
|
461
|
+
return this.inner.queryClusterNominees?.bind(this.inner);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Wraps a {@link TestTransactor} and forces its commit phase to fail a bounded (or unbounded)
|
|
467
|
+
* number of times before delegating, so tests can exercise the sync retry / backoff / give-up
|
|
468
|
+
* path deterministically. Everything else delegates unchanged, so the pend→commit→cancel
|
|
469
|
+
* round-trip runs exactly as in production.
|
|
470
|
+
*/
|
|
471
|
+
export class FlakyCommitTransactor extends DelegatingTransactor {
|
|
472
|
+
/** Number of commit() calls observed so far (across success and forced failure). */
|
|
473
|
+
commitAttempts = 0;
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* @param inner delegate transactor
|
|
477
|
+
* @param failFirstN number of initial commit() calls to fail; Infinity to always fail
|
|
478
|
+
* @param reason the StaleFailure.reason returned on a forced failure
|
|
479
|
+
*/
|
|
480
|
+
constructor(
|
|
481
|
+
inner: TestTransactor,
|
|
482
|
+
private readonly failFirstN: number,
|
|
483
|
+
private readonly reason = 'forced stale',
|
|
484
|
+
) {
|
|
485
|
+
super(inner);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
override async commit(request: CommitRequest): Promise<CommitResult> {
|
|
489
|
+
this.commitAttempts++;
|
|
490
|
+
if (this.commitAttempts <= this.failFirstN) {
|
|
491
|
+
return { success: false, reason: this.reason };
|
|
492
|
+
}
|
|
493
|
+
return this.inner.commit(request);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Commits durably on the inner {@link TestTransactor}, then reports a stale failure anyway —
|
|
499
|
+
* the exact observable shape of `NetworkTransactor.commit`'s TORN ACTION: the collection header
|
|
500
|
+
* and log tail are committed BEFORE the sweep of the remaining blocks, so a later sweep block
|
|
501
|
+
* coming back as a confirmed conflict returns a stale failure over an action whose log entry is
|
|
502
|
+
* already durable.
|
|
503
|
+
*
|
|
504
|
+
* Safe against the inner transactor's bookkeeping because `TransactorSource.transact` cancels
|
|
505
|
+
* the pend on the reported failure and {@link TestTransactor.cancel} only deletes PENDING records
|
|
506
|
+
* — the real commit already promoted them, so the cancel is a no-op.
|
|
507
|
+
*
|
|
508
|
+
* Used by both write paths' own-entry regression suites (collection-own-action-replay.spec.ts and
|
|
509
|
+
* coordinator-own-action-replay.spec.ts); it lives here so the two cannot drift apart.
|
|
510
|
+
*/
|
|
511
|
+
export class CommitLandsButReportsStale extends DelegatingTransactor {
|
|
512
|
+
/** Remaining number of successful commits to mask as stale failures. */
|
|
513
|
+
private injections: number;
|
|
514
|
+
/** Commits that actually landed on the inner transactor (masked or not). */
|
|
515
|
+
landedCommits = 0;
|
|
516
|
+
|
|
517
|
+
constructor(inner: TestTransactor, injections = 1) {
|
|
518
|
+
super(inner);
|
|
519
|
+
this.injections = injections;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
override async commit(request: CommitRequest): Promise<CommitResult> {
|
|
523
|
+
const result = await this.inner.commit(request);
|
|
524
|
+
if (result.success) {
|
|
525
|
+
this.landedCommits++;
|
|
526
|
+
if (this.injections-- > 0) {
|
|
527
|
+
return { success: false, conflict: true, reason: 'stale commit: injected torn-action conflict' };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return result;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** A competing writer: a real write driven against the UNWRAPPED transactor, so its own
|
|
535
|
+
* pend/commit calls are invisible to {@link CompetingWriterTransactor}'s counters and cannot
|
|
536
|
+
* re-trigger the interception. See {@link commitRivalTreeWrite} for the usual implementation. */
|
|
537
|
+
export type RivalWrite = (inner: ITransactor) => Promise<void>;
|
|
538
|
+
|
|
539
|
+
export type CompetingWriterOptions = {
|
|
540
|
+
/** Fires on the first pend whose request satisfies this predicate. `callIndex` is 1-based over
|
|
541
|
+
* ALL pend calls seen (not only matching ones). Default: fire on the first pend call. */
|
|
542
|
+
when?: (request: PendRequest, callIndex: number) => boolean;
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Wraps a {@link TestTransactor} and, exactly once, runs a real competing writer to completion
|
|
547
|
+
* BEFORE delegating the intercepted pend. The rival durably commits (real log entry, real
|
|
548
|
+
* revision bump), so the delegated pend then fails as a GENUINE optimistic-concurrency loss —
|
|
549
|
+
* nothing is forced or faked, unlike {@link FlakyCommitTransactor}, which returns a stale failure
|
|
550
|
+
* without ever advancing a block's revision.
|
|
551
|
+
*
|
|
552
|
+
* That distinction is the whole point: only a rival that actually landed can prove the loser
|
|
553
|
+
* OBSERVED a newer revision, re-applied its work on top of it, and did not lose an update.
|
|
554
|
+
*
|
|
555
|
+
* Two deliberate design constraints:
|
|
556
|
+
*
|
|
557
|
+
* 1. **The trigger is on PEND, and there is no commit trigger.** The rival is a real
|
|
558
|
+
* Collection/Tree, so its write pends with policy `'r'` (see `TransactorSource.transact`), and
|
|
559
|
+
* {@link TestTransactor.pend} rejects a pend whose blocks already carry a *pending* action.
|
|
560
|
+
* Firing before the loser's pend delegates means the loser has pended nothing yet, so the
|
|
561
|
+
* rival pends and commits cleanly and the loser's delegated pend then fails with `missing` — a
|
|
562
|
+
* real conflict. Firing at COMMIT time instead would leave the loser already pending on the
|
|
563
|
+
* shared log-tail block, so the rival's own pend would collide with it and spin through its
|
|
564
|
+
* sync retry budget (~10 attempts, ~21s) while the loser sits awaiting the rival inside its
|
|
565
|
+
* own commit: a livelock dressed up as a slow test. Do not add a commit trigger.
|
|
566
|
+
*
|
|
567
|
+
* 2. **The rival runs before delegation, never inside {@link TestTransactor}'s critical section.**
|
|
568
|
+
* `TestTransactor.get`/`commit` hold per-block latches (`TestTransactor.commit:${blockId}`), and
|
|
569
|
+
* those latch keys are process-global — a rival invoked from inside one of them would
|
|
570
|
+
* self-deadlock on its own reads. Intercepting here in the wrapper, before
|
|
571
|
+
* `await this.inner.pend(...)`, is outside every such section.
|
|
572
|
+
*/
|
|
573
|
+
export class CompetingWriterTransactor extends DelegatingTransactor {
|
|
574
|
+
/** Pend calls observed (including the intercepted one). */
|
|
575
|
+
pendCalls = 0;
|
|
576
|
+
/** Commit calls observed. */
|
|
577
|
+
commitCalls = 0;
|
|
578
|
+
/** The 1-based pend call index the rival fired on; undefined if it never fired. */
|
|
579
|
+
firedAtCall?: number;
|
|
580
|
+
/** How many times the rival ran. Latched to at most 1 — a second firing would collide with the
|
|
581
|
+
* loser's now-pending blocks and the transaction under test could never win. */
|
|
582
|
+
rivalRuns = 0;
|
|
583
|
+
|
|
584
|
+
constructor(
|
|
585
|
+
inner: TestTransactor,
|
|
586
|
+
private readonly rival: RivalWrite,
|
|
587
|
+
private readonly options?: CompetingWriterOptions,
|
|
588
|
+
) {
|
|
589
|
+
super(inner);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
override async pend(request: PendRequest): Promise<PendResult> {
|
|
593
|
+
// Increment synchronously, before any await, so call indexes reflect the caller's fan-out
|
|
594
|
+
// order rather than scheduling order.
|
|
595
|
+
const callIndex = ++this.pendCalls;
|
|
596
|
+
const matches = this.options?.when
|
|
597
|
+
? this.options.when(request, callIndex)
|
|
598
|
+
: callIndex === 1;
|
|
599
|
+
if (this.rivalRuns === 0 && matches) {
|
|
600
|
+
this.rivalRuns++;
|
|
601
|
+
this.firedAtCall = callIndex;
|
|
602
|
+
// A throwing rival escapes as a rejected pend, which pendPhase flattens to a bare message
|
|
603
|
+
// string ("hard failure") with the stack discarded — so name the source in the message, or
|
|
604
|
+
// a broken rival reads as an unexplained coordinator pend failure.
|
|
605
|
+
try {
|
|
606
|
+
await this.rival(this.inner);
|
|
607
|
+
} catch (e) {
|
|
608
|
+
throw new Error(`competing writer failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return this.inner.pend(request);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
override async commit(request: CommitRequest): Promise<CommitResult> {
|
|
615
|
+
this.commitCalls++;
|
|
616
|
+
return this.inner.commit(request);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Durably commit a conflicting change to a tree collection: opens a SECOND {@link Tree} over the
|
|
622
|
+
* same transactor + collection id and replaces `entries` through it, producing a real log entry
|
|
623
|
+
* and a real revision bump — the durable competitor a {@link CompetingWriterTransactor} needs.
|
|
624
|
+
*
|
|
625
|
+
* `Tree.replace` is act + `updateAndSync`, i.e. a full commit through the single-collection sync
|
|
626
|
+
* path, so the rival's write is indistinguishable from any other client's.
|
|
627
|
+
*
|
|
628
|
+
* Pass the UNWRAPPED transactor (that is what {@link RivalWrite} receives).
|
|
629
|
+
*
|
|
630
|
+
* NOTE: opens the rival tree at the DEFAULT node capacity (64), because fan-out is not persisted
|
|
631
|
+
* in the collection header (see Tree.createOrOpen's `nodeCapacity` note). Fine while every rival
|
|
632
|
+
* race is between default-fan-out trees; if a test ever needs to race a small-capacity tree, add a
|
|
633
|
+
* capacity parameter here and pass the same value both sides, or the two writers will split nodes
|
|
634
|
+
* at different fan-outs.
|
|
635
|
+
*/
|
|
636
|
+
export async function commitRivalTreeWrite<TKey, TEntry>(
|
|
637
|
+
inner: ITransactor,
|
|
638
|
+
collectionId: CollectionId,
|
|
639
|
+
keyFromEntry: (entry: TEntry) => TKey,
|
|
640
|
+
entries: TreeReplaceAction<TKey, TEntry>,
|
|
641
|
+
): Promise<void> {
|
|
642
|
+
const tree = await Tree.createOrOpen<TKey, TEntry>(inner, collectionId, keyFromEntry);
|
|
643
|
+
await tree.replace(entries);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function newBlockState(): BlockState {
|
|
647
|
+
return {
|
|
648
|
+
materializedBlocks: new Map(),
|
|
649
|
+
latestRev: 0,
|
|
650
|
+
revisionActions: new Map(),
|
|
651
|
+
pendingActions: new Map(),
|
|
652
|
+
committedActions: new Map()
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Returns the materialized block at the highest revision ≤ the given revision, together with
|
|
657
|
+
* that revision — the caller reports it as {@link GetBlockResult.materialized}. */
|
|
658
|
+
function latestMaterializedAt(blockState: BlockState, maxRev: number): { block: IBlock, rev: number } | undefined {
|
|
659
|
+
for (let rev = maxRev; rev >= 0; rev--) {
|
|
660
|
+
const block = blockState.materializedBlocks.get(rev);
|
|
661
|
+
if (block) return { block, rev };
|
|
662
|
+
}
|
|
663
|
+
return undefined;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* `applyTransform` over cloned inputs, so a returned block never aliases stored state.
|
|
668
|
+
*
|
|
669
|
+
* An absent base is NOT a short circuit: an insert needs no base — `applyTransform` adopts it as
|
|
670
|
+
* the block. Bailing out on `!block` made this double silently drop a pending-only insert read
|
|
671
|
+
* through the pending overlay, the one shape `StorageRepo.get` serves with content but no
|
|
672
|
+
* `materialized` (see docs/internals.md § the `unavailable`/`materialized` bullets). A
|
|
673
|
+
* pending UPDATE over an absent base still resolves to undefined, matching the real repo.
|
|
674
|
+
*/
|
|
675
|
+
function applyTransformSafe(block: IBlock | undefined, transform: Transform): IBlock | undefined {
|
|
676
|
+
return applyTransform(
|
|
677
|
+
block ? structuredClone(block) : undefined,
|
|
678
|
+
transform.insert ? { ...transform, insert: structuredClone(transform.insert) } : transform
|
|
679
|
+
);
|
|
680
|
+
}
|