@optimystic/db-core 0.24.2 → 0.25.1
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/blocks/helpers.d.ts +5 -0
- package/dist/src/blocks/helpers.d.ts.map +1 -1
- package/dist/src/blocks/helpers.js +12 -0
- package/dist/src/blocks/helpers.js.map +1 -1
- package/dist/src/cluster/membership.d.ts +7 -0
- package/dist/src/cluster/membership.d.ts.map +1 -1
- package/dist/src/cluster/membership.js +12 -8
- package/dist/src/cluster/membership.js.map +1 -1
- package/dist/src/cluster/structs.d.ts +27 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/collection/action.d.ts +14 -0
- package/dist/src/collection/action.d.ts.map +1 -1
- package/dist/src/collection/action.js +16 -1
- package/dist/src/collection/action.js.map +1 -1
- package/dist/src/collection/collection.d.ts +273 -4
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +427 -30
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +16 -1
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +19 -0
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/network/i-repo.d.ts +11 -1
- package/dist/src/network/i-repo.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 +22 -0
- package/dist/src/network/stale-failure.js.map +1 -1
- package/dist/src/network/struct.d.ts +66 -21
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts +22 -0
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +44 -5
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +20 -0
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +256 -113
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/operations-hash.d.ts +1 -1
- package/dist/src/transaction/operations-hash.js +1 -1
- package/dist/src/transaction/transaction.d.ts +4 -2
- package/dist/src/transaction/transaction.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +21 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +122 -29
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +11 -5
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +16 -8
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transform/cache-source.d.ts +13 -0
- package/dist/src/transform/cache-source.d.ts.map +1 -1
- package/dist/src/transform/cache-source.js +18 -0
- package/dist/src/transform/cache-source.js.map +1 -1
- package/dist/src/transform/digest.d.ts +18 -0
- package/dist/src/transform/digest.d.ts.map +1 -0
- package/dist/src/transform/digest.js +65 -0
- package/dist/src/transform/digest.js.map +1 -0
- package/dist/src/transform/index.d.ts +1 -0
- package/dist/src/transform/index.d.ts.map +1 -1
- package/dist/src/transform/index.js +1 -0
- package/dist/src/transform/index.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +16 -0
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +40 -1
- package/dist/src/transform/tracker.js.map +1 -1
- package/dist/src/utility/canonical-json.d.ts +11 -0
- package/dist/src/utility/canonical-json.d.ts.map +1 -0
- package/dist/src/utility/canonical-json.js +15 -0
- package/dist/src/utility/canonical-json.js.map +1 -0
- package/dist/src/utility/lru-map.d.ts +2 -0
- package/dist/src/utility/lru-map.d.ts.map +1 -1
- package/dist/src/utility/lru-map.js +4 -0
- package/dist/src/utility/lru-map.js.map +1 -1
- package/package.json +2 -1
- package/src/blocks/helpers.ts +26 -13
- package/src/cluster/membership.ts +87 -85
- package/src/cluster/structs.ts +28 -1
- package/src/collection/action.ts +17 -0
- package/src/collection/collection.ts +1129 -688
- package/src/collections/tree/tree.ts +341 -320
- package/src/index.ts +24 -23
- package/src/network/i-repo.ts +59 -46
- package/src/network/stale-failure.ts +67 -43
- package/src/network/struct.ts +332 -270
- package/src/testing/test-transactor.ts +680 -638
- package/src/transaction/coordinator.ts +1266 -1110
- package/src/transaction/operations-hash.ts +1 -1
- package/src/transaction/transaction.ts +4 -2
- package/src/transactor/network-transactor.ts +123 -32
- package/src/transactor/transactor-source.ts +17 -9
- package/src/transform/cache-source.ts +20 -0
- package/src/transform/digest.ts +72 -0
- package/src/transform/index.ts +1 -0
- package/src/transform/tracker.ts +42 -1
- package/src/utility/canonical-json.ts +16 -0
- package/src/utility/lru-map.ts +5 -0
|
@@ -176,7 +176,7 @@ export function canonicalOperationsPayload(operations: readonly Operation[]): st
|
|
|
176
176
|
* stringify, SHA-256 (base64url) via {@link hashString}, then prefix the versioned
|
|
177
177
|
* {@link OPS_HASH_PREFIX} token (`ops.v1:`) so the wire string is self-describing.
|
|
178
178
|
*
|
|
179
|
-
* This is the fingerprint the coordinator sends in PendRequest.operationsHash and
|
|
179
|
+
* This is the fingerprint the coordinator sends in PendRequest.validation.operationsHash and
|
|
180
180
|
* the validator recomputes; equality of this string is the cross-node agreement
|
|
181
181
|
* that a transaction's operations match.
|
|
182
182
|
*
|
|
@@ -392,8 +392,10 @@ export type ValidationResult = {
|
|
|
392
392
|
* Transaction validator interface.
|
|
393
393
|
* Pluggable validators implement this to verify transaction integrity.
|
|
394
394
|
*
|
|
395
|
-
* Validators are invoked when a node receives a PendRequest
|
|
396
|
-
*
|
|
395
|
+
* Validators are invoked when a node receives a PendRequest carrying a `validation`
|
|
396
|
+
* payload (the transaction plus its operations hash). They re-execute the transaction
|
|
397
|
+
* and verify the operations match. A throw out of {@link validate} is treated by
|
|
398
|
+
* callers as a rejection ("validator-fault: …"), never as an approval or an abstain.
|
|
397
399
|
*/
|
|
398
400
|
export interface ITransactionValidator {
|
|
399
401
|
/**
|
|
@@ -2,10 +2,11 @@ import { peerIdFromString } from "../network/types.js";
|
|
|
2
2
|
import type { PeerId } from "../network/types.js";
|
|
3
3
|
import { highestStaleAt, isConflictFailure } from "../network/stale-failure.js";
|
|
4
4
|
import { BlockUnavailableError, BlockPossiblyStaleError } 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, CoordinatorIntent, BlockUnavailableReason } 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, BlockUnavailableReason, BlockContentDigests } from "../index.js";
|
|
6
6
|
import type { IBlockChangeNotifier, CollectionChangeListener } from "./change-notifier.js";
|
|
7
7
|
import { transformForBlockId, concatTransforms, concatTransform, transformsFromTransform, blockIdsForTransforms } from "../transform/helpers.js";
|
|
8
8
|
import { Tracker } from "../transform/tracker.js";
|
|
9
|
+
import { blockDigestsField } from "../transform/digest.js";
|
|
9
10
|
import { CacheSource } from "../transform/cache-source.js";
|
|
10
11
|
import { TransactorSource } from "./transactor-source.js";
|
|
11
12
|
import { Log } from "../log/log.js";
|
|
@@ -237,13 +238,13 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
237
238
|
// — and only strictly-greater rank replaces, so first-arrival (the stale one)
|
|
238
239
|
// would win the very merge the retry exists to fix. Non-object junk ranks below
|
|
239
240
|
// everything so any real entry replaces it.
|
|
240
|
-
// NOTE: `
|
|
241
|
+
// NOTE: `materialized` (the revision the content actually is) is not part of the ranking, so two peers answering the same
|
|
241
242
|
// pinned get with block-carrying entries at DIFFERENT materialized revisions resolve
|
|
242
243
|
// to whichever arrived first. Not a concern today — cohort peers share the block's
|
|
243
244
|
// revision log, so they agree on the highest committed rev at or below a pin — and the
|
|
244
245
|
// failure direction is safe (a lower recorded revision spuriously stale-rejects rather
|
|
245
246
|
// than wrongly accepting). If peers are ever seen to disagree here, break the tie on
|
|
246
|
-
// the HIGHEST
|
|
247
|
+
// the HIGHEST `materialized.rev` among top-rank entries.
|
|
247
248
|
// `unavailable` answers rank among THEMSELVES by how much they establish, so the merged
|
|
248
249
|
// entry never presents a weaker doubt than some peer actually returned. This matters
|
|
249
250
|
// because the reason travels out verbatim on `BlockUnavailableError` and callers act on
|
|
@@ -687,29 +688,84 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
687
688
|
// consensus commit op → each committing node's StorageRepo.commit stamps it onto the emitted
|
|
688
689
|
// CollectionChangeEvent (the reactivity topic anchor). Without this the per-block RepoCommitRequest
|
|
689
690
|
// drops the collection tail and reactivity origination is gated off (undefined tail → non-member).
|
|
691
|
+
// `request.blockDigests` (when present) is the FULL per-block declaration map for the action;
|
|
692
|
+
// it is threaded whole through commitBlock/commitBlocks and subset per batch at send time (see
|
|
693
|
+
// commitBlocks) so each cohort only signs for the blocks it is actually driving.
|
|
690
694
|
if (request.headerId && !request.blockIds.includes(request.headerId)) {
|
|
691
|
-
const headerResult = await this.commitBlock(request.headerId, request.actionId, request.rev, request.tailId);
|
|
695
|
+
const headerResult = await this.commitBlock(request.headerId, request.actionId, request.rev, request.tailId, request.blockDigests);
|
|
692
696
|
if (!headerResult.success) {
|
|
693
697
|
return headerResult;
|
|
694
698
|
}
|
|
695
699
|
}
|
|
696
700
|
|
|
697
701
|
// Commit the tail block
|
|
698
|
-
const tailResult = await this.commitBlock(request.tailId, request.actionId, request.rev, request.tailId);
|
|
702
|
+
const tailResult = await this.commitBlock(request.tailId, request.actionId, request.rev, request.tailId, request.blockDigests);
|
|
699
703
|
if (!tailResult.success) {
|
|
700
704
|
return tailResult;
|
|
701
705
|
}
|
|
702
706
|
|
|
703
|
-
//
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
)
|
|
707
|
+
// Sweep every non-tail block. The tail is the ONLY exclusion needed: the header-first commit
|
|
708
|
+
// above fires only when the header is NOT in `blockIds`, so a header that IS in `blockIds`
|
|
709
|
+
// belongs here, in this sweep. (The removed second filter clause tested
|
|
710
|
+
// `!blockIds.includes(headerId)` against a `bid` drawn from `blockIds` — unsatisfiable
|
|
711
|
+
// whenever `bid === headerId`, so it never excluded anything.)
|
|
712
|
+
//
|
|
713
|
+
// NOTE: measured, not assumed — the header-first branch above is currently UNREACHABLE from
|
|
714
|
+
// the only production producer of `headerId`. `TransactorSource.transact` sets it solely when
|
|
715
|
+
// the header is a fresh insert, and an inserted id is by construction in the pend's
|
|
716
|
+
// `blockIds`, so `!blockIds.includes(headerId)` is never true there. Instrumenting the branch
|
|
717
|
+
// and running both suites: 0 hits across every db-p2p mesh test, 2 hits in db-core, both from
|
|
718
|
+
// `commit-digest-threading.spec.ts` requests hand-built with the header held out of
|
|
719
|
+
// `blockIds`. So the order that actually runs in production is tail -> sweep, with the header
|
|
720
|
+
// (when the action touches it) inside the sweep — i.e. after the tail. Whether to wire the
|
|
721
|
+
// branch up or delete it, along with the three contracts that still describe it as live, is
|
|
722
|
+
// `tickets/backlog/debt-commit-header-first-branch-is-unreachable`.
|
|
723
|
+
//
|
|
724
|
+
// The tail -> sweep half of that order IS load-bearing; do not reorder it to put the
|
|
725
|
+
// contested blocks first. `Collection.bootstrapContext` documents the guarantee it rests on:
|
|
726
|
+
// "The tail is always committed first (commit protocol guarantee), so it's readable with
|
|
727
|
+
// context=undefined" — that bootstrap is what makes pending non-tail blocks visible to a
|
|
728
|
+
// chain walk. Sweeping before the tail would also let a committed header point at a
|
|
729
|
+
// never-committed tail: a dangling pointer, strictly worse than an orphaned block.
|
|
730
|
+
const remainingBlocks = request.blockIds.filter(bid => bid !== request.tailId);
|
|
708
731
|
if (remainingBlocks.length > 0) {
|
|
709
|
-
const { error } = await this.commitBlocks({ blockIds: remainingBlocks, actionId: request.actionId, rev: request.rev, tailId: request.tailId });
|
|
732
|
+
const { batches, error } = await this.commitBlocks({ blockIds: remainingBlocks, actionId: request.actionId, rev: request.rev, tailId: request.tailId, blockDigests: request.blockDigests });
|
|
710
733
|
if (error) {
|
|
711
|
-
//
|
|
712
|
-
//
|
|
734
|
+
// Split by the failure's NATURE, exactly as commitBlock does for the tail: a RETURNED
|
|
735
|
+
// `success:false` from a cohort coordinator is a confirmed optimistic-concurrency loss
|
|
736
|
+
// (post conversion-arms, coordinators only return non-success for confirmed conflicts) —
|
|
737
|
+
// a rival holds one of these blocks' revisions, no reconciliation will ever apply OUR
|
|
738
|
+
// transform there, and proceeding would acknowledge a torn action (tail committed, the
|
|
739
|
+
// conflicted block permanently pointing elsewhere — the acknowledged-but-absent write).
|
|
740
|
+
// Surface it so the caller cancels and re-drives at a fresh revision.
|
|
741
|
+
const stale = this.staleFromBatches(batches);
|
|
742
|
+
if (stale) {
|
|
743
|
+
return stale;
|
|
744
|
+
}
|
|
745
|
+
// The tail (and header) already committed durably when this returns — a torn action.
|
|
746
|
+
// Refusing is still right, and must NOT be softened back into a blanket tolerance:
|
|
747
|
+
// acknowledging a torn action would report a write as durable while one of its blocks
|
|
748
|
+
// permanently points somewhere else. The two consequences that used to make the refusal
|
|
749
|
+
// itself harmful are now handled upstream, so the writer recovers on its own:
|
|
750
|
+
// - its re-pend no longer meets its OWN durable revision as if a rival held it. All
|
|
751
|
+
// three pend-tier checks carve out `latest.rev === request.rev &&
|
|
752
|
+
// latest.actionId === request.actionId` (StorageRepo.pend, which also skips saving
|
|
753
|
+
// a pending record for such a block; ClusterMember.validatePendOperations; and
|
|
754
|
+
// CoordinatorRepo's stale classification). The retry no longer wedges.
|
|
755
|
+
// - its retry consumes its own already-committed log entry instead of replaying it
|
|
756
|
+
// (Collection.updateInternal's `inFlightActionId` arm, threaded by syncInternal,
|
|
757
|
+
// which reuses one actionId across all of a sync's attempts). It no longer appends
|
|
758
|
+
// a duplicate entry.
|
|
759
|
+
// NOTE: that second half covers the single-collection path only. The multi-collection
|
|
760
|
+
// path (TransactionCoordinator's retry loop) still refreshes without telling the
|
|
761
|
+
// collection which of its own actions is in flight, so a torn multi-collection commit
|
|
762
|
+
// can still record its entry twice — tracked as
|
|
763
|
+
// tickets/fix/refresh-must-always-know-its-own-in-flight-action, and to be fixed there
|
|
764
|
+
// rather than by tolerating the failure here.
|
|
765
|
+
//
|
|
766
|
+
// Transport-shaped failures (throws, no returned refusal) keep the tolerance: the commit
|
|
767
|
+
// consensus for these blocks exists, so lagging peers converge via reconciliation paths
|
|
768
|
+
// (e.g. reads with context).
|
|
713
769
|
try { log('WARN: non-tail commit had errors; proceeding after tail commit: %s', error.message); } catch { /* ignore */ }
|
|
714
770
|
}
|
|
715
771
|
}
|
|
@@ -718,36 +774,57 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
718
774
|
return { success: true };
|
|
719
775
|
}
|
|
720
776
|
|
|
721
|
-
private async commitBlock(blockId: BlockId, actionId: ActionId, rev: number, tailId?: BlockId): Promise<CommitResult> {
|
|
722
|
-
const { batches: tailBatches, error: tailError } = await this.commitBlocks({ blockIds: [blockId], actionId, rev, tailId });
|
|
777
|
+
private async commitBlock(blockId: BlockId, actionId: ActionId, rev: number, tailId?: BlockId, blockDigests?: BlockContentDigests): Promise<CommitResult> {
|
|
778
|
+
const { batches: tailBatches, error: tailError } = await this.commitBlocks({ blockIds: [blockId], actionId, rev, tailId, blockDigests });
|
|
723
779
|
if (tailError) {
|
|
724
780
|
// commit is a pure attempt: stale → { success:false }, transient → throw. Cancellation
|
|
725
781
|
// is the CALLER's responsibility (coordinator cancelPhase; TransactorSource.transact),
|
|
726
782
|
// which owns the retry budget and the committed-vs-pending picture — self-cancelling here
|
|
727
783
|
// would tear down a pend a caller's retry loop is still working against, and double-cancel.
|
|
728
784
|
// Collect and return any active stale failures
|
|
729
|
-
const stale =
|
|
730
|
-
if (stale
|
|
731
|
-
|
|
732
|
-
// returns { missing: [], success:false } — the `reason` PROSE is still dropped rather
|
|
733
|
-
// than surfaced via `throw tailError`. `staleAt` is carried, so the one machine-readable
|
|
734
|
-
// fact in that prose (which block is at which revision) now survives; only the free-form
|
|
735
|
-
// wording is lost. If the wording itself is ever needed, gate this branch on non-empty
|
|
736
|
-
// missing rather than reinstating it unconditionally.
|
|
737
|
-
const staleAt = highestStaleAt(stale.map(b => (b.request!.response! as StaleFailure).staleAt));
|
|
738
|
-
return {
|
|
739
|
-
missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)),
|
|
740
|
-
...(staleAt === undefined ? {} : { staleAt }),
|
|
741
|
-
success: false as const
|
|
742
|
-
};
|
|
785
|
+
const stale = this.staleFromBatches(tailBatches);
|
|
786
|
+
if (stale) {
|
|
787
|
+
return stale;
|
|
743
788
|
}
|
|
744
789
|
throw tailError;
|
|
745
790
|
}
|
|
746
791
|
return { success: true };
|
|
747
792
|
}
|
|
748
793
|
|
|
749
|
-
/**
|
|
750
|
-
|
|
794
|
+
/**
|
|
795
|
+
* Merge the RETURNED `success:false` responses out of a set of commit batches into one
|
|
796
|
+
* {@link StaleFailure}, or `undefined` when every failure was transport-shaped (thrown, no
|
|
797
|
+
* response). Shared by {@link commitBlock} (tail/header) and {@link commit}'s non-tail sweep —
|
|
798
|
+
* both must distinguish a confirmed conflict (return it; the caller cancels and re-drives) from
|
|
799
|
+
* a transient fault (throw / tolerate).
|
|
800
|
+
*
|
|
801
|
+
* NOTE: a reason-only StaleFailure (success:false, no `missing`) lands here too and returns
|
|
802
|
+
* `{ missing: [], success:false }` — the `reason` PROSE is dropped rather than surfaced.
|
|
803
|
+
* `staleAt` is carried, so the one machine-readable fact in that prose (which block is at which
|
|
804
|
+
* revision) survives; only the free-form wording is lost. If the wording itself is ever needed,
|
|
805
|
+
* gate this on non-empty missing rather than reinstating it unconditionally.
|
|
806
|
+
*/
|
|
807
|
+
private staleFromBatches(batches: CoordinatorBatch<BlockId[], CommitResult>[]): StaleFailure | undefined {
|
|
808
|
+
const stale = Array.from(allBatches(batches, b => b.request?.isResponse as boolean && !b.request!.response!.success));
|
|
809
|
+
if (stale.length === 0) {
|
|
810
|
+
return undefined;
|
|
811
|
+
}
|
|
812
|
+
const staleAt = highestStaleAt(stale.map(b => (b.request!.response! as StaleFailure).staleAt));
|
|
813
|
+
return {
|
|
814
|
+
missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)),
|
|
815
|
+
...(staleAt === undefined ? {} : { staleAt }),
|
|
816
|
+
success: false as const
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Attempts to commit a set of blocks, and handles failures and errors.
|
|
821
|
+
*
|
|
822
|
+
* `blockDigests` arrives as the action's FULL declaration map and is narrowed to each batch's own
|
|
823
|
+
* block ids inside the send callback below — never up front. Each batch's message becomes its own
|
|
824
|
+
* cluster record, so shipping the whole map would make one cohort sign for blocks it is not
|
|
825
|
+
* responsible for; and `processBatches` re-batches failed blocks onto different coordinators, so
|
|
826
|
+
* only a send-time subset stays correct across retries. */
|
|
827
|
+
private async commitBlocks({ blockIds, actionId, rev, tailId, blockDigests }: RepoCommitRequest) {
|
|
751
828
|
const expiration = Date.now() + this.timeoutMs;
|
|
752
829
|
// Thread the transaction's actionId so both the initial batch assembly and any
|
|
753
830
|
// per-block retry re-resolution prefer the coordinator pend already resolved.
|
|
@@ -757,7 +834,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
757
834
|
try {
|
|
758
835
|
await processBatches(
|
|
759
836
|
batches,
|
|
760
|
-
(batch) => this.getRepo(batch.peerId).commit({ actionId, blockIds: batch.payload, rev, tailId }, { expiration, dialTimeoutMs: this.dialTimeoutMs }),
|
|
837
|
+
(batch) => this.getRepo(batch.peerId).commit({ actionId, blockIds: batch.payload, rev, tailId, ...digestsFor(blockDigests, batch.payload) }, { expiration, dialTimeoutMs: this.dialTimeoutMs }),
|
|
761
838
|
batch => batch.payload,
|
|
762
839
|
mergeBlocks,
|
|
763
840
|
expiration,
|
|
@@ -898,6 +975,20 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
898
975
|
}
|
|
899
976
|
|
|
900
977
|
|
|
978
|
+
/** The subset of `all` whose ids appear in `batchBlockIds`, wrapped (via {@link blockDigestsField})
|
|
979
|
+
* so it spreads to nothing when the batch declares no digests. Called at SEND time, once per attempt,
|
|
980
|
+
* because `processBatches` re-batches failed blocks onto different coordinators — a subset computed
|
|
981
|
+
* up front would follow the wrong batch on retry. */
|
|
982
|
+
function digestsFor(all: BlockContentDigests | undefined, batchBlockIds: BlockId[]): { blockDigests?: BlockContentDigests } {
|
|
983
|
+
if (!all) return {};
|
|
984
|
+
const subset: BlockContentDigests = {};
|
|
985
|
+
for (const id of batchBlockIds) {
|
|
986
|
+
const digest = all[id];
|
|
987
|
+
if (digest !== undefined) subset[id] = digest;
|
|
988
|
+
}
|
|
989
|
+
return blockDigestsField(subset);
|
|
990
|
+
}
|
|
991
|
+
|
|
901
992
|
/**
|
|
902
993
|
* The owning collection id for an action ref, read from any fetched block's header. A
|
|
903
994
|
* committed-then-invalidated action still has a materialized (compensating) block whose header carries
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomBytes } from '@noble/hashes/utils.js'
|
|
2
2
|
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
|
|
3
|
-
import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms } from "../index.js";
|
|
3
|
+
import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms, BlockContentDigests } from "../index.js";
|
|
4
4
|
import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
|
|
5
5
|
import type { ReadDependency } from "../transaction/transaction.js";
|
|
6
6
|
import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
|
|
7
|
+
import { blockDigestsField } from "../transform/digest.js";
|
|
7
8
|
|
|
8
9
|
export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlock> {
|
|
9
10
|
/** Shared with this collection's CacheSource so cache hits also record dependencies.
|
|
@@ -43,7 +44,7 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
|
|
|
43
44
|
// `result[id]` is undefined. Destructuring that would throw a TypeError.
|
|
44
45
|
const entry = result?.[id];
|
|
45
46
|
if (entry) {
|
|
46
|
-
const { block, state,
|
|
47
|
+
const { block, state, materialized, unavailable, unconfirmedAheadRev } = entry;
|
|
47
48
|
// An entry flagged `unavailable` with no block is the repo saying "I could not find
|
|
48
49
|
// out whether this exists" — an answer that must not be read as absent. Throw rather
|
|
49
50
|
// than return undefined, and record no read dependency (dependencies are recorded
|
|
@@ -84,12 +85,12 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
|
|
|
84
85
|
// read purpose (default `value`) so a purely-structural navigation read can later be
|
|
85
86
|
// dropped from the conflict set (see ReadDependencyCollector / Theorem 5).
|
|
86
87
|
// Record the revision the content was MATERIALIZED at, not the newest the repo holds —
|
|
87
|
-
// see {@link GetBlockResult.
|
|
88
|
+
// see {@link GetBlockResult.materialized} for why `state.latest` is the wrong number
|
|
88
89
|
// and why the fallback preserves today's behaviour for repos that omit the field.
|
|
89
90
|
// Both sinks must take the SAME value: CacheSource learns it via getReadRevision on a
|
|
90
91
|
// miss-load and re-emits it on every later hit, so a split would stamp the cache
|
|
91
92
|
// differently from the collector.
|
|
92
|
-
const rev =
|
|
93
|
+
const rev = materialized?.rev ?? state.latest?.rev ?? 0;
|
|
93
94
|
this.collector.record(id, rev, purpose);
|
|
94
95
|
this.readRevisions.set(id, rev);
|
|
95
96
|
}
|
|
@@ -127,17 +128,23 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
|
|
|
127
128
|
* @param transform - The transforms to apply.
|
|
128
129
|
* @param actionId - The action id.
|
|
129
130
|
* @param rev - The revision number.
|
|
130
|
-
* @param headerId - The Id of the collection's header block.
|
|
131
|
-
*
|
|
132
|
-
*
|
|
131
|
+
* @param headerId - The Id of the collection's header block. Forwarded to the commit only when the header is a
|
|
132
|
+
* fresh insert, which is the create-the-collection race this was meant to order. NOTE: that forwarding no longer
|
|
133
|
+
* has any effect — `NetworkTransactor.commit` runs its header-first step only for a header held OUT of `blockIds`,
|
|
134
|
+
* and an inserted id is always IN it, so the header commits inside the ordinary sweep (after the tail) like any
|
|
135
|
+
* other touched block. Wire it up or drop it: `tickets/backlog/debt-commit-header-first-branch-is-unreachable`.
|
|
133
136
|
* @param tailId - The Id of the collection's log tail block. If specified, this block's transform is performed next
|
|
134
137
|
* (prior to the rest of the block operations), to resolve the "winner" of a race to commit to the collection.
|
|
135
138
|
* @param priority - Aged, advisory retry priority (default 0). Rides on the pend so a repeatedly-losing
|
|
136
139
|
* single-collection sync out-ranks fresh rivals in a concurrent race (`resolveRace`); fairness-only, never
|
|
137
140
|
* affects validity. Omitted from the pend when 0 so the common first-attempt pend serializes exactly as before.
|
|
141
|
+
* @param blockDigests - Optional per-block content declarations for this commit (see {@link BlockContentDigests}),
|
|
142
|
+
* computed by the caller from the same tracker that produced `transform`. Omitted from the commit request when
|
|
143
|
+
* undefined, so a caller that declares nothing produces exactly the request shape as before — the field rides
|
|
144
|
+
* inside every cohort signature's hash preimage, so keeping the shape clean keeps those preimages clean.
|
|
138
145
|
* @returns A promise that resolves to undefined if the action is successful, or a StaleFailure if the action is stale.
|
|
139
146
|
*/
|
|
140
|
-
async transact(transform: Transforms, actionId: ActionId, rev: number, headerId: BlockId, tailId: BlockId, priority = 0): Promise<undefined | StaleFailure> {
|
|
147
|
+
async transact(transform: Transforms, actionId: ActionId, rev: number, headerId: BlockId, tailId: BlockId, priority = 0, blockDigests?: BlockContentDigests): Promise<undefined | StaleFailure> {
|
|
141
148
|
const pendResult = await this.transactor.pend({ transforms: transform, actionId, rev, policy: 'r', ...(priority > 0 ? { priority } : {}) });
|
|
142
149
|
if (!pendResult.success) {
|
|
143
150
|
return pendResult;
|
|
@@ -149,7 +156,8 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
|
|
|
149
156
|
tailId,
|
|
150
157
|
blockIds: pendResult.blockIds,
|
|
151
158
|
actionId,
|
|
152
|
-
rev
|
|
159
|
+
rev,
|
|
160
|
+
...blockDigestsField(blockDigests)
|
|
153
161
|
});
|
|
154
162
|
if (!commitResult.success) {
|
|
155
163
|
await this.transactor.cancel({ actionId, blockIds: pendResult.blockIds });
|
|
@@ -100,6 +100,26 @@ export class CacheSource<T extends IBlock> implements BlockSource<T> {
|
|
|
100
100
|
return structuredClone(block);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/** The block currently cached for `id`, without consulting the source. Cloned (callers apply ops
|
|
104
|
+
* to it) and recency-neutral ({@link LruMap.peek}) — an observation pass must neither pay a
|
|
105
|
+
* network read nor reshape eviction order. Records no read dependency: the caller that peeks
|
|
106
|
+
* already read the block through {@link tryGet} (that is how it got cached), so the dependency
|
|
107
|
+
* exists; a digest pass merely re-describes it. */
|
|
108
|
+
peek(id: BlockId): T | undefined {
|
|
109
|
+
const block = this.cache.peek(id);
|
|
110
|
+
return block === undefined ? undefined : structuredClone(block);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The committed revision of the content currently cached for `id` — the source-reported
|
|
114
|
+
* materialized revision learned on miss-load (see {@link revisions}), NOT the block's own
|
|
115
|
+
* `state.latest.rev`. An LRU-evicted id can leave a stale entry here (see the NOTE on
|
|
116
|
+
* {@link revisions}); callers must therefore require BOTH {@link peek} and this to be present —
|
|
117
|
+
* {@link peek} returns `undefined` for the evicted id, so a stale revision never pairs with a
|
|
118
|
+
* peeked block. */
|
|
119
|
+
getCachedRevision(id: BlockId): number | undefined {
|
|
120
|
+
return this.revisions.get(id);
|
|
121
|
+
}
|
|
122
|
+
|
|
103
123
|
/** Upgrade an already-captured read of `id` to a `value` read in the shared collector,
|
|
104
124
|
* retaining it in the conflict set. The B-tree point-lookup descent calls this (through the
|
|
105
125
|
* Tracker, which forwards) to pin the terminal leaf after recording the interior nodes as
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { BlockId, IBlock } from "../index.js";
|
|
2
|
+
import type { BlockContentDigests } from "../network/struct.js";
|
|
3
|
+
import { canonicalBlockHash } from "../blocks/helpers.js";
|
|
4
|
+
import { createLogger } from "../logger.js";
|
|
5
|
+
import { isRecordEmpty } from "../utility/is-record-empty.js";
|
|
6
|
+
import type { Tracker } from "./tracker.js";
|
|
7
|
+
|
|
8
|
+
const log = createLogger('digest');
|
|
9
|
+
|
|
10
|
+
/** Digests for the blocks the tracker's staged transforms touch, computed WITHOUT loading anything
|
|
11
|
+
* from the source. An id whose base is not already cached is omitted rather than fetched — an
|
|
12
|
+
* omitted id never fails the commit, it only forfeits what a declaration buys (see the NOTE below).
|
|
13
|
+
* Each digest is the {@link canonicalBlockHash} of what {@link Tracker.peekMaterialized} says the
|
|
14
|
+
* block will contain at the committing revision; `baseRev` rides along except for base-independent
|
|
15
|
+
* (inserted) blocks. */
|
|
16
|
+
// NOTE: coverage is bounded by the read cache, not by the transaction. A commit whose update-carrying
|
|
17
|
+
// blocks outnumber the CacheSource capacity (default 128) silently digests only the ids still
|
|
18
|
+
// resident, and the declared count does not merely thin out — it CAPS. Measured through the
|
|
19
|
+
// production path (`Collection.act`/`sync`) in `test/digest-cache-coverage.spec.ts`: with N
|
|
20
|
+
// update-carrying blocks the declared count is 32/32 at N=32, then 126 at N=128, 200, 256 AND 512
|
|
21
|
+
// (126 = the 128 slots less the collection header and log tail), i.e. 100%, 98.4%, 63.0%, 49.2%,
|
|
22
|
+
// 24.6%. Coverage therefore decays as 1/N and an arbitrarily large commit declares an arbitrarily
|
|
23
|
+
// small fraction of itself. The survivors are the newest contiguous run, exactly as LRU eviction
|
|
24
|
+
// predicts.
|
|
25
|
+
// Omission also costs MORE than it used to: it still degrades gracefully on the read path, but an
|
|
26
|
+
// undeclared block retains no durable `BlockCommitProof` and so can never GAIN a holder by push.
|
|
27
|
+
// That consequence is stated once, canonically, at {@link CommitRequest.blockDigests} in
|
|
28
|
+
// `network/struct.ts`; do not restate it here.
|
|
29
|
+
// Still accepted here rather than fixed in place: both remedies are larger than this function —
|
|
30
|
+
// size the cache to the transaction, or carry the base revision alongside the staged updates instead
|
|
31
|
+
// of re-reading it here. Tracked as `debt-digest-coverage-capped-by-read-cache`. Revisit when a
|
|
32
|
+
// workload legitimately commits more update-carrying blocks than the cache holds.
|
|
33
|
+
export async function computeBlockContentDigests<T extends IBlock>(
|
|
34
|
+
tracker: Tracker<T>,
|
|
35
|
+
blockIds: BlockId[]
|
|
36
|
+
): Promise<BlockContentDigests> {
|
|
37
|
+
const digests: BlockContentDigests = {};
|
|
38
|
+
for (const id of blockIds) {
|
|
39
|
+
const peeked = peekOrSkip(tracker, id);
|
|
40
|
+
if (!peeked) continue;
|
|
41
|
+
digests[id] = {
|
|
42
|
+
digest: await canonicalBlockHash(peeked.block),
|
|
43
|
+
...(peeked.baseRev !== undefined ? { baseRev: peeked.baseRev } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return digests;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Wraps `blockDigests` so it spreads onto a request only when there is something to declare. The
|
|
50
|
+
* empty map omits the key entirely rather than sending `{}`: the request is hashed verbatim into
|
|
51
|
+
* every cohort signature preimage, so a commit that declares nothing must serialize exactly as it
|
|
52
|
+
* did before this field existed. Every producer of the field goes through here. */
|
|
53
|
+
export function blockDigestsField(digests: BlockContentDigests | undefined): { blockDigests?: BlockContentDigests } {
|
|
54
|
+
return digests && !isRecordEmpty(digests) ? { blockDigests: digests } : {};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** {@link Tracker.peekMaterialized}, degraded to "undeclared" when materializing throws.
|
|
58
|
+
*
|
|
59
|
+
* Declaring content must never break committing it. Materializing replays the staged ops against the
|
|
60
|
+
* LOCALLY CACHED base, which can legitimately fail — e.g. another action's commit folded into the
|
|
61
|
+
* cache (`CacheSource.transformCache`) shrank an array a staged splice indexes into. That transaction
|
|
62
|
+
* is doomed, but it must die as a retryable stale failure from the pend/commit round trip, not as a
|
|
63
|
+
* TypeError thrown out of `sync()` before the pend. The failure is logged rather than silently eaten,
|
|
64
|
+
* because the same swallow would also hide a genuine `applyTransform` bug. */
|
|
65
|
+
function peekOrSkip<T extends IBlock>(tracker: Tracker<T>, id: BlockId): { block: IBlock; baseRev?: number } | undefined {
|
|
66
|
+
try {
|
|
67
|
+
return tracker.peekMaterialized(id);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
log('block %s left undeclared: materializing against the cached base failed: %o', id, e);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/transform/index.ts
CHANGED
package/src/transform/tracker.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IBlock, BlockId, BlockStore as IBlockStore, BlockHeader, BlockOperation, BlockType, BlockSource as IBlockSource, ReadPurpose } from "../index.js";
|
|
2
|
-
import { applyOperation, applyOperations, emptyTransforms, blockIdsForTransforms } from "./helpers.js";
|
|
2
|
+
import { applyOperation, applyOperations, applyTransform, emptyTransforms, blockIdsForTransforms, transformForBlockId } from "./helpers.js";
|
|
3
3
|
import { ensured } from "../utility/ensured.js";
|
|
4
4
|
|
|
5
5
|
/** A block store that collects transformations, without applying them to the underlying source.
|
|
@@ -65,6 +65,47 @@ export class Tracker<T extends IBlock> implements IBlockStore<T> {
|
|
|
65
65
|
return block; // no-ops path unchanged (source already cloned)
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/** The block `id` materializes to under the staged transforms, computed WITHOUT loading from the
|
|
69
|
+
* source, plus the committed revision of the base used. `undefined` when not computable here —
|
|
70
|
+
* nothing staged for the id, the result is a delete, or an update's base is not locally cached
|
|
71
|
+
* (a commit must not pay a network round trip to describe itself).
|
|
72
|
+
*
|
|
73
|
+
* Materializes with the canonical {@link applyTransform} — the exact function the member side
|
|
74
|
+
* uses at commit — so client and member can never disagree on semantics (insert replaces the
|
|
75
|
+
* block, then updates apply, then delete wins). An insert makes the result base-independent, so
|
|
76
|
+
* `baseRev` is absent; updates-only returns the base's cached committed revision, probed from the
|
|
77
|
+
* source via `peek`/`getCachedRevision` (duck-typed like {@link sourceGeneration}, because Tracker
|
|
78
|
+
* layers over test doubles; `peek` must return a clone — CacheSource's does). Recency-neutral and
|
|
79
|
+
* memo-neutral: observably changes no tracker or source state. */
|
|
80
|
+
peekMaterialized(id: BlockId): { block: IBlock; baseRev?: number } | undefined {
|
|
81
|
+
const transform = transformForBlockId(this.transforms, id);
|
|
82
|
+
if (transform.insert === undefined && transform.updates === undefined && transform.delete === undefined) {
|
|
83
|
+
return undefined; // nothing staged for this id
|
|
84
|
+
}
|
|
85
|
+
if (transform.delete) {
|
|
86
|
+
return undefined; // delete-last-wins: materializes to nothing
|
|
87
|
+
}
|
|
88
|
+
if (transform.insert) {
|
|
89
|
+
// applyTransform mutates the insert in place when updates ride along; transformForBlockId
|
|
90
|
+
// clones `updates` but NOT `insert`, so clone here to keep the staged transform pristine.
|
|
91
|
+
transform.insert = structuredClone(transform.insert);
|
|
92
|
+
const block = applyTransform(undefined, transform);
|
|
93
|
+
return block ? { block } : undefined;
|
|
94
|
+
}
|
|
95
|
+
const src = this.source as {
|
|
96
|
+
peek?: (id: BlockId) => T | undefined;
|
|
97
|
+
getCachedRevision?: (id: BlockId) => number | undefined;
|
|
98
|
+
};
|
|
99
|
+
if (typeof src.peek !== 'function' || typeof src.getCachedRevision !== 'function') return undefined;
|
|
100
|
+
const base = src.peek(id);
|
|
101
|
+
const baseRev = src.getCachedRevision(id);
|
|
102
|
+
// Require BOTH: an LRU-evicted id can leave a stale cached revision behind (see the NOTE on
|
|
103
|
+
// CacheSource's revisions map); peek returning undefined keeps it from pairing with a block.
|
|
104
|
+
if (base === undefined || baseRev === undefined) return undefined;
|
|
105
|
+
const block = applyTransform(base, transform); // base already a clone (peek contract)
|
|
106
|
+
return block ? { block, baseRev } : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
68
109
|
/** Forward a leaf-value upgrade down to the source's read collector (duck-typed: only the
|
|
69
110
|
* CacheSource layer implements it). Lets the B-tree point-lookup descent, which reads through
|
|
70
111
|
* this tracker, pin its terminal leaf as a `value` read after tagging interior nodes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic JSON: sorts object keys so the encoding — and therefore any hash over it — is
|
|
3
|
+
* independent of key insertion order. Arrays keep their order (position is meaning).
|
|
4
|
+
*
|
|
5
|
+
* The ONE implementation behind every db-core agreement hash: block content
|
|
6
|
+
* ({@link canonicalBlockHash}) and cluster record/membership hashes ({@link membershipDigest},
|
|
7
|
+
* `computeMessageHash` and friends). Two nodes that encode differently disagree on honest data, so
|
|
8
|
+
* a second copy is a correctness hazard, not a style nit — import this rather than re-deriving it.
|
|
9
|
+
*/
|
|
10
|
+
export function canonicalJson(value: unknown): string {
|
|
11
|
+
return JSON.stringify(value, (_, v) =>
|
|
12
|
+
v && typeof v === 'object' && !Array.isArray(v)
|
|
13
|
+
? Object.keys(v).sort().reduce((o: Record<string, unknown>, k) => { o[k] = v[k]; return o; }, {})
|
|
14
|
+
: v
|
|
15
|
+
);
|
|
16
|
+
}
|
package/src/utility/lru-map.ts
CHANGED
|
@@ -20,6 +20,11 @@ export class LruMap<K, V> {
|
|
|
20
20
|
return value;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** Read without refreshing recency — for observation passes that must not reshape eviction order. */
|
|
24
|
+
peek(key: K): V | undefined {
|
|
25
|
+
return this.map.get(key);
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
set(key: K, value: V): this {
|
|
24
29
|
// If already present, delete first to refresh position
|
|
25
30
|
if (this.map.has(key)) {
|