@optimystic/db-p2p 0.25.1 → 0.27.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 (107) hide show
  1. package/dist/src/cluster/certified-claims.d.ts +17 -3
  2. package/dist/src/cluster/certified-claims.d.ts.map +1 -1
  3. package/dist/src/cluster/certified-claims.js +5 -3
  4. package/dist/src/cluster/certified-claims.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +57 -92
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +149 -201
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/commit-proof.d.ts +16 -0
  10. package/dist/src/cluster/commit-proof.d.ts.map +1 -1
  11. package/dist/src/cluster/commit-proof.js +32 -1
  12. package/dist/src/cluster/commit-proof.js.map +1 -1
  13. package/dist/src/cluster/quorum-restore.d.ts +81 -28
  14. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  15. package/dist/src/cluster/quorum-restore.js +148 -51
  16. package/dist/src/cluster/quorum-restore.js.map +1 -1
  17. package/dist/src/cluster/race-resolution.d.ts +89 -0
  18. package/dist/src/cluster/race-resolution.d.ts.map +1 -0
  19. package/dist/src/cluster/race-resolution.js +140 -0
  20. package/dist/src/cluster/race-resolution.js.map +1 -0
  21. package/dist/src/cluster/reconcile-block.d.ts +9 -4
  22. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  23. package/dist/src/cluster/reconcile-block.js +28 -11
  24. package/dist/src/cluster/reconcile-block.js.map +1 -1
  25. package/dist/src/cluster/record-operations.d.ts +25 -0
  26. package/dist/src/cluster/record-operations.d.ts.map +1 -0
  27. package/dist/src/cluster/record-operations.js +56 -0
  28. package/dist/src/cluster/record-operations.js.map +1 -0
  29. package/dist/src/cohort-topic/host.d.ts +133 -51
  30. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  31. package/dist/src/cohort-topic/host.js +290 -83
  32. package/dist/src/cohort-topic/host.js.map +1 -1
  33. package/dist/src/libp2p-node-base.d.ts +13 -4
  34. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  35. package/dist/src/libp2p-node-base.js +5 -5
  36. package/dist/src/libp2p-node-base.js.map +1 -1
  37. package/dist/src/repo/cluster-coordinator.d.ts +9 -0
  38. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  39. package/dist/src/repo/cluster-coordinator.js +13 -2
  40. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  41. package/dist/src/repo/coordinator-repo.d.ts +34 -2
  42. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  43. package/dist/src/repo/coordinator-repo.js +57 -3
  44. package/dist/src/repo/coordinator-repo.js.map +1 -1
  45. package/dist/src/storage/block-latch.d.ts +5 -4
  46. package/dist/src/storage/block-latch.d.ts.map +1 -1
  47. package/dist/src/storage/block-latch.js +5 -4
  48. package/dist/src/storage/block-latch.js.map +1 -1
  49. package/dist/src/storage/block-storage.d.ts +1 -1
  50. package/dist/src/storage/block-storage.d.ts.map +1 -1
  51. package/dist/src/storage/block-storage.js +11 -3
  52. package/dist/src/storage/block-storage.js.map +1 -1
  53. package/dist/src/storage/cached-raw-storage.d.ts +17 -1
  54. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  55. package/dist/src/storage/cached-raw-storage.js +8 -1
  56. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  57. package/dist/src/storage/cached-store-driver.d.ts +7 -0
  58. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  59. package/dist/src/storage/cached-store-driver.js +20 -0
  60. package/dist/src/storage/cached-store-driver.js.map +1 -1
  61. package/dist/src/storage/i-block-storage.d.ts +45 -2
  62. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  63. package/dist/src/storage/i-block-storage.js +29 -0
  64. package/dist/src/storage/i-block-storage.js.map +1 -1
  65. package/dist/src/storage/i-raw-storage.d.ts +16 -0
  66. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  67. package/dist/src/storage/kv-raw-storage.d.ts +5 -1
  68. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  69. package/dist/src/storage/kv-raw-storage.js +8 -1
  70. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  71. package/dist/src/storage/raw-store-driver.d.ts +15 -0
  72. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  73. package/dist/src/storage/shared-cache-pool.d.ts +5 -0
  74. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  75. package/dist/src/storage/shared-cache-pool.js +6 -1
  76. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  77. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  78. package/dist/src/storage/storage-repo.js +141 -99
  79. package/dist/src/storage/storage-repo.js.map +1 -1
  80. package/dist/src/storage/with-read-cache.d.ts +9 -5
  81. package/dist/src/storage/with-read-cache.d.ts.map +1 -1
  82. package/dist/src/storage/with-read-cache.js +16 -6
  83. package/dist/src/storage/with-read-cache.js.map +1 -1
  84. package/package.json +2 -2
  85. package/{README.md → readme.md} +2 -2
  86. package/src/cluster/certified-claims.ts +22 -9
  87. package/src/cluster/cluster-repo.ts +199 -212
  88. package/src/cluster/commit-proof.ts +38 -2
  89. package/src/cluster/quorum-restore.ts +183 -56
  90. package/src/cluster/race-resolution.ts +158 -0
  91. package/src/cluster/reconcile-block.ts +34 -11
  92. package/src/cluster/record-operations.ts +59 -0
  93. package/src/cohort-topic/host.ts +423 -118
  94. package/src/libp2p-node-base.ts +18 -9
  95. package/src/repo/cluster-coordinator.ts +1039 -1027
  96. package/src/repo/coordinator-repo.ts +1937 -1855
  97. package/src/storage/block-latch.ts +5 -4
  98. package/src/storage/block-storage.ts +11 -3
  99. package/src/storage/cached-raw-storage.ts +21 -1
  100. package/src/storage/cached-store-driver.ts +23 -0
  101. package/src/storage/i-block-storage.ts +47 -2
  102. package/src/storage/i-raw-storage.ts +17 -0
  103. package/src/storage/kv-raw-storage.ts +8 -1
  104. package/src/storage/raw-store-driver.ts +26 -0
  105. package/src/storage/shared-cache-pool.ts +6 -1
  106. package/src/storage/storage-repo.ts +143 -103
  107. package/src/storage/with-read-cache.ts +16 -6
@@ -90,10 +90,11 @@ export type BlockWriteLatches = {
90
90
 
91
91
  /**
92
92
  * Acquire the write latches for a whole set of blocks at once — the only sanctioned way to hold more
93
- * than one. Two callers need it (`StorageRepo.commit` and `applyInvalidation`), and the property that
94
- * keeps them from deadlocking against each other is not local to either: it is that EVERY multi-latch
95
- * holder acquires in the one global order. Owning that here makes it a property of the module that
96
- * owns the key rather than a rule each call site restates and a third one could get wrong.
93
+ * than one. Three callers need it (`StorageRepo.pend`, `StorageRepo.commit` and `applyInvalidation`),
94
+ * and the property that keeps them from deadlocking against each other is not local to any of them:
95
+ * it is that EVERY multi-latch holder acquires in the one global order. Owning that here makes it a
96
+ * property of the module that owns the key rather than a rule each call site restates and the next
97
+ * one could get wrong.
97
98
  *
98
99
  * Three things this does that a hand-rolled loop keeps getting wrong:
99
100
  * - **Dedups** the ids. `Latches` is a plain FIFO mutex with no re-entrancy, so a repeated id in the
@@ -4,7 +4,7 @@ import type { BlockCommitProof } from "../cluster/commit-proof.js";
4
4
  import type { BlockArchive, BlockMetadata, RestoreCallback, RevisionRange } from "./struct.js";
5
5
  import type { IRawStorage } from "./i-raw-storage.js";
6
6
  import { mergeRanges } from "./helpers.js";
7
- import { RevisionNotCoveredError, type IBlockStorage } from "./i-block-storage.js";
7
+ import { RevisionNotCoveredError, PendRevisionTakenError, type IBlockStorage } from "./i-block-storage.js";
8
8
  import type { BlockWriteLatch } from "./block-latch.js";
9
9
  import { createLogger } from "../logger.js";
10
10
 
@@ -155,10 +155,18 @@ export class BlockStorage implements IBlockStorage {
155
155
  yield* this.storage.listPendingTransactions(this.blockId);
156
156
  }
157
157
 
158
- async savePendingTransaction(actionId: ActionId, transform: Transform, latch: BlockWriteLatch): Promise<void> {
158
+ async savePendingTransaction(actionId: ActionId, transform: Transform, rev: number | undefined, latch: BlockWriteLatch): Promise<void> {
159
159
  this.assertLatch(latch);
160
- log('pend blockId=%s actionId=%s', this.blockId, actionId);
160
+ log('pend blockId=%s actionId=%s rev=%s', this.blockId, actionId, rev);
161
161
  let meta = await this.storage.getMetadata(this.blockId);
162
+ // Refuse a record that could never be promoted (see IBlockStorage.savePendingTransaction).
163
+ // The metadata read above is unconditional anyway, so this costs one comparison and no I/O.
164
+ // `>=` deliberately covers BOTH unpromotable cases at once — our own already-committed
165
+ // revision (commit partitions it as already-done) and a rival's win (commit refuses it as
166
+ // stale) — so this is not an `isOwnRevision` check.
167
+ if (rev !== undefined && meta?.latest !== undefined && meta.latest.rev >= rev) {
168
+ throw new PendRevisionTakenError(this.blockId, actionId, rev, meta.latest);
169
+ }
162
170
  if (!meta) {
163
171
  // A freshly-pended block holds NO committed revision, so it can reconstruct
164
172
  // nothing yet: seed empty ranges. The first commit anchors an OPEN-ENDED span at
@@ -24,11 +24,15 @@ export class RawStorageDriverAdapter implements RawStoreDriver {
24
24
  * Optional passthroughs wired only when the inner storage provides them, mirroring
25
25
  * `KvRawStorage`'s constructor: feature-detection above must observe the inner
26
26
  * storage's true capability. `IRawStorage` has no `close`/`approximateBytesUsed`
27
- * driver names — they map from `listBlockIds`/`getApproximateBytesUsed`.
27
+ * driver names — they map from `listBlockIds`/`getApproximateBytesUsed`. `readCached`
28
+ * keeps its name across the boundary; carrying it up is what lets a re-kerneled cached
29
+ * storage still report itself as cached, and what makes `CachedStoreDriver`'s
30
+ * redundant-wrap guard fire for `new CachedRawStorage(alreadyCachedStorage)`.
28
31
  */
29
32
  listBlockIds?: () => AsyncIterable<BlockId>;
30
33
  approximateBytesUsed?: () => Promise<number>;
31
34
  storeIdentity?: () => StoreIdentity;
35
+ readCached?: true;
32
36
 
33
37
  constructor(private readonly inner: IRawStorage) {
34
38
  if (inner.getStoreIdentity) {
@@ -40,6 +44,9 @@ export class RawStorageDriverAdapter implements RawStoreDriver {
40
44
  if (inner.getApproximateBytesUsed) {
41
45
  this.approximateBytesUsed = () => inner.getApproximateBytesUsed!();
42
46
  }
47
+ if (inner.readCached) {
48
+ this.readCached = true;
49
+ }
43
50
  }
44
51
 
45
52
  // --- metadata ---
@@ -176,6 +183,19 @@ export class RawStorageDriverAdapter implements RawStoreDriver {
176
183
  export class CachedRawStorage extends KvRawStorage {
177
184
  private readonly cacheDriver: CachedStoreDriver;
178
185
 
186
+ /**
187
+ * Always present here: this class is a `KvRawStorage` over a `CachedStoreDriver`, so the
188
+ * base constructor's passthrough always sets it. Narrowed from the base's optional
189
+ * `readCached?: true` purely for the type.
190
+ *
191
+ * `declare` — type-only, NO emit. `tsconfig.base.json` targets ES2022 without
192
+ * `useDefineForClassFields`, which therefore defaults to true, so a plain field
193
+ * declaration here would emit a `defineProperty(this, 'readCached', undefined)` that runs
194
+ * AFTER `super()` and clobber what the base constructor just assigned. Same reason
195
+ * `FileRawStorage` &c. use `declare getStoreIdentity: …`.
196
+ */
197
+ declare readonly readCached: true;
198
+
179
199
  constructor(inner: IRawStorage, pool?: SharedCachePool, label?: string) {
180
200
  const cacheDriver = new CachedStoreDriver(new RawStorageDriverAdapter(inner), pool, label);
181
201
  super(cacheDriver);
@@ -200,11 +200,34 @@ export class CachedStoreDriver implements RawStoreDriver, PoolEntryOwner {
200
200
  approximateBytesUsed?: () => Promise<number>;
201
201
  storeIdentity?: () => StoreIdentity;
202
202
 
203
+ /**
204
+ * This driver IS the read cache, so the marker is unconditional — unlike the passthroughs
205
+ * above, which mirror the inner driver's capabilities. Wrappers above (`KvRawStorage`,
206
+ * `RawStorageDriverAdapter`) carry it up, which is how `withReadCache` recognises BOTH
207
+ * documented cache constructions without naming a concrete class.
208
+ */
209
+ readonly readCached: true = true;
210
+
203
211
  constructor(
204
212
  private readonly inner: RawStoreDriver,
205
213
  private readonly pool: SharedCachePool = defaultCachePool(),
206
214
  label?: string,
207
215
  ) {
216
+ // Stacked wrap — the inner driver already has a read cache at or below it, so this one
217
+ // would only ever read through that one. Caught HERE, and before `registerStore`, because
218
+ // the pool cannot tell a stacked wrap from a side-by-side pair: it sees only that the
219
+ // identity is claimed, and would report the (wrong, and much scarier) divergence failure.
220
+ // Identity-less inner drivers reach the pool's guard not at all, so without this check a
221
+ // stacked wrap over one of them builds silently — pure overhead, never useful.
222
+ if (inner.readCached) {
223
+ throw new Error(
224
+ `redundant read cache: the inner driver is already read-cached`
225
+ + `${inner.storeIdentity ? ` (${JSON.stringify(inner.storeIdentity())})` : ''}, so wrapping `
226
+ + `it again (label ${JSON.stringify(label ?? null)}) stacks a second bookkeeping layer that `
227
+ + `can only ever read through the first — every miss pays twice and nothing is saved. `
228
+ + `Use the inner cached driver as-is.`
229
+ );
230
+ }
208
231
  // Registration is the choke point every construction path shares (this constructor, the
209
232
  // `CachedRawStorage` wrapper, `withReadCache`), so naming the backing store here is what
210
233
  // lets the pool refuse a second, permanently divergent view of it. Throws before anything else is
@@ -20,6 +20,28 @@ export class RevisionNotCoveredError extends Error {
20
20
  }
21
21
  }
22
22
 
23
+ /**
24
+ * Thrown by {@link IBlockStorage.savePendingTransaction} when the block has already committed a
25
+ * revision at or past the one the pending record names, so the record could never be promoted:
26
+ * `StorageRepo.commit` partitions such a block as already-done (`latest.rev === rev`, same action)
27
+ * or refuses it as stale (`latest.rev > rev`, or a rival at the same rev), and neither path runs
28
+ * the promotion that is the only thing removing a pending record on the success path. The record
29
+ * would then be reported as a conflicting in-flight action to every later writer of the block —
30
+ * see **Invariant P** on {@link IBlockStorage.promotePendingTransaction}.
31
+ *
32
+ * It is a throw rather than a returned status because a caller reaching it has reintroduced a
33
+ * check-then-act split that `StorageRepo.pend` closed by classifying and saving under one held
34
+ * latch; a status is a value a caller can ignore, which is the failure mode being designed out.
35
+ */
36
+ export class PendRevisionTakenError extends Error {
37
+ constructor(readonly blockId: BlockId, readonly actionId: ActionId, readonly rev: number, readonly latest: ActionRev) {
38
+ super(`Block ${blockId}: cannot pend action ${actionId} at revision ${rev}; the block is `
39
+ + `already committed at revision ${latest.rev} (action ${latest.actionId}), so the `
40
+ + `pending record could never be promoted`);
41
+ this.name = 'PendRevisionTakenError';
42
+ }
43
+ }
44
+
23
45
  /**
24
46
  * Interface for block-level storage operations.
25
47
  *
@@ -70,8 +92,27 @@ export interface IBlockStorage {
70
92
  /** Lists all pending action IDs */
71
93
  listPendingTransactions(): AsyncIterable<ActionId>;
72
94
 
73
- /** Saves a pending action (seeding this block's metadata when it has none). */
74
- savePendingTransaction(actionId: ActionId, transform: Transform, latch: BlockWriteLatch): Promise<void>;
95
+ /**
96
+ * Saves a pending action (seeding this block's metadata when it has none).
97
+ *
98
+ * `rev` is the revision the pend is claiming — the `rev` of the `PendRequest` this record
99
+ * belongs to, or `undefined` for a claim that names no revision (an insert-only pend). When it
100
+ * names one and the block has already committed at or past it, this REFUSES with
101
+ * {@link PendRevisionTakenError} and writes nothing.
102
+ *
103
+ * The question the refusal asks is **"could this record ever be promoted?"**, not "is this our
104
+ * own revision?" — which is why it does not use `isOwnRevision`. A single `latest.rev >= rev`
105
+ * comparison collapses both unpromotable cases: the writer's own already-committed revision
106
+ * (`===`, which `StorageRepo.commit` partitions as already-done and never promotes) and a
107
+ * rival's win (`>`, which `commit` refuses as stale). The error carries `latest` so the message
108
+ * can name which it was. `rev === undefined` names no revision, so no comparison applies.
109
+ *
110
+ * The refusal is an assertion, not a control-flow path: `StorageRepo.pend` classifies and saves
111
+ * under one multi-block write-latch hold, so every block it saves was observed under that same
112
+ * hold to satisfy `latest === undefined || latest.rev < rev`, and no writer can advance a block
113
+ * without its latch. A throw here means a caller reintroduced check-then-act.
114
+ */
115
+ savePendingTransaction(actionId: ActionId, transform: Transform, rev: number | undefined, latch: BlockWriteLatch): Promise<void>;
75
116
 
76
117
  /** Deletes a pending action */
77
118
  deletePendingTransaction(actionId: ActionId, latch: BlockWriteLatch): Promise<void>;
@@ -104,6 +145,10 @@ export interface IBlockStorage {
104
145
  * writes the committed one. A pending record left beside a committed one can never be promoted,
105
146
  * and is reported as a phantom conflicting action by `StorageRepo.pend` on every later write to
106
147
  * the block.
148
+ *
149
+ * The pend side of the invariant is enforced rather than merely required:
150
+ * {@link savePendingTransaction} refuses ({@link PendRevisionTakenError}) to write a pending
151
+ * record for a revision the block has already reached.
107
152
  */
108
153
  promotePendingTransaction(actionId: ActionId, latch: BlockWriteLatch): Promise<void>;
109
154
 
@@ -68,6 +68,23 @@ export interface IRawStorage {
68
68
  */
69
69
  getStoreIdentity?(): StoreIdentity;
70
70
 
71
+ /**
72
+ * Optional — present (and only ever `true`) when a write-through read cache
73
+ * (`CachedStoreDriver`) sits BELOW this storage, whichever of the two documented
74
+ * constructions built it: `new CachedRawStorage(inner)` or
75
+ * `new KvRawStorage(new CachedStoreDriver(driver))`. Both contain a `CachedStoreDriver`,
76
+ * and both report this marker; `withReadCache` reads it to decide whether to attach a
77
+ * cache, so neither shape is misread as uncached.
78
+ *
79
+ * A property, not a method: there is no behavior to invoke, only a fact to report.
80
+ *
81
+ * OPTIONAL BY DESIGN, and typed as the literal `true` so "present and false" is
82
+ * unrepresentable — truthiness IS the feature detection. Never install a `false` stub: an
83
+ * uncached storage must omit the property entirely, exactly as `getStoreIdentity` /
84
+ * `listBlockIds` / `getApproximateBytesUsed` are omitted when unsupported.
85
+ */
86
+ readCached?: true;
87
+
71
88
  /**
72
89
  * Approximate bytes currently stored by this backend.
73
90
  *
@@ -26,11 +26,15 @@ export class KvRawStorage implements IRawStorage {
26
26
  * (`typeof storage.listBlockIds === 'function'`) sees the driver's true
27
27
  * capability instead of a stub that silently reports 0 / no seed. The same rule
28
28
  * governs `getStoreIdentity`: a stub returning `undefined` would make every store
29
- * look identity-less to consumers that dedupe on it.
29
+ * look identity-less to consumers that dedupe on it. `readCached` follows the same rule
30
+ * for the same reason: it is set only when the driver reports a read cache at or below
31
+ * itself, so `withReadCache` sees whether this composition is REALLY already cached
32
+ * rather than inferring it from a concrete class name (see {@link IRawStorage.readCached}).
30
33
  */
31
34
  listBlockIds?: () => AsyncIterable<BlockId>;
32
35
  getApproximateBytesUsed?: () => Promise<number>;
33
36
  getStoreIdentity?: () => StoreIdentity;
37
+ readCached?: true;
34
38
 
35
39
  constructor(private readonly driver: RawStoreDriver) {
36
40
  if (driver.storeIdentity) {
@@ -42,6 +46,9 @@ export class KvRawStorage implements IRawStorage {
42
46
  if (driver.approximateBytesUsed) {
43
47
  this.getApproximateBytesUsed = () => driver.approximateBytesUsed!();
44
48
  }
49
+ if (driver.readCached) {
50
+ this.readCached = true;
51
+ }
45
52
  }
46
53
 
47
54
  // --- Metadata ---
@@ -113,10 +113,36 @@ export interface RawStoreDriver {
113
113
  * - It identifies the STORE, not its contents.
114
114
  */
115
115
  storeIdentity?(): StoreIdentity;
116
+
117
+ /**
118
+ * Optional — present (and only ever `true`) when a write-through read cache
119
+ * (`CachedStoreDriver`) sits AT or BELOW this driver, so a composition seam can ask
120
+ * "is this already read-cached?" of the composition itself instead of guessing from a
121
+ * concrete class name. Set unconditionally by `CachedStoreDriver` (it *is* the cache) and
122
+ * passed up by every wrapper that fronts a driver, the same way `storeIdentity` is.
123
+ *
124
+ * A property, not a method: there is no behavior to invoke, only a fact to report.
125
+ *
126
+ * OPTIONAL BY DESIGN, and typed as the literal `true` so "present and false" is
127
+ * unrepresentable — truthiness IS the feature detection. Never install a `false` stub: an
128
+ * uncached driver must omit the property entirely, exactly as `storeIdentity` /
129
+ * `listBlockIds` / `approximateBytesUsed` are omitted when unsupported.
130
+ */
131
+ readCached?: true;
132
+
116
133
  /** Optional — enumerate block ids with durable metadata (startup seed). Passed through by the kernel. */
117
134
  listBlockIds?(): AsyncIterable<BlockId>;
118
135
  /** Optional — best cheap byte estimate. Passed through by the kernel. */
119
136
  approximateBytesUsed?(): Promise<number>;
120
137
  /** Optional — release the underlying handle. */
121
138
  close?(): Promise<void>;
139
+
140
+ // NOTE: forwarding each optional above is a WRITTEN contract for wrapper drivers, not an
141
+ // enforced one — a wrapper that forgets one silently reports the composition as less capable
142
+ // than it is, and for `readCached` that means a seam attaches a redundant second cache. Fine
143
+ // today: the only two wrapper drivers in the tree are `CachedStoreDriver` and
144
+ // `RawStorageDriverAdapter`, both forward deliberately (`CachedStoreDriver` withholds `close`
145
+ // on purpose, and says why at its site), and each has direct tests. If a THIRD wrapper driver
146
+ // appears, replace the per-wrapper tests with one shared conformance check that asserts
147
+ // forwarding of each optional, with an explicit per-wrapper exception list.
122
148
  }
@@ -227,6 +227,11 @@ export class SharedCachePool {
227
227
  * a log line is not a way to notice. A registration with no identity is unaffected: it
228
228
  * registers exactly as it always did.
229
229
  *
230
+ * This catches the SIDE-BY-SIDE pair only. A cache stacked over an already-cached inner is a
231
+ * different (merely redundant) failure and is refused earlier, in `CachedStoreDriver`'s
232
+ * constructor, so it never reaches here with the wrong story — see the two `readCached`
233
+ * guards in `packages/db-p2p/docs/storage.md`.
234
+ *
230
235
  * The check runs BEFORE any mutation, so a refused registration leaves the pool exactly as it
231
236
  * was — no store row, no consumed id, no claim.
232
237
  *
@@ -239,7 +244,7 @@ export class SharedCachePool {
239
244
  `two caches over one backing store never converge: ${JSON.stringify(identity)} is already `
240
245
  + `cached (label ${JSON.stringify(claimed.label ?? null)}); this registration `
241
246
  + `(label ${JSON.stringify(label ?? null)}) would be a second, independent view. Share one `
242
- + `CachedRawStorage — withReadCache does this for you — or dispose the first.`
247
+ + `read cache — withReadCache does this for you — or dispose the first.`
243
248
  );
244
249
  }
245
250
  const handle = new CacheStoreHandle(`s${++this.storeCounter}`, label, identity);
@@ -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> {