@optimystic/db-core 0.24.2 → 0.25.1
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,688 +1,1129 @@
|
|
|
1
|
-
import type { IBlock, Action, ActionType, ActionHandler, BlockId, ITransactor, BlockStore, Transforms, ActionId } from "../index.js";
|
|
2
|
-
import { Log } from "../log/log.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
import type {
|
|
12
|
-
import
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
*
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
|
|
205
|
-
source
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
*
|
|
639
|
-
*/
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1
|
+
import type { IBlock, Action, ActionType, ActionHandler, BlockId, ITransactor, BlockStore, Transforms, ActionId } from "../index.js";
|
|
2
|
+
import { Log } from "../log/log.js";
|
|
3
|
+
import type { ActionEntry } from "../log/struct.js";
|
|
4
|
+
import { Atomic } from "../transform/atomic.js";
|
|
5
|
+
import { Tracker } from "../transform/tracker.js";
|
|
6
|
+
import { CacheSource } from "../transform/cache-source.js";
|
|
7
|
+
import { computeBlockContentDigests } from "../transform/digest.js";
|
|
8
|
+
import { copyTransforms, isTransformsEmpty } from "../transform/helpers.js";
|
|
9
|
+
import { TransactorSource } from "../transactor/transactor-source.js";
|
|
10
|
+
import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
|
|
11
|
+
import type { CollectionHeaderBlock, CollectionId, ICollection, SyncOptions } from "./index.js";
|
|
12
|
+
import { CollectionHeaderVanishedError, SyncRetryExhaustedError } from "./struct.js";
|
|
13
|
+
import type { ActionContext } from "./action.js";
|
|
14
|
+
import { actionIdAt } from "./action.js";
|
|
15
|
+
import type { ReadDependency } from "../transaction/transaction.js";
|
|
16
|
+
import { clampPriority } from "../transaction/transaction.js";
|
|
17
|
+
import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
|
|
18
|
+
import { randomBytes } from '@noble/hashes/utils.js';
|
|
19
|
+
import { toString as uint8ArrayToString } from 'uint8arrays/to-string';
|
|
20
|
+
import { Latches } from "../utility/latches.js";
|
|
21
|
+
import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
|
|
22
|
+
import { createLogger } from "../logger.js";
|
|
23
|
+
|
|
24
|
+
const log = createLogger('collection');
|
|
25
|
+
|
|
26
|
+
/** Which of {@link Collection.advanceContext}'s two callers is reporting — printed as `site=` on
|
|
27
|
+
* every line it emits, because the two compare DIFFERENT pairs of things and a divergence means
|
|
28
|
+
* something different in each:
|
|
29
|
+
*
|
|
30
|
+
* - `refresh` ({@link Collection.updateInternal}) — this instance's own copy against the stored
|
|
31
|
+
* log. A divergence here indicts a forked REPLICA: two copies of one collection id built
|
|
32
|
+
* separately, each internally consistent.
|
|
33
|
+
* - `attach` ({@link Collection.attachToLog}, during open) — the log tail block's claim about
|
|
34
|
+
* which action produced the latest revision, against a walk of that same tail's own chain. Both
|
|
35
|
+
* sides come from storage, so a divergence here indicts STORAGE being self-inconsistent about
|
|
36
|
+
* one revision, not a replica.
|
|
37
|
+
*
|
|
38
|
+
* Without this field the two are indistinguishable in a log, and they lead an operator to
|
|
39
|
+
* completely different places. */
|
|
40
|
+
type DivergenceSite = 'refresh' | 'attach';
|
|
41
|
+
|
|
42
|
+
/** The lowest revision two {@link ActionContext}s provably disagree at, and the action each names
|
|
43
|
+
* there — what {@link Collection.earliestFork} reports and `collection:lineage-divergence` prints
|
|
44
|
+
* as `forkRev=` / `heldAction=` / `readAction=`. */
|
|
45
|
+
type LineageFork = { rev: number, heldAction: ActionId, readAction: ActionId };
|
|
46
|
+
|
|
47
|
+
/** Default base backoff (and historical fixed delay) between sync retries, in ms. */
|
|
48
|
+
const PendingRetryDelayMs = 100;
|
|
49
|
+
/** Default max consecutive no-progress stale-failure retries before {@link Collection.sync} gives up. */
|
|
50
|
+
const DefaultMaxAttempts = 10;
|
|
51
|
+
/** Default ceiling on a single exponential-backoff sleep, in ms. */
|
|
52
|
+
const DefaultMaxBackoffMs = 5000;
|
|
53
|
+
|
|
54
|
+
export type CollectionInitOptions<TAction> = {
|
|
55
|
+
modules: Record<ActionType, ActionHandler<TAction>>;
|
|
56
|
+
createHeaderBlock: (id: BlockId, store: BlockStore<IBlock>) => IBlock;
|
|
57
|
+
/** Called for each local action that is potentially in conflict with a remote action.
|
|
58
|
+
* @param action - The local action to check
|
|
59
|
+
* @param potential - The remote actions that are potentially in conflict
|
|
60
|
+
* @returns The original action (return the same instance to keep it as-is), a replacement
|
|
61
|
+
* action (return a new instance to apply instead of the original — it is re-staged via
|
|
62
|
+
* the conflict replay), or undefined to discard this action
|
|
63
|
+
*/
|
|
64
|
+
filterConflict?: (action: Action<TAction>, potential: Action<TAction>[]) => Action<TAction> | undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Options for building a committed read view (see {@link Collection.createReadTracker}
|
|
68
|
+
* and `Tree.readView`). */
|
|
69
|
+
export interface ReadViewOptions {
|
|
70
|
+
/** Record read dependencies into the collection's shared collector.
|
|
71
|
+
* Default false — a pinned committed view is not part of any transaction's
|
|
72
|
+
* conflict set, so its reads must not be able to fail the writer's commit
|
|
73
|
+
* validation. Deferred-constraint safety does not depend on these reads:
|
|
74
|
+
* validator peers re-execute the transaction's recorded statements against
|
|
75
|
+
* their own committed state, so a constraint that no longer holds is caught
|
|
76
|
+
* at validation regardless. */
|
|
77
|
+
recordReads?: boolean;
|
|
78
|
+
/** Pin the view to this committed boundary instead of the collection's CURRENT
|
|
79
|
+
* action context. Pass a {@link CollectionSnapshot.context} so the view describes
|
|
80
|
+
* the same committed moment the snapshot's transforms sat on — even when the
|
|
81
|
+
* collection has committed further since (e.g. a multi-tree commit sweep that has
|
|
82
|
+
* already flushed THIS tree but not its siblings). Blocks cached at revisions
|
|
83
|
+
* newer than the pin are excluded from the view's warm seed and refetched from
|
|
84
|
+
* the transactor at the pinned revision. Default: the current context. */
|
|
85
|
+
pinContext?: ActionContext;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A point-in-time copy of a collection's staged (un-synced) state, produced by
|
|
89
|
+
* {@link Collection.snapshotPending} and consumed by {@link Collection.restorePending}. */
|
|
90
|
+
export interface CollectionSnapshot<TAction> {
|
|
91
|
+
/** Deep-cloned tracker transforms at snapshot time. */
|
|
92
|
+
transforms: Transforms;
|
|
93
|
+
/** Pending actions queued at snapshot time. */
|
|
94
|
+
pending: Action<TAction>[];
|
|
95
|
+
/** The committed boundary (action context) the staged state sat on when captured.
|
|
96
|
+
* `undefined` for a collection with no committed revision yet (an invented
|
|
97
|
+
* collection whose header/root still live in the tracker). A read view built
|
|
98
|
+
* from this snapshot pins to this boundary (see {@link ReadViewOptions.pinContext}),
|
|
99
|
+
* so the view stays coherent with the snapshot's transforms even if the
|
|
100
|
+
* collection commits further before the view is built. */
|
|
101
|
+
context?: ActionContext;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class Collection<TAction> implements ICollection<TAction> {
|
|
105
|
+
private pending: Action<TAction>[] = [];
|
|
106
|
+
private readonly latchId: string;
|
|
107
|
+
|
|
108
|
+
/** The action id of a write currently in flight ON THIS INSTANCE'S BEHALF, or `undefined`
|
|
109
|
+
* outside a write. Read by {@link updateInternal}: if the committed log now carries an entry
|
|
110
|
+
* under this id, that action's work is already durable despite the failure answer that sent us
|
|
111
|
+
* back here — `NetworkTransactor.commit` commits the collection header and log tail BEFORE
|
|
112
|
+
* sweeping the remaining blocks, so a later sweep block confirming a conflict reports failure
|
|
113
|
+
* over an action whose log entry already landed. Such an entry is CONSUMED
|
|
114
|
+
* ({@link consumeOwnEntry}) rather than replayed, because replaying re-appends content the
|
|
115
|
+
* committed tail already carries, producing a duplicate entry under one action id at two
|
|
116
|
+
* revisions.
|
|
117
|
+
*
|
|
118
|
+
* The collection owns this fact rather than taking it as a `updateInternal` argument so that no
|
|
119
|
+
* refresh path can forget to supply it — {@link update} and {@link updateAndSync} are refreshes
|
|
120
|
+
* on behalf of a READER, the field is unset for them, and the consume branch cannot fire. Before
|
|
121
|
+
* this was a field, `TransactionCoordinator.commit`'s inter-attempt refresh went through
|
|
122
|
+
* `update()` and was therefore indistinguishable from a reader refresh even though the
|
|
123
|
+
* coordinator held the very id it was retrying.
|
|
124
|
+
*
|
|
125
|
+
* LIFETIME is the whole attempt CYCLE, not the latched span: it must survive the refresh
|
|
126
|
+
* BETWEEN a failed attempt and its retry, which is the only moment it is ever read. In
|
|
127
|
+
* {@link syncInternal} that cycle is contained inside the collection latch `sync()` holds; in
|
|
128
|
+
* `TransactionCoordinator.commit` the inter-attempt `update()` runs OUTSIDE the commit latch
|
|
129
|
+
* span by design (`Latches` is non-reentrant), so the coordinator's clear necessarily runs
|
|
130
|
+
* latch-free. That is safe: this is a single field write, {@link beginInFlightAction}'s
|
|
131
|
+
* disposer only clears an id it still owns, and the only reader runs under the latch — so the
|
|
132
|
+
* worst a foreign concurrent refresh can see is a cleared field (it stops consuming), never a
|
|
133
|
+
* field it should not have consumed. */
|
|
134
|
+
private inFlightActionId?: ActionId;
|
|
135
|
+
|
|
136
|
+
protected constructor(
|
|
137
|
+
public readonly id: CollectionId,
|
|
138
|
+
public readonly transactor: ITransactor,
|
|
139
|
+
private readonly handlers: Record<ActionType, ActionHandler<TAction>>,
|
|
140
|
+
private readonly source: TransactorSource<IBlock>,
|
|
141
|
+
/** Cache of unmodified blocks from the source */
|
|
142
|
+
private readonly sourceCache: CacheSource<IBlock>,
|
|
143
|
+
/** Tracked Changes */
|
|
144
|
+
public readonly tracker: Tracker<IBlock>,
|
|
145
|
+
private readonly filterConflict?: (action: Action<TAction>, potential: Action<TAction>[]) => Action<TAction> | undefined,
|
|
146
|
+
/** Short random tag naming THIS instance (see {@link newInstanceTag}). Open paths generate
|
|
147
|
+
* it BEFORE construction (so pre-construction diagnostics such as attachToLog can carry it);
|
|
148
|
+
* the default covers direct construction in tests. */
|
|
149
|
+
public readonly instanceTag: string = Collection.newInstanceTag(),
|
|
150
|
+
) {
|
|
151
|
+
// Instance-scoped, deliberately NOT shared across instances of one collection id. The
|
|
152
|
+
// latch protects per-instance state only — the tracker, the pending queue, and
|
|
153
|
+
// source.actionContext, none of which two instances over the same id share — while
|
|
154
|
+
// cross-instance races are resolved by the transactor's optimistic concurrency (that is
|
|
155
|
+
// the design; the old process-global `Collection:${id}` key serialized instances by
|
|
156
|
+
// accident). Instance scope is also what lets TransactionCoordinator hold this latch
|
|
157
|
+
// across its whole commit span: `Latches` is a non-reentrant FIFO mutex, and a rival
|
|
158
|
+
// writer driving a SECOND instance of the same id from inside transactor.pend (see
|
|
159
|
+
// CompetingWriterTransactor) would otherwise wait on the very latch the parked commit
|
|
160
|
+
// holds — a deadlock, not contention.
|
|
161
|
+
this.latchId = `Collection:${this.id}#${this.instanceTag}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A fresh instance tag: four random bytes rendered base64url — six characters, enough that
|
|
165
|
+
* two instances over one collection id do not collide by accident, short enough to ride on
|
|
166
|
+
* every trace line (same shape as the node tag in quereus-plugin-optimystic's
|
|
167
|
+
* collection-factory). Scopes {@link latchId} per instance and labels diagnostics. */
|
|
168
|
+
private static newInstanceTag(): string {
|
|
169
|
+
return uint8ArrayToString(randomBytes(4), 'base64url');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Open an EXISTING collection.
|
|
173
|
+
*
|
|
174
|
+
* Resolves to `undefined` when the header block probe comes back empty — an
|
|
175
|
+
* authoritatively absent header, meaning nothing has ever been committed under this id.
|
|
176
|
+
* A header the storage layer could not RETRIEVE (a revision this node cannot
|
|
177
|
+
* reconstruct, an unreachable cohort) is not absent: the probe throws
|
|
178
|
+
* {@link BlockUnavailableError} instead of resolving `undefined`, so an unreachable
|
|
179
|
+
* collection can never be mistaken for a nonexistent one.
|
|
180
|
+
*
|
|
181
|
+
* Use this wherever reading — not creating — is what was meant. {@link createOrOpen}
|
|
182
|
+
* would instead stage a fresh empty collection, and reads through it would report an
|
|
183
|
+
* absent dataset as a legitimately empty one. */
|
|
184
|
+
static async open<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction> | undefined> {
|
|
185
|
+
const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
|
|
186
|
+
if (!header) {
|
|
187
|
+
// Return before anything is staged: the tracker's transforms stay empty, so a caller
|
|
188
|
+
// that ignores the undefined cannot later sync a phantom collection into existence.
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
// Generated BEFORE attachToLog so log-attach-time diagnostics can name the instance
|
|
192
|
+
// the same way post-construction ones do.
|
|
193
|
+
const instanceTag = Collection.newInstanceTag();
|
|
194
|
+
await Collection.attachToLog<TAction>(source, transactor, tracker, id, instanceTag, header);
|
|
195
|
+
return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict, instanceTag);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Open an existing collection, or stage a fresh empty one in the local tracker when the
|
|
199
|
+
* header is authoritatively absent. Nothing is written to storage until {@link sync}.
|
|
200
|
+
*
|
|
201
|
+
* Correct only where inventing a collection is genuinely intended — a first write, a
|
|
202
|
+
* bootstrap path. The create branch logs `collection:invented`; prefer {@link open} on
|
|
203
|
+
* any pure read path. */
|
|
204
|
+
static async createOrOpen<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction>> {
|
|
205
|
+
const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
|
|
206
|
+
|
|
207
|
+
// Pre-construction for the same reason as in open(): see the comment there.
|
|
208
|
+
const instanceTag = Collection.newInstanceTag();
|
|
209
|
+
if (header) { // Collection already exists
|
|
210
|
+
await Collection.attachToLog<TAction>(source, transactor, tracker, id, instanceTag, header);
|
|
211
|
+
} else { // Collection does not exist
|
|
212
|
+
log('collection:invented id=%s — no committed header found; staging a fresh empty collection', id);
|
|
213
|
+
const headerBlock = init.createHeaderBlock(id, tracker);
|
|
214
|
+
tracker.insert(headerBlock);
|
|
215
|
+
source.actionContext = undefined;
|
|
216
|
+
await Log.open<Action<TAction>>(tracker, id);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict, instanceTag);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The per-instance read wiring every open path needs, plus the header probe result.
|
|
223
|
+
* Shared by {@link open} and {@link createOrOpen} so the two cannot drift. */
|
|
224
|
+
private static async probeHeader(transactor: ITransactor, id: CollectionId): Promise<{
|
|
225
|
+
source: TransactorSource<IBlock>,
|
|
226
|
+
sourceCache: CacheSource<IBlock>,
|
|
227
|
+
tracker: Tracker<IBlock>,
|
|
228
|
+
header: CollectionHeaderBlock | undefined,
|
|
229
|
+
}> {
|
|
230
|
+
// Start with a context that has an infinite revision number to ensure that we always fetch the latest log information.
|
|
231
|
+
// One shared read-dependency collector feeds both the source (direct structural reads) and the cache (every
|
|
232
|
+
// cache hit/miss), so a block read from either layer records a dependency — cache hits included.
|
|
233
|
+
const collector = new ReadDependencyCollector();
|
|
234
|
+
const source = new TransactorSource(id, transactor, undefined, collector);
|
|
235
|
+
const sourceCache = new CacheSource(source, undefined, collector);
|
|
236
|
+
const tracker = new Tracker(sourceCache);
|
|
237
|
+
const header = await source.tryGet(id) as CollectionHeaderBlock | undefined;
|
|
238
|
+
return { source, sourceCache, tracker, header };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Walk an existing collection's log and point the source at its latest action context.
|
|
242
|
+
* A header we just probed successfully but whose log will not open is a fault, not an
|
|
243
|
+
* absence — throw rather than let the collection read as empty. (The re-read goes through
|
|
244
|
+
* the tracker/cache, so it can disagree with the probe when storage is flaky mid-open.) */
|
|
245
|
+
private static async attachToLog<TAction>(
|
|
246
|
+
source: TransactorSource<IBlock>,
|
|
247
|
+
transactor: ITransactor,
|
|
248
|
+
tracker: Tracker<IBlock>,
|
|
249
|
+
id: CollectionId,
|
|
250
|
+
/** The tag the calling open path minted for the Collection it is ABOUT to construct, so a
|
|
251
|
+
* diagnostic emitted here carries the same instance name as every post-construction one. */
|
|
252
|
+
instanceTag: string,
|
|
253
|
+
header: CollectionHeaderBlock,
|
|
254
|
+
): Promise<void> {
|
|
255
|
+
// Bootstrap ActionContext from the committed tail before walking the chain.
|
|
256
|
+
// This allows the transactor to serve pending non-tail blocks during Log.open.
|
|
257
|
+
await Collection.bootstrapContext(source, transactor, header);
|
|
258
|
+
|
|
259
|
+
const collectionLog = await Log.open<Action<TAction>>(tracker, id);
|
|
260
|
+
if (!collectionLog) {
|
|
261
|
+
throw new Error(`Log not found for collection ${id}`);
|
|
262
|
+
}
|
|
263
|
+
// Monotonic, not an overwrite: getActionContext resolves undefined when the chain has no
|
|
264
|
+
// tail or the tail block carries zero entries, and that must not erase the revision
|
|
265
|
+
// bootstrapContext just read off the committed tail.
|
|
266
|
+
Collection.advanceContext(source, id, instanceTag, 'attach', await collectionLog.getActionContext());
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Adopt a freshly-read action context WITHOUT ever lowering the revision already held.
|
|
270
|
+
*
|
|
271
|
+
* The revision a collection last committed at is knowledge it earned; a read that found
|
|
272
|
+
* nothing — or found an older view of the log — cannot un-earn it. Silently accepting the
|
|
273
|
+
* lower value makes the next sync ask for a revision that is long gone, and every retry
|
|
274
|
+
* repeats the same doomed request because each retry re-runs the same losing read.
|
|
275
|
+
*
|
|
276
|
+
* Equal revisions still adopt `next`: the rev is unchanged but its `committed` list may be
|
|
277
|
+
* more complete than what we hold.
|
|
278
|
+
*
|
|
279
|
+
* This is also the one seam where lineage divergence is observable: at every revision BOTH
|
|
280
|
+
* sides name an action for, the two ids must agree. Revision
|
|
281
|
+
* numbers are per-collection counters, so two separately-built copies under one id can each
|
|
282
|
+
* occupy the same revision with DIFFERENT actions while each stays internally self-consistent
|
|
283
|
+
* — {@link reportShortfall} structurally cannot see that (its two numbers come from one
|
|
284
|
+
* chain), and this is the only place two `committed` lists meet. Naming different actions at
|
|
285
|
+
* one revision proves the two sides are different lineages (`collection:lineage-divergence`;
|
|
286
|
+
* see docs/debugging.md § "Did the refresh itself fail to close the gap?"). WHICH two sides
|
|
287
|
+
* depends on the caller, and the line says so in `site=` — see {@link DivergenceSite}, which
|
|
288
|
+
* defines the two values and what each one indicts.
|
|
289
|
+
*
|
|
290
|
+
* Every line from here also carries `tag=`, the {@link Collection.instanceTag} of the handle
|
|
291
|
+
* reporting. One process routinely holds several handles on one collection id; without the
|
|
292
|
+
* tag, two handles' lines interleave into what reads like one handle contradicting itself.
|
|
293
|
+
*
|
|
294
|
+
* Logs, does not throw — same reasoning as {@link reportShortfall}: `update()` runs
|
|
295
|
+
* blanket-style over every registered collection between commit retries, and aborting here
|
|
296
|
+
* would promote a diagnosis to production behaviour before the line has ever been seen to
|
|
297
|
+
* fire in the wild. Adoption then proceeds unchanged, which means the line is a PER-DISCOVERY
|
|
298
|
+
* report, not a per-refresh one: adopting `next` overwrites the held lineage marker with the
|
|
299
|
+
* log's, so the next refresh of this instance compares log-to-log and stays silent — even
|
|
300
|
+
* though block content materialized under the old lineage may still be in caches. The line
|
|
301
|
+
* marks the refresh that first observed the disagreement.
|
|
302
|
+
*
|
|
303
|
+
* NOTE: adoption resolves the CONTEXT disagreement, not the content one — the read caches on
|
|
304
|
+
* this instance still hold blocks materialized under the old lineage, and since the revision
|
|
305
|
+
* did not change nothing re-reads them. Conditional today: no fork has been reproduced (see
|
|
306
|
+
* the still-open upstream reproducer), so this instrument exists to find out whether one
|
|
307
|
+
* happens at all. If the line is ever seen firing in the field, decide then whether a
|
|
308
|
+
* divergence should also drop the read cache (and whether to keep re-reporting per refresh)
|
|
309
|
+
* — that is a behaviour change, and this seam deliberately makes none.
|
|
310
|
+
*
|
|
311
|
+
* The comparison is {@link earliestFork}, not a single lookup at the current revision: the
|
|
312
|
+
* two `committed` lists overlap across several revisions, and the LOWEST one they disagree at
|
|
313
|
+
* is where the lineages actually parted — a fork below the current revision was previously
|
|
314
|
+
* silent. `forkRev=` names it, `heldAction=`/`readAction=` are the two ids AT it, and
|
|
315
|
+
* `heldRev=`/`readRev=` are the two contexts' own revisions, so the line says both where the
|
|
316
|
+
* split began and how far each side has since travelled.
|
|
317
|
+
*
|
|
318
|
+
* The refusal line reports its two action ids at `readRev=` — the read's revision — on BOTH
|
|
319
|
+
* sides, because that is the only revision the two can be compared at: `next` never names an
|
|
320
|
+
* action above its own revision, so looking each side up at its own revision would compare
|
|
321
|
+
* different revisions and print two different ids for one honest lineage. Equal ids there mean
|
|
322
|
+
* the read is an older view of THIS lineage (ordinary lag, correctly refused); different ids
|
|
323
|
+
* mean a fork; `none` on the held side means this handle's own list does not reach back to the
|
|
324
|
+
* read's revision — the signature of a context bootstrapped from an over-claiming tail (see
|
|
325
|
+
* the NOTE in {@link bootstrapContext}), which is exactly the case {@link earliestFork} has no
|
|
326
|
+
* shared revision to report on.
|
|
327
|
+
*
|
|
328
|
+
* Gated on `log.enabled`, like every {@link actionIdAt} caller: the comparison buys nothing
|
|
329
|
+
* when the line has no sink, and the lists — one entry per commit between context reads,
|
|
330
|
+
* truncated at each checkpoint — are only walked on a run that has the namespace turned on.
|
|
331
|
+
* Silence proves nothing either way, because a revision is only comparable when BOTH sides
|
|
332
|
+
* name an action for it: an invented collection has no context at all, a revision slot the log
|
|
333
|
+
* gave to a checkpoint or invalidation entry names none, and a revision older than the read
|
|
334
|
+
* log's most recent checkpoint has already fallen off the read side's list. */
|
|
335
|
+
private static advanceContext(
|
|
336
|
+
source: TransactorSource<IBlock>,
|
|
337
|
+
id: CollectionId,
|
|
338
|
+
instanceTag: string,
|
|
339
|
+
site: DivergenceSite,
|
|
340
|
+
next: ActionContext | undefined,
|
|
341
|
+
): void {
|
|
342
|
+
const current = source.actionContext;
|
|
343
|
+
if (next === undefined) {
|
|
344
|
+
return; // The read learned nothing — keep what we already know.
|
|
345
|
+
}
|
|
346
|
+
if (current !== undefined && log.enabled) {
|
|
347
|
+
const fork = Collection.earliestFork(current, next);
|
|
348
|
+
if (fork !== undefined) {
|
|
349
|
+
log('collection:lineage-divergence id=%s tag=%s site=%s forkRev=%d heldAction=%s readAction=%s heldRev=%d readRev=%d',
|
|
350
|
+
id, instanceTag, site, fork.rev, fork.heldAction, fork.readAction, current.rev, next.rev);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (current !== undefined && next.rev < current.rev) {
|
|
354
|
+
// The refusal itself is unconditional; only the id lookups that explain it are gated.
|
|
355
|
+
if (log.enabled) {
|
|
356
|
+
log('collection:context-not-lowered id=%s tag=%s site=%s heldRev=%d readRev=%d heldAction=%s readAction=%s',
|
|
357
|
+
id, instanceTag, site, current.rev, next.rev,
|
|
358
|
+
actionIdAt(current, next.rev) ?? 'none', actionIdAt(next, next.rev) ?? 'none');
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
source.actionContext = next;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The EARLIEST revision the two contexts provably disagree about: the lowest revision both
|
|
366
|
+
* `committed` lists name an action for, where the two ids differ.
|
|
367
|
+
*
|
|
368
|
+
* Comparing only at the holder's current revision — what this used to do — misses a fork that
|
|
369
|
+
* began earlier and has since been overtaken by same-numbered commits on both sides, which is
|
|
370
|
+
* the shape a replica that forked and kept writing actually has. Taking the lowest disagreeing
|
|
371
|
+
* revision instead names the split point rather than an arbitrary later symptom of it.
|
|
372
|
+
*
|
|
373
|
+
* Revisions only one side names are skipped, not treated as disagreement: {@link actionIdAt}'s
|
|
374
|
+
* `undefined` is legitimate (checkpoint/invalidation slots, and revisions that predate the
|
|
375
|
+
* other side's most recent checkpoint), so a one-sided entry is missing evidence, not evidence
|
|
376
|
+
* of a fork.
|
|
377
|
+
*
|
|
378
|
+
* NOTE: linear in the two lists, which hold one entry per commit between context reads and
|
|
379
|
+
* truncate at each checkpoint. Every caller is `log.enabled`-gated, so this does not run at
|
|
380
|
+
* all on a normal run; if a non-diagnostic caller ever appears, index by revision instead. */
|
|
381
|
+
private static earliestFork(held: ActionContext, read: ActionContext): LineageFork | undefined {
|
|
382
|
+
// NOTE: a `committed` list carrying TWO entries at one revision would be a defect in its own
|
|
383
|
+
// right, and this keeps the last of them arbitrarily. Harmless while every caller is a
|
|
384
|
+
// diagnostic; if such a list is ever seen, report the duplicate rather than silently
|
|
385
|
+
// picking one.
|
|
386
|
+
const readIds = new Map(read.committed.map(entry => [entry.rev, entry.actionId]));
|
|
387
|
+
let earliest: LineageFork | undefined;
|
|
388
|
+
for (const entry of held.committed) {
|
|
389
|
+
const readAction = readIds.get(entry.rev);
|
|
390
|
+
if (readAction === undefined || readAction === entry.actionId) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (earliest === undefined || entry.rev < earliest.rev) {
|
|
394
|
+
earliest = { rev: entry.rev, heldAction: entry.actionId, readAction };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return earliest;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Report a refresh that failed to move FORWARDS past a revision it had already read for
|
|
401
|
+
* itself — the sibling of {@link advanceContext}'s `collection:context-not-lowered`, which
|
|
402
|
+
* reports a collection declining to move BACKWARDS.
|
|
403
|
+
*
|
|
404
|
+
* `tailRev` is what the committed log tail claimed is committed under this id; `after` is
|
|
405
|
+
* where a SEPARATE read path (the chain walk) actually landed. Landing below the claim means
|
|
406
|
+
* this refresh closed nothing, which from outside the class is otherwise indistinguishable
|
|
407
|
+
* from "there was nothing newer to adopt".
|
|
408
|
+
*
|
|
409
|
+
* This detects LAG, and only lag. It CANNOT see lineage divergence: both of its numbers
|
|
410
|
+
* come from the same chain — `tailRev` off the tail block this collection's own header
|
|
411
|
+
* names, `after` from a walk of that same chain — and a forked replica is internally
|
|
412
|
+
* self-consistent, its tail claiming exactly what its own walk reaches. Two copies of one
|
|
413
|
+
* collection id holding the same revision under different actions therefore keep this line
|
|
414
|
+
* silent forever. That case is `collection:lineage-divergence`, reported from
|
|
415
|
+
* {@link advanceContext}, which compares action ids — the one value comparable across
|
|
416
|
+
* copies — rather than revision counters.
|
|
417
|
+
*
|
|
418
|
+
* Carries the same `tag=` as {@link advanceContext}'s lines, and for the same reason: several
|
|
419
|
+
* handles on one collection id otherwise read as one self-contradicting handle.
|
|
420
|
+
*
|
|
421
|
+
* Logs, does not throw: `update()` is called blanket-style over every registered collection
|
|
422
|
+
* between commit retries, and a shortfall is not yet known to be illegitimate — an abort here
|
|
423
|
+
* would promote an unproven diagnosis to production behaviour. Deliberately does NOT adopt
|
|
424
|
+
* `tailRev` either: the two numbers come from different read paths, and papering over the
|
|
425
|
+
* disagreement destroys the evidence this line exists to produce. */
|
|
426
|
+
private static reportShortfall(id: CollectionId, instanceTag: string, tailRev: number | undefined, before: number | undefined, after: number | undefined): void {
|
|
427
|
+
if (tailRev === undefined || (after !== undefined && after >= tailRev)) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
log('collection:context-short-of-tail id=%s tag=%s before=%s after=%s tail=%d',
|
|
431
|
+
id, instanceTag, before ?? 'none', after ?? 'none', tailRev);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async act(...actions: Action<TAction>[]) {
|
|
435
|
+
const release = await Latches.acquire(this.latchId);
|
|
436
|
+
try {
|
|
437
|
+
await this.actInternal(...actions);
|
|
438
|
+
} finally {
|
|
439
|
+
release();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private async actInternal(...actions: Action<TAction>[]) {
|
|
444
|
+
await this.internalTransact(...actions);
|
|
445
|
+
this.pending.push(...actions);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private async internalTransact(...actions: Action<TAction>[]) {
|
|
449
|
+
const atomic = new Atomic(this.tracker);
|
|
450
|
+
|
|
451
|
+
for (const action of actions) {
|
|
452
|
+
const handler = this.handlers[action.type];
|
|
453
|
+
if (!handler) {
|
|
454
|
+
throw new Error(`No handler for action type ${action.type}`);
|
|
455
|
+
}
|
|
456
|
+
await handler(action, atomic);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
atomic.commit();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Load external changes and update our context to the latest log revision - resolve any conflicts with our pending actions. */
|
|
463
|
+
async update() {
|
|
464
|
+
const release = await Latches.acquire(this.latchId);
|
|
465
|
+
try {
|
|
466
|
+
await this.updateInternal();
|
|
467
|
+
} finally {
|
|
468
|
+
release();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Drops the pending actions this sync's OWN committed entry already made durable, instead of
|
|
473
|
+
* replaying them into a duplicate entry (see {@link inFlightActionId}).
|
|
474
|
+
*
|
|
475
|
+
* `addActions` wrote exactly the snapshot pending list under this action id, and the entry's
|
|
476
|
+
* actions are therefore the LEADING `entry.actions.length` items of `this.pending` — anything
|
|
477
|
+
* staged since is behind them, because {@link actInternal} appends. Under
|
|
478
|
+
* {@link syncInternal} nothing can even be staged mid-cycle (`act()` shares the collection
|
|
479
|
+
* latch); under `TransactionCoordinator.commit` the mark spans a latch-free inter-attempt
|
|
480
|
+
* window, so an `act()` there CAN grow `pending` — still only at the tail, so the slice stays
|
|
481
|
+
* right.
|
|
482
|
+
*
|
|
483
|
+
* The guard below is the load-bearing part: `slice` fails SILENTLY if that correspondence ever
|
|
484
|
+
* breaks, dropping actions that were never committed, so an entry longer than `pending` throws
|
|
485
|
+
* instead of losing work. (See the sibling note on `syncInternal`'s post-commit replay, which
|
|
486
|
+
* rests on the same invariant.) */
|
|
487
|
+
private consumeOwnEntry(entry: ActionEntry<Action<TAction>>) {
|
|
488
|
+
if (entry.actions.length > this.pending.length) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
`Collection ${this.id}: own committed entry for action ${entry.actionId} holds `
|
|
491
|
+
+ `${entry.actions.length} actions but only ${this.pending.length} are pending; `
|
|
492
|
+
+ `consuming it would drop actions that were never committed`);
|
|
493
|
+
}
|
|
494
|
+
// `mutated` is unconditional, even for a zero-action entry: the tracker still holds this
|
|
495
|
+
// action's staged transforms, and only the replay at the end of `updateInternal` — which
|
|
496
|
+
// resets the tracker and re-stages just what remains — drops them. That reset is what turns
|
|
497
|
+
// `hasUnsyncedChanges()` false so the sync loop exits reporting the success the writer is
|
|
498
|
+
// owed (the action IS durable).
|
|
499
|
+
return { after: this.pending.slice(entry.actions.length), mutated: true };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Maps each pending action to its effective form against a remote entry: the original, a
|
|
503
|
+
* replacement, or dropped. A replacement or a discard changes the pending set; the tracker still
|
|
504
|
+
* holds the pre-filter transforms, so report it as mutated to force a replay that re-stages
|
|
505
|
+
* against the effective actions. Identity comparison per the contract: keep => same instance,
|
|
506
|
+
* replace => new instance.
|
|
507
|
+
* NOTE: a filterConflict hook that always allocates a fresh (but equal) instance instead of
|
|
508
|
+
* returning the same one forces a replay on every update — if that ever shows up as a hot path,
|
|
509
|
+
* compare by value/id here instead of by reference. */
|
|
510
|
+
private filterAgainstEntry(entry: ActionEntry<Action<TAction>>) {
|
|
511
|
+
const before = this.pending;
|
|
512
|
+
const after = before
|
|
513
|
+
.map(p => this.doFilterConflict(p, entry.actions))
|
|
514
|
+
.filter((a): a is Action<TAction> => a !== undefined);
|
|
515
|
+
return { after, mutated: after.length !== before.length || after.some((a, i) => a !== before[i]) };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Refresh this instance against the stored log: adopt the latest committed revision, resolve
|
|
519
|
+
* pending actions against everything that landed since, and replay them if anything conflicts.
|
|
520
|
+
*
|
|
521
|
+
* Takes no in-flight action id — it reads {@link inFlightActionId} off `this`, which is set for
|
|
522
|
+
* exactly the write attempt cycles that own one (see that field). Callers cannot get this wrong
|
|
523
|
+
* by omission. */
|
|
524
|
+
private async updateInternal() {
|
|
525
|
+
// Start with a context that can see to the end of the log
|
|
526
|
+
const source = new TransactorSource(this.id, this.transactor, undefined);
|
|
527
|
+
const tracker = new Tracker(source);
|
|
528
|
+
|
|
529
|
+
// Bootstrap context from committed tail so pending blocks are accessible.
|
|
530
|
+
// Read through tracker so Chain.open inside Log.open reuses the cached header.
|
|
531
|
+
// A header the storage layer could not retrieve throws BlockUnavailableError out of
|
|
532
|
+
// this read (it is not a StaleFailure, so sync's retry loop does not absorb it).
|
|
533
|
+
const header = await tracker.tryGet(this.id) as CollectionHeaderBlock | undefined;
|
|
534
|
+
if (header) {
|
|
535
|
+
await Collection.bootstrapContext(source, this.transactor, header);
|
|
536
|
+
} else if (this.source.actionContext) {
|
|
537
|
+
// An absent header is only believable for a collection that has never committed.
|
|
538
|
+
// We hold a committed revision, so the two answers contradict each other — surface it
|
|
539
|
+
// as a fault instead of no-opping into a forgotten revision and a rev-1 retry spin.
|
|
540
|
+
// NOTE: this aborts every caller of update(), including TransactionCoordinator's
|
|
541
|
+
// blanket refresh of ALL registered collections between commit retries — a
|
|
542
|
+
// non-participant with a momentarily-absent header now fails the whole retry rather
|
|
543
|
+
// than being skipped. That is the intended loud failure; if it ever shows up as
|
|
544
|
+
// otherwise-healthy transactions aborting, narrow that refresh to the transaction's
|
|
545
|
+
// participants (see the note at coordinator.ts's update loop) rather than softening
|
|
546
|
+
// this throw.
|
|
547
|
+
throw new CollectionHeaderVanishedError(this.id, this.source.actionContext.rev);
|
|
548
|
+
}
|
|
549
|
+
// Falling through means the header is genuinely absent AND we hold no revision: nothing
|
|
550
|
+
// was ever committed under this id. Log.open reads the same block id, so it too resolves
|
|
551
|
+
// undefined and everything below no-ops — correct here, rather than a masked failure.
|
|
552
|
+
|
|
553
|
+
// The revision the committed tail just claimed, captured before anything else can touch
|
|
554
|
+
// the local source. This is the authoritative "latest committed under this id" number,
|
|
555
|
+
// read straight off the tail block's state; the chain walk below arrives at its own
|
|
556
|
+
// number by a different path, and the two disagreeing is worth saying out loud (see the
|
|
557
|
+
// {@link reportShortfall} call after advanceContext). Stays undefined when there is no header, no
|
|
558
|
+
// tail, or a tail with no `latest` — all legitimate "nothing committed yet" states.
|
|
559
|
+
const tailRev = source.actionContext?.rev;
|
|
560
|
+
|
|
561
|
+
// Get the latest entries from the log, starting from where we left off
|
|
562
|
+
const actionContext = this.source.actionContext;
|
|
563
|
+
const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
|
|
564
|
+
const latest = collectionLog ? await collectionLog.getFrom(actionContext?.rev ?? 0) : undefined;
|
|
565
|
+
|
|
566
|
+
// Process the entries and track the blocks they affect
|
|
567
|
+
let anyConflicts = false;
|
|
568
|
+
for (const entry of latest?.entries ?? []) {
|
|
569
|
+
const isOwnEntry = this.inFlightActionId !== undefined && entry.actionId === this.inFlightActionId;
|
|
570
|
+
const { after, mutated } = isOwnEntry
|
|
571
|
+
? this.consumeOwnEntry(entry)
|
|
572
|
+
: this.filterAgainstEntry(entry);
|
|
573
|
+
this.pending = after;
|
|
574
|
+
this.sourceCache.clear(entry.blockIds);
|
|
575
|
+
anyConflicts = anyConflicts || mutated || this.tracker.conflicts(new Set(entry.blockIds)).length > 0;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// React to durable invalidations that landed since we last synced. getFrom intentionally skips
|
|
579
|
+
// invalidation entries (they are not pending/committed actions), so surface them separately: an
|
|
580
|
+
// invalidation reverted committed content this client may have read, so treat it like a stale
|
|
581
|
+
// read — drop the reverted blocks from the read cache and replay pending work against the reverted
|
|
582
|
+
// base (docs/right-is-right.md §Client notification). De-duped across cascade children by reverted
|
|
583
|
+
// block; over-inclusive by design (over-invalidation just resubmits — it never wrongly retains).
|
|
584
|
+
const invalidations = collectionLog ? await collectionLog.getInvalidationsFrom(actionContext?.rev ?? 0) : [];
|
|
585
|
+
if (invalidations.length > 0) {
|
|
586
|
+
const revertedBlockIds = [...new Set(invalidations.flatMap(inv => inv.reverted.map(r => r.blockId)))];
|
|
587
|
+
this.sourceCache.clear(revertedBlockIds);
|
|
588
|
+
if (this.pending.length > 0) {
|
|
589
|
+
anyConflicts = true;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Update our context to the latest — monotonically. An empty/unopenable log yields no
|
|
594
|
+
// context at all, and a log read that lags what we already committed yields an older one;
|
|
595
|
+
// neither is grounds for forgetting the revision we hold. This must happen BEFORE
|
|
596
|
+
// replayActions below: replay re-reads blocks through this.source, which materializes
|
|
597
|
+
// content at this.actionContext.rev — if the cursor hasn't advanced yet, replay re-reads
|
|
598
|
+
// at the revision we're leaving and refills the cache with stale content that nothing
|
|
599
|
+
// will invalidate again (the log entry that would have cleared it was already consumed).
|
|
600
|
+
Collection.advanceContext(this.source, this.id, this.instanceTag, 'refresh', latest?.context);
|
|
601
|
+
|
|
602
|
+
Collection.reportShortfall(this.id, this.instanceTag, tailRev, actionContext?.rev, this.source.actionContext?.rev);
|
|
603
|
+
|
|
604
|
+
// On conflicts, re-stage the pending actions against the adopted revision. The affected
|
|
605
|
+
// blocks were already dropped from sourceCache above (per log entry / per invalidation),
|
|
606
|
+
// so the replay's reads re-materialize from the transactor.
|
|
607
|
+
// NOTE: a throw out of replayActions leaves the tracker holding only the transforms
|
|
608
|
+
// replayed so far while `pending` still lists them all; the caller's error handling is
|
|
609
|
+
// expected to abort/reset the collection rather than keep staging. If replay ever gains a
|
|
610
|
+
// routinely-throwing read path, rebuild into a scratch tracker and swap on success.
|
|
611
|
+
if (anyConflicts) {
|
|
612
|
+
await this.replayActions();
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Capture the current staged state — tracker transforms plus the pending
|
|
617
|
+
* action queue — so it can be restored later via {@link restorePending}.
|
|
618
|
+
*
|
|
619
|
+
* Use to bracket a unit of staged DML that may need to be rolled back. Unlike
|
|
620
|
+
* a blanket "reset to empty", restoring this snapshot preserves any structural
|
|
621
|
+
* baseline that predates the staged DML — most importantly a brand-new
|
|
622
|
+
* collection's header/root blocks, which live in the tracker (uncommitted)
|
|
623
|
+
* until the first sync. Resetting such a collection to empty would leave it
|
|
624
|
+
* unreadable; restoring the snapshot returns it to its prior (readable) state.
|
|
625
|
+
*
|
|
626
|
+
* The returned snapshot is deep-cloned and independent of subsequent mutations.
|
|
627
|
+
* Synchronous and latch-free: intended to bracket transaction-scoped staging,
|
|
628
|
+
* when no concurrent act/sync is in flight. */
|
|
629
|
+
snapshotPending(): CollectionSnapshot<TAction> {
|
|
630
|
+
return {
|
|
631
|
+
transforms: copyTransforms(this.tracker.transforms),
|
|
632
|
+
pending: [...this.pending],
|
|
633
|
+
context: structuredClone(this.source.actionContext),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Restore the staged state captured by {@link snapshotPending}, discarding any
|
|
638
|
+
* mutations staged since. Reads through the collection then observe exactly the
|
|
639
|
+
* snapshot state again; storage is untouched because nothing was ever synced. */
|
|
640
|
+
restorePending(snapshot: CollectionSnapshot<TAction>): void {
|
|
641
|
+
this.tracker.reset(copyTransforms(snapshot.transforms));
|
|
642
|
+
this.pending = [...snapshot.pending];
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** A read-only {@link Tracker} pinned to this collection's committed state AS OF the
|
|
646
|
+
* moment of this call, seeded with a (deep-copied) set of pre-transaction transforms.
|
|
647
|
+
* Reads through it observe exactly that revision plus exactly those transforms — NOT
|
|
648
|
+
* the mutations staged into this collection's live tracker afterward, and NOT commits
|
|
649
|
+
* that fold into (or clear) the live read cache while the view is being walked. Used
|
|
650
|
+
* to build a committed read view (see {@link Tree.readView}) that a scan can trust
|
|
651
|
+
* from first row to last.
|
|
652
|
+
*
|
|
653
|
+
* The pinning has three legs, built in ONE synchronous block so they all describe the
|
|
654
|
+
* same instant (do not introduce an await between them):
|
|
655
|
+
* - a private {@link TransactorSource} whose action context is a deep copy FROZEN at
|
|
656
|
+
* view-creation time, so a block first read after a later commit still materializes
|
|
657
|
+
* at the pinned revision (the transactor honours `context.rev` on get);
|
|
658
|
+
* - a private {@link CacheSource} nothing else references, so the live collection's
|
|
659
|
+
* `transformCache`/`clear` cannot reach it;
|
|
660
|
+
* - that private cache is seeded from the shared cache's current entries, so the
|
|
661
|
+
* common committed read (deferred CHECK over a warm cache) stays warm instead of
|
|
662
|
+
* refetching every block over the network.
|
|
663
|
+
*
|
|
664
|
+
* By default the view records NO read dependencies — it is not part of any
|
|
665
|
+
* transaction's conflict set (see {@link ReadViewOptions.recordReads}).
|
|
666
|
+
*
|
|
667
|
+
* NOTE: each view holds up to the cache LRU budget (128) of cloned blocks plus
|
|
668
|
+
* whatever it faults in. Views are per-scan and dropped when the scan ends; a very
|
|
669
|
+
* long-lived committed scan pins that much memory.
|
|
670
|
+
*
|
|
671
|
+
* NOTE: an INVENTED collection (createOrOpen found no header, so `actionContext` is
|
|
672
|
+
* undefined) pins to no revision — its private source asks the transactor for the
|
|
673
|
+
* latest. Harmless today because such a collection's blocks all live in the tracker
|
|
674
|
+
* transforms, so a view never reaches storage; if invented collections ever gain
|
|
675
|
+
* committed blocks not covered by their transforms, this view would follow storage
|
|
676
|
+
* forward instead of staying pinned.
|
|
677
|
+
*
|
|
678
|
+
* When {@link ReadViewOptions.pinContext} is supplied, the view pins to THAT
|
|
679
|
+
* boundary instead of the current context, and cache entries committed at a newer
|
|
680
|
+
* revision are dropped from the seed (they would otherwise be served blindly — the
|
|
681
|
+
* cache never checks a hit against the requested context). Dropped entries are
|
|
682
|
+
* refetched from the transactor, which resolves the highest committed revision at
|
|
683
|
+
* or below the pin. This is what lets a snapshot captured BEFORE a commit still
|
|
684
|
+
* yield a coherent pre-commit view AFTER that commit folded into the shared cache
|
|
685
|
+
* (a mid-sweep multi-tree commit being the motivating case). */
|
|
686
|
+
createReadTracker(transforms: Transforms, options?: ReadViewOptions): Tracker<IBlock> {
|
|
687
|
+
const collector = options?.recordReads ? this.source.getCollector() : undefined;
|
|
688
|
+
const pinContext = options?.pinContext ?? this.source.actionContext;
|
|
689
|
+
const pinRev = options?.pinContext?.rev;
|
|
690
|
+
let seed = this.sourceCache.snapshotEntries();
|
|
691
|
+
if (pinRev !== undefined) {
|
|
692
|
+
// NOTE: entries whose revision the cache never learned read as 0 and pass this
|
|
693
|
+
// filter. Committed blocks always carry a real revision (the transactor reports
|
|
694
|
+
// it on load; transformCache stamps it on fold), so a rev-0 entry newer than the
|
|
695
|
+
// pin does not occur on the paths that reach here.
|
|
696
|
+
seed = seed.filter(([, , revision]) => revision <= pinRev);
|
|
697
|
+
}
|
|
698
|
+
const pinnedSource = new TransactorSource<IBlock>(
|
|
699
|
+
this.id, this.transactor, structuredClone(pinContext), collector);
|
|
700
|
+
const pinnedCache = new CacheSource<IBlock>(
|
|
701
|
+
pinnedSource, undefined, collector, seed);
|
|
702
|
+
return new Tracker(pinnedCache, copyTransforms(transforms));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** The staged (not-yet-synced) actions queued by {@link act}.
|
|
706
|
+
*
|
|
707
|
+
* Exposed so a {@link TransactionCoordinator} can append them to the log at
|
|
708
|
+
* commit time — mirroring what {@link sync} does internally — when the actions
|
|
709
|
+
* were staged directly into this collection (e.g. through a Tree's stage())
|
|
710
|
+
* rather than applied via the coordinator's own action path. */
|
|
711
|
+
getPendingActions(): Action<TAction>[] {
|
|
712
|
+
return this.pending;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** Drop the staged actions after they have been committed through a
|
|
716
|
+
* coordinator. Counterpart to {@link getPendingActions}; {@link sync} clears
|
|
717
|
+
* its own pending inline, so this is only needed when commit was orchestrated
|
|
718
|
+
* externally. */
|
|
719
|
+
clearPendingActions(): void {
|
|
720
|
+
this.pending = [];
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Whether {@link sync} has anything to push: staged actions, or tracker transforms
|
|
724
|
+
* that were never committed (an INVENTED collection's header/root blocks live there
|
|
725
|
+
* until its first sync, with no pending action to name them).
|
|
726
|
+
*
|
|
727
|
+
* This is the exact predicate {@link syncInternal} loops on, so `false` means a sync
|
|
728
|
+
* would commit nothing. Exposed so a caller that would otherwise flush
|
|
729
|
+
* unconditionally can skip the round trip — note that {@link Tree.sync} routes
|
|
730
|
+
* through {@link updateAndSync}, so an unnecessary flush still pays a full,
|
|
731
|
+
* cache-bypassing {@link update} before discovering it has nothing to do. */
|
|
732
|
+
hasUnsyncedChanges(): boolean {
|
|
733
|
+
return this.pending.length > 0 || !isTransformsEmpty(this.tracker.transforms);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** The committed revision this collection currently READS at, or `undefined` for an
|
|
737
|
+
* INVENTED collection that has never adopted a committed revision
|
|
738
|
+
* ({@link createOrOpen} found no header and staged a fresh empty one).
|
|
739
|
+
*
|
|
740
|
+
* Not the revision a pending write will land at: {@link getNextRev} is this plus one
|
|
741
|
+
* (`undefined` counting as 0), so a diagnostic that prints this value BEFORE a commit
|
|
742
|
+
* is naming the revision the commit will supersede, not the one it produces.
|
|
743
|
+
*
|
|
744
|
+
* DIAGNOSTIC ONLY — do not branch on this. Every block this collection reads is
|
|
745
|
+
* materialized at this revision ({@link TransactorSource.tryGet} passes it as the
|
|
746
|
+
* read context), and the revision advances ONLY through an explicit call on THIS
|
|
747
|
+
* instance — {@link update} or {@link sync} on the single-node path, or
|
|
748
|
+
* {@link recordCommitted} when a {@link TransactionCoordinator} commits this
|
|
749
|
+
* collection (the session/consensus path, where no `update()` is involved at all).
|
|
750
|
+
* Nothing moves it passively — not time, not another collection's
|
|
751
|
+
* commit, not a peer's notification. So a collection sitting here at a lagging
|
|
752
|
+
* revision silently serves an old root with no error, and two collections in one
|
|
753
|
+
* process can be at different revisions at the same instant. That gap is invisible
|
|
754
|
+
* from outside the class without this accessor, which is the whole reason it
|
|
755
|
+
* exists: `docs/debugging.md` (§ "Which revision did a read descend?") explains
|
|
756
|
+
* how an operator reads the difference. */
|
|
757
|
+
committedRevision(): number | undefined {
|
|
758
|
+
return this.source.actionContext?.rev;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** The id of the action that PRODUCED the revision {@link committedRevision} reports —
|
|
762
|
+
* this collection's lineage marker at that revision — or `undefined` when the action
|
|
763
|
+
* context holds no entry at the current revision.
|
|
764
|
+
*
|
|
765
|
+
* `undefined` is legitimate, not an error: an INVENTED collection has no context at
|
|
766
|
+
* all, and otherwise {@link actionIdAt} resolves nothing at the current revision for
|
|
767
|
+
* the reasons listed there. A caller printing this must therefore carry a placeholder
|
|
768
|
+
* rather than invent an id. The contexts this class writes itself
|
|
769
|
+
* ({@link recordCommitted}, the inline bump in `syncInternal`,
|
|
770
|
+
* {@link bootstrapContext}) always do hold one.
|
|
771
|
+
*
|
|
772
|
+
* DIAGNOSTIC ONLY — do not branch on this. Its value is the one thing about a revision
|
|
773
|
+
* that IS comparable across collections and across nodes: a revision number is
|
|
774
|
+
* per-collection and says nothing on its own, so two nodes reporting the same
|
|
775
|
+
* collection id at the same revision are indistinguishable between "one collection,
|
|
776
|
+
* one node lagging" and "two separately-built collections each counting from 1". Equal
|
|
777
|
+
* action ids mean one lineage; different action ids at the same revision mean two.
|
|
778
|
+
* `docs/debugging.md` (§ "Which revision did a read descend?") spells out how an
|
|
779
|
+
* operator reads the pair. */
|
|
780
|
+
committedActionId(): ActionId | undefined {
|
|
781
|
+
const context = this.source.actionContext;
|
|
782
|
+
return context === undefined ? undefined : actionIdAt(context, context.rev);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** Fold a just-committed set of transforms into this collection's read cache
|
|
786
|
+
* so subsequent reads (and stages) through THIS instance observe the committed
|
|
787
|
+
* state, mirroring what {@link sync} does inline after a successful transact.
|
|
788
|
+
*
|
|
789
|
+
* Needed when commit was orchestrated externally (a coordinator): the tracker
|
|
790
|
+
* is reset to empty, but the cache still holds the pre-commit blocks. Without
|
|
791
|
+
* this, a collection that already had committed state (e.g. a pre-synced index
|
|
792
|
+
* tree, or any collection on its second commit) keeps serving the stale prior
|
|
793
|
+
* revision because {@link update} sees its rev is already current and refetches
|
|
794
|
+
* nothing. Call BEFORE resetting the tracker (the transforms are read live).
|
|
795
|
+
*
|
|
796
|
+
* @param revision - the committed revision these transforms land at (from
|
|
797
|
+
* {@link recordCommitted}), so cached read-dependency revisions advance to it. */
|
|
798
|
+
applyCommittedToCache(transforms: Transforms, revision: number): void {
|
|
799
|
+
this.sourceCache.transformCache(transforms, revision);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Next revision this collection would commit at (current committed rev + 1). */
|
|
803
|
+
getNextRev(): number {
|
|
804
|
+
return (this.source.actionContext?.rev ?? 0) + 1;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** Record a just-committed action: append its ActionRev to the committed list
|
|
808
|
+
* and advance the revision. Returns the new revision. Mirrors the inline bump
|
|
809
|
+
* in {@link syncInternal} — which needs no such rev check because it computes and
|
|
810
|
+
* uses its `newRev` inside one latched span.
|
|
811
|
+
*
|
|
812
|
+
* @param rev - the revision this action was PENDED at, captured once (at the log
|
|
813
|
+
* append in `TransactionCoordinator.applyActionsToCollection`) and threaded through
|
|
814
|
+
* the pend/commit round trips. Storage assigned the action THAT number; recording it
|
|
815
|
+
* at any other would fork this instance's revision counter from storage permanently
|
|
816
|
+
* (context adoption is one-way — see {@link advanceContext}). With the coordinator
|
|
817
|
+
* holding this instance's latch across the whole commit span the mismatch cannot
|
|
818
|
+
* happen; the throw is the tripwire for any path that still bypasses the latch. */
|
|
819
|
+
recordCommitted(actionId: ActionId, rev: number): number {
|
|
820
|
+
const expected = this.getNextRev();
|
|
821
|
+
if (rev !== expected) {
|
|
822
|
+
throw new Error(`Collection ${this.id}: action ${actionId} was pended at rev ${rev} ` +
|
|
823
|
+
`but the collection now expects rev ${expected} — the collection was refreshed mid-commit`);
|
|
824
|
+
}
|
|
825
|
+
this.source.actionContext = {
|
|
826
|
+
committed: [...(this.source.actionContext?.committed ?? []), { actionId, rev }],
|
|
827
|
+
rev,
|
|
828
|
+
};
|
|
829
|
+
return rev;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Acquire this instance's latch — the same mutex {@link act}, {@link update},
|
|
833
|
+
* {@link sync}, and {@link updateAndSync} serialize behind — returning its release.
|
|
834
|
+
* Exists so a TransactionCoordinator can hold the latch across its WHOLE commit span
|
|
835
|
+
* (log append → pend → commit → local fold), keeping any refresh of this instance from
|
|
836
|
+
* interleaving with a mid-flight commit. `Latches` is non-reentrant: while holding this,
|
|
837
|
+
* the holder must not call any of those latched methods on this instance. The caller
|
|
838
|
+
* MUST call the release exactly once, in a `finally`. */
|
|
839
|
+
acquireLatch(): Promise<() => void> {
|
|
840
|
+
return Latches.acquire(this.latchId);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Bracket a write attempt cycle on this instance under `actionId`, so a refresh taken between
|
|
844
|
+
* a failed attempt and its retry recognises that action's own already-durable log entry (see
|
|
845
|
+
* {@link inFlightActionId} for why, and for the lifetime this must span).
|
|
846
|
+
*
|
|
847
|
+
* The returned disposer clears the mark and MUST be called in a `finally` covering every exit
|
|
848
|
+
* from the retry cycle — return, retry exhaustion, partial commit, hard error, abort. A mark
|
|
849
|
+
* left behind would let a LATER, unrelated refresh consume a foreign entry that happens to
|
|
850
|
+
* carry the same id. The clear is id-guarded, so a disposer whose mark has since been replaced
|
|
851
|
+
* by another attempt is a no-op rather than wiping the newer one; disposers may therefore be
|
|
852
|
+
* called out of order and more than once. Shaped like {@link acquireLatch} deliberately: a
|
|
853
|
+
* disposer is harder to forget than a paired `end…` call.
|
|
854
|
+
*
|
|
855
|
+
* A concurrent READER's refresh landing in that latch-free window is fine, and is reachable
|
|
856
|
+
* today (`OptimysticModule` declares `concurrencyMode = 'reentrant-reads'`, so scans inside a
|
|
857
|
+
* transaction share one instance and only serialize on the latch). Consuming is a property of
|
|
858
|
+
* the (instance, action id) pair, not of who calls: whoever refreshes first drops exactly the
|
|
859
|
+
* durable entry's actions and the write's own later refresh then finds nothing new. The leading
|
|
860
|
+
* slice stays right because {@link actInternal} APPENDS, so anything staged after the entry was
|
|
861
|
+
* written survives it.
|
|
862
|
+
*
|
|
863
|
+
* NOTE: a concurrent WRITER is the hazard. The mark deliberately outlives the latch (see
|
|
864
|
+
* {@link inFlightActionId}), so two writes overlapping on ONE instance can trample each other's:
|
|
865
|
+
* a second write that acquires the latch between the first's failed attempt and its
|
|
866
|
+
* retry-refresh replaces the id, and the first's refresh then reads the SECOND write's id —
|
|
867
|
+
* consuming that write's durable entry and dropping pending actions of its own that never
|
|
868
|
+
* landed. Not reachable today: a coordinator commit and a `sync()` both run on one session call
|
|
869
|
+
* path, which is the same assumption the participant selection in
|
|
870
|
+
* `TransactionCoordinator.commitOnce` and its `rollback` already rest on. If a second writer is
|
|
871
|
+
* ever allowed to drive the SAME instance concurrently, this must become a per-attempt token (a
|
|
872
|
+
* mark object compared by identity, refusing to replace a live one) rather than a bare id. */
|
|
873
|
+
beginInFlightAction(actionId: ActionId): () => void {
|
|
874
|
+
this.inFlightActionId = actionId;
|
|
875
|
+
return () => {
|
|
876
|
+
if (this.inFlightActionId === actionId) {
|
|
877
|
+
this.inFlightActionId = undefined;
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Push our pending actions to the transactor */
|
|
883
|
+
async sync(options?: SyncOptions) {
|
|
884
|
+
const release = await Latches.acquire(this.latchId);
|
|
885
|
+
try {
|
|
886
|
+
await this.syncInternal(options);
|
|
887
|
+
} finally {
|
|
888
|
+
release();
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** Mints the one action id this sync reuses across all of its retry attempts, and owns it for
|
|
893
|
+
* the WHOLE cycle — including the inter-attempt refresh, which is the only thing that reads it
|
|
894
|
+
* (see {@link inFlightActionId}). `sync()`/`updateAndSync()` hold the collection latch across
|
|
895
|
+
* all of this, so the mark's lifetime is contained inside the latched span here; the disposer
|
|
896
|
+
* runs on every exit, including a throw out of retry exhaustion or an abort. */
|
|
897
|
+
private async syncInternal(options?: SyncOptions) {
|
|
898
|
+
const bytes = randomBytes(16);
|
|
899
|
+
const actionId = uint8ArrayToString(bytes, 'base64url');
|
|
900
|
+
|
|
901
|
+
const endInFlight = this.beginInFlightAction(actionId);
|
|
902
|
+
try {
|
|
903
|
+
await this.syncAttempts(actionId, options);
|
|
904
|
+
} finally {
|
|
905
|
+
endInFlight();
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** The retry loop behind {@link syncInternal}, run with `actionId` already marked in flight. */
|
|
910
|
+
private async syncAttempts(actionId: ActionId, options?: SyncOptions) {
|
|
911
|
+
const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
|
|
912
|
+
const baseBackoffMs = options?.baseBackoffMs ?? PendingRetryDelayMs;
|
|
913
|
+
const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
|
|
914
|
+
const deadlineMs = options?.deadlineMs;
|
|
915
|
+
const signal = options?.signal;
|
|
916
|
+
const startedAt = Date.now();
|
|
917
|
+
|
|
918
|
+
// Count of consecutive stale failures that made no forward progress. Reset to 0 on every
|
|
919
|
+
// successful transact, so the cap bounds only a persistently-failing sync — a legitimate
|
|
920
|
+
// large multi-batch sync (which iterates many times committing progress) never trips it.
|
|
921
|
+
let consecutiveFailures = 0;
|
|
922
|
+
let lastReason: string | undefined;
|
|
923
|
+
// Last confirmed revision a responder reported holding. Purely diagnostic — it is reported
|
|
924
|
+
// in the exhaustion error and never consulted to decide whether to keep retrying.
|
|
925
|
+
let lastStaleAt: { blockId: BlockId; rev: number } | undefined;
|
|
926
|
+
|
|
927
|
+
while (this.hasUnsyncedChanges()) {
|
|
928
|
+
if (signal?.aborted) {
|
|
929
|
+
throw makeAbortError(signal);
|
|
930
|
+
}
|
|
931
|
+
// Progress-agnostic ceiling: give up if the wall-clock deadline passed.
|
|
932
|
+
if (deadlineMs !== undefined && Date.now() - startedAt >= deadlineMs) {
|
|
933
|
+
throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason ?? 'deadline exceeded', lastStaleAt);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Snapshot the pending actions so that any new actions aren't assumed to be part of this action
|
|
937
|
+
const pending = [...this.pending];
|
|
938
|
+
|
|
939
|
+
// Create a snapshot tracker for the action, so that we can ditch the log changes if we have to retry the action
|
|
940
|
+
const snapshot = copyTransforms(this.tracker.transforms);
|
|
941
|
+
const tracker = new Tracker(this.sourceCache, snapshot);
|
|
942
|
+
|
|
943
|
+
// Add the action to the log (in local tracking space)
|
|
944
|
+
const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
|
|
945
|
+
if (!collectionLog) {
|
|
946
|
+
throw new Error(`Log not found for collection ${this.id}`);
|
|
947
|
+
}
|
|
948
|
+
const newRev = (this.source.actionContext?.rev ?? 0) + 1;
|
|
949
|
+
const addResult = await collectionLog.addActions(pending, actionId, newRev, () => tracker.transformedBlockIds());
|
|
950
|
+
|
|
951
|
+
// Declare what each touched block will contain once committed, computed from this snapshot
|
|
952
|
+
// tracker (which layers over `this.sourceCache`, so the peek/getCachedRevision probes are
|
|
953
|
+
// live). Purely local — an id whose base is not already cached is simply omitted and falls
|
|
954
|
+
// back to corroboration on the member side. Computed AFTER the log append so the log tail
|
|
955
|
+
// and header transforms this attempt just staged are digested too.
|
|
956
|
+
// NOTE: recomputed from scratch on every retry attempt (the snapshot tracker is rebuilt each
|
|
957
|
+
// iteration), so a sync that loses N races pays N full hashing passes over its touched
|
|
958
|
+
// blocks. Unmeasured and cheap relative to the round trips it is retrying; if a
|
|
959
|
+
// high-contention sync ever shows digest hashing in a profile, memoize per (id, staged ops).
|
|
960
|
+
const blockDigests = await computeBlockContentDigests(tracker, tracker.transformedBlockIds());
|
|
961
|
+
|
|
962
|
+
// Commit the action to the transactor. Carry the aged retry priority derived from the
|
|
963
|
+
// consecutive-failure count so a sync that keeps losing concurrent races out-ranks fresh
|
|
964
|
+
// (priority-0) rivals in the cluster's resolveRace (fairness-only; capped at MaxPriority).
|
|
965
|
+
// First attempt has consecutiveFailures == 0, so priority 0 — the common pend is unchanged.
|
|
966
|
+
const staleFailure = await this.source.transact(tracker.transforms, actionId, newRev, this.id, addResult.tailPath.block.header.id, clampPriority(consecutiveFailures), blockDigests);
|
|
967
|
+
if (staleFailure) {
|
|
968
|
+
consecutiveFailures++;
|
|
969
|
+
lastReason = staleFailure.reason ?? lastReason;
|
|
970
|
+
lastStaleAt = staleFailure.staleAt ?? lastStaleAt;
|
|
971
|
+
// Give up once the consecutive no-progress budget is exhausted, so a transactor that
|
|
972
|
+
// persistently rejects the sync can no longer hold the collection latch forever.
|
|
973
|
+
// NOTE: this also bounds the legitimate `pending`-wait case (retrying the same action
|
|
974
|
+
// while another commit is in flight), which used to retry indefinitely. Default 10
|
|
975
|
+
// attempts ≈ 21s of exponential backoff. If a high-contention workload legitimately
|
|
976
|
+
// needs to wait longer for a pending commit to clear, raise maxAttempts for that caller.
|
|
977
|
+
if (consecutiveFailures >= maxAttempts) {
|
|
978
|
+
throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason, lastStaleAt);
|
|
979
|
+
}
|
|
980
|
+
// Back off before every retry (any stale failure — reason/missing/pending), growing
|
|
981
|
+
// exponentially from the base delay up to the cap, with proportional random jitter so a
|
|
982
|
+
// herd of clients that lost the same race does not re-collide on the next tick (see
|
|
983
|
+
// utility/backoff.ts). The abortable sleep lets an aborted sync reject promptly instead
|
|
984
|
+
// of finishing the sleep.
|
|
985
|
+
// NOTE: the `missing`/`reason` conflict paths now pay this backoff too (they previously
|
|
986
|
+
// retried with zero delay); that is what stops the persistent-`reason` hot spin. If a
|
|
987
|
+
// high-contention workload ever shows this base delay as recovery latency, lower
|
|
988
|
+
// baseBackoffMs for that caller rather than reintroducing the zero-delay retry.
|
|
989
|
+
const delay = jitteredBackoffMs(consecutiveFailures - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
|
|
990
|
+
await abortableDelay(delay, signal);
|
|
991
|
+
// Fetch latest state - updateInternal() will call replayActions() if there are conflicts.
|
|
992
|
+
// This sync's actionId is marked in flight for the whole cycle (see syncInternal), so
|
|
993
|
+
// the refresh recognizes a log entry written by THIS action (a commit that landed
|
|
994
|
+
// durably but answered stale — see the entry loop in updateInternal) and consumes it
|
|
995
|
+
// rather than replaying it into a duplicate entry.
|
|
996
|
+
await this.updateInternal();
|
|
997
|
+
} else {
|
|
998
|
+
// Forward progress: reset the no-progress budget.
|
|
999
|
+
consecutiveFailures = 0;
|
|
1000
|
+
lastReason = undefined;
|
|
1001
|
+
lastStaleAt = undefined;
|
|
1002
|
+
// Clear the pending actions that were part of this action
|
|
1003
|
+
this.pending = this.pending.slice(pending.length);
|
|
1004
|
+
// Reset cache and replay any actions that were added during the action
|
|
1005
|
+
const transforms = tracker.reset();
|
|
1006
|
+
// NOTE: this replay runs BEFORE the cache fold and the context bump below — the
|
|
1007
|
+
// inverse of the order `updateInternal` and `TransactionCoordinator.commitOnce`
|
|
1008
|
+
// both document as required, where the newly committed state must be visible
|
|
1009
|
+
// before anything re-reads. Dormant today: `act()` and `syncInternal` take the
|
|
1010
|
+
// same collection latch, so `this.pending` cannot grow during the transact above
|
|
1011
|
+
// and the slice always leaves it empty, making this replay a no-op reset. If a
|
|
1012
|
+
// path ever stages outside that latch, move the transformCache + actionContext
|
|
1013
|
+
// lines above this replay — otherwise the replay re-reads at the superseded
|
|
1014
|
+
// revision over a cache that has not yet seen the commit, and re-stages onto a
|
|
1015
|
+
// root the commit already replaced.
|
|
1016
|
+
await this.replayActions();
|
|
1017
|
+
this.sourceCache.transformCache(transforms, newRev);
|
|
1018
|
+
this.source.actionContext = this.source.actionContext
|
|
1019
|
+
? { committed: [...this.source.actionContext.committed, { actionId, rev: newRev }], rev: newRev }
|
|
1020
|
+
: { committed: [{ actionId, rev: newRev }], rev: newRev };
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
async updateAndSync(options?: SyncOptions) {
|
|
1026
|
+
const release = await Latches.acquire(this.latchId);
|
|
1027
|
+
try {
|
|
1028
|
+
await this.updateInternal();
|
|
1029
|
+
await this.syncInternal(options);
|
|
1030
|
+
} finally {
|
|
1031
|
+
release();
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
async *selectLog(forward = true): AsyncIterableIterator<Action<TAction>> {
|
|
1036
|
+
const collectionLog = await Log.open<Action<TAction>>(this.tracker, this.id);
|
|
1037
|
+
if (!collectionLog) {
|
|
1038
|
+
throw new Error(`Log not found for collection ${this.id}`);
|
|
1039
|
+
}
|
|
1040
|
+
for await (const entry of collectionLog.select(undefined, forward)) {
|
|
1041
|
+
if (entry.action) {
|
|
1042
|
+
// NOTE: copy-then-reverse to avoid mutating the stored log entry array.
|
|
1043
|
+
// Once tsconfig targets ES2023, `entry.action.actions.toReversed()` is cleaner.
|
|
1044
|
+
yield* forward ? entry.action.actions : [...entry.action.actions].reverse();
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
private async replayActions() {
|
|
1050
|
+
this.tracker.reset();
|
|
1051
|
+
// Replay pending actions against the fresh tracker state (always called under latch)
|
|
1052
|
+
for (const action of this.pending) {
|
|
1053
|
+
await this.internalTransact(action);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
getReadDependencies(): ReadDependency[] {
|
|
1058
|
+
return this.source.getReadDependencies();
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
clearReadDependencies(): void {
|
|
1062
|
+
this.source.clearReadDependencies();
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** Called for each local action that may be in conflict with a remote action (always called under latch).
|
|
1066
|
+
* @param action - The local action to check
|
|
1067
|
+
* @param potential - The remote actions that are potentially in conflict
|
|
1068
|
+
* @returns The effective action to keep: the original (unchanged), a replacement
|
|
1069
|
+
* instance (applied instead of the original), or undefined to discard it.
|
|
1070
|
+
*/
|
|
1071
|
+
protected doFilterConflict(action: Action<TAction>, potential: Action<TAction>[]): Action<TAction> | undefined {
|
|
1072
|
+
return this.filterConflict ? this.filterConflict(action, potential) : action;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Bootstrap ActionContext from the committed tail block's state.
|
|
1076
|
+
* The tail is always committed first (commit protocol guarantee), so it's readable
|
|
1077
|
+
* with context=undefined. Its state.latest contains the ActionRev of the most recent
|
|
1078
|
+
* committed action — exactly the proof needed for the transactor to serve pending
|
|
1079
|
+
* non-tail blocks during chain walks.
|
|
1080
|
+
*
|
|
1081
|
+
* This read goes to the transactor directly rather than through {@link TransactorSource},
|
|
1082
|
+
* so it has to honour the `unavailable` flag itself: a tail the repo could not retrieve
|
|
1083
|
+
* must not degrade into "no context", which would leave the chain walk unable to see
|
|
1084
|
+
* pending non-tail blocks and the collection reading as if they did not exist. A tail
|
|
1085
|
+
* with no `state.latest` and NO flag is a real answer (nothing committed yet) and still
|
|
1086
|
+
* no-ops.
|
|
1087
|
+
*
|
|
1088
|
+
* The same goes for `unconfirmedAheadRev`: this unpinned tail read is the ONE seam where a
|
|
1089
|
+
* lagging collection can learn a newer revision exists — every later data read is pinned to
|
|
1090
|
+
* the context seeded here. Silently seeding from a tail the repo could not confirm is
|
|
1091
|
+
* current would freeze the collection at the stale revision with nothing ever reporting a
|
|
1092
|
+
* problem, so it throws the same way TransactorSource.tryGet does for its unpinned reads
|
|
1093
|
+
* (see the tradeoff NOTE there).
|
|
1094
|
+
*/
|
|
1095
|
+
private static async bootstrapContext(
|
|
1096
|
+
source: TransactorSource<IBlock>,
|
|
1097
|
+
transactor: ITransactor,
|
|
1098
|
+
header: CollectionHeaderBlock,
|
|
1099
|
+
): Promise<void> {
|
|
1100
|
+
const tailId = header.tailId;
|
|
1101
|
+
if (tailId) {
|
|
1102
|
+
const tailResult = await transactor.get({ blockIds: [tailId] });
|
|
1103
|
+
const tailEntry = tailResult?.[tailId];
|
|
1104
|
+
if (tailEntry?.unavailable !== undefined && tailEntry.block == null) {
|
|
1105
|
+
throw new BlockUnavailableError(tailId, tailEntry.unavailable);
|
|
1106
|
+
}
|
|
1107
|
+
if (tailEntry?.unconfirmedAheadRev !== undefined) {
|
|
1108
|
+
throw new BlockPossiblyStaleError(tailId, tailEntry.unconfirmedAheadRev);
|
|
1109
|
+
}
|
|
1110
|
+
const tailState = tailEntry?.state;
|
|
1111
|
+
// NOTE: this number is adopted on trust, and adoption is one-way (advanceContext never
|
|
1112
|
+
// lowers it). A tail that over-claims therefore pins the collection at a revision its
|
|
1113
|
+
// own log can never reach, permanently: every later refresh walks the log, reads the
|
|
1114
|
+
// real (lower) revision, and is refused — so the instance emits
|
|
1115
|
+
// `collection:context-not-lowered` forever while `collection:context-short-of-tail`
|
|
1116
|
+
// stays silent (the held revision is at or above what the tail claims). No condition
|
|
1117
|
+
// that makes a real tail over-claim has been demonstrated; this was seen only through a
|
|
1118
|
+
// test double built to lie (see collection.spec.ts, 'a refresh that lands short of the
|
|
1119
|
+
// tail it just read'). If an over-claiming tail is ever observed in the field, the fix
|
|
1120
|
+
// belongs here — validate the claim against the log before pinning — not in the refresh.
|
|
1121
|
+
if (tailState?.latest) {
|
|
1122
|
+
source.actionContext = {
|
|
1123
|
+
committed: [{ actionId: tailState.latest.actionId, rev: tailState.latest.rev }],
|
|
1124
|
+
rev: tailState.latest.rev,
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|