@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.
- package/dist/src/cluster/certified-claims.d.ts +17 -3
- package/dist/src/cluster/certified-claims.d.ts.map +1 -1
- package/dist/src/cluster/certified-claims.js +5 -3
- package/dist/src/cluster/certified-claims.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +57 -92
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +149 -201
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/commit-proof.d.ts +16 -0
- package/dist/src/cluster/commit-proof.d.ts.map +1 -1
- package/dist/src/cluster/commit-proof.js +32 -1
- package/dist/src/cluster/commit-proof.js.map +1 -1
- package/dist/src/cluster/quorum-restore.d.ts +81 -28
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
- package/dist/src/cluster/quorum-restore.js +148 -51
- package/dist/src/cluster/quorum-restore.js.map +1 -1
- package/dist/src/cluster/race-resolution.d.ts +89 -0
- package/dist/src/cluster/race-resolution.d.ts.map +1 -0
- package/dist/src/cluster/race-resolution.js +140 -0
- package/dist/src/cluster/race-resolution.js.map +1 -0
- package/dist/src/cluster/reconcile-block.d.ts +9 -4
- package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
- package/dist/src/cluster/reconcile-block.js +28 -11
- package/dist/src/cluster/reconcile-block.js.map +1 -1
- package/dist/src/cluster/record-operations.d.ts +25 -0
- package/dist/src/cluster/record-operations.d.ts.map +1 -0
- package/dist/src/cluster/record-operations.js +56 -0
- package/dist/src/cluster/record-operations.js.map +1 -0
- package/dist/src/cohort-topic/host.d.ts +133 -51
- package/dist/src/cohort-topic/host.d.ts.map +1 -1
- package/dist/src/cohort-topic/host.js +290 -83
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +13 -4
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +5 -5
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +9 -0
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +13 -2
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +34 -2
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +57 -3
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/storage/block-latch.d.ts +5 -4
- package/dist/src/storage/block-latch.d.ts.map +1 -1
- package/dist/src/storage/block-latch.js +5 -4
- package/dist/src/storage/block-latch.js.map +1 -1
- package/dist/src/storage/block-storage.d.ts +1 -1
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +11 -3
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/cached-raw-storage.d.ts +17 -1
- package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/cached-raw-storage.js +8 -1
- package/dist/src/storage/cached-raw-storage.js.map +1 -1
- package/dist/src/storage/cached-store-driver.d.ts +7 -0
- package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
- package/dist/src/storage/cached-store-driver.js +20 -0
- package/dist/src/storage/cached-store-driver.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +45 -2
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-block-storage.js +29 -0
- package/dist/src/storage/i-block-storage.js.map +1 -1
- package/dist/src/storage/i-raw-storage.d.ts +16 -0
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.d.ts +5 -1
- package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.js +8 -1
- package/dist/src/storage/kv-raw-storage.js.map +1 -1
- package/dist/src/storage/raw-store-driver.d.ts +15 -0
- package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.d.ts +5 -0
- package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.js +6 -1
- package/dist/src/storage/shared-cache-pool.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +141 -99
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/storage/with-read-cache.d.ts +9 -5
- package/dist/src/storage/with-read-cache.d.ts.map +1 -1
- package/dist/src/storage/with-read-cache.js +16 -6
- package/dist/src/storage/with-read-cache.js.map +1 -1
- package/package.json +2 -2
- package/{README.md → readme.md} +2 -2
- package/src/cluster/certified-claims.ts +22 -9
- package/src/cluster/cluster-repo.ts +199 -212
- package/src/cluster/commit-proof.ts +38 -2
- package/src/cluster/quorum-restore.ts +183 -56
- package/src/cluster/race-resolution.ts +158 -0
- package/src/cluster/reconcile-block.ts +34 -11
- package/src/cluster/record-operations.ts +59 -0
- package/src/cohort-topic/host.ts +423 -118
- package/src/libp2p-node-base.ts +18 -9
- package/src/repo/cluster-coordinator.ts +1039 -1027
- package/src/repo/coordinator-repo.ts +1937 -1855
- package/src/storage/block-latch.ts +5 -4
- package/src/storage/block-storage.ts +11 -3
- package/src/storage/cached-raw-storage.ts +21 -1
- package/src/storage/cached-store-driver.ts +23 -0
- package/src/storage/i-block-storage.ts +47 -2
- package/src/storage/i-raw-storage.ts +17 -0
- package/src/storage/kv-raw-storage.ts +8 -1
- package/src/storage/raw-store-driver.ts +26 -0
- package/src/storage/shared-cache-pool.ts +6 -1
- package/src/storage/storage-repo.ts +143 -103
- 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.
|
|
94
|
-
* keeps them from deadlocking against each other is not local to
|
|
95
|
-
* holder acquires in the one global order. Owning that here makes it a
|
|
96
|
-
* owns the key rather than a rule each call site restates and
|
|
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
|
-
/**
|
|
74
|
-
|
|
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
|
-
+ `
|
|
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
|
|
536
|
+
// for them (see pass 2).
|
|
535
537
|
const satisfied = new Set<BlockId>();
|
|
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
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
610
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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> {
|