@serfab/cadre-core 1.17.0 → 1.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 (148) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-invite-protocol.d.ts +35 -13
  3. package/dist/cadre-invite-protocol.js +90 -25
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-node.d.ts +586 -255
  6. package/dist/cadre-node.js +1182 -566
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +66 -9
  9. package/dist/control-cohort.js +67 -9
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +451 -193
  12. package/dist/control-database.js +701 -359
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +46 -43
  15. package/dist/control-formation-recorder.js +77 -78
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-protocol-guard.d.ts +6 -0
  18. package/dist/control-protocol-guard.js +14 -2
  19. package/dist/control-protocol-guard.js.map +1 -1
  20. package/dist/control-schema-version.d.ts +48 -31
  21. package/dist/control-schema-version.js +67 -45
  22. package/dist/control-schema-version.js.map +1 -1
  23. package/dist/control-schema.d.ts +8 -8
  24. package/dist/control-schema.js +16 -16
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +42 -7
  27. package/dist/control-write-retry.js +87 -19
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/enrolled-machine-store.d.ts +3 -3
  30. package/dist/enrolled-machine-store.js +3 -3
  31. package/dist/formation-token.d.ts +48 -0
  32. package/dist/formation-token.js +26 -0
  33. package/dist/formation-token.js.map +1 -0
  34. package/dist/index.d.ts +15 -14
  35. package/dist/index.js +17 -14
  36. package/dist/index.js.map +1 -1
  37. package/dist/joined-strand-store.d.ts +26 -9
  38. package/dist/joined-strand-store.js +63 -24
  39. package/dist/joined-strand-store.js.map +1 -1
  40. package/dist/link-budget.d.ts +9 -7
  41. package/dist/link-budget.js +9 -7
  42. package/dist/link-budget.js.map +1 -1
  43. package/dist/member-redemption.d.ts +15 -9
  44. package/dist/member-redemption.js +13 -8
  45. package/dist/member-redemption.js.map +1 -1
  46. package/dist/membership-view.d.ts +91 -0
  47. package/dist/membership-view.js +110 -0
  48. package/dist/membership-view.js.map +1 -0
  49. package/dist/owner-grant-protocol.d.ts +11 -7
  50. package/dist/owner-grant-protocol.js +31 -17
  51. package/dist/owner-grant-protocol.js.map +1 -1
  52. package/dist/peer-addr-book.d.ts +10 -1
  53. package/dist/peer-addr-book.js +4 -3
  54. package/dist/peer-addr-book.js.map +1 -1
  55. package/dist/peer-join-backfill.d.ts +96 -30
  56. package/dist/peer-join-backfill.js +272 -128
  57. package/dist/peer-join-backfill.js.map +1 -1
  58. package/dist/pending-join-runner.d.ts +48 -18
  59. package/dist/pending-join-runner.js +74 -56
  60. package/dist/pending-join-runner.js.map +1 -1
  61. package/dist/seed-bootstrap.d.ts +47 -40
  62. package/dist/seed-bootstrap.js +66 -61
  63. package/dist/seed-bootstrap.js.map +1 -1
  64. package/dist/self-address-update.d.ts +13 -0
  65. package/dist/self-address-update.js +19 -0
  66. package/dist/self-address-update.js.map +1 -0
  67. package/dist/strand-database.d.ts +23 -2
  68. package/dist/strand-database.js +26 -6
  69. package/dist/strand-database.js.map +1 -1
  70. package/dist/strand-first-sync-gate.d.ts +176 -89
  71. package/dist/strand-first-sync-gate.js +288 -122
  72. package/dist/strand-first-sync-gate.js.map +1 -1
  73. package/dist/strand-formation-deadlines.d.ts +9 -9
  74. package/dist/strand-formation-deadlines.js +9 -9
  75. package/dist/strand-formation-manager.d.ts +23 -17
  76. package/dist/strand-formation-manager.js +46 -44
  77. package/dist/strand-formation-manager.js.map +1 -1
  78. package/dist/strand-formation-protocol.d.ts +5 -8
  79. package/dist/strand-formation-protocol.js +101 -16
  80. package/dist/strand-formation-protocol.js.map +1 -1
  81. package/dist/strand-instance-manager.d.ts +26 -17
  82. package/dist/strand-instance-manager.js +119 -50
  83. package/dist/strand-instance-manager.js.map +1 -1
  84. package/dist/strand-membership-reconciler.d.ts +16 -13
  85. package/dist/strand-membership-reconciler.js +28 -22
  86. package/dist/strand-membership-reconciler.js.map +1 -1
  87. package/dist/strand-membership-writer.d.ts +40 -28
  88. package/dist/strand-membership-writer.js +212 -153
  89. package/dist/strand-membership-writer.js.map +1 -1
  90. package/dist/strand-move.js +5 -2
  91. package/dist/strand-move.js.map +1 -1
  92. package/dist/strand-revocation-enforcer.d.ts +134 -60
  93. package/dist/strand-revocation-enforcer.js +144 -71
  94. package/dist/strand-revocation-enforcer.js.map +1 -1
  95. package/dist/strand-solicitation.d.ts +50 -39
  96. package/dist/strand-solicitation.js +10 -3
  97. package/dist/strand-solicitation.js.map +1 -1
  98. package/dist/strand-watcher.d.ts +80 -13
  99. package/dist/strand-watcher.js +152 -48
  100. package/dist/strand-watcher.js.map +1 -1
  101. package/dist/table-change-watch.d.ts +63 -0
  102. package/dist/table-change-watch.js +209 -0
  103. package/dist/table-change-watch.js.map +1 -0
  104. package/dist/timeout-scheduler.d.ts +4 -3
  105. package/dist/timeout-scheduler.js +4 -3
  106. package/dist/timeout-scheduler.js.map +1 -1
  107. package/dist/types.d.ts +90 -49
  108. package/dist/types.js +4 -4
  109. package/dist/types.js.map +1 -1
  110. package/package.json +8 -8
  111. package/src/cadre-invite-protocol.ts +119 -31
  112. package/src/cadre-node.ts +1289 -588
  113. package/src/control-cohort.ts +86 -9
  114. package/src/control-database.ts +848 -403
  115. package/src/control-formation-recorder.ts +359 -353
  116. package/src/control-protocol-guard.ts +16 -2
  117. package/src/control-schema-version.ts +101 -51
  118. package/src/control-schema.ts +16 -16
  119. package/src/control-write-retry.ts +91 -19
  120. package/src/delegate-admission.ts +351 -351
  121. package/src/enrolled-machine-store.ts +3 -3
  122. package/src/formation-token.ts +63 -0
  123. package/src/index.ts +30 -18
  124. package/src/joined-strand-store.ts +69 -24
  125. package/src/link-budget.ts +9 -7
  126. package/src/member-redemption.ts +20 -13
  127. package/src/membership-view.ts +144 -0
  128. package/src/owner-grant-protocol.ts +33 -18
  129. package/src/peer-addr-book.ts +4 -3
  130. package/src/peer-join-backfill.ts +961 -761
  131. package/src/pending-join-runner.ts +76 -53
  132. package/src/seed-bootstrap.ts +72 -67
  133. package/src/self-address-update.ts +19 -0
  134. package/src/strand-database.ts +352 -322
  135. package/src/strand-first-sync-gate.ts +334 -134
  136. package/src/strand-formation-deadlines.ts +227 -227
  137. package/src/strand-formation-manager.ts +731 -725
  138. package/src/strand-formation-protocol.ts +122 -24
  139. package/src/strand-instance-manager.ts +2092 -2026
  140. package/src/strand-membership-reconciler.ts +31 -25
  141. package/src/strand-membership-writer.ts +2093 -2019
  142. package/src/strand-move.ts +5 -2
  143. package/src/strand-revocation-enforcer.ts +740 -654
  144. package/src/strand-solicitation.ts +61 -34
  145. package/src/strand-watcher.ts +175 -56
  146. package/src/table-change-watch.ts +285 -0
  147. package/src/timeout-scheduler.ts +29 -28
  148. package/src/types.ts +89 -51
@@ -1,15 +1,22 @@
1
1
  import debug from 'debug';
2
+ import { jsonEncodedBytes, MAX_BLOCK_BYTES } from '@optimystic/db-core';
2
3
  import { BlockTransferClient, buildBlockTransferProtocol } from '@optimystic/db-p2p';
3
4
  import { peerJoinPushBudget } from './link-budget.js';
4
5
  // Peer-join whole-store block catch-up, shared by the STRAND networks and the CONTROL
5
6
  // network. On a new peer's `peer:identify` result — libp2p's identify protocol runs once per
6
7
  // connection and reports the protocols the remote supports — naming this network's own
7
- // block-transfer protocol, it schedules a debounced push of every committed, materialized
8
- // block in the local raw store to that peer, so a machine that joined after blocks were
9
- // committed still ends up physically holding them. Optimystic has no cohort-join catch-up
10
- // of its own; a block committed while its writer was alone has exactly one holder forever
11
- // without this (named collection-header blocks are written once, at collection creation,
12
- // and their revision never moves again — so no later commit ever carries them anywhere).
8
+ // block-transfer protocol, it schedules a push of every committed, materialized block in the
9
+ // local raw store that the peer does not already hold (see "What is pushed" below), so a
10
+ // machine that joined after blocks were committed still ends up physically holding them.
11
+ // Optimystic has no cohort-join catch-up of its own; a block committed while its writer was
12
+ // alone has exactly one holder forever without this (named collection-header blocks are
13
+ // written once, at collection creation, and their revision never moves again — so no later
14
+ // commit ever carries them anywhere).
15
+ //
16
+ // A peer this instance has never started a run for is run at once; later schedules for it wait
17
+ // out `debounceMs`. The joining machine's first-sync gate waits for this push rather than
18
+ // fetching the same blocks one read at a time, so on first contact a settle window only delays
19
+ // the joiner. Identify churn during that first run is replayed once the run ends.
13
20
  //
14
21
  // A peer whose identify does not name the protocol — a bare circuit relay, a bootstrap node,
15
22
  // or (on the control network) a stranger the inbound gate admits without membership — is
@@ -20,7 +27,7 @@ import { peerJoinPushBudget } from './link-budget.js';
20
27
  // printing exactly that warning about their own relay, forever, on every reconnect. See
21
28
  // {@link speaksBlockTransfer}.
22
29
  //
23
- // NOTE: this copies the WHOLE local store to every newly connected peer, which is right
30
+ // NOTE: this offers the WHOLE local store to every newly connected peer, which is right
24
31
  // while a network is one party's handful of machines (see docs/architecture.md →
25
32
  // Replication cluster size — the control network replicates to the whole party by intent,
26
33
  // and "On a strand, the machine count is not a party count"). If these meshes ever get
@@ -32,10 +39,11 @@ import { peerJoinPushBudget } from './link-budget.js';
32
39
  // than running two.
33
40
  //
34
41
  // NOTE: strand nodes pass db-p2p no `kvStore`, so Optimystic's under-replication ledger (the
35
- // copies a node owes peers) dies at every restart. This module's whole-store push on connect is
36
- // what delivers a write a machine made alone after it restarts (`joiner-first-commit-over-slow-relay`,
37
- // restart arm). If this module is narrowed (the cohort filter above) or deleted, give strand
38
- // nodes a persistent kvStore first.
42
+ // copies a node owes peers) dies at every restart. This module's push on connect is what
43
+ // delivers a write a machine made alone after it restarts (`joiner-first-commit-over-slow-relay`,
44
+ // restart arm): the peer did not sign that commit, so the block is still offered to it. If this
45
+ // module is narrowed (the cohort filter above) or deleted, give strand nodes a persistent
46
+ // kvStore first.
39
47
  //
40
48
  // Membership — the one place the two networks differ, expressed as the optional
41
49
  // `authorizePeer` dep:
@@ -50,11 +58,12 @@ import { peerJoinPushBudget } from './link-budget.js';
50
58
  // invitation, configured bootstrap/relay peers — see docs/architecture.md, the
51
59
  // control-network inbound connection gate). Pushing the whole control store to such a
52
60
  // peer would hand a stranger the party's entire membership, addresses and strand list.
53
- // `CadreNode` passes `isAuthorizedMember`; the gate is consulted at PUSH time, not at
54
- // schedule time, and fails closed on a thrown check. A denied run is not memoized, so
55
- // the peer is retried on its next `peer:identify` (a reconnect re-runs identify) — or
56
- // sooner, via `scheduleConnectedPeers()` on a membership change (the production join order is
57
- // connect-then-authorize, so the denial at dial time is the expected first pass).
61
+ // `CadreNode` passes a lookup in its in-memory authorized-member snapshot; the gate is
62
+ // consulted at PUSH time, not at schedule time, and fails closed on a thrown check. A denied
63
+ // run is not memoized, so the peer is retried on its next `peer:identify` (a reconnect
64
+ // re-runs identify) — or sooner, via `scheduleConnectedPeers()` on a membership change (the
65
+ // production join order is connect-then-authorize, so the denial at dial time is the
66
+ // expected first pass).
58
67
  //
59
68
  // Both per-push deadlines are DERIVED from the declared link round trip
60
69
  // (`link-budget.ts`), not fixed milliseconds. That is not tuning — it is what makes this
@@ -89,21 +98,17 @@ import { peerJoinPushBudget } from './link-budget.js';
89
98
  // budgets its dial at `transferTimeoutMs ?? 30000` — the one dial budget in the stack
90
99
  // above the measured relayed setup cost — so it succeeded where this one could not.
91
100
  //
92
- // A NON-CLEAN run is NOT on its own enough to re-arm, and the distinction is what keeps the
93
- // backoff from becoming a worse problem than the one it fixes. `clean` also goes false for
94
- // outcomes no amount of retrying can change: the membership gate DENIED the peer; this
95
- // network's raw storage implements no `listBlockIds`, so the catch-up is inert; or the
96
- // receiver reported blocks in `missing`, which it does per block for a payload it cannot
97
- // parse and for a revision whose retained commit proof this node does not hold (a push
98
- // carrying no proof is refused outright by a receiver running the default
99
- // `requirePushCertificate: true`, and an unretained proof is the ordinary case — see
100
- // {@link Chunk.proofs}). Re-arming on those would re-push the WHOLE store to that peer every
101
- // `maxRetryBackoffMs` for as long as the node runs, and report the failure below as a link
102
- // budget problem when nothing about the link is wrong. All three still leave the peer
103
- // un-memoized, so its next `peer:identify` retries — the behaviour that predates the
104
- // backoff, and the right one for a verdict rather than a timeout. Denial in particular is
105
- // re-driven on purpose by `scheduleConnectedPeers()` the moment the membership commit lands,
106
- // because the control network's join order is connect-then-authorize.
101
+ // A retry re-sends only the blocks the peer has not yet accepted (see "What is pushed"), so a
102
+ // run that died part-way through the store resumes where it stopped.
103
+ //
104
+ // Only a failed push re-arms. Two other outcomes also leave a run short of catching the peer
105
+ // up, and no amount of retrying changes them: the membership gate DENIED the peer, or this
106
+ // network's raw storage implements no `listBlockIds`, so the catch-up is inert. Re-arming on
107
+ // those would retry every `maxRetryBackoffMs` for as long as the node runs, and report the
108
+ // failure below as a link budget problem when nothing about the link is wrong. Neither marks
109
+ // the peer caught up, so its next `peer:identify` retries. Denial in particular is re-driven on
110
+ // purpose by `scheduleConnectedPeers()` the moment the membership commit lands, because the
111
+ // control network's join order is connect-then-authorize.
107
112
  //
108
113
  // A re-arm is also skipped when the peer is no longer CONNECTED. This module's trigger is
109
114
  // `peer:identify`, so a peer that went away already has one: re-arming instead would leave
@@ -114,6 +119,43 @@ import { peerJoinPushBudget } from './link-budget.js';
114
119
  // nothing at all: the connection simply never appears and only a DEBUG log mentions a dial
115
120
  // timeout.
116
121
  //
122
+ // WHAT IS PUSHED: each block once per peer. Per peer, this module remembers for the runtime the
123
+ // revision of each block the peer accepted and the revision it refused ({@link PeerMemo}). A
124
+ // refusal is the receiver's `missing` list: a payload it cannot parse, a block it failed to
125
+ // persist, or a revision whose retained commit proof this node does not hold (a receiver running the default
126
+ // `requirePushCertificate: true` refuses a push carrying no proof, and an unretained proof is
127
+ // the ordinary case — see {@link Chunk.proofs}). A refusal is a per-block verdict: logged once
128
+ // when it happens, and the block is offered to that peer again only once its local revision
129
+ // moves past the refused one. A run skips a block, counting it in `alreadyHeld`, when:
130
+ //
131
+ // - the peer accepted that revision or a later one from this node;
132
+ // - the peer refused that revision;
133
+ // - the commit proof retained for that revision carries the peer's own commit-round approval.
134
+ // A block's proof lists the cohort members that signed the commit round for exactly that
135
+ // revision, so a founder's solo commit is never pushed back to it by a joiner now holding it,
136
+ // and a revision two machines committed together is not pushed between them.
137
+ //
138
+ // The proof rule stands in for "do not push a block back to the peer it came from": Optimystic
139
+ // reports a push's sender only to its own rebalance monitor and exposes it to no embedder.
140
+ //
141
+ // NOTE: accepted tradeoff — a signer that crashed between signing and applying, or later lost its
142
+ // store, is not re-sent that revision by this path; read repair covers its reads, as for any block
143
+ // a cohort member lacks. Revisit if Optimystic tells embedders who holds what (it takes reports,
144
+ // `reportBlockHeld`, but gives none) or a measurement shows pushes this rule misses.
145
+ //
146
+ // NOTE: the reply does not say why a block is missing, so a receiver's transient persist failure
147
+ // is remembered as a refusal too and not retried this runtime; read repair covers that block's
148
+ // reads. If receivers log `persist:fail` in practice, ask Optimystic for a per-block reason in
149
+ // the push reply and remember only the verdicts a retry cannot change.
150
+ //
151
+ // A peer is marked caught up (`done`) once a run finishes with no push that threw, the gate
152
+ // admitted it and the store is listable. A refusal does not hold that back, since it is
153
+ // remembered per block. It stays caught up until its last connection closes
154
+ // (`peer:disconnect`): `peer:identify` churn for a connected, caught-up peer is dropped before
155
+ // any work, and a reconnect re-runs past the debounce, pushing only what the memo and the
156
+ // proofs do not cover. {@link PeerJoinBackfill.forceCatchUpPeer} runs past `done` too, with the
157
+ // same skips.
158
+ //
117
159
  // NOTE: the protocol check happens once — either when a connection's `peer:identify` fires,
118
160
  // or, for a peer already connected when this instance starts, when `scheduleConnectedPeers()`
119
161
  // reads its protocols back from the peer store — and is never repeated once a peer is
@@ -132,17 +174,10 @@ import { peerJoinPushBudget } from './link-budget.js';
132
174
  //
133
175
  // NOTE: a peer that disconnects INSIDE a backoff wait still costs the one attempt that wait
134
176
  // was already armed for — the connectivity check is made when the re-arm is decided, not when
135
- // the timer fires, and this module subscribes to `peer:identify` only. One dial, not a
136
- // recurring one; if a node ever holds many transient peers, subscribe to `connection:close`
137
- // and clear the pending timer there.
177
+ // the timer fires, and the `peer:disconnect` handler leaves backoff state alone (see
178
+ // `forgetCaughtUp`). One dial, not a recurring one; if a node ever holds many transient peers,
179
+ // clear the pending timer in that handler.
138
180
  const log = debug('sereus:cadre:peer-join-backfill');
139
- /**
140
- * The block-transfer protocol's hard cap on one length-prefixed message. Mirrors
141
- * `MAX_BLOCK_MESSAGE_BYTES` in `@optimystic/db-p2p`'s `protocol-limits.ts` (8 MiB), which
142
- * that package does not re-export from its index — keep the two in sync if upstream ever
143
- * changes it or starts exporting it.
144
- */
145
- export const MAX_BLOCK_MESSAGE_BYTES = 8 * 1024 * 1024;
146
181
  /**
147
182
  * Whether an identified peer's protocol list names this network's own block-transfer
148
183
  * protocol — the one gate that decides whether {@link PeerJoinBackfill} schedules a peer at
@@ -176,14 +211,27 @@ export const DEFAULT_PEER_JOIN_BACKFILL = {
176
211
  maxRetryBackoffMs: 60000
177
212
  };
178
213
  function emptyResult() {
179
- return { offered: 0, accepted: 0, rejected: [], uncommitted: 0, unmaterialized: 0, capped: 0, oversized: [], denied: false };
214
+ return { offered: 0, alreadyHeld: 0, accepted: 0, rejected: [], uncommitted: 0, unmaterialized: 0, capped: 0, oversized: [], denied: false };
180
215
  }
181
- /**
182
- * Base64 wire size of a raw buffer — `pushBlocks` base64-encodes each block into the JSON
183
- * request, so the protocol cap must be judged against the encoded size, not the raw bytes.
184
- */
185
- function base64WireBytes(rawBytes) {
186
- return Math.ceil(rawBytes / 3) * 4;
216
+ /** Whether the peer's replies already settled revision `rev` of `blockId`, either way. */
217
+ function memoSettled(memo, blockId, rev) {
218
+ return (memo.accepted.get(blockId) ?? -Infinity) >= rev
219
+ || (memo.rejected.get(blockId) ?? -Infinity) >= rev;
220
+ }
221
+ /** Whether `peerId` approved the commit round that `proof` certifies. */
222
+ function signedBy(proof, peerId) {
223
+ return proof?.commits[peerId]?.type === 'approve';
224
+ }
225
+ /** Record one push reply in the memo: each block as accepted or refused at the revision pushed. */
226
+ function recordReply(memo, revs, ids, missing) {
227
+ for (const id of ids) {
228
+ const rev = revs[id].rev;
229
+ const verdicts = missing.has(id) ? memo.rejected : memo.accepted;
230
+ verdicts.set(id, Math.max(verdicts.get(id) ?? -Infinity, rev));
231
+ }
232
+ }
233
+ function emptyChunk() {
234
+ return { blocks: {}, meta: {}, proofs: {}, bytes: 0 };
187
235
  }
188
236
  /**
189
237
  * Copies every block in one network's own raw store to each peer its libp2p node connects
@@ -193,8 +241,8 @@ function base64WireBytes(rawBytes) {
193
241
  *
194
242
  * Best-effort throughout: nothing here throws into a libp2p event handler or into a
195
243
  * runtime bring-up; a failed chunk is logged and the run continues. A peer is marked
196
- * fully caught up ONLY after a run with no thrown chunk and an empty `missing` list, so
197
- * the next `peer:identify` from a peer whose catch-up failed retries it.
244
+ * caught up only after a run with no thrown chunk, so a peer whose catch-up failed is
245
+ * retried; each block is pushed to each peer once (see "What is pushed" in the module comment).
198
246
  */
199
247
  export class PeerJoinBackfill {
200
248
  constructor(deps, config) {
@@ -202,10 +250,22 @@ export class PeerJoinBackfill {
202
250
  this.started = false;
203
251
  this.stopped = false;
204
252
  /**
205
- * Peers fully caught up this runtime (never rescheduled until the runtime is rebuilt; only
206
- * {@link forceCatchUpPeer} pushes to one again).
253
+ * Peers caught up since their current connection opened. Cleared for a peer when its last
254
+ * connection closes, so a reconnect re-runs; {@link forceCatchUpPeer} runs past it.
207
255
  */
208
256
  this.done = new Set();
257
+ /**
258
+ * Peers that disconnected while their run was in flight, so that run does not mark them
259
+ * {@link done} after the disconnect cleared it. Cleared when the run ends.
260
+ */
261
+ this.disconnectedInFlight = new Set();
262
+ /** Per-peer, per-block verdicts from push replies; see {@link PeerMemo}. */
263
+ this.memos = new Map();
264
+ /**
265
+ * Peers a run has been started for. A peer not in it is run at once on its first schedule;
266
+ * later schedules wait out `debounceMs`.
267
+ */
268
+ this.contacted = new Set();
209
269
  /**
210
270
  * Peers with a catch-up currently running, each with that run (suppresses concurrent
211
271
  * duplicates; {@link forceCatchUpPeer} waits on the run).
@@ -213,9 +273,9 @@ export class PeerJoinBackfill {
213
273
  this.inFlight = new Map();
214
274
  /**
215
275
  * Peers whose (re-)schedule arrived while their OWN run was in flight, replayed once
216
- * that run finishes. Load-bearing for the gated path: the gate's authorization check is
217
- * a control-database read, so the window between it and the run's end is wide enough for
218
- * the very membership commit that would have authorized the peer to land inside it —
276
+ * that run finishes. Load-bearing for the gated path: the gate is judged once, at the start
277
+ * of a run, and the run's pushes take long enough for the very membership commit that would
278
+ * have authorized the peer to land after that check —
219
279
  * dropping that schedule (rather than deferring it) leaves the denied peer waiting for a
220
280
  * reconnect, which is exactly what the re-arm exists to avoid.
221
281
  */
@@ -242,18 +302,23 @@ export class PeerJoinBackfill {
242
302
  this.schedulePeer(peerId);
243
303
  }
244
304
  };
305
+ this.onPeerDisconnect = (evt) => {
306
+ this.forgetCaughtUp(evt.detail.toString());
307
+ };
245
308
  }
246
309
  /**
247
310
  * Subscribe to `peer:identify` AND schedule a catch-up for already-connected peers whose
248
311
  * stored protocols already name this network's block-transfer protocol — a peer that does
249
- * not speak it (a bare circuit relay, a bootstrap node) is never scheduled at all. See
250
- * {@link speaksBlockTransfer} and the module comment.
312
+ * not speak it (a bare circuit relay, a bootstrap node) is never scheduled at all. Also
313
+ * subscribes to `peer:disconnect`, so a peer caught up before it went away is run again on
314
+ * its return. See {@link speaksBlockTransfer} and the module comment.
251
315
  */
252
316
  start() {
253
317
  if (this.started || this.stopped || !this.config.enabled)
254
318
  return;
255
319
  this.started = true;
256
320
  this.deps.libp2p.addEventListener('peer:identify', this.onPeerIdentify);
321
+ this.deps.libp2p.addEventListener('peer:disconnect', this.onPeerDisconnect);
257
322
  // A runtime rebuilt over live connections (resumeStrand) never sees their peer:identify —
258
323
  // it already fired before this instance existed, but its result is persisted in the peer
259
324
  // store — so walk what is already connected once.
@@ -268,6 +333,7 @@ export class PeerJoinBackfill {
268
333
  this.stopped = true;
269
334
  if (this.started) {
270
335
  this.deps.libp2p.removeEventListener('peer:identify', this.onPeerIdentify);
336
+ this.deps.libp2p.removeEventListener('peer:disconnect', this.onPeerDisconnect);
271
337
  }
272
338
  for (const timer of this.timers.values()) {
273
339
  clearTimeout(timer);
@@ -277,6 +343,24 @@ export class PeerJoinBackfill {
277
343
  this.retryAfter.clear();
278
344
  log('[%s] stopped', this.deps.label);
279
345
  }
346
+ /**
347
+ * A peer's last connection closed: it is no longer caught up, so its next `peer:identify`
348
+ * re-runs. Only `done` is cleared. The memo stays (it keeps the re-run to what moved),
349
+ * `contacted` stays (a link flapping inside one settle window costs one run), and the
350
+ * backoff state stays (clearing it re-dials an unreachable peer on every reconnect; see the
351
+ * module comment's retry paragraph).
352
+ *
353
+ * When the returning peer wrote while away with a cohort that named this node, its own
354
+ * re-run pushes those blocks here and reports them through `reportBlockHeld`, so its
355
+ * under-replication drain does not push them again. Revisit if a measurement shows reconnect
356
+ * re-runs dominating push traffic.
357
+ */
358
+ forgetCaughtUp(key) {
359
+ this.done.delete(key);
360
+ if (this.inFlight.has(key)) {
361
+ this.disconnectedInFlight.add(key);
362
+ }
363
+ }
280
364
  /**
281
365
  * (Re-)schedule a debounced catch-up for every currently-connected peer whose protocols, as
282
366
  * already stored in the libp2p peer store, name this network's block-transfer protocol. A
@@ -318,7 +402,11 @@ export class PeerJoinBackfill {
318
402
  this.schedulePeer(peerId);
319
403
  }
320
404
  }
321
- /** Debounced entry point for connection churn: one run per peer per settle window. */
405
+ /**
406
+ * Entry point for connection churn: one run per peer per settle window, except that a peer
407
+ * never run for is run at once (on a zero-delay timer, so a burst of identify events in one
408
+ * turn still collapses, and no work happens inside the libp2p event dispatch).
409
+ */
322
410
  schedulePeer(peerId) {
323
411
  const key = peerId.toString();
324
412
  if (this.stopped || this.done.has(key))
@@ -339,10 +427,11 @@ export class PeerJoinBackfill {
339
427
  const existing = this.timers.get(key);
340
428
  if (existing)
341
429
  clearTimeout(existing);
430
+ const delayMs = this.contacted.has(key) ? this.config.debounceMs : 0;
342
431
  this.timers.set(key, setTimeout(() => {
343
432
  this.timers.delete(key);
344
433
  void this.catchUpPeer(peerId);
345
- }, this.config.debounceMs));
434
+ }, delayMs));
346
435
  }
347
436
  /**
348
437
  * Run one peer's catch-up now, bypassing the debounce. Never rejects. Returns an
@@ -357,14 +446,16 @@ export class PeerJoinBackfill {
357
446
  return await this.trackRun(peerId);
358
447
  }
359
448
  /**
360
- * Run one peer's catch-up now and wait for it, whatever this instance remembers about the
361
- * peer: wait out a run already in flight for it, then push the whole store again even when
362
- * the peer is memoized as caught up. Never rejects; an all-zero result only when stopped.
449
+ * Run one peer's catch-up now and wait for it, even when the peer is memoized as caught up:
450
+ * wait out a run already in flight for it, then run again. The run still skips every block the
451
+ * peer is known to hold (it accepted or refused that revision, or signed it), so for a peer that
452
+ * holds the whole store it pushes nothing and returns `alreadyHeld` equal to the store size.
453
+ * Never rejects; an all-zero result only when stopped.
363
454
  *
364
455
  * For a caller that must know the peer holds this store before it proceeds, where
365
456
  * {@link catchUpPeer}'s empty answer would hide a run that was denied (a `peer:identify`
366
- * run can pass the gate a moment before the caller authorized the peer) or a memo older
367
- * than the commits since.
457
+ * run can pass the gate a moment before the caller authorized the peer) or commits made
458
+ * since the peer was caught up.
368
459
  */
369
460
  async forceCatchUpPeer(peerId) {
370
461
  const key = peerId.toString();
@@ -382,6 +473,7 @@ export class PeerJoinBackfill {
382
473
  */
383
474
  async trackRun(peerId) {
384
475
  const key = peerId.toString();
476
+ this.contacted.add(key);
385
477
  const run = this.settleRun(peerId);
386
478
  this.inFlight.set(key, run);
387
479
  try {
@@ -389,6 +481,7 @@ export class PeerJoinBackfill {
389
481
  }
390
482
  finally {
391
483
  this.inFlight.delete(key);
484
+ this.disconnectedInFlight.delete(key);
392
485
  // Replay a schedule that arrived mid-run. `schedulePeer` re-checks `done`, so a run
393
486
  // that finished clean re-arms nothing; a denied or failed one gets its retry.
394
487
  if (this.rearmAfterFlight.delete(key)) {
@@ -401,28 +494,32 @@ export class PeerJoinBackfill {
401
494
  const key = peerId.toString();
402
495
  try {
403
496
  const { result, clean, pushFailed } = await this.runCatchUp(peerId);
404
- // NOTE: a run that hit `maxBlocks` is still "clean" and still memoizes the peer, so
405
- // the tail past the ceiling never reaches it. Deliberate: enumeration is not
406
- // resumable, so not memoizing would re-push the same prefix on every reconnect
407
- // without ever advancing. Loud in the log below (capped > 0). If a store can
408
- // realistically exceed maxBlocks, the fix is a resumable cursor, not either policy.
497
+ // NOTE: a run that hit `maxBlocks` is still "clean" and still marks the peer caught up,
498
+ // so no `peer:identify` run reaches the tail past the ceiling. Loud in the log below
499
+ // (capped > 0). Blocks the peer is known to hold are not offered and do not count toward
500
+ // the ceiling, so a forced run resumes past the prefix already sent. If a store can
501
+ // realistically exceed maxBlocks, leave a capped run out of `done` so each identify run
502
+ // sends the next `maxBlocks`.
409
503
  if (clean && !this.stopped) {
410
- this.done.add(key);
504
+ // A disconnect mid-run cleared `done` already; re-adding it would drop the reconnect.
505
+ // Not judged by `isConnected`: hand-driven runs target peers with no connection.
506
+ if (!this.disconnectedInFlight.has(key)) {
507
+ this.done.add(key);
508
+ }
411
509
  this.failures.delete(key);
412
510
  this.retryAfter.delete(key);
413
511
  this.warned.delete(key);
414
512
  }
415
513
  else if (pushFailed && !this.stopped && this.started && this.isConnected(peerId)) {
416
- // `pushFailed`, not `!clean`: a denial, an inert store and a receiver's per-block
417
- // rejection are all verdicts a retry cannot change, and re-arming on them would
418
- // re-push the whole store to that peer forever. See the module comment.
514
+ // `pushFailed`, not `!clean`: a denial and an inert store are verdicts a retry cannot
515
+ // change, and re-arming on them would retry forever. See the module comment.
419
516
  //
420
517
  // Gated on `started` too: the re-arm exists to REPLACE a peer:identify-driven retry,
421
518
  // so a caller driving `catchUpPeer` by hand against a backfill that was never started
422
519
  // owns its own retry policy and must not be left holding a background timer.
423
520
  this.scheduleRetryWithBackoff(peerId);
424
521
  }
425
- log('[%s] catch-up peer=%s offered=%d accepted=%d rejected=%d uncommitted=%d unmaterialized=%d capped=%d oversized=%d denied=%s done=%s', this.deps.label, key, result.offered, result.accepted, result.rejected.length, result.uncommitted, result.unmaterialized, result.capped, result.oversized.length, result.denied, clean);
522
+ log('[%s] catch-up peer=%s offered=%d alreadyHeld=%d accepted=%d rejected=%d uncommitted=%d unmaterialized=%d capped=%d oversized=%d denied=%s done=%s', this.deps.label, key, result.offered, result.alreadyHeld, result.accepted, result.rejected.length, result.uncommitted, result.unmaterialized, result.capped, result.oversized.length, result.denied, clean);
426
523
  return result;
427
524
  }
428
525
  catch (error) {
@@ -474,14 +571,44 @@ export class PeerJoinBackfill {
474
571
  + 'these budgets and libp2p\'s own connection limits are derived from network.linkRoundTripMs (see link-budget.ts); '
475
572
  + 'a link slower than that declaration, or a peer that declared a faster one, cannot open a relayed connection at all.');
476
573
  }
574
+ /** This peer's memo, created empty on first use. */
575
+ memoFor(key) {
576
+ let memo = this.memos.get(key);
577
+ if (!memo) {
578
+ memo = { accepted: new Map(), rejected: new Map() };
579
+ this.memos.set(key, memo);
580
+ }
581
+ return memo;
582
+ }
477
583
  /**
478
- * The actual copy. `clean` = every chunk pushed and the remote persisted every block.
584
+ * Tell the node which blocks of one push reply the peer accepted, at the revision pushed.
585
+ * Best-effort: a failed report is logged and costs only the drain's duplicate push.
586
+ */
587
+ async reportAccepted(key, revs, ids, missing) {
588
+ const report = this.deps.reportBlockHeld;
589
+ if (!report)
590
+ return;
591
+ for (const id of ids) {
592
+ if (missing.has(id))
593
+ continue;
594
+ try {
595
+ await report(id, revs[id].rev, key);
596
+ }
597
+ catch (error) {
598
+ log('[%s] reportBlockHeld for peer=%s block=%s failed: %s', this.deps.label, key, id, error.message);
599
+ }
600
+ }
601
+ }
602
+ /**
603
+ * The actual copy. `clean` = the gate admitted the peer, the store is listable and no push
604
+ * threw (a block the receiver refused is remembered per block instead, see {@link PeerMemo}).
479
605
  * `pushFailed` = at least one push THREW, which is the only non-clean outcome a retry can
480
- * change; the others are verdicts (denied, inert store, blocks the receiver refused).
606
+ * change; the others are verdicts (denied, inert store).
481
607
  */
482
608
  async runCatchUp(peerId) {
483
609
  const result = emptyResult();
484
610
  const { storage } = this.deps;
611
+ const key = peerId.toString();
485
612
  // The membership gate, judged at push time (never at schedule time — authorization can
486
613
  // change during the debounce window in either direction). Fails CLOSED on a throw: a
487
614
  // gate that cannot answer must not leak the store. A denied run is not clean, so the
@@ -507,8 +634,8 @@ export class PeerJoinBackfill {
507
634
  return { result, clean: false, pushFailed: false };
508
635
  }
509
636
  const client = this.createPushClient(peerId);
510
- const encoder = new TextEncoder();
511
- let chunk = { ids: [], buffers: [], meta: {}, proofs: {}, bytes: 0 };
637
+ const memo = this.memoFor(key);
638
+ let chunk = emptyChunk();
512
639
  let chunkFailed = false;
513
640
  let anyChunkDelivered = false;
514
641
  /**
@@ -519,19 +646,22 @@ export class PeerJoinBackfill {
519
646
  */
520
647
  const peerUnreachable = () => chunkFailed && !anyChunkDelivered;
521
648
  const flush = async () => {
522
- if (chunk.ids.length === 0)
649
+ const { blocks, meta, proofs } = chunk;
650
+ const ids = Object.keys(blocks);
651
+ if (ids.length === 0)
523
652
  return;
524
- const { ids, buffers, meta, proofs } = chunk;
525
- chunk = { ids: [], buffers: [], meta: {}, proofs: {}, bytes: 0 };
653
+ chunk = emptyChunk();
526
654
  try {
527
655
  // ONE certification value rather than two arguments, so a caller cannot pair a proof
528
656
  // for one revision with meta for another — both were written from the same `latest`.
529
- const response = await client.pushBlocks(ids, buffers, 'replication', { blockMeta: meta, blockProofs: proofs }, {
657
+ const response = await client.pushBlocks(blocks, 'replication', { blockMeta: meta, blockProofs: proofs }, {
530
658
  dialTimeoutMs: this.config.dialTimeoutMs,
531
659
  responseTimeoutMs: this.config.responseTimeoutMs
532
660
  });
533
661
  anyChunkDelivered = true;
534
662
  const missing = new Set(response.missing);
663
+ recordReply(memo, meta, ids, missing);
664
+ await this.reportAccepted(key, meta, ids, missing);
535
665
  for (const id of ids) {
536
666
  if (missing.has(id)) {
537
667
  result.rejected.push(id);
@@ -541,12 +671,12 @@ export class PeerJoinBackfill {
541
671
  }
542
672
  }
543
673
  if (response.missing.length > 0) {
544
- log('[%s] peer=%s rejected %d block(s): %o', this.deps.label, peerId.toString(), response.missing.length, response.missing);
674
+ log('[%s] peer=%s rejected %d block(s), not offered to it again at these revisions: %o', this.deps.label, key, response.missing.length, response.missing);
545
675
  }
546
676
  }
547
677
  catch (error) {
548
678
  chunkFailed = true;
549
- log('[%s] push to peer=%s failed for %d block(s): %s', this.deps.label, peerId.toString(), ids.length, error.message);
679
+ log('[%s] push to peer=%s failed for %d block(s): %s', this.deps.label, key, ids.length, error.message);
550
680
  }
551
681
  };
552
682
  for await (const blockId of storage.listBlockIds()) {
@@ -558,65 +688,79 @@ export class PeerJoinBackfill {
558
688
  result.capped += 1;
559
689
  continue;
560
690
  }
561
- const metadata = await storage.getMetadata(blockId);
562
- const latest = metadata?.latest;
563
- if (!latest) {
564
- result.uncommitted += 1;
691
+ const offer = await this.readOffer(blockId, key, memo, result);
692
+ if (!offer)
565
693
  continue;
566
- }
567
- // NOTE: a block whose latest revision is a DELETE materializes to nothing (a
568
- // tombstone), so it is skipped here — a peer that joins after such a delete never
569
- // receives the tombstone from this path and relies on read repair for it. Fine
570
- // while nothing deletes whole blocks pre-join; if the whole-store coverage gate in
571
- // strand-membership-closed-strand-e2e ever reports a tombstone residue, teach this
572
- // to push the promoted delete transform instead of skipping.
573
- const block = await storage.getMaterializedBlock(blockId, latest.actionId);
574
- if (!block) {
575
- result.unmaterialized += 1;
576
- log('[%s] block=%s has latest rev %d but no materialized content; skipped', this.deps.label, blockId, latest.rev);
577
- continue;
578
- }
579
- const buffer = encoder.encode(JSON.stringify(block));
580
- // NOTE: MAX_BLOCK_MESSAGE_BYTES caps the whole framed request, not one block, and
581
- // this test ignores the request envelope (ids array, blockMeta, JSON punctuation).
582
- // A lone block within a few KiB of the cap therefore still ships and is rejected by
583
- // the receiver's length-prefix decoder — a permanent chunk failure for that peer. No
584
- // block comes close today; if one ever can, subtract a measured envelope allowance
585
- // here rather than raising the cap.
586
- if (base64WireBytes(buffer.length) >= MAX_BLOCK_MESSAGE_BYTES) {
587
- result.oversized.push(blockId);
588
- log('[%s] block=%s is %d bytes (%d on the wire) — exceeds the %d-byte protocol cap alone; skipped', this.deps.label, blockId, buffer.length, base64WireBytes(buffer.length), MAX_BLOCK_MESSAGE_BYTES);
589
- continue;
590
- }
591
694
  // Flush before adding when this block would overflow either budget. A single block
592
695
  // larger than maxChunkBytes still ships — alone, in its own chunk.
593
- if (chunk.ids.length > 0
594
- && (chunk.ids.length + 1 > this.config.maxChunkBlocks || chunk.bytes + buffer.length > this.config.maxChunkBytes)) {
696
+ const inChunk = Object.keys(chunk.blocks).length;
697
+ if (inChunk > 0
698
+ && (inChunk + 1 > this.config.maxChunkBlocks || chunk.bytes + offer.bytes > this.config.maxChunkBytes)) {
595
699
  await flush();
596
700
  if (this.stopped || peerUnreachable())
597
701
  break;
598
702
  }
599
- chunk.ids.push(blockId);
600
- chunk.buffers.push(buffer);
601
- chunk.meta[blockId] = { rev: latest.rev, actionId: latest.actionId };
602
- // Read the proof for EXACTLY the revision being pushed, from the same `latest` the meta
603
- // above was written from. Absent is normal (nothing retained a proof for that revision);
604
- // the block still ships with its meta and the receiver decides.
605
- const proof = await storage.getBlockProof(blockId, latest.rev);
606
- if (proof) {
607
- chunk.proofs[blockId] = proof;
703
+ chunk.blocks[blockId] = offer.block;
704
+ chunk.meta[blockId] = { rev: offer.latest.rev, actionId: offer.latest.actionId };
705
+ if (offer.proof) {
706
+ chunk.proofs[blockId] = offer.proof;
608
707
  }
609
- chunk.bytes += buffer.length;
708
+ chunk.bytes += offer.bytes;
610
709
  result.offered += 1;
611
710
  }
612
711
  if (!this.stopped && !peerUnreachable()) {
613
712
  await flush();
614
713
  }
615
714
  if (result.capped > 0) {
616
- log('[%s] peer=%s catch-up CAPPED at %d blocks; %d block id(s) not attempted', this.deps.label, peerId.toString(), this.config.maxBlocks, result.capped);
715
+ log('[%s] peer=%s catch-up CAPPED at %d blocks; %d block id(s) not attempted', this.deps.label, key, this.config.maxBlocks, result.capped);
716
+ }
717
+ return { result, clean: !chunkFailed, pushFailed: chunkFailed };
718
+ }
719
+ /**
720
+ * Read one block for the push to `key`, or count why it is skipped in `result` and return
721
+ * nothing: not committed, already held by the peer (memo or its own commit signature), not
722
+ * materialized, or over the per-block size limit. The proof is read for EXACTLY the revision the
723
+ * meta will name, from the same `latest`; absent is normal (nothing retained a proof for that
724
+ * revision), and the block still ships with its meta for the receiver to decide.
725
+ */
726
+ async readOffer(blockId, key, memo, result) {
727
+ const { storage } = this.deps;
728
+ const latest = (await storage.getMetadata(blockId))?.latest;
729
+ if (!latest) {
730
+ result.uncommitted += 1;
731
+ return undefined;
732
+ }
733
+ if (memoSettled(memo, blockId, latest.rev)) {
734
+ result.alreadyHeld += 1;
735
+ return undefined;
736
+ }
737
+ const proof = await storage.getBlockProof(blockId, latest.rev);
738
+ if (signedBy(proof, key)) {
739
+ result.alreadyHeld += 1;
740
+ return undefined;
741
+ }
742
+ // NOTE: a block whose latest revision is a DELETE materializes to nothing (a
743
+ // tombstone), so it is skipped here — a peer that joins after such a delete never
744
+ // receives the tombstone from this path and relies on read repair for it. Fine
745
+ // while nothing deletes whole blocks pre-join; if the whole-store coverage gate in
746
+ // strand-membership-closed-strand-e2e ever reports a tombstone residue, teach this
747
+ // to push the promoted delete transform instead of skipping.
748
+ const block = await storage.getMaterializedBlock(blockId, latest.actionId);
749
+ if (!block) {
750
+ result.unmaterialized += 1;
751
+ log('[%s] block=%s has latest rev %d but no materialized content; skipped', this.deps.label, blockId, latest.rev);
752
+ return undefined;
753
+ }
754
+ // A writer refuses a block over MAX_BLOCK_BYTES, and Optimystic sizes the push frame to carry
755
+ // one block at that limit with its meta and proof. A block past the limit grew some other way
756
+ // and may not fit the frame, and a push that does not fit fails on every run.
757
+ const bytes = jsonEncodedBytes(block);
758
+ if (bytes > MAX_BLOCK_BYTES) {
759
+ result.oversized.push(blockId);
760
+ log('[%s] block=%s encodes to %d bytes, over the %d-byte per-block limit; skipped', this.deps.label, blockId, bytes, MAX_BLOCK_BYTES);
761
+ return undefined;
617
762
  }
618
- const clean = !chunkFailed && result.rejected.length === 0;
619
- return { result, clean, pushFailed: chunkFailed };
763
+ return { latest, block, bytes, proof };
620
764
  }
621
765
  }
622
766
  //# sourceMappingURL=peer-join-backfill.js.map