@optimystic/db-core 0.18.0 → 0.19.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 (31) hide show
  1. package/dist/src/collection/collection.d.ts +10 -0
  2. package/dist/src/collection/collection.d.ts.map +1 -1
  3. package/dist/src/collection/collection.js +53 -9
  4. package/dist/src/collection/collection.js.map +1 -1
  5. package/dist/src/collection/struct.d.ts +43 -1
  6. package/dist/src/collection/struct.d.ts.map +1 -1
  7. package/dist/src/collection/struct.js +42 -2
  8. package/dist/src/collection/struct.js.map +1 -1
  9. package/dist/src/network/i-key-network.d.ts +17 -0
  10. package/dist/src/network/i-key-network.d.ts.map +1 -1
  11. package/dist/src/network/stale-failure.d.ts +21 -0
  12. package/dist/src/network/stale-failure.d.ts.map +1 -1
  13. package/dist/src/network/stale-failure.js +29 -0
  14. package/dist/src/network/stale-failure.js.map +1 -1
  15. package/dist/src/network/struct.d.ts +17 -0
  16. package/dist/src/network/struct.d.ts.map +1 -1
  17. package/dist/src/network/struct.js.map +1 -1
  18. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  19. package/dist/src/transaction/coordinator.js +12 -6
  20. package/dist/src/transaction/coordinator.js.map +1 -1
  21. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  22. package/dist/src/transactor/network-transactor.js +30 -13
  23. package/dist/src/transactor/network-transactor.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/collection/collection.ts +54 -9
  26. package/src/collection/struct.ts +38 -1
  27. package/src/network/i-key-network.ts +18 -0
  28. package/src/network/stale-failure.ts +30 -0
  29. package/src/network/struct.ts +14 -0
  30. package/src/transaction/coordinator.ts +16 -6
  31. 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
+ }
@@ -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(collectionId: CollectionId, readonly conflict: boolean, reason?: string) {
33
- super(`Pend failed for collection ${collectionId}: ${reason ?? (conflict ? 'stale conflict' : 'rejected')}`);
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. Harmless (a non-participant's update() just fetches latest) and the
186
- // registered set is small today; if a coordinator ever holds many collections and this
187
- // shows up as retry latency, narrow it to the transaction's participating collections.
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` string is dropped rather than
677
- // surfaced via `throw tailError`. Fine today (failure still propagates); if reason-only
678
- // commit rejections ever need their diagnostic reason, gate this branch on non-empty missing.
679
- return { missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)), success: false as const };
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
  /**