@optimystic/db-core 0.27.0 → 0.29.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.map +1 -1
- package/dist/src/collection/collection.js +78 -6
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collection/struct.d.ts +53 -0
- package/dist/src/collection/struct.d.ts.map +1 -1
- package/dist/src/collection/struct.js +40 -0
- package/dist/src/collection/struct.js.map +1 -1
- package/dist/src/network/stale-failure.d.ts +4 -1
- package/dist/src/network/stale-failure.d.ts.map +1 -1
- package/dist/src/network/stale-failure.js +4 -1
- package/dist/src/network/stale-failure.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +32 -2
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +5 -1
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +109 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +230 -17
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +12 -0
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +41 -2
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transactor/transactor.d.ts +5 -0
- package/dist/src/transactor/transactor.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/collection/collection.ts +81 -6
- package/src/collection/struct.ts +157 -98
- package/src/logger.ts +10 -10
- package/src/network/stale-failure.ts +4 -1
- package/src/testing/test-transactor.ts +34 -3
- package/src/transaction/coordinator.ts +5 -1
- package/src/transactor/network-transactor.ts +237 -37
- package/src/transactor/transactor-source.ts +39 -2
- package/src/transactor/transactor.ts +49 -44
package/package.json
CHANGED
|
@@ -8,8 +8,9 @@ import { computeBlockContentDigests } from "../transform/digest.js";
|
|
|
8
8
|
import { copyTransforms, isTransformsEmpty } from "../transform/helpers.js";
|
|
9
9
|
import { TransactorSource } from "../transactor/transactor-source.js";
|
|
10
10
|
import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
|
|
11
|
+
import { highestStaleAt } from "../network/stale-failure.js";
|
|
11
12
|
import type { CollectionHeaderBlock, CollectionId, ICollection, SyncOptions } from "./index.js";
|
|
12
|
-
import { CollectionHeaderVanishedError, SyncRetryExhaustedError } from "./struct.js";
|
|
13
|
+
import { CollectionHeaderVanishedError, SyncRetryExhaustedError, SyncRevisionStalledError } from "./struct.js";
|
|
13
14
|
import type { ActionContext } from "./action.js";
|
|
14
15
|
import { actionIdAt } from "./action.js";
|
|
15
16
|
import type { ReadDependency } from "../transaction/transaction.js";
|
|
@@ -50,6 +51,15 @@ const PendingRetryDelayMs = 100;
|
|
|
50
51
|
const DefaultMaxAttempts = 10;
|
|
51
52
|
/** Default ceiling on a single exponential-backoff sleep, in ms. */
|
|
52
53
|
const DefaultMaxBackoffMs = 5000;
|
|
54
|
+
/** Default consecutive stalled refreshes — ones that moved this collection's revision NOWHERE
|
|
55
|
+
* while a responder CONFIRMED a revision at or above the one being requested — before
|
|
56
|
+
* {@link Collection.sync} gives up. Two, not one, so a single transiently-lagging read is absorbed.
|
|
57
|
+
*
|
|
58
|
+
* NOTE: two is a judgement call, not a measurement — nothing here counts how often a legitimate
|
|
59
|
+
* loser reads a view that moves nowhere for two consecutive rounds. The check only strikes when the
|
|
60
|
+
* refresh made NO progress at all, so a client merely catching up is already excluded; if a
|
|
61
|
+
* spurious `SyncRevisionStalledError` ever shows up under real contention anyway, raise this. */
|
|
62
|
+
const DefaultMaxStalledAttempts = 2;
|
|
53
63
|
|
|
54
64
|
export type CollectionInitOptions<TAction> = {
|
|
55
65
|
modules: Record<ActionType, ActionHandler<TAction>>;
|
|
@@ -918,6 +928,7 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
918
928
|
const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
|
|
919
929
|
const baseBackoffMs = options?.baseBackoffMs ?? PendingRetryDelayMs;
|
|
920
930
|
const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
|
|
931
|
+
const maxStalledAttempts = options?.maxStalledAttempts ?? DefaultMaxStalledAttempts;
|
|
921
932
|
const deadlineMs = options?.deadlineMs;
|
|
922
933
|
const signal = options?.signal;
|
|
923
934
|
const startedAt = Date.now();
|
|
@@ -927,19 +938,77 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
927
938
|
// large multi-batch sync (which iterates many times committing progress) never trips it.
|
|
928
939
|
let consecutiveFailures = 0;
|
|
929
940
|
let lastReason: string | undefined;
|
|
930
|
-
//
|
|
931
|
-
//
|
|
941
|
+
// Highest confirmed revision any responder has reported holding, accumulated with the
|
|
942
|
+
// codebase's single rule for picking among several candidates. Reported on the error, and
|
|
943
|
+
// the evidence the stall check below reasons from.
|
|
932
944
|
let lastStaleAt: { blockId: BlockId; rev: number } | undefined;
|
|
945
|
+
// Whether the failure most recently handled carried its OWN staleAt. A strike needs the
|
|
946
|
+
// responder to have re-confirmed the number this round, not merely an older observation
|
|
947
|
+
// left standing in `lastStaleAt`.
|
|
948
|
+
let lastFailureConfirmedStaleAt = false;
|
|
949
|
+
// Consecutive refreshes that moved `getNextRev()` nowhere at all while a confirmed revision
|
|
950
|
+
// stood at or above it.
|
|
951
|
+
let consecutiveStalls = 0;
|
|
952
|
+
// The revision the PREVIOUS iteration would have requested, so the stall check can tell a
|
|
953
|
+
// refresh that moved nowhere from one that is still climbing toward the confirmed number.
|
|
954
|
+
let previousRequestedRev: number | undefined;
|
|
933
955
|
|
|
934
956
|
while (this.hasUnsyncedChanges()) {
|
|
935
957
|
if (signal?.aborted) {
|
|
936
958
|
throw makeAbortError(signal);
|
|
937
959
|
}
|
|
938
|
-
// Progress-agnostic ceiling: give up if the wall-clock deadline passed.
|
|
960
|
+
// Progress-agnostic ceiling: give up if the wall-clock deadline passed. Deliberately
|
|
961
|
+
// ahead of the stall check: the deadline is the documented outer bound, so a sync that
|
|
962
|
+
// is both past it and stalled reports the deadline.
|
|
939
963
|
if (deadlineMs !== undefined && Date.now() - startedAt >= deadlineMs) {
|
|
940
964
|
throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason ?? 'deadline exceeded', lastStaleAt);
|
|
941
965
|
}
|
|
942
966
|
|
|
967
|
+
// Can the attempt about to run possibly differ from the one that just failed? Only when
|
|
968
|
+
// BOTH of these hold is the answer provably no:
|
|
969
|
+
//
|
|
970
|
+
// - The revision it would request is at or below one a responder CONFIRMED is taken. A
|
|
971
|
+
// producer sets `staleAt` only after reading that revision as durably held by someone
|
|
972
|
+
// else out of its own storage, revisions are one per-collection counter that every
|
|
973
|
+
// commit touches, and a confirmed revision never becomes un-taken (invalidation takes
|
|
974
|
+
// a NEW slot). So the request is already lost before it is sent.
|
|
975
|
+
// - The refresh in between moved the collection nowhere. `advanceContext` never lowers
|
|
976
|
+
// the held revision, so "nowhere" is exactly `requestedRev` unchanged since the last
|
|
977
|
+
// iteration. A refresh that moved forward WITHOUT clearing the confirmed number is a
|
|
978
|
+
// collection still climbing (it read a replica that lags the holder — the same
|
|
979
|
+
// partial catch-up `reportShortfall` exists to report), and its next attempt is a
|
|
980
|
+
// genuinely different request that may yet win.
|
|
981
|
+
//
|
|
982
|
+
// This is NOT a second answer to "is this failure retryable?" — `isConflictFailure`
|
|
983
|
+
// remains the sole rule for that, untouched. It only ever stops a loop that rule had
|
|
984
|
+
// already decided to continue.
|
|
985
|
+
const requestedRev = this.getNextRev();
|
|
986
|
+
const refreshMoved = previousRequestedRev !== undefined && requestedRev > previousRequestedRev;
|
|
987
|
+
previousRequestedRev = requestedRev;
|
|
988
|
+
if (lastStaleAt !== undefined) {
|
|
989
|
+
if (requestedRev > lastStaleAt.rev || refreshMoved) {
|
|
990
|
+
// Either the refresh adopted a revision above the confirmed one — ordinary
|
|
991
|
+
// contention, where the rival's commit is exactly what we just read — or it made
|
|
992
|
+
// partial forward progress. Both mean the next attempt differs. Not a stall.
|
|
993
|
+
consecutiveStalls = 0;
|
|
994
|
+
} else if (lastFailureConfirmedStaleAt) {
|
|
995
|
+
consecutiveStalls++;
|
|
996
|
+
if (log.enabled) {
|
|
997
|
+
log('collection:sync-stalled id=%s tag=%s heldRev=%s requestedRev=%d staleBlock=%s staleRev=%d strike=%d of=%d',
|
|
998
|
+
this.id, this.instanceTag, this.source.actionContext?.rev ?? 'none', requestedRev,
|
|
999
|
+
lastStaleAt.blockId, lastStaleAt.rev, consecutiveStalls, maxStalledAttempts);
|
|
1000
|
+
}
|
|
1001
|
+
// Two strikes, not one: a legitimate loser can transiently read a view that has
|
|
1002
|
+
// not yet caught up with the rival's commit, which looks identical for one round.
|
|
1003
|
+
if (consecutiveStalls >= maxStalledAttempts) {
|
|
1004
|
+
throw new SyncRevisionStalledError(this.id, consecutiveFailures, lastStaleAt,
|
|
1005
|
+
requestedRev, this.source.actionContext?.rev, lastReason);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
// Else: the responder told us nothing new this round. No strike, and no reset either
|
|
1009
|
+
// — the budget stays bounded by maxAttempts.
|
|
1010
|
+
}
|
|
1011
|
+
|
|
943
1012
|
// Snapshot the pending actions so that any new actions aren't assumed to be part of this action
|
|
944
1013
|
const pending = [...this.pending];
|
|
945
1014
|
|
|
@@ -961,7 +1030,7 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
961
1030
|
if (!collectionLog) {
|
|
962
1031
|
throw new Error(`Log not found for collection ${this.id}`);
|
|
963
1032
|
}
|
|
964
|
-
const newRev =
|
|
1033
|
+
const newRev = this.getNextRev();
|
|
965
1034
|
const addResult = await collectionLog.addActions(pending, actionId, newRev, () => tracker.transformedBlockIds());
|
|
966
1035
|
|
|
967
1036
|
// Declare what each touched block will contain once committed, computed from this snapshot
|
|
@@ -983,7 +1052,11 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
983
1052
|
if (staleFailure) {
|
|
984
1053
|
consecutiveFailures++;
|
|
985
1054
|
lastReason = staleFailure.reason ?? lastReason;
|
|
986
|
-
|
|
1055
|
+
// Highest-wins, not last-wins: the next request has to clear EVERY holder, so a later
|
|
1056
|
+
// responder reporting a LOWER number understates the binding constraint. Same rule the
|
|
1057
|
+
// producers and the transactor's aggregation already use.
|
|
1058
|
+
lastStaleAt = highestStaleAt([lastStaleAt, staleFailure.staleAt]);
|
|
1059
|
+
lastFailureConfirmedStaleAt = staleFailure.staleAt !== undefined;
|
|
987
1060
|
// Give up once the consecutive no-progress budget is exhausted, so a transactor that
|
|
988
1061
|
// persistently rejects the sync can no longer hold the collection latch forever.
|
|
989
1062
|
// NOTE: this also bounds the legitimate `pending`-wait case (retrying the same action
|
|
@@ -1015,6 +1088,8 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
1015
1088
|
consecutiveFailures = 0;
|
|
1016
1089
|
lastReason = undefined;
|
|
1017
1090
|
lastStaleAt = undefined;
|
|
1091
|
+
lastFailureConfirmedStaleAt = false;
|
|
1092
|
+
consecutiveStalls = 0;
|
|
1018
1093
|
// Clear the pending actions that were part of this action
|
|
1019
1094
|
this.pending = this.pending.slice(pending.length);
|
|
1020
1095
|
// Reset cache and replay any actions that were added during the action
|
package/src/collection/struct.ts
CHANGED
|
@@ -1,98 +1,157 @@
|
|
|
1
|
-
import type { IBlock, BlockId, Action } from "../index.js";
|
|
2
|
-
import type { IChainHeader } from "../chain/chain-nodes.js";
|
|
3
|
-
import type { RandFn } from "../utility/backoff.js";
|
|
4
|
-
|
|
5
|
-
export type CollectionId = BlockId;
|
|
6
|
-
|
|
7
|
-
export type CollectionHeaderBlock = IBlock & Partial<IChainHeader>;
|
|
8
|
-
|
|
9
|
-
/** Bounds the retry loop inside {@link ICollection.sync} / {@link ICollection.updateAndSync}
|
|
10
|
-
* so a transactor that keeps rejecting the sync can no longer spin the collection latch forever.
|
|
11
|
-
* All fields are optional; unset fields fall back to conservative defaults. */
|
|
12
|
-
export interface SyncOptions {
|
|
13
|
-
/** Max consecutive stale-failure retries that make no progress before giving up.
|
|
14
|
-
* The counter resets to 0 on every successful commit, so a legitimate large multi-batch
|
|
15
|
-
* sync (which iterates many times making forward progress) is never falsely tripped.
|
|
16
|
-
* Default 10. */
|
|
17
|
-
maxAttempts?: number;
|
|
18
|
-
/** Optional wall-clock deadline in ms measured from the start of the sync call. Independent
|
|
19
|
-
* of the attempt count — a progress-agnostic ceiling. Unset means no deadline. */
|
|
20
|
-
deadlineMs?: number;
|
|
21
|
-
/** Base backoff delay in ms applied before the first retry; subsequent retries grow the delay
|
|
22
|
-
* exponentially up to {@link maxBackoffMs}. Default 100. */
|
|
23
|
-
baseBackoffMs?: number;
|
|
24
|
-
/** Upper bound on any single backoff sleep, in ms. Default 5000. */
|
|
25
|
-
maxBackoffMs?: number;
|
|
26
|
-
/** Optional abort signal. Checked at the top of each loop iteration and raced against the
|
|
27
|
-
* backoff sleep, so an aborted sync rejects promptly (with an AbortError) rather than finishing
|
|
28
|
-
* the current sleep. */
|
|
29
|
-
signal?: AbortSignal;
|
|
30
|
-
/** Advanced/testing hook: source of uniform [0,1) randomness for the backoff jitter. Defaults to
|
|
31
|
-
* the package CSPRNG; inject a deterministic sequence to assert exact retry delays. */
|
|
32
|
-
rand?: RandFn;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
export
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
1
|
+
import type { IBlock, BlockId, Action } from "../index.js";
|
|
2
|
+
import type { IChainHeader } from "../chain/chain-nodes.js";
|
|
3
|
+
import type { RandFn } from "../utility/backoff.js";
|
|
4
|
+
|
|
5
|
+
export type CollectionId = BlockId;
|
|
6
|
+
|
|
7
|
+
export type CollectionHeaderBlock = IBlock & Partial<IChainHeader>;
|
|
8
|
+
|
|
9
|
+
/** Bounds the retry loop inside {@link ICollection.sync} / {@link ICollection.updateAndSync}
|
|
10
|
+
* so a transactor that keeps rejecting the sync can no longer spin the collection latch forever.
|
|
11
|
+
* All fields are optional; unset fields fall back to conservative defaults. */
|
|
12
|
+
export interface SyncOptions {
|
|
13
|
+
/** Max consecutive stale-failure retries that make no progress before giving up.
|
|
14
|
+
* The counter resets to 0 on every successful commit, so a legitimate large multi-batch
|
|
15
|
+
* sync (which iterates many times making forward progress) is never falsely tripped.
|
|
16
|
+
* Default 10. */
|
|
17
|
+
maxAttempts?: number;
|
|
18
|
+
/** Optional wall-clock deadline in ms measured from the start of the sync call. Independent
|
|
19
|
+
* of the attempt count — a progress-agnostic ceiling. Unset means no deadline. */
|
|
20
|
+
deadlineMs?: number;
|
|
21
|
+
/** Base backoff delay in ms applied before the first retry; subsequent retries grow the delay
|
|
22
|
+
* exponentially up to {@link maxBackoffMs}. Default 100. */
|
|
23
|
+
baseBackoffMs?: number;
|
|
24
|
+
/** Upper bound on any single backoff sleep, in ms. Default 5000. */
|
|
25
|
+
maxBackoffMs?: number;
|
|
26
|
+
/** Optional abort signal. Checked at the top of each loop iteration and raced against the
|
|
27
|
+
* backoff sleep, so an aborted sync rejects promptly (with an AbortError) rather than finishing
|
|
28
|
+
* the current sleep. */
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
/** Advanced/testing hook: source of uniform [0,1) randomness for the backoff jitter. Defaults to
|
|
31
|
+
* the package CSPRNG; inject a deterministic sequence to assert exact retry delays. */
|
|
32
|
+
rand?: RandFn;
|
|
33
|
+
/** Consecutive refreshes that move this collection's revision NOWHERE while a responder has
|
|
34
|
+
* CONFIRMED a revision at or above the one the next attempt would request, before sync gives up
|
|
35
|
+
* with {@link SyncRevisionStalledError}. Such a retry provably re-sends the identical, already
|
|
36
|
+
* lost request, so the wait buys nothing. Two absorbs one transiently-lagging read; set it to
|
|
37
|
+
* {@link maxAttempts} or higher to restore the pre-existing behaviour of burning the whole
|
|
38
|
+
* budget. Default 2.
|
|
39
|
+
*
|
|
40
|
+
* Neither shape of progress trips this. Ordinary contention: the rival's commit is what the
|
|
41
|
+
* refresh adopts, so the next request lands above the confirmed revision. A collection still
|
|
42
|
+
* catching up: the refresh moves the revision forward without yet clearing the confirmed
|
|
43
|
+
* number, so the next request differs from the one that just failed. Either resets the counter.
|
|
44
|
+
* {@link deadlineMs} is still checked first, so a sync that is both past its deadline and
|
|
45
|
+
* stalled reports the deadline (as {@link SyncRetryExhaustedError}), not the stall. */
|
|
46
|
+
maxStalledAttempts?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Thrown by {@link ICollection.sync} / {@link ICollection.updateAndSync} when the retry budget
|
|
50
|
+
* (attempt count or deadline) is exhausted while the transactor keeps returning stale failures.
|
|
51
|
+
* Catchable so callers can surface a clear "gave up syncing" condition instead of hanging. */
|
|
52
|
+
export class SyncRetryExhaustedError extends Error {
|
|
53
|
+
constructor(
|
|
54
|
+
readonly collectionId: CollectionId,
|
|
55
|
+
readonly attempts: number,
|
|
56
|
+
readonly lastReason?: string,
|
|
57
|
+
/** The last confirmed revision a responder reported holding, if any responder reported one
|
|
58
|
+
* (see `StaleFailure.staleAt`). Absent whenever no rejection carried a confirmed number —
|
|
59
|
+
* which is normal, not a signal that the failure was something other than a lost race. */
|
|
60
|
+
readonly staleAt?: { blockId: BlockId; rev: number },
|
|
61
|
+
) {
|
|
62
|
+
super(`sync for collection ${collectionId} exhausted ${attempts} retries` +
|
|
63
|
+
(lastReason ? `: ${lastReason}` : '') +
|
|
64
|
+
(staleAt ? `, last seen block ${staleAt.blockId} at rev ${staleAt.rev}` : ''));
|
|
65
|
+
this.name = 'SyncRetryExhaustedError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Thrown by {@link ICollection.sync} / {@link ICollection.updateAndSync} when refreshing
|
|
70
|
+
* repeatedly moved this collection nowhere at all while a responder confirmed a revision at or
|
|
71
|
+
* above the one being requested — the client's view of the current revision disagrees with the
|
|
72
|
+
* cluster's, and retrying would re-send the identical taken number.
|
|
73
|
+
*
|
|
74
|
+
* The distinction matters because the two failures need different responses. Plain exhaustion
|
|
75
|
+
* means "I lost a race too many times", and waiting longer or retrying later can succeed. This
|
|
76
|
+
* one means the next attempt is provably identical to the one that just failed, so the remaining
|
|
77
|
+
* budget buys nothing — the caller's view of the collection has to be repaired first.
|
|
78
|
+
*
|
|
79
|
+
* Sync deliberately does NOT adopt the responder's revision to get past this. `staleAt` is a bare
|
|
80
|
+
* number, not content: submitting this client's staged transforms at a revision built on a
|
|
81
|
+
* history it never read would overwrite that history silently. Reconciling a genuine fork is
|
|
82
|
+
* partition healing's job (docs/transactions.md), not the retry loop's.
|
|
83
|
+
*
|
|
84
|
+
* Extends {@link SyncRetryExhaustedError} so existing callers that catch the base class keep
|
|
85
|
+
* working; catch this subclass to distinguish "my revision view is wrong" from "I lost a race
|
|
86
|
+
* too many times". */
|
|
87
|
+
export class SyncRevisionStalledError extends SyncRetryExhaustedError {
|
|
88
|
+
/** Required here, unlike on the base class — it is the evidence the stall is based on. */
|
|
89
|
+
declare readonly staleAt: { blockId: BlockId; rev: number };
|
|
90
|
+
|
|
91
|
+
constructor(
|
|
92
|
+
collectionId: CollectionId,
|
|
93
|
+
attempts: number,
|
|
94
|
+
staleAt: { blockId: BlockId; rev: number },
|
|
95
|
+
/** The revision the next attempt would have requested. */
|
|
96
|
+
readonly requestedRev: number,
|
|
97
|
+
/** The revision this client believes is current. `undefined` for a collection that has
|
|
98
|
+
* committed nothing. */
|
|
99
|
+
readonly heldRev: number | undefined,
|
|
100
|
+
lastReason?: string,
|
|
101
|
+
) {
|
|
102
|
+
super(collectionId, attempts, lastReason, staleAt);
|
|
103
|
+
// The base class's "exhausted N retries" wording is deliberately NOT reused: it reads as
|
|
104
|
+
// ordinary contention, which is exactly the misdiagnosis this class exists to prevent.
|
|
105
|
+
this.message = `sync for collection ${collectionId} stopped after ${attempts} attempts: `
|
|
106
|
+
+ `this client holds rev ${heldRev ?? 'none'} and would request rev ${requestedRev}, `
|
|
107
|
+
+ `but block ${staleAt.blockId} is confirmed committed at rev ${staleAt.rev} and `
|
|
108
|
+
+ `refreshing did not close the gap`
|
|
109
|
+
+ (lastReason ? `: ${lastReason}` : '');
|
|
110
|
+
this.name = 'SyncRevisionStalledError';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Thrown when a collection that already holds a committed revision reads its own header
|
|
115
|
+
* block as authoritatively absent.
|
|
116
|
+
*
|
|
117
|
+
* The two facts contradict each other: this client has proof that something was committed
|
|
118
|
+
* under this id (it holds the revision it committed at, or the one it read off the log tail),
|
|
119
|
+
* and storage has just answered that nothing ever was. Exactly one of them is wrong, so this
|
|
120
|
+
* is a fault rather than an absence — the same reasoning `Collection.attachToLog` applies to a
|
|
121
|
+
* header that probes fine but whose log will not open.
|
|
122
|
+
*
|
|
123
|
+
* Deliberately NOT a `StaleFailure`: {@link ICollection.sync}'s retry loop only absorbs
|
|
124
|
+
* returned stale failures, so throwing this aborts the sync immediately with a named
|
|
125
|
+
* diagnosis instead of letting it spin the full retry budget re-requesting a revision it
|
|
126
|
+
* has silently forgotten.
|
|
127
|
+
*
|
|
128
|
+
* NOTE: durable invalidation restores reverted content to its as-if-absent state, so once the
|
|
129
|
+
* cascade runs end-to-end (docs/right-is-right.md § Durable Invalidation), reverting the commit
|
|
130
|
+
* that CREATED a collection would make its header legitimately absent for a client still holding
|
|
131
|
+
* that revision — a reversal, not a contradiction, which this message would misdiagnose.
|
|
132
|
+
* Aborting is still the right action there; if it ever fires for that reason, distinguish the
|
|
133
|
+
* two by checking the log for an invalidation of the held revision before wording the error. */
|
|
134
|
+
export class CollectionHeaderVanishedError extends Error {
|
|
135
|
+
constructor(
|
|
136
|
+
readonly collectionId: CollectionId,
|
|
137
|
+
/** The committed revision this collection held when the header read came back absent. */
|
|
138
|
+
readonly heldRev: number,
|
|
139
|
+
) {
|
|
140
|
+
super(`collection ${collectionId} holds committed revision ${heldRev}, but its header block `
|
|
141
|
+
+ `read as absent — storage reported that nothing was ever committed under this id`);
|
|
142
|
+
this.name = 'CollectionHeaderVanishedError';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ICollection<TAction> {
|
|
147
|
+
readonly id: CollectionId;
|
|
148
|
+
act(...actions: Action<TAction>[]): Promise<void>;
|
|
149
|
+
update(): Promise<void>;
|
|
150
|
+
sync(options?: SyncOptions): Promise<void>;
|
|
151
|
+
updateAndSync(options?: SyncOptions): Promise<void>;
|
|
152
|
+
selectLog(forward?: boolean): AsyncIterableIterator<Action<TAction>>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type CreateCollectionAction = Action<void> & {
|
|
156
|
+
type: "create";
|
|
157
|
+
};
|
package/src/logger.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import debug from 'debug'
|
|
2
|
-
|
|
3
|
-
const BASE_NAMESPACE = 'optimystic:db-core'
|
|
4
|
-
|
|
5
|
-
export function createLogger(subNamespace: string): debug.Debugger {
|
|
6
|
-
return debug(`${BASE_NAMESPACE}:${subNamespace}`)
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export const verbose = typeof process !== 'undefined'
|
|
10
|
-
&& (process.env.OPTIMYSTIC_VERBOSE === '1' || process.env.OPTIMYSTIC_VERBOSE === 'true');
|
|
1
|
+
import debug from 'debug'
|
|
2
|
+
|
|
3
|
+
const BASE_NAMESPACE = 'optimystic:db-core'
|
|
4
|
+
|
|
5
|
+
export function createLogger(subNamespace: string): debug.Debugger {
|
|
6
|
+
return debug(`${BASE_NAMESPACE}:${subNamespace}`)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const verbose = typeof process !== 'undefined'
|
|
10
|
+
&& (process.env.OPTIMYSTIC_VERBOSE === '1' || process.env.OPTIMYSTIC_VERBOSE === 'true');
|
|
@@ -31,7 +31,10 @@ export function isConflictFailure(failure: StaleFailure): boolean {
|
|
|
31
31
|
* NOTE: comparing revisions across blocks is only meaningful because one pend covers one
|
|
32
32
|
* collection, so every candidate comes from the same revision counter. If a pend is ever allowed
|
|
33
33
|
* to span collections, these numbers come from unrelated counters and selection must become
|
|
34
|
-
* per-collection.
|
|
34
|
+
* per-collection. `Collection.syncAttempts` now compares the winner against its OWN next revision
|
|
35
|
+
* to decide whether a retry could differ (`SyncRevisionStalledError`), so a cross-collection
|
|
36
|
+
* candidate would not merely muddy a diagnostic — it would fail a sync against an unrelated
|
|
37
|
+
* counter.
|
|
35
38
|
*/
|
|
36
39
|
export function highestStaleAt(candidates: readonly StaleFailure['staleAt'][]): StaleFailure['staleAt'] {
|
|
37
40
|
let best: StaleFailure['staleAt'];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ITransactor, GetBlockResults, ActionBlocks, BlockActionStatus, PendResult, CommitResult, PendRequest, BlockId, CommitRequest, BlockGets, IBlock, ActionId, ActionTransforms, Transform, Transforms, ClusterNomineesResult, CollectionId } from "../index.js";
|
|
1
|
+
import type { ITransactor, GetBlockResults, ActionBlocks, BlockActionStatus, PendResult, CommitResult, PendRequest, BlockId, CommitRequest, BlockGets, IBlock, ActionId, ActionRev, ActionTransforms, StaleFailure, Transform, Transforms, ClusterNomineesResult, CollectionId } from "../index.js";
|
|
2
|
+
import { highestStaleAt, isOwnRevision } from "../network/stale-failure.js";
|
|
2
3
|
import { ensuredMap } from "../utility/ensured.js";
|
|
3
4
|
import { Latches } from "../utility/latches.js";
|
|
4
5
|
import { applyTransform, blockIdsForTransforms, transformForBlockId, emptyTransforms, concatTransform, transformsFromTransform } from "../transform/index.js";
|
|
@@ -169,6 +170,10 @@ export class TestTransactor implements ITransactor {
|
|
|
169
170
|
const blockIds = blockIdsForTransforms(transforms);
|
|
170
171
|
const conflictingPendings: { blockId: BlockId, actionId: ActionId }[] = [];
|
|
171
172
|
const missing: ActionTransforms[] = [];
|
|
173
|
+
// Confirmed revisions this pend is up against, mirroring StorageRepo.pend: a block whose own
|
|
174
|
+
// storage is already at or past the requested revision, held by someone other than this same
|
|
175
|
+
// action. Reported as `staleAt` so a caller reading the shared harness sees the real shape.
|
|
176
|
+
const staleCandidates: StaleFailure['staleAt'][] = [];
|
|
172
177
|
|
|
173
178
|
// Check for conflicts (pending or committed based on rev/insert)
|
|
174
179
|
for (const blockId of blockIds) {
|
|
@@ -188,6 +193,13 @@ export class TestTransactor implements ITransactor {
|
|
|
188
193
|
if (rev !== undefined || blockTransform.insert) {
|
|
189
194
|
const checkRev = rev ?? 0; // Check from revision 0 if it's an insert
|
|
190
195
|
if (blockState.latestRev >= checkRev) {
|
|
196
|
+
// Mirrors StorageRepo.pend exactly: only a real revision race yields a
|
|
197
|
+
// meaningful `staleAt`. A rev-less pend reaches here as an insert collision
|
|
198
|
+
// (`checkRev` degraded to 0), where the block's revision answers a question
|
|
199
|
+
// nobody asked; and our own durable half of a torn action is not a rival's win.
|
|
200
|
+
if (rev !== undefined && !isOwnRevision(latestActionRev(blockState), rev, actionId)) {
|
|
201
|
+
staleCandidates.push({ blockId, rev: blockState.latestRev });
|
|
202
|
+
}
|
|
191
203
|
// Collect conflicting committed actions
|
|
192
204
|
const missingForBlock = new Map<ActionId, { rev: number, transform: Transform }>();
|
|
193
205
|
for (let r = checkRev as number; r <= blockState.latestRev; r++) {
|
|
@@ -219,10 +231,12 @@ export class TestTransactor implements ITransactor {
|
|
|
219
231
|
// `conflict: true` on the three optimistic-concurrency returns below mirrors what
|
|
220
232
|
// StorageRepo.pend now emits, so consumers of this test transactor see the real shape.
|
|
221
233
|
if (missing.length > 0) {
|
|
234
|
+
const staleAt = highestStaleAt(staleCandidates);
|
|
222
235
|
return {
|
|
223
236
|
success: false,
|
|
224
237
|
conflict: true,
|
|
225
|
-
missing
|
|
238
|
+
missing,
|
|
239
|
+
...(staleAt ? { staleAt } : {})
|
|
226
240
|
};
|
|
227
241
|
}
|
|
228
242
|
|
|
@@ -326,7 +340,16 @@ export class TestTransactor implements ITransactor {
|
|
|
326
340
|
.find(([, aId]) => aId === actionId)?.[0] ?? rev,
|
|
327
341
|
transforms
|
|
328
342
|
}));
|
|
329
|
-
|
|
343
|
+
// Same rule as StorageRepo.commit's missedCommits branch: report the highest confirmed
|
|
344
|
+
// revision a stale block is already at, skipping one held by this very action (the
|
|
345
|
+
// durable half of a torn action, which its own retry must not be refused by).
|
|
346
|
+
const staleAt = highestStaleAt(staleBlocks.map(blockId => {
|
|
347
|
+
const blockState = this.blocks.get(blockId)!;
|
|
348
|
+
return isOwnRevision(latestActionRev(blockState), rev, actionId)
|
|
349
|
+
? undefined
|
|
350
|
+
: { blockId, rev: blockState.latestRev };
|
|
351
|
+
}));
|
|
352
|
+
return { success: false, missing, ...(staleAt ? { staleAt } : {}) };
|
|
330
353
|
}
|
|
331
354
|
|
|
332
355
|
// Verify all blocks have the pending action
|
|
@@ -749,6 +772,14 @@ function newBlockState(): BlockState {
|
|
|
749
772
|
};
|
|
750
773
|
}
|
|
751
774
|
|
|
775
|
+
/** This block's latest committed revision in the shape {@link isOwnRevision} compares — the
|
|
776
|
+
* harness equivalent of `IBlockStorage.getLatest()`. `undefined` when nothing is recorded at that
|
|
777
|
+
* revision, which is how a never-written block reads (`latestRev` starts at 0 with no entry). */
|
|
778
|
+
function latestActionRev(blockState: BlockState): ActionRev | undefined {
|
|
779
|
+
const actionId = blockState.revisionActions.get(blockState.latestRev);
|
|
780
|
+
return actionId === undefined ? undefined : { actionId, rev: blockState.latestRev };
|
|
781
|
+
}
|
|
782
|
+
|
|
752
783
|
/** Returns the materialized block at the highest revision ≤ the given revision, together with
|
|
753
784
|
* that revision — the caller reports it as {@link GetBlockResult.materialized}. */
|
|
754
785
|
function latestMaterializedAt(blockState: BlockState, maxRev: number): { block: IBlock, rev: number } | undefined {
|
|
@@ -1465,11 +1465,15 @@ export class TransactionCoordinator {
|
|
|
1465
1465
|
// Fan out the per-collection cancels concurrently. Each is best-effort: a cancel
|
|
1466
1466
|
// fault is logged and swallowed so it cannot mask the pend/commit failure that
|
|
1467
1467
|
// triggered this sweep, and so one failed cancel does not abort the others.
|
|
1468
|
+
// The log line has to be enough to IDENTIFY the stranded pend on its own — action id and
|
|
1469
|
+
// the exact blocks — because `NetworkTransactor.cancel` only throws here once it has
|
|
1470
|
+
// retried and still reached nobody, which means those blocks' pending records are standing
|
|
1471
|
+
// and will refuse every later write to them until something removes them by hand.
|
|
1468
1472
|
const cancels = Array.from(pendedBlockIds.entries())
|
|
1469
1473
|
.filter(([collectionId]) => !excludeCollections?.has(collectionId))
|
|
1470
1474
|
.map(([collectionId, blockIds]) =>
|
|
1471
1475
|
this.transactor.cancel({ actionId, blockIds }).catch(err => {
|
|
1472
|
-
log('cancelPhase:
|
|
1476
|
+
log('cancelPhase: cancel did not discharge — pending records may be stranded actionId=%s collection=%s blocks=%o: %o', actionId, collectionId, blockIds, err);
|
|
1473
1477
|
})
|
|
1474
1478
|
);
|
|
1475
1479
|
await Promise.all(cancels);
|