@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.
Files changed (37) hide show
  1. package/dist/src/collection/collection.d.ts.map +1 -1
  2. package/dist/src/collection/collection.js +78 -6
  3. package/dist/src/collection/collection.js.map +1 -1
  4. package/dist/src/collection/struct.d.ts +53 -0
  5. package/dist/src/collection/struct.d.ts.map +1 -1
  6. package/dist/src/collection/struct.js +40 -0
  7. package/dist/src/collection/struct.js.map +1 -1
  8. package/dist/src/network/stale-failure.d.ts +4 -1
  9. package/dist/src/network/stale-failure.d.ts.map +1 -1
  10. package/dist/src/network/stale-failure.js +4 -1
  11. package/dist/src/network/stale-failure.js.map +1 -1
  12. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  13. package/dist/src/testing/test-transactor.js +32 -2
  14. package/dist/src/testing/test-transactor.js.map +1 -1
  15. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  16. package/dist/src/transaction/coordinator.js +5 -1
  17. package/dist/src/transaction/coordinator.js.map +1 -1
  18. package/dist/src/transactor/network-transactor.d.ts +109 -1
  19. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  20. package/dist/src/transactor/network-transactor.js +230 -17
  21. package/dist/src/transactor/network-transactor.js.map +1 -1
  22. package/dist/src/transactor/transactor-source.d.ts +12 -0
  23. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  24. package/dist/src/transactor/transactor-source.js +41 -2
  25. package/dist/src/transactor/transactor-source.js.map +1 -1
  26. package/dist/src/transactor/transactor.d.ts +5 -0
  27. package/dist/src/transactor/transactor.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/collection/collection.ts +81 -6
  30. package/src/collection/struct.ts +157 -98
  31. package/src/logger.ts +10 -10
  32. package/src/network/stale-failure.ts +4 -1
  33. package/src/testing/test-transactor.ts +34 -3
  34. package/src/transaction/coordinator.ts +5 -1
  35. package/src/transactor/network-transactor.ts +237 -37
  36. package/src/transactor/transactor-source.ts +39 -2
  37. package/src/transactor/transactor.ts +49 -44
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optimystic/db-core",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "type": "module",
5
5
  "description": "Core database functionality for Optimystic",
6
6
  "main": "dist/src/index.js",
@@ -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
- // Last confirmed revision a responder reported holding. Purely diagnostic — it is reported
931
- // in the exhaustion error and never consulted to decide whether to keep retrying.
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 = (this.source.actionContext?.rev ?? 0) + 1;
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
- lastStaleAt = staleFailure.staleAt ?? lastStaleAt;
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
@@ -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
- /** Thrown by {@link ICollection.sync} / {@link ICollection.updateAndSync} when the retry budget
36
- * (attempt count or deadline) is exhausted while the transactor keeps returning stale failures.
37
- * Catchable so callers can surface a clear "gave up syncing" condition instead of hanging. */
38
- export class SyncRetryExhaustedError extends Error {
39
- constructor(
40
- readonly collectionId: CollectionId,
41
- readonly attempts: number,
42
- readonly lastReason?: string,
43
- /** The last confirmed revision a responder reported holding, if any responder reported one
44
- * (see `StaleFailure.staleAt`). Absent whenever no rejection carried a confirmed number
45
- * which is normal, not a signal that the failure was something other than a lost race. */
46
- readonly staleAt?: { blockId: BlockId; rev: number },
47
- ) {
48
- super(`sync for collection ${collectionId} exhausted ${attempts} retries` +
49
- (lastReason ? `: ${lastReason}` : '') +
50
- (staleAt ? `, last seen block ${staleAt.blockId} at rev ${staleAt.rev}` : ''));
51
- this.name = 'SyncRetryExhaustedError';
52
- }
53
- }
54
-
55
- /** Thrown when a collection that already holds a committed revision reads its own header
56
- * block as authoritatively absent.
57
- *
58
- * The two facts contradict each other: this client has proof that something was committed
59
- * under this id (it holds the revision it committed at, or the one it read off the log tail),
60
- * and storage has just answered that nothing ever was. Exactly one of them is wrong, so this
61
- * is a fault rather than an absence — the same reasoning `Collection.attachToLog` applies to a
62
- * header that probes fine but whose log will not open.
63
- *
64
- * Deliberately NOT a `StaleFailure`: {@link ICollection.sync}'s retry loop only absorbs
65
- * returned stale failures, so throwing this aborts the sync immediately with a named
66
- * diagnosis instead of letting it spin the full retry budget re-requesting a revision it
67
- * has silently forgotten.
68
- *
69
- * NOTE: durable invalidation restores reverted content to its as-if-absent state, so once the
70
- * cascade runs end-to-end (docs/right-is-right.md § Durable Invalidation), reverting the commit
71
- * that CREATED a collection would make its header legitimately absent for a client still holding
72
- * that revision — a reversal, not a contradiction, which this message would misdiagnose.
73
- * Aborting is still the right action there; if it ever fires for that reason, distinguish the
74
- * two by checking the log for an invalidation of the held revision before wording the error. */
75
- export class CollectionHeaderVanishedError extends Error {
76
- constructor(
77
- readonly collectionId: CollectionId,
78
- /** The committed revision this collection held when the header read came back absent. */
79
- readonly heldRev: number,
80
- ) {
81
- super(`collection ${collectionId} holds committed revision ${heldRev}, but its header block `
82
- + `read as absent storage reported that nothing was ever committed under this id`);
83
- this.name = 'CollectionHeaderVanishedError';
84
- }
85
- }
86
-
87
- export interface ICollection<TAction> {
88
- readonly id: CollectionId;
89
- act(...actions: Action<TAction>[]): Promise<void>;
90
- update(): Promise<void>;
91
- sync(options?: SyncOptions): Promise<void>;
92
- updateAndSync(options?: SyncOptions): Promise<void>;
93
- selectLog(forward?: boolean): AsyncIterableIterator<Action<TAction>>;
94
- }
95
-
96
- export type CreateCollectionAction = Action<void> & {
97
- type: "create";
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
- return { success: false, missing };
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: best-effort cancel failed collection=%s: %o', collectionId, err);
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);