-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsync.js
More file actions
239 lines (224 loc) · 10.5 KB
/
Copy pathsync.js
File metadata and controls
239 lines (224 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
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
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
// THE DELTA/OP SYNC WIRE: an Automerge-repo-style
// gossip protocol between peers that hold SEPARATE commit stores. Two peers
// exchange their DAG frontier (head content-ids), each computes which commits
// the other lacks (ancestor-set difference), and ships those as a DELTA:
// per commit only the op payload + parent refs + author replica-id, never the
// whole state. A state snapshot (src/serialize.js) is used only where one is
// genuinely warranted -- an initial bulk catch-up, offered when the delta
// would exceed the snapshot (see deltaOrSnapshot).
//
// CONTENT ADDRESSING (the ONE hash). Two separate stores assign
// different LOCAL ids ('c0'..) to the same commit, so the wire speaks GLOBAL
// content-ids minted by the core Merkle-DAG derivation commitContentId
// (src/hash.js): an authored commit hashes {parents, replica, seq, payload},
// a merge commit hashes its SORTED parent ids (so merge(a,b) and merge(b,a)
// are the SAME commit and never diverge into a spurious criss-cross), the root
// hashes {root:true}. Peers dedup on the global id: a commit already held is
// skipped. This is the SAME content id the git-persistence demo uses on disk
// (WIRE and DISK agree). The `hash` constructor argument is pluggable
// (default: the SHA content id).
//
// HEAD-SYNC DISCIPLINE preserved: a peer only ever merges its CURRENT head
// with the CURRENT head another peer just advertised over the wire (now local
// after ingest), never with a stale interior commit -- exactly the hypothesis
// the commit GC's gc_safety consumes. merges go through the same lca()
// criss-cross gate as the shared-store runtime.
//
// THE FRONTIER IS BUILT FROM THE WIRE. Each peer maintains the same evidence
// frontier as the shared-store Replica (src/frontier.js), here fed by what
// arrives over sync: absorbing a peer's commits advances the per-replica
// evidence commits, so stableCut() is certifiable exactly once the peer has
// heard from everyone. This coupling makes the sync wire and the
// evidence-certificate producer one subsystem.
import { Dag } from './dag.js';
import { lca, CrissCrossError } from './lca.js';
import { frontierOf, stableCut } from './frontier.js';
import { encode as encodeSnapshot } from './serialize.js';
import { commitContentId, contentId } from './hash.js';
const utf8 = new TextEncoder();
/** Byte size of a JSON-serializable wire message (browser-safe; no Buffer). */
export const wireBytes = (msg) => utf8.encode(JSON.stringify(msg)).length;
export class Peer {
#headId;
constructor(datatype, name, { hash = contentId } = {}) {
this.datatype = datatype;
this.name = name;
this.hash = hash;
this.dag = new Dag();
this.seq = 0;
this.gid = new Map(); // local id -> global content id
this.byGid = new Map(); // global content id -> local id
this.registered = new Set([name]); // replica ids this peer has heard of
const root = this.dag.add({ parents: [], op: null, state: datatype.init() });
this.#index(root);
this.#headId = root.id;
this.frontier = frontierOf(this.dag, this.#headId);
}
get head() { return this.dag.get(this.#headId); }
get headGid() { return this.gid.get(this.#headId); }
read() { return this.datatype.read(this.head.state); }
#gidOf(commit) {
const pg = commit.parents.map((p) => this.gid.get(p));
return commitContentId(commit, pg, { fingerprint: this.datatype.fingerprint, hash: this.hash });
}
#index(commit) {
const g = this.#gidOf(commit);
this.gid.set(commit.id, g);
this.byGid.set(g, commit.id);
return g;
}
#refresh() { this.frontier = frontierOf(this.dag, this.#headId); }
/** Apply one op on this peer's own current head (the only local mutator). */
commit(payload) {
const state = this.datatype.apply(this.head.state, payload);
const c = this.dag.add({
parents: [this.#headId], op: { replica: this.name, seq: this.seq++, payload }, state });
this.#index(c);
this.#headId = c.id;
this.#refresh();
return this.gid.get(c.id);
}
/** The set of global ids in this head's reflexive ancestry (a have-summary;
* cheap hashes, not payloads). */
ancestryGids() {
const s = new Set();
for (const cid of this.dag.ancestorSet(this.#headId)) s.add(this.gid.get(cid));
return s;
}
/** The DELTA: this head's ancestry commits whose global id the peer lacks,
* as wire commits (payload + parent refs + author id; NO state), in a
* parents-before-children order (local ids increase with creation). */
delta(theirGids) {
const missing = [];
for (const cid of this.dag.ancestorSet(this.#headId)) {
if (theirGids.has(this.gid.get(cid))) continue;
const c = this.dag.get(cid);
if (c.parents.length === 0) continue; // root is shared, never shipped
missing.push({ cid, c });
}
missing.sort((x, y) => Number(x.cid.slice(1)) - Number(y.cid.slice(1)));
return missing.map(({ c }) => ({
gid: this.gid.get(c.id),
parents: c.parents.map((p) => this.gid.get(p)),
op: c.op ? { replica: c.op.replica, seq: c.op.seq } : null,
payload: c.op ? c.op.payload : null,
}));
}
/** Ingest a delta: add each missing commit, recomputing its state (apply for
* authored, merge3 for merges) -- never trusting a transmitted state. The
* recomputed global id must match the wire id (content-address gate). */
ingest(wireCommits) {
let added = 0;
for (const wc of wireCommits) {
if (this.byGid.has(wc.gid)) continue; // dedup: already held
const localParents = wc.parents.map((g) => this.byGid.get(g));
if (localParents.some((p) => p === undefined)) {
throw new Error(`ingest: unknown parent for ${wc.gid} (delta not ancestor-closed)`);
}
let op, state;
if (wc.op === null) {
const [p0, p1] = localParents; // merge commit
const l = lca(this.dag, p0, p1);
state = this.datatype.merge3(
this.dag.get(l).state, this.dag.get(p0).state, this.dag.get(p1).state);
op = null;
} else {
op = { replica: wc.op.replica, seq: wc.op.seq, payload: wc.payload };
state = this.datatype.apply(this.dag.get(localParents[0]).state, wc.payload);
this.registered.add(wc.op.replica);
}
const c = this.dag.add({ parents: localParents, op, state });
const g = this.#index(c);
if (g !== wc.gid) throw new Error(`content-address mismatch: recomputed ${g} != wire ${wc.gid}`);
added++;
}
if (added > 0) this.#refresh();
return added;
}
/** Merge this peer's current head with the (now-local) commit named `gid`.
* Fast-forwards when one subsumes the other, else a head-sync merge through
* the unique lca (CrissCrossError on a criss-cross, the criss-cross gate). */
mergeWithGid(gid) {
const b = this.byGid.get(gid);
if (b === undefined) throw new Error(`mergeWithGid: ${gid} not present (ingest first)`);
const a = this.#headId;
if (a === b) return this.headGid;
if (this.dag.isAncestor(a, b)) { this.#headId = b; this.#refresh(); return this.headGid; }
if (this.dag.isAncestor(b, a)) return this.headGid; // a subsumes b: no change
const l = lca(this.dag, a, b);
const merged = this.datatype.merge3(
this.dag.get(l).state, this.dag.get(a).state, this.dag.get(b).state);
const c = this.dag.add({ parents: [a, b], op: null, state: merged });
this.#index(c);
this.#headId = c.id;
this.#refresh();
return this.headGid;
}
/** The certified stable cut over the replicas this peer has heard of
* (src/frontier.js), fed entirely by wire arrivals. */
stableCut() {
return stableCut(this.dag, this.#headId, [...this.registered], this.name);
}
/** Whole-state snapshot (src/serialize.js) and its byte size -- the
* baseline the delta beats, and the initial-bulk-catch-up payload. */
snapshot() { return encodeSnapshot(this.head.state); }
snapshotBytes() { return this.snapshot().length; }
}
/** ONE full bidirectional sync round between two peers. Each computes the
* other's delta (from head frontiers snapshotted BEFORE any merge), both
* ingest, then both merge their head with the other's advertised head. After
* the round both stores hold every commit and (content-addressing the shared
* merge) their heads carry equal reads -- unless the pair criss-crosses, in
* which case the merges are gated (merged=false). Returns the DELTA payload
* bytes exchanged and the whole-state baseline. */
export function syncPeers(a, b) {
const hasA = a.ancestryGids(), hasB = b.ancestryGids();
const toB = a.delta(hasB); // commits B lacks, from A
const toA = b.delta(hasA); // commits A lacks, from B
const aHead = a.headGid, bHead = b.headGid;
const baseline = a.snapshotBytes() + b.snapshotBytes();
b.ingest(toB);
a.ingest(toA);
let merged = true;
try {
b.mergeWithGid(aHead);
a.mergeWithGid(bHead);
} catch (e) {
if (e instanceof CrissCrossError) merged = false; else throw e;
}
const bytes = wireBytes({ t: 'delta', c: toB }) + wireBytes({ t: 'delta', c: toA });
return { bytes, baseline, toBCount: toB.length, toACount: toA.length, merged };
}
/** The wire delta between two heads in a SHARED store (benchmark helper): the
* commits in fromHead's ancestry the toHead lacks, as wire commits (payload +
* parent refs + author id, NO state) -- the same shape syncPeers ships over
* separate stores, so wireBytes(...) measures the real per-sync payload the
* protocol would transmit. Uses the shared local ids as global ids (unique
* within one store). */
export function sharedDelta(dag, fromHeadId, toHeadId) {
const have = dag.ancestorSet(toHeadId);
const out = [];
for (const cid of dag.ancestorSet(fromHeadId)) {
if (have.has(cid)) continue;
const c = dag.get(cid);
if (c.parents.length === 0) continue; // root shared
out.push({
gid: cid, parents: c.parents,
op: c.op ? { replica: c.op.replica, seq: c.op.seq } : null,
payload: c.op ? c.op.payload : null,
});
}
return out;
}
/** deltaOrSnapshot: the encoder's choice for a one-shot catch-up message --
* the delta unless it exceeds the whole-state snapshot, in which case a bulk
* snapshot is smaller (a brand-new peer, or one very far behind). This is the
* one place src/serialize.js is genuinely the right wire payload. */
export function deltaOrSnapshot(sender, receiverGids) {
const d = sender.delta(receiverGids);
const deltaSize = wireBytes({ t: 'delta', c: d });
const snapSize = sender.snapshotBytes();
return deltaSize <= snapSize
? { kind: 'delta', commits: d, bytes: deltaSize }
: { kind: 'snapshot', bytes: snapSize };
}