@optimystic/db-core 0.18.0 → 0.20.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/collection/collection.d.ts +10 -0
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +53 -9
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collection/struct.d.ts +43 -1
- package/dist/src/collection/struct.d.ts.map +1 -1
- package/dist/src/collection/struct.js +42 -2
- package/dist/src/collection/struct.js.map +1 -1
- package/dist/src/network/i-key-network.d.ts +17 -0
- package/dist/src/network/i-key-network.d.ts.map +1 -1
- package/dist/src/network/stale-failure.d.ts +21 -0
- package/dist/src/network/stale-failure.d.ts.map +1 -1
- package/dist/src/network/stale-failure.js +29 -0
- package/dist/src/network/stale-failure.js.map +1 -1
- package/dist/src/network/struct.d.ts +17 -0
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +12 -6
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +30 -13
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/package.json +1 -1
- package/src/collection/collection.ts +54 -9
- package/src/collection/struct.ts +38 -1
- package/src/network/i-key-network.ts +18 -0
- package/src/network/stale-failure.ts +30 -0
- package/src/network/struct.ts +14 -0
- package/src/transaction/coordinator.ts +16 -6
- package/src/transactor/network-transactor.ts +34 -14
|
@@ -11,3 +11,33 @@ import type { StaleFailure } from "./struct.js";
|
|
|
11
11
|
export function isConflictFailure(failure: StaleFailure): boolean {
|
|
12
12
|
return failure.conflict ?? Boolean(failure.missing?.length || failure.pending?.length);
|
|
13
13
|
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The single rule for picking one {@link StaleFailure.staleAt} out of several candidates.
|
|
17
|
+
*
|
|
18
|
+
* Highest `rev` wins: the losing writer's next request has to clear EVERY holder, so the largest
|
|
19
|
+
* confirmed revision is the binding constraint and any smaller one understates it. Ties keep the
|
|
20
|
+
* earlier candidate. Undefined entries (a block or batch with no confirmed number, or a peer that
|
|
21
|
+
* predates the field) contribute nothing, and an all-undefined input yields undefined so callers
|
|
22
|
+
* can omit the key rather than emit `staleAt: undefined`.
|
|
23
|
+
*
|
|
24
|
+
* Every site that has more than one candidate calls this — the producers scanning several blocks
|
|
25
|
+
* (`StorageRepo.pend`/`.commit`, `CoordinatorRepo.classifyStaleRejection`) as well as
|
|
26
|
+
* `NetworkTransactor` rebuilding one response from many per-batch ones. Uniformity is what makes
|
|
27
|
+
* the transactor's aggregate meaningful: if a producer reported an arbitrary block instead of its
|
|
28
|
+
* highest, taking the max across producers would still understate the constraint.
|
|
29
|
+
*
|
|
30
|
+
* NOTE: comparing revisions across blocks is only meaningful because one pend covers one
|
|
31
|
+
* collection, so every candidate comes from the same revision counter. If a pend is ever allowed
|
|
32
|
+
* to span collections, these numbers come from unrelated counters and selection must become
|
|
33
|
+
* per-collection.
|
|
34
|
+
*/
|
|
35
|
+
export function highestStaleAt(candidates: readonly StaleFailure['staleAt'][]): StaleFailure['staleAt'] {
|
|
36
|
+
let best: StaleFailure['staleAt'];
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
if (candidate !== undefined && (best === undefined || candidate.rev > best.rev)) {
|
|
39
|
+
best = candidate;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return best;
|
|
43
|
+
}
|
package/src/network/struct.ts
CHANGED
|
@@ -79,6 +79,20 @@ export type StaleFailure = {
|
|
|
79
79
|
* Read it through `isConflictFailure` rather than testing it directly.
|
|
80
80
|
*/
|
|
81
81
|
conflict?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* The block that already occupies (or is past) the requested revision, and the revision the
|
|
84
|
+
* responder holds for it.
|
|
85
|
+
*
|
|
86
|
+
* CONFIRMED-ONLY: set this only when the producer read the revision out of its own storage.
|
|
87
|
+
* A producer that merely suspects staleness — or that learned of it from another peer's
|
|
88
|
+
* free-form reject text — must leave it absent. Absent means "no confirmed number", never
|
|
89
|
+
* "not stale".
|
|
90
|
+
*
|
|
91
|
+
* DIAGNOSTIC, NOT A RETRYABILITY SIGNAL: `conflict` (read via `isConflictFailure`) remains the
|
|
92
|
+
* single source of truth for "can a re-read and re-pend win?". Never branch retry decisions on
|
|
93
|
+
* the presence of this field.
|
|
94
|
+
*/
|
|
95
|
+
staleAt?: { blockId: BlockId; rev: number };
|
|
82
96
|
};
|
|
83
97
|
|
|
84
98
|
export type PendResult = PendSuccess | StaleFailure;
|
|
@@ -29,8 +29,17 @@ const DefaultMaxBackoffMs = 5000;
|
|
|
29
29
|
* the flag off the rejection.
|
|
30
30
|
*/
|
|
31
31
|
class PendRejectedError extends Error {
|
|
32
|
-
constructor(
|
|
33
|
-
|
|
32
|
+
constructor(
|
|
33
|
+
collectionId: CollectionId,
|
|
34
|
+
readonly conflict: boolean,
|
|
35
|
+
reason?: string,
|
|
36
|
+
/** Confirmed revision the responder holds, from `StaleFailure.staleAt`. Folded into the
|
|
37
|
+
* message because pendPhase collapses this error to its `.message` string, which is the only
|
|
38
|
+
* form that reaches an embedder through the transaction result's `error` field. */
|
|
39
|
+
staleAt?: { blockId: BlockId; rev: number },
|
|
40
|
+
) {
|
|
41
|
+
super(`Pend failed for collection ${collectionId}: ${reason ?? (conflict ? 'stale conflict' : 'rejected')}`
|
|
42
|
+
+ (staleAt ? ` (block ${staleAt.blockId} at rev ${staleAt.rev})` : ''));
|
|
34
43
|
this.name = 'PendRejectedError';
|
|
35
44
|
}
|
|
36
45
|
}
|
|
@@ -182,9 +191,10 @@ export class TransactionCoordinator {
|
|
|
182
191
|
// Re-read fresh state before re-attempting so the next commit pends against current
|
|
183
192
|
// revisions (mirrors how Collection.sync calls updateInternal() before retrying).
|
|
184
193
|
// NOTE: refreshes EVERY registered collection, not only the participants of this
|
|
185
|
-
// transaction.
|
|
186
|
-
//
|
|
187
|
-
//
|
|
194
|
+
// transaction. Not free: a non-participant's update() throws CollectionHeaderVanishedError
|
|
195
|
+
// if its header momentarily reads absent while it holds a committed revision, aborting
|
|
196
|
+
// this retry. The registered set is small today; if that (or retry latency) ever bites,
|
|
197
|
+
// narrow this to the transaction's participating collections.
|
|
188
198
|
for (const collection of this.collections.values()) {
|
|
189
199
|
await collection.update();
|
|
190
200
|
}
|
|
@@ -934,7 +944,7 @@ export class TransactionCoordinator {
|
|
|
934
944
|
// `conflict`, and only where no producer set it do we fall back to inferring from
|
|
935
945
|
// `missing`/`pending`. Either way a conflict is an optimistic-concurrency loss, clearable
|
|
936
946
|
// by a re-read; anything else is a hard rejection (storage/policy) that re-driving won't fix.
|
|
937
|
-
throw new PendRejectedError(collectionId, isConflictFailure(pendResult), pendResult.reason);
|
|
947
|
+
throw new PendRejectedError(collectionId, isConflictFailure(pendResult), pendResult.reason, pendResult.staleAt);
|
|
938
948
|
}
|
|
939
949
|
|
|
940
950
|
return { collectionId, blockIds: pendResult.blockIds };
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { peerIdFromString } from "../network/types.js";
|
|
2
2
|
import type { PeerId } from "../network/types.js";
|
|
3
|
-
import { isConflictFailure } from "../network/stale-failure.js";
|
|
3
|
+
import { highestStaleAt, isConflictFailure } from "../network/stale-failure.js";
|
|
4
4
|
import { BlockUnavailableError } from "../network/struct.js";
|
|
5
|
-
import type { ActionTransforms, ActionBlocks, BlockActionStatus, ITransactor, PendSuccess, StaleFailure, IKeyNetwork, BlockId, GetBlockResults, PendResult, CommitResult, PendRequest, IRepo, BlockGets, Transforms, CommitRequest, ActionId, RepoCommitRequest, ClusterNomineesResult, CollectionId, IBlock } from "../index.js";
|
|
5
|
+
import type { ActionTransforms, ActionBlocks, BlockActionStatus, ITransactor, PendSuccess, StaleFailure, IKeyNetwork, BlockId, GetBlockResults, PendResult, CommitResult, PendRequest, IRepo, BlockGets, Transforms, CommitRequest, ActionId, RepoCommitRequest, ClusterNomineesResult, CollectionId, IBlock, CoordinatorIntent } from "../index.js";
|
|
6
6
|
import type { IBlockChangeNotifier, CollectionChangeListener } from "./change-notifier.js";
|
|
7
7
|
import { transformForBlockId, groupBy, concatTransforms, concatTransform, transformsFromTransform, blockIdsForTransforms, Log, Tracker, CacheSource, TransactorSource } from "../index.js";
|
|
8
8
|
import { blockIdToBytes } from "../utility/block-id-to-bytes.js";
|
|
@@ -108,11 +108,16 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
108
108
|
const t0 = Date.now();
|
|
109
109
|
log('get blockIds=%d', distinctBlockIds.length);
|
|
110
110
|
|
|
111
|
+
// `intent: 'read'` throughout this method: a read that can find no reachable
|
|
112
|
+
// coordinator may still be answered from the local replica (degraded but reported),
|
|
113
|
+
// where a write on the same evidence may not. See CoordinatorIntent.
|
|
111
114
|
const batches = await this.batchesForPayload<BlockId[], GetBlockResults>(
|
|
112
115
|
distinctBlockIds,
|
|
113
116
|
distinctBlockIds,
|
|
114
117
|
(gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(bid => bid === blockId)],
|
|
115
|
-
[]
|
|
118
|
+
[],
|
|
119
|
+
undefined,
|
|
120
|
+
'read'
|
|
116
121
|
);
|
|
117
122
|
|
|
118
123
|
const expiration = Date.now() + this.timeoutMs;
|
|
@@ -125,7 +130,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
125
130
|
batch => batch.payload,
|
|
126
131
|
(gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(bid => bid === blockId)],
|
|
127
132
|
expiration,
|
|
128
|
-
async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
|
|
133
|
+
async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' })
|
|
129
134
|
);
|
|
130
135
|
} catch (e) {
|
|
131
136
|
error = e as Error;
|
|
@@ -179,7 +184,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
179
184
|
b.payload,
|
|
180
185
|
(gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)],
|
|
181
186
|
Array.from(excluded),
|
|
182
|
-
async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
|
|
187
|
+
async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' })
|
|
183
188
|
);
|
|
184
189
|
if (retries.length > 0) {
|
|
185
190
|
b.subsumedBy = [...(b.subsumedBy ?? []), ...retries];
|
|
@@ -189,7 +194,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
189
194
|
batch => batch.payload,
|
|
190
195
|
(gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)],
|
|
191
196
|
expiration,
|
|
192
|
-
async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
|
|
197
|
+
async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' })
|
|
193
198
|
);
|
|
194
199
|
}
|
|
195
200
|
}));
|
|
@@ -555,10 +560,15 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
555
560
|
// (then `every` is both safe and tighter), or if mixed-outcome pends show up as wasted
|
|
556
561
|
// retry latency in practice.
|
|
557
562
|
const conflict = stale.some(b => isConflictFailure(b.request!.response! as StaleFailure));
|
|
563
|
+
// Deliberately NOT first-wins like `reason` above — `highestStaleAt` takes the largest
|
|
564
|
+
// confirmed revision, which is the binding constraint on the client's next request.
|
|
565
|
+
// Its doc comment carries the rule and the one-pend-one-collection assumption it rests on.
|
|
566
|
+
const staleAt = highestStaleAt(stale.map(b => (b.request!.response! as StaleFailure).staleAt));
|
|
558
567
|
return {
|
|
559
568
|
success: false,
|
|
560
569
|
conflict,
|
|
561
570
|
...(reason === undefined ? {} : { reason }),
|
|
571
|
+
...(staleAt === undefined ? {} : { staleAt }),
|
|
562
572
|
missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)),
|
|
563
573
|
};
|
|
564
574
|
}
|
|
@@ -673,10 +683,17 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
673
683
|
const stale = Array.from(allBatches(tailBatches, b => b.request?.isResponse as boolean && !b.request!.response!.success));
|
|
674
684
|
if (stale.length > 0) {
|
|
675
685
|
// NOTE: a reason-only StaleFailure (success:false, no `missing`) lands here too and
|
|
676
|
-
// returns { missing: [], success:false } — the `reason`
|
|
677
|
-
// surfaced via `throw tailError`.
|
|
678
|
-
//
|
|
679
|
-
|
|
686
|
+
// returns { missing: [], success:false } — the `reason` PROSE is still dropped rather
|
|
687
|
+
// than surfaced via `throw tailError`. `staleAt` is carried, so the one machine-readable
|
|
688
|
+
// fact in that prose (which block is at which revision) now survives; only the free-form
|
|
689
|
+
// wording is lost. If the wording itself is ever needed, gate this branch on non-empty
|
|
690
|
+
// missing rather than reinstating it unconditionally.
|
|
691
|
+
const staleAt = highestStaleAt(stale.map(b => (b.request!.response! as StaleFailure).staleAt));
|
|
692
|
+
return {
|
|
693
|
+
missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)),
|
|
694
|
+
...(staleAt === undefined ? {} : { staleAt }),
|
|
695
|
+
success: false as const
|
|
696
|
+
};
|
|
680
697
|
}
|
|
681
698
|
throw tailError;
|
|
682
699
|
}
|
|
@@ -729,14 +746,16 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
729
746
|
getBlockPayload: (payload: TPayload, blockId: BlockId, mergeWithPayload: TPayload | undefined) => TPayload,
|
|
730
747
|
excludedPeers: PeerId[],
|
|
731
748
|
/** When set, prefer a coordinator this transaction already resolved at pend (see {@link resolveCoordinator}). */
|
|
732
|
-
actionId?: ActionId
|
|
749
|
+
actionId?: ActionId,
|
|
750
|
+
/** What the batches will be used for. Defaults to `'write'` — see {@link CoordinatorIntent}. */
|
|
751
|
+
intent: CoordinatorIntent = 'write'
|
|
733
752
|
): Promise<CoordinatorBatch<TPayload, TResponse>[]> {
|
|
734
753
|
return createBatchesForPayload<TPayload, TResponse>(
|
|
735
754
|
blockIds,
|
|
736
755
|
payload,
|
|
737
756
|
getBlockPayload,
|
|
738
757
|
excludedPeers,
|
|
739
|
-
async (blockId, options) => this.resolveCoordinator(blockId, options, actionId)
|
|
758
|
+
async (blockId, options) => this.resolveCoordinator(blockId, options, actionId, intent)
|
|
740
759
|
);
|
|
741
760
|
}
|
|
742
761
|
|
|
@@ -751,7 +770,8 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
751
770
|
private async resolveCoordinator(
|
|
752
771
|
blockId: BlockId,
|
|
753
772
|
options: { excludedPeers: PeerId[] },
|
|
754
|
-
actionId: ActionId | undefined
|
|
773
|
+
actionId: ActionId | undefined,
|
|
774
|
+
intent: CoordinatorIntent = 'write'
|
|
755
775
|
): Promise<PeerId> {
|
|
756
776
|
if (actionId !== undefined) {
|
|
757
777
|
const cached = this.txnCoordinatorCache.get(actionId)?.coordinators.get(blockId);
|
|
@@ -759,7 +779,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
759
779
|
return cached;
|
|
760
780
|
}
|
|
761
781
|
}
|
|
762
|
-
return this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options);
|
|
782
|
+
return this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent });
|
|
763
783
|
}
|
|
764
784
|
|
|
765
785
|
/**
|