@optimystic/db-core 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cohort-topic/membership/verifier.d.ts +8 -0
- package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
- package/dist/src/cohort-topic/membership/verifier.js +17 -0
- package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
- package/dist/src/cohort-topic/promotion.d.ts +25 -0
- package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
- package/dist/src/cohort-topic/promotion.js +58 -11
- package/dist/src/cohort-topic/promotion.js.map +1 -1
- package/dist/src/cohort-topic/wire/primitives.d.ts +6 -0
- package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/primitives.js +6 -0
- package/dist/src/cohort-topic/wire/primitives.js.map +1 -1
- package/dist/src/cohort-topic/wire/types.d.ts +5 -0
- package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.js +10 -19
- package/dist/src/cohort-topic/wire/validate.js.map +1 -1
- package/dist/src/collection/collection.d.ts +8 -1
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +19 -3
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/matchmaking/wire.d.ts +2 -2
- package/dist/src/matchmaking/wire.d.ts.map +1 -1
- package/dist/src/matchmaking/wire.js +3 -3
- package/dist/src/matchmaking/wire.js.map +1 -1
- package/dist/src/network/struct.d.ts +5 -5
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/testing/index.d.ts +1 -0
- package/dist/src/testing/index.d.ts.map +1 -1
- package/dist/src/testing/index.js +1 -0
- package/dist/src/testing/index.js.map +1 -1
- package/dist/src/testing/refresh-probe.d.ts +31 -0
- package/dist/src/testing/refresh-probe.d.ts.map +1 -0
- package/dist/src/testing/refresh-probe.js +39 -0
- package/dist/src/testing/refresh-probe.js.map +1 -0
- package/dist/src/testing/test-transactor.d.ts +58 -5
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +87 -4
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +90 -6
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +311 -90
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/errors.d.ts +42 -0
- package/dist/src/transaction/errors.d.ts.map +1 -1
- package/dist/src/transaction/errors.js +50 -0
- package/dist/src/transaction/errors.js.map +1 -1
- package/dist/src/transaction/index.d.ts +1 -1
- package/dist/src/transaction/index.d.ts.map +1 -1
- package/dist/src/transaction/index.js +1 -1
- package/dist/src/transaction/index.js.map +1 -1
- package/dist/src/transaction/session.d.ts +7 -3
- package/dist/src/transaction/session.d.ts.map +1 -1
- package/dist/src/transaction/session.js +7 -3
- package/dist/src/transaction/session.js.map +1 -1
- package/dist/src/transaction/transaction.d.ts +12 -2
- package/dist/src/transaction/transaction.d.ts.map +1 -1
- package/dist/src/transaction/transaction.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +6 -26
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +4 -6
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +4 -6
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transform/atomic.d.ts.map +1 -1
- package/dist/src/transform/atomic.js +7 -0
- package/dist/src/transform/atomic.js.map +1 -1
- package/dist/src/transform/base-pins.d.ts +48 -0
- package/dist/src/transform/base-pins.d.ts.map +1 -0
- package/dist/src/transform/base-pins.js +65 -0
- package/dist/src/transform/base-pins.js.map +1 -0
- package/dist/src/transform/digest.d.ts +4 -2
- package/dist/src/transform/digest.d.ts.map +1 -1
- package/dist/src/transform/digest.js +14 -19
- package/dist/src/transform/digest.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +31 -3
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +105 -13
- package/dist/src/transform/tracker.js.map +1 -1
- package/package.json +1 -1
- package/src/cohort-topic/membership/verifier.ts +487 -461
- package/src/cohort-topic/promotion.ts +86 -14
- package/src/cohort-topic/wire/primitives.ts +195 -188
- package/src/cohort-topic/wire/types.ts +480 -475
- package/src/cohort-topic/wire/validate.ts +504 -512
- package/src/collection/collection.ts +19 -3
- package/src/matchmaking/wire.ts +605 -603
- package/src/network/struct.ts +5 -5
- package/src/testing/index.ts +1 -0
- package/src/testing/refresh-probe.ts +52 -0
- package/src/testing/test-transactor.ts +100 -4
- package/src/transaction/coordinator.ts +312 -99
- package/src/transaction/errors.ts +141 -91
- package/src/transaction/index.ts +1 -1
- package/src/transaction/session.ts +7 -3
- package/src/transaction/transaction.ts +12 -2
- package/src/transactor/network-transactor.ts +1025 -1045
- package/src/transactor/transactor-source.ts +170 -172
- package/src/transform/atomic.ts +6 -0
- package/src/transform/base-pins.ts +83 -0
- package/src/transform/digest.ts +14 -19
- package/src/transform/tracker.ts +108 -15
|
@@ -1,172 +1,170 @@
|
|
|
1
|
-
import { randomBytes } from '@noble/hashes/utils.js'
|
|
2
|
-
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
|
|
3
|
-
import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms, BlockContentDigests } from "../index.js";
|
|
4
|
-
import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
|
|
5
|
-
import type { ReadDependency } from "../transaction/transaction.js";
|
|
6
|
-
import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
|
|
7
|
-
import { blockDigestsField } from "../transform/digest.js";
|
|
8
|
-
|
|
9
|
-
export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlock> {
|
|
10
|
-
/** Shared with this collection's CacheSource so cache hits also record dependencies.
|
|
11
|
-
* Defaults to a private instance so internal log-walk sources (which never need a
|
|
12
|
-
* transaction read set) work standalone. */
|
|
13
|
-
private readonly collector: ReadDependencyCollector;
|
|
14
|
-
/** Last revision observed per id, so CacheSource can learn the revision on a miss-load
|
|
15
|
-
* (it calls {@link getReadRevision} right after this source serves the block). */
|
|
16
|
-
private readRevisions = new Map<BlockId, number>();
|
|
17
|
-
|
|
18
|
-
constructor(
|
|
19
|
-
private readonly collectionId: BlockId,
|
|
20
|
-
private readonly transactor: ITransactor,
|
|
21
|
-
public actionContext: ActionContext | undefined,
|
|
22
|
-
collector?: ReadDependencyCollector,
|
|
23
|
-
) {
|
|
24
|
-
this.collector = collector ?? new ReadDependencyCollector();
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
createBlockHeader(type: BlockType, newId?: BlockId): BlockHeader {
|
|
28
|
-
return {
|
|
29
|
-
type,
|
|
30
|
-
id: newId ?? this.generateId(),
|
|
31
|
-
collectionId: this.collectionId,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
generateId(): BlockId {
|
|
36
|
-
// 256-bits to fully utilize DHT address space
|
|
37
|
-
return uint8ArrayToString(randomBytes(32), 'base64url')
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async tryGet(id: BlockId, purpose: ReadPurpose = 'value'): Promise<TBlock | undefined> {
|
|
41
|
-
const result = await this.transactor.get({ blockIds: [id], context: this.actionContext });
|
|
42
|
-
// Guard the per-key entry: some transactors return a sparse result that omits `id`
|
|
43
|
-
// entirely (e.g. block genuinely not found), so `result` is a truthy object but
|
|
44
|
-
// `result[id]` is undefined. Destructuring that would throw a TypeError.
|
|
45
|
-
const entry = result?.[id];
|
|
46
|
-
if (entry) {
|
|
47
|
-
const { block, state, materialized, unavailable, unconfirmedAheadRev } = entry;
|
|
48
|
-
// An entry flagged `unavailable` with no block is the repo saying "I could not find
|
|
49
|
-
// out whether this exists" — an answer that must not be read as absent. Throw rather
|
|
50
|
-
// than return undefined, and record no read dependency (dependencies are recorded
|
|
51
|
-
// only for blocks that actually exist). A repo that omits the flag stays authoritative.
|
|
52
|
-
if (!block && unavailable) {
|
|
53
|
-
throw new BlockUnavailableError(id, unavailable);
|
|
54
|
-
}
|
|
55
|
-
// A read whose surviving answer is marked possibly-behind (`unconfirmedAheadRev`
|
|
56
|
-
// outlived the transactor's retry round: every reachable coordinator served content it
|
|
57
|
-
// could not confirm current) must not pose as an answer for a view that should CONTAIN
|
|
58
|
-
// the claimed revision. Two such views, the same test the coordinator applies when it
|
|
59
|
-
// stamps: an UNPINNED "give me latest" read — the tail read is the one seam where a
|
|
60
|
-
// lagging collection can learn the truth (Collection.bootstrapContext), and silently
|
|
61
|
-
// serving doubted content there is exactly how a collection view freezes forever — and
|
|
62
|
-
// a read PINNED AT OR ABOVE the claim, whose snapshot is missing a revision the cohort
|
|
63
|
-
// says exists inside it. A read pinned strictly BELOW the claim keeps working: it
|
|
64
|
-
// legitimately asks for an older view, which is being served correctly.
|
|
65
|
-
// No read dependency is recorded: the throw means nothing was read.
|
|
66
|
-
// NOTE: accepted tradeoff — this converts a silent wrong answer into a loud failure. A
|
|
67
|
-
// node partitioned from every coordinator able to confirm currency used to read (stale)
|
|
68
|
-
// data indefinitely without any signal; it now raises BlockPossiblyStaleError on the
|
|
69
|
-
// reads that should contain the claim, until the partition heals or the claim is
|
|
70
|
-
// settled. Deliberate: the
|
|
71
|
-
// silent alternative is a collection view that forks and freezes with no report
|
|
72
|
-
// (ticket coordinator-serves-stale-data-as-if-confirmed). Revisit only if a
|
|
73
|
-
// degraded-read mode (serve-with-warning) becomes a product requirement.
|
|
74
|
-
if (unconfirmedAheadRev !== undefined
|
|
75
|
-
&& (this.actionContext === undefined || this.actionContext.rev >= unconfirmedAheadRev)) {
|
|
76
|
-
throw new BlockPossiblyStaleError(id, unconfirmedAheadRev);
|
|
77
|
-
}
|
|
78
|
-
// Record a read dependency only for a block that actually exists. A transactor may return a
|
|
79
|
-
// populated entry with `block: undefined` for a genuinely-missing block (TestTransactor does;
|
|
80
|
-
// the Network transactor always populates the key); recording there would add a phantom
|
|
81
|
-
// dependency for a nonexistent block. This makes the "absent reads nothing" contract uniform
|
|
82
|
-
// with the sparse-result case (entry omitted) — see transactor-source.spec.ts sparse test.
|
|
83
|
-
if (block) {
|
|
84
|
-
// Record read dependency for optimistic concurrency control, carrying the caller's
|
|
85
|
-
// read purpose (default `value`) so a purely-structural navigation read can later be
|
|
86
|
-
// dropped from the conflict set (see ReadDependencyCollector / Theorem 5).
|
|
87
|
-
// Record the revision the content was MATERIALIZED at, not the newest the repo holds —
|
|
88
|
-
// see {@link GetBlockResult.materialized} for why `state.latest` is the wrong number
|
|
89
|
-
// and why the fallback preserves today's behaviour for repos that omit the field.
|
|
90
|
-
// Both sinks must take the SAME value: CacheSource learns it via getReadRevision on a
|
|
91
|
-
// miss-load and re-emits it on every later hit, so a split would stamp the cache
|
|
92
|
-
// differently from the collector.
|
|
93
|
-
const rev = materialized?.rev ?? state.latest?.rev ?? 0;
|
|
94
|
-
this.collector.record(id, rev, purpose);
|
|
95
|
-
this.readRevisions.set(id, rev);
|
|
96
|
-
}
|
|
97
|
-
// TODO: if the state reports that there is a pending action, record this so that we are sure to update before syncing
|
|
98
|
-
//state.pendings
|
|
99
|
-
return block as TBlock;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** The revision observed the last time this source served {@link id} (from its committed
|
|
104
|
-
* state), or undefined if this source has never served it. CacheSource reads this on a
|
|
105
|
-
* miss-load to learn the revision to record and store. */
|
|
106
|
-
getReadRevision(id: BlockId): number | undefined {
|
|
107
|
-
return this.readRevisions.get(id);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
getReadDependencies(): ReadDependency[] {
|
|
111
|
-
return this.collector.getReadDependencies();
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** The collector this source records into — the one shared with the collection's
|
|
115
|
-
* CacheSource. Exposed ONLY so a pinned read view built with `recordReads: true`
|
|
116
|
-
* can feed the same per-transaction read set (see Collection.createReadTracker);
|
|
117
|
-
* every other consumer should go through {@link getReadDependencies}. */
|
|
118
|
-
getCollector(): ReadDependencyCollector {
|
|
119
|
-
return this.collector;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
clearReadDependencies(): void {
|
|
123
|
-
this.collector.clear();
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Attempts to apply the given transforms in a transactional manner.
|
|
128
|
-
* @param transform - The transforms to apply.
|
|
129
|
-
* @param actionId - The action id.
|
|
130
|
-
* @param rev - The revision number.
|
|
131
|
-
* @param headerId - The Id of the collection's header block.
|
|
132
|
-
* fresh insert,
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* @param
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
172
|
-
|
|
1
|
+
import { randomBytes } from '@noble/hashes/utils.js'
|
|
2
|
+
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
|
|
3
|
+
import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms, BlockContentDigests } from "../index.js";
|
|
4
|
+
import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
|
|
5
|
+
import type { ReadDependency } from "../transaction/transaction.js";
|
|
6
|
+
import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
|
|
7
|
+
import { blockDigestsField } from "../transform/digest.js";
|
|
8
|
+
|
|
9
|
+
export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlock> {
|
|
10
|
+
/** Shared with this collection's CacheSource so cache hits also record dependencies.
|
|
11
|
+
* Defaults to a private instance so internal log-walk sources (which never need a
|
|
12
|
+
* transaction read set) work standalone. */
|
|
13
|
+
private readonly collector: ReadDependencyCollector;
|
|
14
|
+
/** Last revision observed per id, so CacheSource can learn the revision on a miss-load
|
|
15
|
+
* (it calls {@link getReadRevision} right after this source serves the block). */
|
|
16
|
+
private readRevisions = new Map<BlockId, number>();
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly collectionId: BlockId,
|
|
20
|
+
private readonly transactor: ITransactor,
|
|
21
|
+
public actionContext: ActionContext | undefined,
|
|
22
|
+
collector?: ReadDependencyCollector,
|
|
23
|
+
) {
|
|
24
|
+
this.collector = collector ?? new ReadDependencyCollector();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
createBlockHeader(type: BlockType, newId?: BlockId): BlockHeader {
|
|
28
|
+
return {
|
|
29
|
+
type,
|
|
30
|
+
id: newId ?? this.generateId(),
|
|
31
|
+
collectionId: this.collectionId,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
generateId(): BlockId {
|
|
36
|
+
// 256-bits to fully utilize DHT address space
|
|
37
|
+
return uint8ArrayToString(randomBytes(32), 'base64url')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async tryGet(id: BlockId, purpose: ReadPurpose = 'value'): Promise<TBlock | undefined> {
|
|
41
|
+
const result = await this.transactor.get({ blockIds: [id], context: this.actionContext });
|
|
42
|
+
// Guard the per-key entry: some transactors return a sparse result that omits `id`
|
|
43
|
+
// entirely (e.g. block genuinely not found), so `result` is a truthy object but
|
|
44
|
+
// `result[id]` is undefined. Destructuring that would throw a TypeError.
|
|
45
|
+
const entry = result?.[id];
|
|
46
|
+
if (entry) {
|
|
47
|
+
const { block, state, materialized, unavailable, unconfirmedAheadRev } = entry;
|
|
48
|
+
// An entry flagged `unavailable` with no block is the repo saying "I could not find
|
|
49
|
+
// out whether this exists" — an answer that must not be read as absent. Throw rather
|
|
50
|
+
// than return undefined, and record no read dependency (dependencies are recorded
|
|
51
|
+
// only for blocks that actually exist). A repo that omits the flag stays authoritative.
|
|
52
|
+
if (!block && unavailable) {
|
|
53
|
+
throw new BlockUnavailableError(id, unavailable);
|
|
54
|
+
}
|
|
55
|
+
// A read whose surviving answer is marked possibly-behind (`unconfirmedAheadRev`
|
|
56
|
+
// outlived the transactor's retry round: every reachable coordinator served content it
|
|
57
|
+
// could not confirm current) must not pose as an answer for a view that should CONTAIN
|
|
58
|
+
// the claimed revision. Two such views, the same test the coordinator applies when it
|
|
59
|
+
// stamps: an UNPINNED "give me latest" read — the tail read is the one seam where a
|
|
60
|
+
// lagging collection can learn the truth (Collection.bootstrapContext), and silently
|
|
61
|
+
// serving doubted content there is exactly how a collection view freezes forever — and
|
|
62
|
+
// a read PINNED AT OR ABOVE the claim, whose snapshot is missing a revision the cohort
|
|
63
|
+
// says exists inside it. A read pinned strictly BELOW the claim keeps working: it
|
|
64
|
+
// legitimately asks for an older view, which is being served correctly.
|
|
65
|
+
// No read dependency is recorded: the throw means nothing was read.
|
|
66
|
+
// NOTE: accepted tradeoff — this converts a silent wrong answer into a loud failure. A
|
|
67
|
+
// node partitioned from every coordinator able to confirm currency used to read (stale)
|
|
68
|
+
// data indefinitely without any signal; it now raises BlockPossiblyStaleError on the
|
|
69
|
+
// reads that should contain the claim, until the partition heals or the claim is
|
|
70
|
+
// settled. Deliberate: the
|
|
71
|
+
// silent alternative is a collection view that forks and freezes with no report
|
|
72
|
+
// (ticket coordinator-serves-stale-data-as-if-confirmed). Revisit only if a
|
|
73
|
+
// degraded-read mode (serve-with-warning) becomes a product requirement.
|
|
74
|
+
if (unconfirmedAheadRev !== undefined
|
|
75
|
+
&& (this.actionContext === undefined || this.actionContext.rev >= unconfirmedAheadRev)) {
|
|
76
|
+
throw new BlockPossiblyStaleError(id, unconfirmedAheadRev);
|
|
77
|
+
}
|
|
78
|
+
// Record a read dependency only for a block that actually exists. A transactor may return a
|
|
79
|
+
// populated entry with `block: undefined` for a genuinely-missing block (TestTransactor does;
|
|
80
|
+
// the Network transactor always populates the key); recording there would add a phantom
|
|
81
|
+
// dependency for a nonexistent block. This makes the "absent reads nothing" contract uniform
|
|
82
|
+
// with the sparse-result case (entry omitted) — see transactor-source.spec.ts sparse test.
|
|
83
|
+
if (block) {
|
|
84
|
+
// Record read dependency for optimistic concurrency control, carrying the caller's
|
|
85
|
+
// read purpose (default `value`) so a purely-structural navigation read can later be
|
|
86
|
+
// dropped from the conflict set (see ReadDependencyCollector / Theorem 5).
|
|
87
|
+
// Record the revision the content was MATERIALIZED at, not the newest the repo holds —
|
|
88
|
+
// see {@link GetBlockResult.materialized} for why `state.latest` is the wrong number
|
|
89
|
+
// and why the fallback preserves today's behaviour for repos that omit the field.
|
|
90
|
+
// Both sinks must take the SAME value: CacheSource learns it via getReadRevision on a
|
|
91
|
+
// miss-load and re-emits it on every later hit, so a split would stamp the cache
|
|
92
|
+
// differently from the collector.
|
|
93
|
+
const rev = materialized?.rev ?? state.latest?.rev ?? 0;
|
|
94
|
+
this.collector.record(id, rev, purpose);
|
|
95
|
+
this.readRevisions.set(id, rev);
|
|
96
|
+
}
|
|
97
|
+
// TODO: if the state reports that there is a pending action, record this so that we are sure to update before syncing
|
|
98
|
+
//state.pendings
|
|
99
|
+
return block as TBlock;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The revision observed the last time this source served {@link id} (from its committed
|
|
104
|
+
* state), or undefined if this source has never served it. CacheSource reads this on a
|
|
105
|
+
* miss-load to learn the revision to record and store. */
|
|
106
|
+
getReadRevision(id: BlockId): number | undefined {
|
|
107
|
+
return this.readRevisions.get(id);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
getReadDependencies(): ReadDependency[] {
|
|
111
|
+
return this.collector.getReadDependencies();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The collector this source records into — the one shared with the collection's
|
|
115
|
+
* CacheSource. Exposed ONLY so a pinned read view built with `recordReads: true`
|
|
116
|
+
* can feed the same per-transaction read set (see Collection.createReadTracker);
|
|
117
|
+
* every other consumer should go through {@link getReadDependencies}. */
|
|
118
|
+
getCollector(): ReadDependencyCollector {
|
|
119
|
+
return this.collector;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
clearReadDependencies(): void {
|
|
123
|
+
this.collector.clear();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Attempts to apply the given transforms in a transactional manner.
|
|
128
|
+
* @param transform - The transforms to apply.
|
|
129
|
+
* @param actionId - The action id.
|
|
130
|
+
* @param rev - The revision number.
|
|
131
|
+
* @param headerId - The Id of the collection's header block. Forwarded to the commit only when the header is a
|
|
132
|
+
* fresh insert, so the collection-identifying metadata (see `CommitRequest.headerId`) is present on the commit
|
|
133
|
+
* that creates it.
|
|
134
|
+
* @param tailId - The Id of the collection's log tail block. This block's transform is committed FIRST
|
|
135
|
+
* (prior to the rest of the block operations), to resolve the "winner" of a race to commit to the collection.
|
|
136
|
+
* @param priority - Aged, advisory retry priority (default 0). Rides on the pend so a repeatedly-losing
|
|
137
|
+
* single-collection sync out-ranks fresh rivals in a concurrent race (`resolveRace`); fairness-only, never
|
|
138
|
+
* affects validity. Omitted from the pend when 0 so the common first-attempt pend serializes exactly as before.
|
|
139
|
+
* @param blockDigests - Optional per-block content declarations for this commit (see {@link BlockContentDigests}),
|
|
140
|
+
* computed by the caller from the same tracker that produced `transform`. Omitted from the commit request when
|
|
141
|
+
* undefined, so a caller that declares nothing produces exactly the request shape as before — the field rides
|
|
142
|
+
* inside every cohort signature's hash preimage, so keeping the shape clean keeps those preimages clean.
|
|
143
|
+
* @returns A promise that resolves to undefined if the action is successful, or a StaleFailure if the action is stale.
|
|
144
|
+
*/
|
|
145
|
+
async transact(transform: Transforms, actionId: ActionId, rev: number, headerId: BlockId, tailId: BlockId, priority = 0, blockDigests?: BlockContentDigests): Promise<undefined | StaleFailure> {
|
|
146
|
+
const pendResult = await this.transactor.pend({ transforms: transform, actionId, rev, policy: 'r', ...(priority > 0 ? { priority } : {}) });
|
|
147
|
+
if (!pendResult.success) {
|
|
148
|
+
return pendResult;
|
|
149
|
+
}
|
|
150
|
+
const isNew = transform.inserts && Object.hasOwn(transform.inserts, headerId);
|
|
151
|
+
try {
|
|
152
|
+
const commitResult = await this.transactor.commit({
|
|
153
|
+
headerId: isNew ? headerId : undefined,
|
|
154
|
+
tailId,
|
|
155
|
+
blockIds: pendResult.blockIds,
|
|
156
|
+
actionId,
|
|
157
|
+
rev,
|
|
158
|
+
...blockDigestsField(blockDigests)
|
|
159
|
+
});
|
|
160
|
+
if (!commitResult.success) {
|
|
161
|
+
await this.transactor.cancel({ actionId, blockIds: pendResult.blockIds });
|
|
162
|
+
return commitResult;
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {
|
|
165
|
+
await this.transactor.cancel({ actionId, blockIds: pendResult.blockIds });
|
|
166
|
+
throw e;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
package/src/transform/atomic.ts
CHANGED
|
@@ -8,6 +8,12 @@ export class Atomic<TBlock extends IBlock> extends Tracker<TBlock> {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
commit() {
|
|
11
|
+
// Hand the bases pinned inside this atomic to the parent tracker BEFORE reset() wipes this
|
|
12
|
+
// store — without this, a single act() carrying more actions than the read cache holds loses
|
|
13
|
+
// digest coverage, because by the flush below the cache has already evicted the early bases.
|
|
14
|
+
// The parent gets a COPY (adopt), not this store itself: sharing the store would let the
|
|
15
|
+
// reset() below wipe the parent's pins a line before flushing into it.
|
|
16
|
+
if (this.store instanceof Tracker) this.store.pins.adopt(this.pins);
|
|
11
17
|
const transform = this.reset();
|
|
12
18
|
applyTransformToStore(transform, this.store);
|
|
13
19
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { BlockId, IBlock } from "../index.js";
|
|
2
|
+
|
|
3
|
+
/** The committed base of one block, captured at the moment an update for it was staged. */
|
|
4
|
+
export type PinnedBase = {
|
|
5
|
+
/** Cloned base content. Callers MUST clone again before applying a transform — applyTransform
|
|
6
|
+
* mutates, and the same pin is re-used by every retry attempt's digest pass. */
|
|
7
|
+
block: IBlock;
|
|
8
|
+
/** Committed revision of that base (CacheSource.getCachedRevision at pin time). */
|
|
9
|
+
rev: number;
|
|
10
|
+
/** Source drift generation at pin time (CacheSource.getGeneration). A pin whose generation no
|
|
11
|
+
* longer matches the source's is STALE and must not be used. */
|
|
12
|
+
gen: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Per-transaction map of block id -> {@link PinnedBase}. Owned by a {@link Tracker}, shared by
|
|
16
|
+
* reference across the trackers of one transaction (the collection's live tracker and each
|
|
17
|
+
* per-attempt snapshot tracker), so a base pinned when an update was staged is still available
|
|
18
|
+
* when the digest pass runs — regardless of whether the read cache has since evicted it.
|
|
19
|
+
*
|
|
20
|
+
* NOTE: memory shape — one cloned base block per update-carrying block, held from the first
|
|
21
|
+
* update staged for it until the owning tracker's next reset(). Peak retention is proportional
|
|
22
|
+
* to the transaction's own write footprint (the same set of blocks whose ids and ops the commit
|
|
23
|
+
* request already carries), and every transaction boundary reclaims it via reset(). */
|
|
24
|
+
export class BasePins {
|
|
25
|
+
private pins = new Map<BlockId, PinnedBase>();
|
|
26
|
+
/** The base source every `rev`/`gen` in here was read from — see {@link bindAuthority}. */
|
|
27
|
+
private authority: unknown;
|
|
28
|
+
private bound = false;
|
|
29
|
+
|
|
30
|
+
/** Claim this store for `source`, the base source at the bottom of the binding tracker's stack.
|
|
31
|
+
* A fresh store binds; a store being joined must present the same source. Both `rev` and `gen`
|
|
32
|
+
* are counters private to one source — a second CacheSource numbers generations from 0 for the
|
|
33
|
+
* same ids — so a store shared across two sources would let a pin taken from one pass the
|
|
34
|
+
* freshness check against the other and declare the wrong content. Throwing here makes that
|
|
35
|
+
* mistake impossible to make quietly. */
|
|
36
|
+
bindAuthority(source: unknown): void {
|
|
37
|
+
if (!this.bound) {
|
|
38
|
+
this.authority = source;
|
|
39
|
+
this.bound = true;
|
|
40
|
+
} else if (this.authority !== source) {
|
|
41
|
+
throw new Error('BasePins shared between trackers over different base sources: pin revisions and generations are per-source counters and are not comparable across them.');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get(id: BlockId): PinnedBase | undefined {
|
|
46
|
+
return this.pins.get(id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
set(id: BlockId, pin: PinnedBase): void {
|
|
50
|
+
this.pins.set(id, pin);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
delete(id: BlockId): void {
|
|
54
|
+
this.pins.delete(id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Drop every pin whose id is not in `keep`. Called from Tracker.reset with the ids still
|
|
58
|
+
* carried by the new transforms' `updates`, so a rollback keeps its pins and a plain reset
|
|
59
|
+
* clears them. */
|
|
60
|
+
retainOnly(keep: Iterable<BlockId>): void {
|
|
61
|
+
const keepSet = new Set(keep);
|
|
62
|
+
for (const id of this.pins.keys()) {
|
|
63
|
+
if (!keepSet.has(id)) this.pins.delete(id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Copy every entry of `other` in, overwriting. Called from Atomic.commit so pins captured
|
|
68
|
+
* inside the atomic survive into the parent tracker's store. Overwriting is deliberate: the
|
|
69
|
+
* atomic's pin is the later observation of the same base chain, and the use-time freshness
|
|
70
|
+
* check re-validates it anyway. */
|
|
71
|
+
adopt(other: BasePins): void {
|
|
72
|
+
if (other.authority !== this.authority) {
|
|
73
|
+
throw new Error('BasePins.adopt across different base sources: pin revisions and generations are not comparable across them.');
|
|
74
|
+
}
|
|
75
|
+
for (const [id, pin] of other.pins) {
|
|
76
|
+
this.pins.set(id, pin);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get size(): number {
|
|
81
|
+
return this.pins.size;
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/transform/digest.ts
CHANGED
|
@@ -8,28 +8,23 @@ import type { Tracker } from "./tracker.js";
|
|
|
8
8
|
const log = createLogger('digest');
|
|
9
9
|
|
|
10
10
|
/** Digests for the blocks the tracker's staged transforms touch, computed WITHOUT loading anything
|
|
11
|
-
* from the source. An id whose base is
|
|
12
|
-
* omitted id never fails the commit, it only forfeits what a declaration buys (
|
|
11
|
+
* from the source. An id whose base is neither pinned nor cached is omitted rather than fetched —
|
|
12
|
+
* an omitted id never fails the commit, it only forfeits what a declaration buys (an undeclared
|
|
13
|
+
* block retains no durable `BlockCommitProof` and so can never GAIN a holder by push; stated once,
|
|
14
|
+
* canonically, at {@link CommitRequest.blockDigests} in `network/struct.ts`).
|
|
13
15
|
* Each digest is the {@link canonicalBlockHash} of what {@link Tracker.peekMaterialized} says the
|
|
14
16
|
* block will contain at the committing revision; `baseRev` rides along except for base-independent
|
|
15
17
|
* (inserted) blocks. */
|
|
16
|
-
// NOTE:
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// production path (`Collection.act`/`sync`) in
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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.
|
|
18
|
+
// NOTE: declarability follows what the transaction read and staged, not read-cache residency. Each
|
|
19
|
+
// updated block's committed base is pinned at the moment its update is staged (Tracker.update ->
|
|
20
|
+
// BasePins) and held until the transaction boundary, so a commit of any size declares 100% of the
|
|
21
|
+
// blocks whose bases it read — verified through the production path (`Collection.act`/`sync`) in
|
|
22
|
+
// `test/digest-cache-coverage.spec.ts` at 2x and 4x the cache capacity. The two remaining
|
|
23
|
+
// legitimate omissions: a delete (materializes to nothing) and a blind update to a block this node
|
|
24
|
+
// never read whose base is not cached (nothing to declare, and a commit must never pay a network
|
|
25
|
+
// read to describe itself). Residual gap: read-far-then-update — a block read, then evicted by
|
|
26
|
+
// 128+ other reads, and only then updated, finds nothing to pin; see the NOTE at the pin site in
|
|
27
|
+
// `tracker.ts`.
|
|
33
28
|
export async function computeBlockContentDigests<T extends IBlock>(
|
|
34
29
|
tracker: Tracker<T>,
|
|
35
30
|
blockIds: BlockId[]
|