@optimystic/db-p2p 0.25.0 → 0.26.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.
Files changed (77) hide show
  1. package/dist/src/cluster/cluster-repo.d.ts +47 -91
  2. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  3. package/dist/src/cluster/cluster-repo.js +138 -200
  4. package/dist/src/cluster/cluster-repo.js.map +1 -1
  5. package/dist/src/cluster/race-resolution.d.ts +89 -0
  6. package/dist/src/cluster/race-resolution.d.ts.map +1 -0
  7. package/dist/src/cluster/race-resolution.js +140 -0
  8. package/dist/src/cluster/race-resolution.js.map +1 -0
  9. package/dist/src/cluster/record-operations.d.ts +25 -0
  10. package/dist/src/cluster/record-operations.d.ts.map +1 -0
  11. package/dist/src/cluster/record-operations.js +56 -0
  12. package/dist/src/cluster/record-operations.js.map +1 -0
  13. package/dist/src/cohort-topic/host.d.ts +133 -51
  14. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  15. package/dist/src/cohort-topic/host.js +290 -83
  16. package/dist/src/cohort-topic/host.js.map +1 -1
  17. package/dist/src/libp2p-node-base.d.ts +7 -4
  18. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  19. package/dist/src/libp2p-node-base.js +5 -5
  20. package/dist/src/libp2p-node-base.js.map +1 -1
  21. package/dist/src/storage/block-latch.d.ts +5 -4
  22. package/dist/src/storage/block-latch.d.ts.map +1 -1
  23. package/dist/src/storage/block-latch.js +5 -4
  24. package/dist/src/storage/block-latch.js.map +1 -1
  25. package/dist/src/storage/block-storage.d.ts +1 -1
  26. package/dist/src/storage/block-storage.d.ts.map +1 -1
  27. package/dist/src/storage/block-storage.js +11 -3
  28. package/dist/src/storage/block-storage.js.map +1 -1
  29. package/dist/src/storage/cached-raw-storage.d.ts +17 -1
  30. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  31. package/dist/src/storage/cached-raw-storage.js +8 -1
  32. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  33. package/dist/src/storage/cached-store-driver.d.ts +7 -0
  34. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  35. package/dist/src/storage/cached-store-driver.js +20 -0
  36. package/dist/src/storage/cached-store-driver.js.map +1 -1
  37. package/dist/src/storage/i-block-storage.d.ts +45 -2
  38. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  39. package/dist/src/storage/i-block-storage.js +29 -0
  40. package/dist/src/storage/i-block-storage.js.map +1 -1
  41. package/dist/src/storage/i-raw-storage.d.ts +16 -0
  42. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  43. package/dist/src/storage/kv-raw-storage.d.ts +5 -1
  44. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  45. package/dist/src/storage/kv-raw-storage.js +8 -1
  46. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  47. package/dist/src/storage/raw-store-driver.d.ts +15 -0
  48. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  49. package/dist/src/storage/shared-cache-pool.d.ts +5 -0
  50. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  51. package/dist/src/storage/shared-cache-pool.js +6 -1
  52. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  53. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  54. package/dist/src/storage/storage-repo.js +141 -99
  55. package/dist/src/storage/storage-repo.js.map +1 -1
  56. package/dist/src/storage/with-read-cache.d.ts +9 -5
  57. package/dist/src/storage/with-read-cache.d.ts.map +1 -1
  58. package/dist/src/storage/with-read-cache.js +16 -6
  59. package/dist/src/storage/with-read-cache.js.map +1 -1
  60. package/package.json +2 -2
  61. package/{README.md → readme.md} +2 -2
  62. package/src/cluster/cluster-repo.ts +187 -211
  63. package/src/cluster/race-resolution.ts +158 -0
  64. package/src/cluster/record-operations.ts +59 -0
  65. package/src/cohort-topic/host.ts +423 -118
  66. package/src/libp2p-node-base.ts +12 -9
  67. package/src/storage/block-latch.ts +5 -4
  68. package/src/storage/block-storage.ts +11 -3
  69. package/src/storage/cached-raw-storage.ts +21 -1
  70. package/src/storage/cached-store-driver.ts +23 -0
  71. package/src/storage/i-block-storage.ts +47 -2
  72. package/src/storage/i-raw-storage.ts +17 -0
  73. package/src/storage/kv-raw-storage.ts +8 -1
  74. package/src/storage/raw-store-driver.ts +26 -0
  75. package/src/storage/shared-cache-pool.ts +6 -1
  76. package/src/storage/storage-repo.ts +143 -103
  77. package/src/storage/with-read-cache.ts +16 -6
@@ -520,6 +520,8 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
520
520
  };
521
521
  }
522
522
 
523
+ // Already deduped: `blockIdsForTransforms` builds its result through a Set. So the pass-2 save
524
+ // loop below cannot write one block twice, and the echoed `blockIds` carries no duplicate.
523
525
  const blockIds = blockIdsForTransforms(request.transforms);
524
526
  log('pend actionId=%s blockIds=%d rev=%s', request.actionId, blockIds.length, request.rev);
525
527
  const pendings: ActionPending[] = [];
@@ -531,124 +533,162 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
531
533
  // Blocks this action ALREADY committed at exactly the requested revision — the durable half
532
534
  // of a torn action whose retry reuses the same actionId. Sibling of the `alreadyDone`
533
535
  // partition in `commit` below: satisfied, not merely non-stale, so no pending is recorded
534
- // for them (see the fan-out at the end of this method).
536
+ // for them (see pass 2).
535
537
  const satisfied = new Set<BlockId>();
536
-
537
- // Potential race condition: A concurrent commit operation could complete
538
- // between the conflict checks (latest.rev, listPendingTransactions) and the
539
- // savePendingTransaction call below. This pend operation might succeed based on
540
- // stale information, but the subsequent commit for this pend would likely
541
- // fail correctly later if a conflict arose. Locking here could make the initial
542
- // check more accurate but adds overhead. The current approach prioritizes
543
- // letting the commit be the final arbiter.
544
- for (const blockId of blockIds) {
545
- const blockStorage = this.createBlockStorage(blockId);
546
- const transforms = transformForBlockId(request.transforms, blockId);
547
-
548
- // Handle any conflicting revisions FIRST: a block this same action already committed at
549
- // exactly the requested revision is satisfied, and skips both this check and the
550
- // pending-action listing below.
551
- if (request.rev !== undefined || transforms.insert) {
552
- const latest = await blockStorage.getLatest();
553
- // Our own already-durable work, met again by a retry (see {@link isOwnRevision}):
554
- // treating it as a stale rival would refuse the writer with its own commit.
555
- // NOTE: a rev-less pend (`request.rev === undefined`, an insert-only claim) can
556
- // never match, so a torn action retried WITHOUT a revision is still refused by its
557
- // own insert. No production caller sends one `TransactorSource.transact` and the
558
- // multi-collection coordinator both require a rev so this is unreachable today;
559
- // if a rev-less write path ever appears, match on `latest.actionId` alone here.
560
- if (isOwnRevision(latest, request.rev, request.actionId)) {
561
- satisfied.add(blockId);
562
- continue;
563
- }
564
- if (latest && latest.rev >= (request.rev ?? 0)) {
565
- // Only a real revision race yields a meaningful `staleAt`. When `request.rev` is
566
- // undefined this same branch fires for an insert collision (the comparison degrades
567
- // to `latest.rev >= 0`, true for any existing block), and reporting that block's
568
- // revision would be a number that answers a question nobody asked.
569
- if (request.rev !== undefined) {
570
- staleAt = highestStaleAt([staleAt, { blockId, rev: latest.rev }]);
538
+ // Blocks observed at or past the requested revision under a DIFFERENT action — a real stale
539
+ // loss. Counted separately from `missing` because the two are not the same question: `missing`
540
+ // is the catch-up the loser is handed, and a node whose revision index is sparse over
541
+ // [request.rev, latest.rev] hands back an empty one while still having lost. Gating the
542
+ // refusal on the enumeration would then let a block pass classification that pass 2 cannot
543
+ // write (`savePendingTransaction` refuses it), turning a stale answer into a throw. `commit`
544
+ // takes the same position it pushes a `missedCommits` entry "even if transforms is empty,
545
+ // because we want to reject the older version".
546
+ let staleCount = 0;
547
+
548
+ // Classifying and saving are ONE atomic step per pend: both passes below run inside a single
549
+ // multi-block write-latch hold, so no commit can land between deciding a block is pendable
550
+ // and writing its pending record. That is the whole property a pend never writes a pending
551
+ // record for a revision already taken. Such a record could never be promoted (`commit`
552
+ // partitions the block as already-done or refuses it as stale, and promotion is the only
553
+ // thing that removes a record on the success path), and would then be reported as a
554
+ // conflicting in-flight action to every later writer of the block. See docs/repository.md,
555
+ // Invariant P; `BlockStorage.savePendingTransaction` refuses such a write outright.
556
+ //
557
+ // TWO passes, not one interleaved loop: with a single loop a block refused partway through
558
+ // would leave records already written for its predecessors, and retracting those under the
559
+ // hold could delete a record an EARLIER pend of the same action legitimately left. Classify
560
+ // everything before writing anything, and no record is ever written that must be taken back.
561
+ //
562
+ // Everything inside the hold is local storage I/O. No network I/O and no caller-supplied
563
+ // code may enter it — `checkPendValidation` above can call the caller's validation hook,
564
+ // which is precisely why it stays outside. `commit` keeps the same rule. Acquiring through
565
+ // `acquireBlockWriteLatches` (deduped, sorted) is what keeps the three multi-latch holders —
566
+ // this, `commit`, and `applyInvalidation` free of deadlock, and no caller of `pend`
567
+ // (`ClusterRepo`, `CoordinatorRepo`, `service.ts`) holds a block latch, so the hold cannot
568
+ // re-enter itself.
569
+ //
570
+ // NOTE: a pend now blocks concurrent commits on its blocks for the span of BOTH passes, not
571
+ // just its writes. Accepted: every call inside is local storage I/O, and `commit` already
572
+ // holds the same set for a comparable span. If pend latency on contended blocks ever shows
573
+ // up in a profile, two things inside the hold scale with width and are the ones to look at:
574
+ // the policy-'r' arm reads one transform per rival, and pass 2 awaits its saves one block at
575
+ // a time (where the pre-latch code fanned out with `Promise.all`). Sequential is the
576
+ // deliberate choice — a throw mid-pass then strands records for FEWER blocks, not more — so
577
+ // batch or fan out only with that tradeoff in hand.
578
+ const { latches, release } = await acquireBlockWriteLatches(blockIds);
579
+ try {
580
+ // --- Pass 1: classify. Every read below runs under the hold. ---
581
+ for (const blockId of blockIds) {
582
+ const blockStorage = this.createBlockStorage(blockId);
583
+ const transforms = transformForBlockId(request.transforms, blockId);
584
+
585
+ // Handle any conflicting revisions FIRST: a block this same action already committed at
586
+ // exactly the requested revision is satisfied, and skips both this check and the
587
+ // pending-action listing below.
588
+ if (request.rev !== undefined || transforms.insert) {
589
+ const latest = await blockStorage.getLatest();
590
+ // Our own already-durable work, met again by a retry (see {@link isOwnRevision}):
591
+ // treating it as a stale rival would refuse the writer with its own commit.
592
+ // NOTE: a rev-less pend (`request.rev === undefined`, an insert-only claim) can
593
+ // never match, so a torn action retried WITHOUT a revision is still refused by its
594
+ // own insert. No production caller sends one — `TransactorSource.transact` and the
595
+ // multi-collection coordinator both require a rev — so this is unreachable today;
596
+ // if a rev-less write path ever appears, match on `latest.actionId` alone here.
597
+ if (isOwnRevision(latest, request.rev, request.actionId)) {
598
+ satisfied.add(blockId);
599
+ continue;
571
600
  }
572
- const transforms = await asyncIteratorToArray(blockStorage.listRevisions(request.rev ?? 0, latest.rev));
573
- for (const actionRev of transforms) {
574
- const transform = await blockStorage.getTransaction(actionRev.actionId);
575
- if (!transform) {
576
- throw new Error(`Missing action ${actionRev.actionId} for block ${blockId}`);
601
+ if (latest && latest.rev >= (request.rev ?? 0)) {
602
+ // Only a real revision race yields a meaningful `staleAt`. When `request.rev` is
603
+ // undefined this same branch fires for an insert collision (the comparison degrades
604
+ // to `latest.rev >= 0`, true for any existing block), and reporting that block's
605
+ // revision would be a number that answers a question nobody asked.
606
+ if (request.rev !== undefined) {
607
+ staleAt = highestStaleAt([staleAt, { blockId, rev: latest.rev }]);
608
+ }
609
+ staleCount++;
610
+ const missedRevisions = await asyncIteratorToArray(blockStorage.listRevisions(request.rev ?? 0, latest.rev));
611
+ for (const actionRev of missedRevisions) {
612
+ const transform = await blockStorage.getTransaction(actionRev.actionId);
613
+ if (!transform) {
614
+ throw new Error(`Missing action ${actionRev.actionId} for block ${blockId}`);
615
+ }
616
+ missing.push({
617
+ actionId: actionRev.actionId,
618
+ rev: actionRev.rev,
619
+ transforms: transformsFromTransform(transform, blockId)
620
+ });
577
621
  }
578
- missing.push({
579
- actionId: actionRev.actionId,
580
- rev: actionRev.rev,
581
- transforms: transformsFromTransform(transform, blockId)
582
- });
583
622
  }
584
623
  }
585
- }
586
624
 
587
- // Then handle any pending actions
588
- const pending = await asyncIteratorToArray(blockStorage.listPendingTransactions());
589
- pendings.push(...pending.map(actionId => ({ blockId, actionId })));
590
- }
591
-
592
- if (missing.length) {
593
- log('pend:stale actionId=%s missing=%d', request.actionId, missing.length);
594
- return {
595
- success: false,
596
- conflict: true,
597
- missing,
598
- ...(staleAt === undefined ? {} : { staleAt })
599
- };
600
- }
625
+ // Then handle any pending actions
626
+ const pending = await asyncIteratorToArray(blockStorage.listPendingTransactions());
627
+ pendings.push(...pending.map(actionId => ({ blockId, actionId })));
628
+ }
601
629
 
602
- if (pendings.length > 0) {
603
- if (request.policy === 'f') { // Fail on pending actions
604
- return { success: false, conflict: true, pending: pendings };
605
- } else if (request.policy === 'r') { // Return populated pending actions
630
+ // Every refusal below returns having written ZERO pending records — that is what pass 1
631
+ // finishing before pass 2 begins buys.
632
+ if (staleCount > 0) {
633
+ log('pend:stale actionId=%s stale=%d missing=%d', request.actionId, staleCount, missing.length);
606
634
  return {
607
635
  success: false,
608
636
  conflict: true,
609
- pending: await Promise.all(pendings.map(async action => {
610
- const blockStorage = this.createBlockStorage(action.blockId);
611
- return {
612
- blockId: action.blockId,
613
- actionId: action.actionId,
614
- transform: (await blockStorage.getPendingTransaction(action.actionId))
615
- ?? (await blockStorage.getTransaction(action.actionId))! // Possible that since enumeration, the action has been promoted
616
- }
617
- }))
637
+ missing,
638
+ ...(staleAt === undefined ? {} : { staleAt })
618
639
  };
619
640
  }
620
- }
621
641
 
642
+ if (pendings.length > 0) {
643
+ if (request.policy === 'f') { // Fail on pending actions
644
+ return { success: false, conflict: true, pending: pendings };
645
+ } else if (request.policy === 'r') { // Return populated pending actions
646
+ return {
647
+ success: false,
648
+ conflict: true,
649
+ pending: await Promise.all(pendings.map(async action => {
650
+ const blockStorage = this.createBlockStorage(action.blockId);
651
+ return {
652
+ blockId: action.blockId,
653
+ actionId: action.actionId,
654
+ // The fallback stays: a rival enumerated on a block we hold cannot be promoted
655
+ // out from under us mid-hold, but a partially-overlapping pend can still have
656
+ // promoted one on a block outside this hold.
657
+ transform: (await blockStorage.getPendingTransaction(action.actionId))
658
+ ?? (await blockStorage.getTransaction(action.actionId))!
659
+ }
660
+ }))
661
+ };
662
+ }
663
+ }
622
664
 
623
- // Simultaneously save pending action for each block
624
- // Note: that this is not atomic, after we checked for conflicts and pending actions
625
- // new pending or committed actions may have been added. This is okay, because
626
- // this check during pend is conservative.
627
- //
628
- // Each block's pending write runs under THAT block's write latch, one latch per branch and
629
- // never nested: savePendingTransaction seeds the block's metadata blob when it has none, and
630
- // an unlatched seed racing a concurrent commit/replica on a fresh block erases the `latest`
631
- // the other writer just landed. Never more than one block latch is held by a branch, so this
632
- // cannot deadlock against commit's sorted multi-latch acquisition.
633
- //
634
- // `satisfied` blocks are skipped: `commit`'s `alreadyDone` arm skips `internalCommit`, the
635
- // only thing that promotes (and thereby removes) a pending record, so a pending saved here
636
- // would never clear — a permanent durable reservation that the rival-pending checks (this
637
- // method's listPendingTransactions scan, and `ClusterMember.validatePendOperations`) refuse
638
- // every future writer against, a worse wedge than the one this carve-out fixes. They still
639
- // ride in the returned `blockIds` so `cancel` covers them (deleting an absent pending is a
640
- // no-op that writes no metadata).
641
- await Promise.all(blockIds.filter(blockId => !satisfied.has(blockId)).map(blockId => {
642
- const blockStorage = this.createBlockStorage(blockId);
643
- const blockTransform = transformForBlockId(request.transforms, blockId);
644
- return withBlockWriteLatch(blockId, latch => blockStorage.savePendingTransaction(request.actionId, blockTransform, latch));
645
- }));
665
+ // --- Pass 2: save. Same hold, so nothing advanced a block since pass 1 observed it. ---
666
+ //
667
+ // `satisfied` blocks are skipped: `commit`'s `alreadyDone` arm skips `internalCommit`, the
668
+ // only thing that promotes (and thereby removes) a pending record, so a pending saved here
669
+ // would never clear — a permanent durable reservation that the rival-pending checks (this
670
+ // method's listPendingTransactions scan, and `ClusterMember.validatePendOperations`) refuse
671
+ // every future writer against. They still ride in the returned `blockIds` so `cancel`
672
+ // covers them (deleting an absent pending is a no-op that writes no metadata).
673
+ for (const blockId of blockIds) {
674
+ if (satisfied.has(blockId)) {
675
+ continue;
676
+ }
677
+ const blockStorage = this.createBlockStorage(blockId);
678
+ const blockTransform = transformForBlockId(request.transforms, blockId);
679
+ await blockStorage.savePendingTransaction(request.actionId, blockTransform, request.rev, latches.get(blockId)!);
680
+ }
646
681
 
647
- return {
648
- success: true,
649
- pending: pendings,
650
- blockIds
651
- } as PendSuccess;
682
+ return {
683
+ success: true,
684
+ pending: pendings,
685
+ blockIds
686
+ } as PendSuccess;
687
+ } finally {
688
+ // Releases on every path, including the early returns above and the
689
+ // `Missing action … for block …` throw inside pass 1.
690
+ release();
691
+ }
652
692
  }
653
693
 
654
694
  async cancel(actionRef: ActionBlocks, _options?: MessageOptions): Promise<void> {
@@ -102,8 +102,12 @@ class Lease implements ReadCacheLease {
102
102
  * Returns the storage **unchanged**, with no `lease`, when caching would not pay:
103
103
  * - `MemoryRawStorage` is already in memory; the cache would duplicate every map entry's
104
104
  * bookkeeping with nothing to save (see `CachedStoreDriver`'s class doc).
105
- * - An already-cached storage (a host that wrapped before handing it over) is not wrapped twice,
106
- * and stays the host's to dispose.
105
+ * - A storage that reports {@link IRawStorage.readCached} (a host that attached a cache before
106
+ * handing it over) is not wrapped twice, and stays the host's to dispose. The capability, not
107
+ * a class check: BOTH documented constructions — `new CachedRawStorage(inner)` and
108
+ * `new KvRawStorage(new CachedStoreDriver(driver))` — report it, and only the first is a
109
+ * `CachedRawStorage`. Checking the class instead sent the driver-level shape down the
110
+ * construct path, where the pool's identity guard threw and the host's node failed to start.
107
111
  *
108
112
  * Why this is needed at all: `BlockStorage` re-reads block metadata on essentially every
109
113
  * operation and `StorageRepo` builds a fresh `BlockStorage` per block per call, so nothing above
@@ -122,9 +126,9 @@ class Lease implements ReadCacheLease {
122
126
  * unwrapped instance handed to two consumers). Identity is one-directional — equal proves
123
127
  * sameness, unequal proves nothing — so backends whose identity under-approximates (path
124
128
  * aliases, two handles over one database; each backend's `NOTE:` lists its gaps) can still end
125
- * up with two caches; and a host that builds its own `CachedRawStorage` never enters the
126
- * registry, so a second consumer wrapping a fresh instance over that store still gets a second
127
- * cache. What remains of Invariant 5 (`packages/db-p2p/docs/storage.md`) is the cross-process
129
+ * up with two caches; and a host that builds its own cache in either construction — never
130
+ * enters the registry, so a second consumer wrapping a fresh instance over that store still gets
131
+ * a second cache. What remains of Invariant 5 (`packages/db-p2p/docs/storage.md`) is the cross-process
128
132
  * case: the filesystem driver takes no lock, and a second process's writes bypass this cache.
129
133
  *
130
134
  * On a dedupe hit the FIRST caller's `label` and `pool` stick: `pool.stats()` shows whoever
@@ -148,7 +152,13 @@ class Lease implements ReadCacheLease {
148
152
  * dedupe hit.
149
153
  */
150
154
  export function withReadCache(storage: IRawStorage, label?: string, pool?: SharedCachePool): ResolvedReadCache {
151
- if (storage instanceof MemoryRawStorage || storage instanceof CachedRawStorage) {
155
+ // `readCached` is a plain property read, which keeps this whole function synchronous — see
156
+ // the "no `await` between lookup and insert" note above; never make it an accessor that
157
+ // could become async. `MemoryRawStorage` never carries the marker (its driver is uncached);
158
+ // it is excluded for a different reason and keeps its own class check. Note a plain
159
+ // `KvRawStorage` over a bare `MemoryStoreDriver` is NOT a `MemoryRawStorage` and is still
160
+ // wrapped — deliberate, and pinned by a test.
161
+ if (storage instanceof MemoryRawStorage || storage.readCached) {
152
162
  return { storage, lease: undefined };
153
163
  }
154
164