@serfab/cadre-core 1.3.0 → 1.5.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/README.md +5 -3
- package/dist/cadre-node.d.ts +70 -34
- package/dist/cadre-node.js +197 -60
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +24 -10
- package/dist/control-database.js +48 -16
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +4 -4
- package/dist/control-formation-recorder.js +6 -6
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-read-retry.d.ts +12 -0
- package/dist/control-read-retry.js +12 -0
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-write-retry.d.ts +5 -4
- package/dist/control-write-retry.js +24 -7
- package/dist/control-write-retry.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +10 -5
- package/dist/index.js.map +1 -1
- package/dist/link-budget.d.ts +182 -0
- package/dist/link-budget.js +195 -0
- package/dist/link-budget.js.map +1 -0
- package/dist/membership-connection-gater.d.ts +18 -5
- package/dist/membership-connection-gater.js +50 -10
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/peer-dial.d.ts +36 -17
- package/dist/peer-dial.js +37 -17
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +58 -4
- package/dist/peer-join-backfill.js +165 -20
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/relay-reservation.d.ts +59 -11
- package/dist/relay-reservation.js +169 -43
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.js +2 -1
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/storage-scope.d.ts +50 -12
- package/dist/storage-scope.js +85 -12
- package/dist/storage-scope.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +43 -5
- package/dist/strand-first-sync-gate.js +55 -7
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-manager.js +2 -1
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-id.d.ts +35 -0
- package/dist/strand-id.js +46 -0
- package/dist/strand-id.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +27 -2
- package/dist/strand-instance-manager.js +75 -15
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +200 -45
- package/dist/strand-membership-reconciler.js +302 -86
- package/dist/strand-membership-reconciler.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +3 -2
- package/dist/strand-membership-writer.js +67 -90
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +3 -3
- package/dist/strand-network-config.js +3 -3
- package/dist/strand-solicitation.js +2 -1
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-watcher.d.ts +45 -0
- package/dist/strand-watcher.js +74 -13
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +190 -24
- package/dist/types.js +73 -4
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/cadre-node.ts +7445 -7302
- package/src/control-database.ts +48 -16
- package/src/control-formation-recorder.ts +6 -6
- package/src/control-read-retry.ts +214 -202
- package/src/control-write-retry.ts +24 -7
- package/src/index.ts +29 -2
- package/src/link-budget.ts +217 -0
- package/src/membership-connection-gater.ts +56 -11
- package/src/peer-dial.ts +39 -17
- package/src/peer-join-backfill.ts +192 -23
- package/src/relay-reservation.ts +1119 -951
- package/src/seed-bootstrap.ts +2 -1
- package/src/storage-scope.ts +163 -78
- package/src/strand-first-sync-gate.ts +55 -7
- package/src/strand-formation-manager.ts +647 -646
- package/src/strand-id.ts +48 -0
- package/src/strand-instance-manager.ts +1516 -1440
- package/src/strand-membership-reconciler.ts +334 -92
- package/src/strand-membership-writer.ts +1962 -1987
- package/src/strand-network-config.ts +250 -250
- package/src/strand-solicitation.ts +500 -499
- package/src/strand-watcher.ts +388 -325
- package/src/types.ts +192 -23
|
@@ -26,39 +26,93 @@
|
|
|
26
26
|
* that finds no member row AND no staged invitation is not an unfinished join but a wait
|
|
27
27
|
* for someone to admit this party — nothing this machine can do faster — so it re-arms on
|
|
28
28
|
* the flat poll interval and resets the ladder. The loop stops on the done state.
|
|
29
|
-
* - **Done state**: the party's member row is visible locally
|
|
30
|
-
*
|
|
31
|
-
* business, not this loop's; a resume rebuilds the reconciler and
|
|
32
|
-
* scratch (every write is idempotent, so re-running is free)
|
|
29
|
+
* - **Done state**: the party's member row is visible locally, this machine's own binding
|
|
30
|
+
* is in place, AND no invitation is still staged — the loop stops. A LATER revocation is
|
|
31
|
+
* the enforcer's business, not this loop's; a resume rebuilds the reconciler and
|
|
32
|
+
* re-verifies from scratch (every write is idempotent, so re-running is free), and a
|
|
33
|
+
* fresh invitation staged on a done loop re-arms it in place ({@link rearm}).
|
|
33
34
|
*
|
|
34
35
|
* ## One pass
|
|
35
36
|
*
|
|
36
|
-
* 1. **Self-revocation check.** If the enforcer flags this node's own party as revoked
|
|
37
|
-
* stop rather than fight — re-admission arrives (if ever) via
|
|
37
|
+
* 1. **Self-revocation check.** If the enforcer flags this node's own party as revoked and
|
|
38
|
+
* no invitation is staged, stop rather than fight — re-admission arrives (if ever) via
|
|
39
|
+
* a fresh formation, which re-arms the loop. With an invitation staged the loop reports
|
|
40
|
+
* the dead end (below) and carries on: the redemption is attempted like any other, and a
|
|
41
|
+
* manager's re-admission lets a later pass finish it.
|
|
38
42
|
* 2. **Ensure membership.** Member row visible → done with this step; if a staged
|
|
39
43
|
* invitation is still unspent, BURN it ({@link burnInvite} — the `ConsumedInvite` row
|
|
40
|
-
* alone) so the bearer credential cannot be spent by anyone else
|
|
41
|
-
*
|
|
44
|
+
* alone) so the bearer credential cannot be spent by anyone else. A burn refused by the
|
|
45
|
+
* cohort keeps the invitation staged for the next pass (it is the same write gate a
|
|
46
|
+
* redemption faces, and the local member row may be stale — see "Re-arming"); a dead
|
|
47
|
+
* invitation is dropped. Member row absent + staged invitation → {@link consumeInvite}
|
|
42
48
|
* under the party's own key, retried across passes until the `Invite` row has
|
|
43
49
|
* replicated here and the write commits. Absent with no invitation (founder rows not
|
|
44
50
|
* yet synced, or a machine whose party never joined) → keep waiting, escalating to one
|
|
45
51
|
* visible warning after {@link IDLE_PASSES_BEFORE_ESCALATION} passes, never throwing.
|
|
46
52
|
* 3. **Ensure the binding.** {@link registerMemberPeer} (insert-if-absent, restart-safe)
|
|
47
53
|
* with this node's own strand transport peer id — only after step 2 sees the member
|
|
48
|
-
* row locally (its deferred `MemberExists` reads the live local table).
|
|
54
|
+
* row locally (its deferred `MemberExists` reads the live local table). Done only once
|
|
55
|
+
* the binding is in place AND nothing is staged; a binding written while a burn keeps
|
|
56
|
+
* being refused leaves the loop live to settle the invitation later.
|
|
49
57
|
*
|
|
50
|
-
* ##
|
|
58
|
+
* ## Stops, and re-arming
|
|
59
|
+
*
|
|
60
|
+
* Two kinds of stop. A RE-ARMABLE stop is one a fresh invitation should reopen:
|
|
61
|
+
*
|
|
62
|
+
* - **Done** (above).
|
|
63
|
+
* - **Self-revoked with nothing staged** (step 1 above).
|
|
64
|
+
*
|
|
65
|
+
* A PERMANENT stop is one a fresh invitation changes nothing about — {@link rearm} is a
|
|
66
|
+
* no-op after it:
|
|
51
67
|
*
|
|
52
68
|
* - **Sealed strand**: `consumeInvite` rejected by `ConsumedInvite.NotSealed` — nobody
|
|
53
69
|
* can ever be admitted, so retrying forever is noise. Terminal-with-log.
|
|
54
|
-
* - **Self-revoked** (step 1 above).
|
|
55
70
|
* - **Undecodable party key**: nothing can be signed; loud log, stop.
|
|
71
|
+
* - **The public {@link stop}**: `StrandInstanceManager.releaseRuntime` and
|
|
72
|
+
* `clearOwnMemberPeerBinding` both call it and mean it — the latter stops the loop and
|
|
73
|
+
* awaits {@link settle} precisely so no pass can re-register the binding it is about to
|
|
74
|
+
* delete, and a re-arm that restarted the loop would undo that.
|
|
75
|
+
*
|
|
76
|
+
* `CadreNode.adoptFormationMembershipInvite` stages every fresh invitation and notifies
|
|
77
|
+
* the instance manager, which calls {@link rearm}: a done loop reopens and runs one pass
|
|
78
|
+
* at once; a running loop is merely kicked. This is what lets a party that was REMOVED
|
|
79
|
+
* from the strand, and then handed a new invitation, actually attempt it — the loop
|
|
80
|
+
* finished during its first join, long before the removal.
|
|
81
|
+
*
|
|
82
|
+
* On a removed party the local replica is usually stale: the cohort cut this machine off
|
|
83
|
+
* at about the moment the removal was written, so `isStrandMember` still answers true and
|
|
84
|
+
* a re-armed pass lands in the already-member arm. That is why the burn arm keeps a
|
|
85
|
+
* refused invitation staged and why the done state requires nothing staged — otherwise
|
|
86
|
+
* the fresh credential would be dropped on the first refused burn and the loop would latch
|
|
87
|
+
* done again, with the only trace a debug line.
|
|
56
88
|
*
|
|
57
89
|
* A DEAD invitation that is not terminal for the loop — expired, cancelled, or already
|
|
58
90
|
* consumed by someone else (`ConsumedInvite`'s primary key) — is dropped with a log and
|
|
59
91
|
* the loop keeps idling: a fresh formation stages a new invitation, and a manager-side
|
|
60
92
|
* admission (`addMemberByManager`) seats the member row without one.
|
|
61
93
|
*
|
|
94
|
+
* ## Reporting a blocked re-join
|
|
95
|
+
*
|
|
96
|
+
* Redeeming (or burning) writes into the strand, and the machines that would carry that
|
|
97
|
+
* write are exactly the ones the remaining members refuse once this party is removed. So
|
|
98
|
+
* the attempt is expected to fail, and the failure is REPORTED rather than left to the
|
|
99
|
+
* ladder: one `console.warn` plus the {@link StrandMembershipReconcilerDeps.onRejoinBlocked}
|
|
100
|
+
* callback (`CadreNode`'s `strand:rejoin-blocked` event), at most once per re-arm cycle.
|
|
101
|
+
* Two triggers:
|
|
102
|
+
*
|
|
103
|
+
* - **Confirmed** — a pass finds this party self-revoked AND an invitation staged. The node
|
|
104
|
+
* knows it was removed and is holding a credential it cannot spend.
|
|
105
|
+
* - **Probable** — {@link UNFINISHED_PASSES_BEFORE_ESCALATION} consecutive attempted passes
|
|
106
|
+
* left the invitation staged. The case that matters most in the field: a removed party
|
|
107
|
+
* that never learned it was removed (the removal did not replicate here before the cut).
|
|
108
|
+
* It is a suspicion, not a verdict — the invitation's `Invite` row may simply not have
|
|
109
|
+
* replicated here yet — and the warning names both causes.
|
|
110
|
+
*
|
|
111
|
+
* The remedy in either case is a remaining manager admitting this party's member key
|
|
112
|
+
* directly (`addMemberByManager`): that lifts the refusal, replication resumes, and the
|
|
113
|
+
* still-running loop sees the member row, burns the leftover credential and finishes by
|
|
114
|
+
* itself.
|
|
115
|
+
*
|
|
62
116
|
* ## Sharing the database with the app
|
|
63
117
|
*
|
|
64
118
|
* The strand `Database` this loop writes to is the one the app holds. Every write here
|
|
@@ -97,6 +151,16 @@ const log = debug('sereus:cadre:strand-membership-reconciler');
|
|
|
97
151
|
* founder-row replication is visible without the debug namespace enabled.
|
|
98
152
|
*/
|
|
99
153
|
export const IDLE_PASSES_BEFORE_ESCALATION = 10;
|
|
154
|
+
/**
|
|
155
|
+
* Consecutive passes that ran against a live database and ended with the staged
|
|
156
|
+
* invitation still unsettled — whatever refused it — before the loop reports a probable
|
|
157
|
+
* blocked re-join (see "Reporting a blocked re-join" in the module doc). The same order
|
|
158
|
+
* as {@link IDLE_PASSES_BEFORE_ESCALATION}: about three minutes at the default cadence
|
|
159
|
+
* (the ladder's five doubling rungs, then five passes at the 30 s cap). Counted by outcome
|
|
160
|
+
* rather than by classified failure because a cut-off machine can fail before any write is
|
|
161
|
+
* classified: its membership READ may already throw.
|
|
162
|
+
*/
|
|
163
|
+
export const UNFINISHED_PASSES_BEFORE_ESCALATION = 10;
|
|
100
164
|
/**
|
|
101
165
|
* First retry delay, ms, for a pass that left the join UNFINISHED — a `consumeInvite`
|
|
102
166
|
* whose `Strand.Invite` row has not replicated to this machine yet, a cohort briefly
|
|
@@ -131,12 +195,14 @@ const SEALED_REJECTION = /CHECK constraint failed: NotSealed\b/;
|
|
|
131
195
|
*/
|
|
132
196
|
const DEAD_INVITE_REJECTION = /CHECK constraint failed: (?:NotExpired|NotCancelled)\b|UNIQUE constraint failed: ConsumedInvite\.InviteKey\b/;
|
|
133
197
|
/**
|
|
134
|
-
* Route a `consumeInvite` rejection.
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
198
|
+
* Route a `consumeInvite` or `burnInvite` rejection. Both write the `Strand.ConsumedInvite`
|
|
199
|
+
* row under the same `NotExpired` / `NotCancelled` / `NotSealed` / primary-key constraints
|
|
200
|
+
* (a burn writes that row alone), so one classifier serves both. Typed checks run before any
|
|
201
|
+
* text check: a busy refusal (another transaction was open, so nothing ran) and a
|
|
202
|
+
* half-committed join are recognised by their error's type anywhere in the `cause` chain.
|
|
203
|
+
* The busy refusal carries Quereus's own refusal as its cause, and a half-committed join's
|
|
204
|
+
* message embeds collection names and the underlying failure's text, either of which a text
|
|
205
|
+
* matcher can misread. The sealed and dead-invitation checks then read the top-level message.
|
|
140
206
|
*
|
|
141
207
|
* A second loaded copy of `@optimystic/db-core` (or of the plugin) would fail `instanceof`; the
|
|
142
208
|
* half-commit then falls through the anchored texts to `retry`, and once the saved `ConsumedInvite`
|
|
@@ -197,9 +263,19 @@ export class StrandMembershipReconciler {
|
|
|
197
263
|
this.tail = Promise.resolve();
|
|
198
264
|
this.started = false;
|
|
199
265
|
this.stoppedFlag = false;
|
|
266
|
+
/**
|
|
267
|
+
* Whether the last stop was one {@link rearm} may not undo — see "Stops, and re-arming"
|
|
268
|
+
* in the module doc. Latched by the public {@link stop} and the permanent terminal
|
|
269
|
+
* states, never cleared.
|
|
270
|
+
*/
|
|
271
|
+
this.permanentlyStopped = false;
|
|
200
272
|
this.doneFlag = false;
|
|
201
273
|
this.idlePasses = 0;
|
|
202
274
|
this.idleEscalated = false;
|
|
275
|
+
/** Consecutive attempted passes that left the invitation staged — see {@link UNFINISHED_PASSES_BEFORE_ESCALATION}. */
|
|
276
|
+
this.unfinishedPasses = 0;
|
|
277
|
+
/** Whether the blocked re-join was already reported this re-arm cycle (one report, either trigger). */
|
|
278
|
+
this.rejoinBlockedReported = false;
|
|
203
279
|
/**
|
|
204
280
|
* Whether the pass that just ran found nothing to act on — no member row and no staged
|
|
205
281
|
* invitation. That is a wait to be admitted, not an unfinished join, so it re-arms on the
|
|
@@ -215,11 +291,14 @@ export class StrandMembershipReconciler {
|
|
|
215
291
|
this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
|
|
216
292
|
this.pollIntervalMs = config?.pollIntervalMs ?? DEFAULT_REVOCATION_POLL_INTERVAL_MS;
|
|
217
293
|
}
|
|
218
|
-
/**
|
|
294
|
+
/**
|
|
295
|
+
* True once member row + own binding were both confirmed with nothing staged and the
|
|
296
|
+
* loop stopped. Cleared again by {@link rearm}.
|
|
297
|
+
*/
|
|
219
298
|
get done() {
|
|
220
299
|
return this.doneFlag;
|
|
221
300
|
}
|
|
222
|
-
/** True
|
|
301
|
+
/** True while the loop is stopped — done, terminal, or externally stopped. */
|
|
223
302
|
get stopped() {
|
|
224
303
|
return this.stoppedFlag;
|
|
225
304
|
}
|
|
@@ -245,13 +324,23 @@ export class StrandMembershipReconciler {
|
|
|
245
324
|
async settle() {
|
|
246
325
|
await this.tail;
|
|
247
326
|
}
|
|
248
|
-
/**
|
|
327
|
+
/**
|
|
328
|
+
* Disarm the retry timer, for good: a later {@link rearm} is refused. A pass already in
|
|
329
|
+
* flight completes but writes idempotently — {@link settle} is what waits it out.
|
|
330
|
+
*/
|
|
249
331
|
stop() {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
332
|
+
this.halt('stopped by the runtime', true);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* A fresh invitation was staged: reopen a loop that stopped on a re-armable state (done,
|
|
336
|
+
* or self-revoked with nothing staged) and run one pass at once; merely kick a loop that
|
|
337
|
+
* is still running; do nothing after a permanent stop. Never rejects, and serialized
|
|
338
|
+
* like {@link reconcile}: the reopen happens when the queued pass STARTS, after any pass
|
|
339
|
+
* in flight has settled, so a `done` that pass latches cannot swallow the re-arm — and a
|
|
340
|
+
* public {@link stop} that lands first still wins, because the queued pass re-checks it.
|
|
341
|
+
*/
|
|
342
|
+
rearm() {
|
|
343
|
+
return this.enqueue(() => this.reopen() ? this.doPass() : Promise.resolve());
|
|
255
344
|
}
|
|
256
345
|
/**
|
|
257
346
|
* Run one pass now. Never rejects. Serialized: concurrent calls chain, so no two
|
|
@@ -261,35 +350,64 @@ export class StrandMembershipReconciler {
|
|
|
261
350
|
* published — REPLACES the pending timer rather than running alongside it.
|
|
262
351
|
*/
|
|
263
352
|
reconcile() {
|
|
353
|
+
return this.enqueue(() => this.doPass());
|
|
354
|
+
}
|
|
355
|
+
/** Chain `pass` after every pass already queued; the tail never rejects. */
|
|
356
|
+
enqueue(pass) {
|
|
264
357
|
// The catch is what keeps the chain alive. `doPass` contains its own failures, but its
|
|
265
358
|
// `finally` calls into the injected scheduler, and a throw from there would leave `tail`
|
|
266
359
|
// REJECTED — after which every later `reconcile()` short-circuits on it and the join
|
|
267
|
-
// stalls for good, silently. Swallowing it here makes the "never rejects" contract
|
|
268
|
-
//
|
|
269
|
-
// than incidental.
|
|
270
|
-
const run = this.tail.then(
|
|
360
|
+
// stalls for good, silently. Swallowing it here makes the "never rejects" contract
|
|
361
|
+
// {@link reconcile}, {@link rearm}, {@link settle} and the `void`-ed call sites all rely
|
|
362
|
+
// on structural rather than incidental.
|
|
363
|
+
const run = this.tail.then(pass).catch((error) => {
|
|
271
364
|
log('[%s] reconcile pass threw outside its own handler — the chain continues: %o', this.deps.label, error);
|
|
272
365
|
});
|
|
273
366
|
this.tail = run;
|
|
274
367
|
return run;
|
|
275
368
|
}
|
|
369
|
+
/**
|
|
370
|
+
* The re-arm itself, run at the head of the queued pass: `false` refuses the pass (a
|
|
371
|
+
* permanent stop), a running loop is left as it is, and a re-armable stop is reopened
|
|
372
|
+
* with the idle and unfinished counters, both escalation latches and the retry ladder
|
|
373
|
+
* reset — a fresh invitation is a fresh cycle.
|
|
374
|
+
*/
|
|
375
|
+
reopen() {
|
|
376
|
+
if (this.permanentlyStopped) {
|
|
377
|
+
log('[%s] re-arm refused — the loop is stopped for good', this.deps.label);
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
if (!this.stoppedFlag) {
|
|
381
|
+
// NOTE: a running loop keeps its counters and its once-per-cycle report latch, so a
|
|
382
|
+
// party whose loop already reported and is then re-formed AGAIN gets no second report
|
|
383
|
+
// for the replacement invitation. If that second report is ever wanted, key the latch
|
|
384
|
+
// on the staged invitation's key rather than on the re-arm.
|
|
385
|
+
log('[%s] fresh invitation staged on a running loop — kicking a pass', this.deps.label);
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
this.stoppedFlag = false;
|
|
389
|
+
this.doneFlag = false;
|
|
390
|
+
this.idlePasses = 0;
|
|
391
|
+
this.idleEscalated = false;
|
|
392
|
+
this.unfinishedPasses = 0;
|
|
393
|
+
this.rejoinBlockedReported = false;
|
|
394
|
+
this.retryDelayMs = undefined;
|
|
395
|
+
log('[%s] membership reconciler re-armed by a fresh invitation', this.deps.label);
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
276
398
|
/** One serialized pass; contains every failure (contract: never rejects). */
|
|
277
399
|
async doPass() {
|
|
278
400
|
if (this.stoppedFlag || this.doneFlag)
|
|
279
401
|
return;
|
|
280
402
|
this.lastPassIdle = false;
|
|
281
403
|
try {
|
|
282
|
-
if (this.deps.isSelfRevoked?.() === true) {
|
|
283
|
-
// NOTE:
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
// its first join long before it was removed. Measured end to end in
|
|
290
|
-
// `strand-party-removal-via-formation-e2e.integration.ts` (test 2), tracked as
|
|
291
|
-
// `backlog/bug-removed-party-cannot-redeem-its-way-back`.
|
|
292
|
-
this.finish('this party is revoked from the strand — re-admission arrives (if ever) via a fresh formation');
|
|
404
|
+
if (this.deps.isSelfRevoked?.() === true && !this.stagedInviteBlockedBySelfRevocation()) {
|
|
405
|
+
// NOTE: the enforcer's flag lags a re-admission by up to one of its polls. If the
|
|
406
|
+
// pass that settled the last staged invitation then failed to write the binding, this
|
|
407
|
+
// stop lands with the binding missing until the next resume rebuilds the loop. A
|
|
408
|
+
// missing binding only mis-credits diversity today; if it ever gates admission, make
|
|
409
|
+
// this stop also require the binding to be in place.
|
|
410
|
+
this.finish('this party is revoked from the strand — re-admission arrives (if ever) via a fresh formation', false);
|
|
293
411
|
return;
|
|
294
412
|
}
|
|
295
413
|
const keyPair = this.resolveKeyPair();
|
|
@@ -298,6 +416,21 @@ export class StrandMembershipReconciler {
|
|
|
298
416
|
const db = this.deps.getDatabase();
|
|
299
417
|
if (!db)
|
|
300
418
|
return; // no live database this instant (quiesce race) — next tick decides
|
|
419
|
+
await this.attempt(db, keyPair);
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
log('[%s] reconcile pass failed — retrying next tick: %o', this.deps.label, error);
|
|
423
|
+
}
|
|
424
|
+
finally {
|
|
425
|
+
this.scheduleNext();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Steps 2 and 3 against a live database, with the outcome counted toward the probable
|
|
430
|
+
* blocked re-join report whether the attempt returned or threw.
|
|
431
|
+
*/
|
|
432
|
+
async attempt(db, keyPair) {
|
|
433
|
+
try {
|
|
301
434
|
if (!(await this.ensureMembership(db, keyPair)))
|
|
302
435
|
return;
|
|
303
436
|
// NOTE: the redemption (`Member` + `ConsumedInvite`) and the binding below are two
|
|
@@ -312,11 +445,8 @@ export class StrandMembershipReconciler {
|
|
|
312
445
|
// 27 + 18 baseline.
|
|
313
446
|
await this.ensureBinding(db, keyPair);
|
|
314
447
|
}
|
|
315
|
-
catch (error) {
|
|
316
|
-
log('[%s] reconcile pass failed — retrying next tick: %o', this.deps.label, error);
|
|
317
|
-
}
|
|
318
448
|
finally {
|
|
319
|
-
this.
|
|
449
|
+
this.noteAttemptOutcome();
|
|
320
450
|
}
|
|
321
451
|
}
|
|
322
452
|
/**
|
|
@@ -378,34 +508,33 @@ export class StrandMembershipReconciler {
|
|
|
378
508
|
}, OWN_TRANSACTION);
|
|
379
509
|
}
|
|
380
510
|
catch (error) {
|
|
381
|
-
this.handleConsumeFailure(error);
|
|
511
|
+
this.handleConsumeFailure(error, invite);
|
|
382
512
|
return false;
|
|
383
513
|
}
|
|
384
|
-
this.deps.pendingInvite?.clear();
|
|
514
|
+
this.deps.pendingInvite?.clear(invite);
|
|
385
515
|
log('[%s] redeemed the staged membership invitation — Member row seated under this party\'s key', this.deps.label);
|
|
386
516
|
return true;
|
|
387
517
|
}
|
|
388
518
|
/**
|
|
389
|
-
* The already-member arm: spend a still-staged invitation so nobody else can.
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
* present
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
519
|
+
* The already-member arm: spend a still-staged invitation so nobody else can. The stage
|
|
520
|
+
* is cleared once the burn lands or the invitation is known dead (already spent,
|
|
521
|
+
* cancelled, expired, or the strand sealed around a party that is still a member) — with
|
|
522
|
+
* the member row present a dead credential has no further local use, and keeping it
|
|
523
|
+
* staged would leave `getPendingMembershipInvite` lying. A refused burn keeps it staged:
|
|
524
|
+
* a busy refusal tried nothing, and a write the cohort refused is the removed-party shape
|
|
525
|
+
* ("Stops, and re-arming" in the module doc), where the local member row is stale and the
|
|
526
|
+
* credential is the one thing a later manager admission lets this loop settle.
|
|
396
527
|
*
|
|
397
|
-
* NOTE:
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
* `feat-strand-member-allowlist-admission` lands.
|
|
528
|
+
* NOTE: this REPLACES an earlier accepted tradeoff that never retried a burn which failed
|
|
529
|
+
* for a transient reason and dropped the invitation instead. That decision was made when
|
|
530
|
+
* this arm could only ever see an invitation staged before the first join finished, so a
|
|
531
|
+
* dropped credential cost nothing but a spendable bearer token. {@link rearm} makes the
|
|
532
|
+
* arm reachable with a freshly issued invitation the app wants redeemed, and dropping
|
|
533
|
+
* that one on the first refused write is the silent failure this loop exists to avoid.
|
|
404
534
|
*/
|
|
405
535
|
async burnLeftoverInvite(db, keyPair) {
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
if (!pending || !invite)
|
|
536
|
+
const invite = this.deps.pendingInvite?.get();
|
|
537
|
+
if (!invite)
|
|
409
538
|
return;
|
|
410
539
|
try {
|
|
411
540
|
await burnInvite(db, {
|
|
@@ -413,23 +542,49 @@ export class StrandMembershipReconciler {
|
|
|
413
542
|
invitePrivateKey: invite.invitePrivateKey,
|
|
414
543
|
memberKey: keyPair.publicKeyB64,
|
|
415
544
|
}, OWN_TRANSACTION);
|
|
416
|
-
log('[%s] burned the leftover invitation (member row already present)', this.deps.label);
|
|
417
545
|
}
|
|
418
546
|
catch (error) {
|
|
419
|
-
|
|
547
|
+
this.handleBurnFailure(error, invite);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
this.deps.pendingInvite?.clear(invite);
|
|
551
|
+
log('[%s] burned the leftover invitation (member row already present)', this.deps.label);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Act on a `burnInvite` rejection as {@link classifyConsumeFailure} routes it. Unlike a
|
|
555
|
+
* redemption, a sealed strand is not terminal here — the member row is present, so this
|
|
556
|
+
* party is a member of the sealed strand and only its leftover credential is dead — and a
|
|
557
|
+
* half-committed burn (one collection, so a report of it means the commit's durability
|
|
558
|
+
* was in doubt) is retried: the next pass either lands it or fails on the row's primary
|
|
559
|
+
* key, which drops it as dead.
|
|
560
|
+
*/
|
|
561
|
+
handleBurnFailure(error, invite) {
|
|
562
|
+
const failure = classifyConsumeFailure(error);
|
|
563
|
+
switch (failure.kind) {
|
|
564
|
+
case 'busy':
|
|
420
565
|
log('[%s] burning the leftover invitation deferred — the app has a transaction open; it stays staged '
|
|
421
566
|
+ 'for the next pass', this.deps.label);
|
|
422
567
|
return;
|
|
423
|
-
|
|
424
|
-
|
|
568
|
+
case 'sealed':
|
|
569
|
+
case 'dead-invite':
|
|
570
|
+
log('[%s] the leftover invitation is dead (already spent, cancelled, expired, or the strand sealed) — '
|
|
571
|
+
+ 'dropping it: %s', this.deps.label, errorMessage(error));
|
|
572
|
+
this.deps.pendingInvite?.clear(invite);
|
|
573
|
+
return;
|
|
574
|
+
case 'half-committed':
|
|
575
|
+
case 'retry':
|
|
576
|
+
log('[%s] burning the leftover invitation was refused — it stays staged and is retried next tick '
|
|
577
|
+
+ '(the cohort refusing this machine, or a transient write failure): %s', this.deps.label, errorMessage(error));
|
|
578
|
+
return;
|
|
425
579
|
}
|
|
426
|
-
pending.clear();
|
|
427
580
|
}
|
|
428
581
|
/**
|
|
429
582
|
* Step 3 of the pass: write this machine's own `MemberPeer` binding (insert-if-absent)
|
|
430
|
-
* and,
|
|
431
|
-
* (a quiesce racing the pass) defers to the next tick; a
|
|
432
|
-
* included — is contained by the pass's outer catch and
|
|
583
|
+
* and, once it is in place with nothing staged, latch the done state and stop the loop.
|
|
584
|
+
* A missing transport peer id (a quiesce racing the pass) defers to the next tick; a
|
|
585
|
+
* write failure — a busy refusal included — is contained by the pass's outer catch and
|
|
586
|
+
* retried. A binding written while an invitation is still staged (its burn keeps being
|
|
587
|
+
* refused) is not done: the loop stays live so a later pass can settle the credential.
|
|
433
588
|
*/
|
|
434
589
|
async ensureBinding(db, keyPair) {
|
|
435
590
|
const peerId = this.deps.getOwnPeerId();
|
|
@@ -438,15 +593,19 @@ export class StrandMembershipReconciler {
|
|
|
438
593
|
return;
|
|
439
594
|
}
|
|
440
595
|
await registerMemberPeer(db, { memberKeyPair: keyPair, peerId }, OWN_TRANSACTION);
|
|
596
|
+
if (this.deps.pendingInvite?.get()) {
|
|
597
|
+
log('[%s] own MemberPeer binding is in place but an invitation is still staged — not done yet', this.deps.label);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
441
600
|
this.doneFlag = true;
|
|
442
|
-
this.finish('member row and own MemberPeer binding are both in place');
|
|
601
|
+
this.finish('member row and own MemberPeer binding are both in place', false);
|
|
443
602
|
}
|
|
444
603
|
/**
|
|
445
604
|
* Act on a `consumeInvite` rejection as {@link classifyConsumeFailure} routes it: busy → keep
|
|
446
605
|
* the invitation and retry; half-committed → warn and drop; sealed → terminal; dead
|
|
447
606
|
* invitation → drop; anything else → retry.
|
|
448
607
|
*/
|
|
449
|
-
handleConsumeFailure(error) {
|
|
608
|
+
handleConsumeFailure(error, invite) {
|
|
450
609
|
const failure = classifyConsumeFailure(error);
|
|
451
610
|
switch (failure.kind) {
|
|
452
611
|
case 'busy':
|
|
@@ -454,12 +613,12 @@ export class StrandMembershipReconciler {
|
|
|
454
613
|
+ 'database; retrying next tick', this.deps.label);
|
|
455
614
|
return;
|
|
456
615
|
case 'half-committed':
|
|
457
|
-
this.reportHalfCommittedJoin(failure, error);
|
|
616
|
+
this.reportHalfCommittedJoin(failure, error, invite);
|
|
458
617
|
return;
|
|
459
618
|
case 'sealed':
|
|
460
619
|
// The staged credential is dead with the seal; drop it so the cache stays honest.
|
|
461
|
-
this.deps.pendingInvite?.clear();
|
|
462
|
-
this.finish('the strand is sealed — nobody can ever be admitted, so the staged invitation is dead');
|
|
620
|
+
this.deps.pendingInvite?.clear(invite);
|
|
621
|
+
this.finish('the strand is sealed — nobody can ever be admitted, so the staged invitation is dead', true);
|
|
463
622
|
return;
|
|
464
623
|
case 'dead-invite':
|
|
465
624
|
// NOTE: accepted tradeoff — dropping a dead invitation does not mark the pass idle,
|
|
@@ -469,14 +628,59 @@ export class StrandMembershipReconciler {
|
|
|
469
628
|
// source of dead invitations ever repeats per pass.
|
|
470
629
|
log('[%s] the staged invitation is dead (expired, cancelled, or consumed elsewhere) — dropping it; '
|
|
471
630
|
+ 'a fresh formation stages a new one: %s', this.deps.label, errorMessage(error));
|
|
472
|
-
this.deps.pendingInvite?.clear();
|
|
631
|
+
this.deps.pendingInvite?.clear(invite);
|
|
473
632
|
return;
|
|
474
633
|
case 'retry':
|
|
475
|
-
log('[%s] consumeInvite failed — retrying next tick (Invite row not yet replicated here,
|
|
476
|
-
+ 'write failure): %s', this.deps.label, errorMessage(error));
|
|
634
|
+
log('[%s] consumeInvite failed — retrying next tick (Invite row not yet replicated here, the cohort '
|
|
635
|
+
+ 'refusing this machine, or a transient write failure): %s', this.deps.label, errorMessage(error));
|
|
477
636
|
return;
|
|
478
637
|
}
|
|
479
638
|
}
|
|
639
|
+
/**
|
|
640
|
+
* Count an attempted pass that left the invitation staged (whatever stopped it — a
|
|
641
|
+
* refused or busy write, or a read that threw on a cut-off machine); one that settled
|
|
642
|
+
* it, or finished, ends the streak. At {@link UNFINISHED_PASSES_BEFORE_ESCALATION}
|
|
643
|
+
* report the PROBABLE blocked re-join.
|
|
644
|
+
*/
|
|
645
|
+
noteAttemptOutcome() {
|
|
646
|
+
if (this.doneFlag || !this.deps.pendingInvite?.get()) {
|
|
647
|
+
this.unfinishedPasses = 0;
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
this.unfinishedPasses += 1;
|
|
651
|
+
if (this.unfinishedPasses >= UNFINISHED_PASSES_BEFORE_ESCALATION) {
|
|
652
|
+
this.reportRejoinBlocked(`${this.unfinishedPasses} membership reconcile passes have left the staged membership invitation `
|
|
653
|
+
+ 'unsettled. Either this party was removed from the strand and the remaining members are refusing its '
|
|
654
|
+
+ 'machines, or the invitation\'s Strand.Invite row has not replicated here yet.');
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Step 1's CONFIRMED trigger: self-revoked with an invitation staged. Reports (once per
|
|
659
|
+
* re-arm cycle) and answers `true` so the pass carries on — the redemption is attempted
|
|
660
|
+
* and refused like any other write, and a manager's re-admission lets a later pass finish.
|
|
661
|
+
* `false` (nothing staged) means step 1 stops the loop as before.
|
|
662
|
+
*/
|
|
663
|
+
stagedInviteBlockedBySelfRevocation() {
|
|
664
|
+
if (!this.deps.pendingInvite?.get())
|
|
665
|
+
return false;
|
|
666
|
+
this.reportRejoinBlocked('this party is revoked from the strand and holds a staged membership invitation it cannot spend: the '
|
|
667
|
+
+ 'remaining members refuse this machine\'s strand writes, so the redemption cannot land.');
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* ONE `console.warn` and one callback per re-arm cycle, whichever trigger fires first —
|
|
672
|
+
* see "Reporting a blocked re-join" in the module doc. `cause` is the trigger's own
|
|
673
|
+
* sentence; the remedy and the loop's posture are the same for both.
|
|
674
|
+
*/
|
|
675
|
+
reportRejoinBlocked(cause) {
|
|
676
|
+
if (this.rejoinBlockedReported)
|
|
677
|
+
return;
|
|
678
|
+
this.rejoinBlockedReported = true;
|
|
679
|
+
console.warn(`[sereus] strand ${this.deps.label}: ${cause} A fresh invitation cannot re-admit a removed party by itself — `
|
|
680
|
+
+ 'a remaining manager must admit this party\'s member key directly (addMemberByManager). The membership '
|
|
681
|
+
+ 'loop keeps retrying quietly and completes the join once a Member row for this party appears.');
|
|
682
|
+
this.deps.onRejoinBlocked?.();
|
|
683
|
+
}
|
|
480
684
|
/**
|
|
481
685
|
* A redemption only part of which was saved (see "Half-committed join" in the module doc):
|
|
482
686
|
* ONE visible warning naming both halves, and the staged invitation dropped. The pass is not
|
|
@@ -493,7 +697,7 @@ export class StrandMembershipReconciler {
|
|
|
493
697
|
* path. Revisit if a `Member`-saved half-commit is ever observed, or when
|
|
494
698
|
* `blocked/strand-half-committed-join-recovery` is decided.
|
|
495
699
|
*/
|
|
496
|
-
reportHalfCommittedJoin(failure, error) {
|
|
700
|
+
reportHalfCommittedJoin(failure, error, invite) {
|
|
497
701
|
console.warn(`[sereus] strand ${this.deps.label}: redeeming the staged membership invitation was only partly saved. `
|
|
498
702
|
+ `Saved: [${failure.saved.join(', ')}]. Not saved: [${failure.unsaved.join(', ')}]. `
|
|
499
703
|
+ 'The invitation is dropped: a saved ConsumedInvite row spends it, so it cannot be redeemed again. '
|
|
@@ -501,7 +705,7 @@ export class StrandMembershipReconciler {
|
|
|
501
705
|
+ 'cannot become one from this invitation — a manager must admit it directly (addMemberByManager). '
|
|
502
706
|
+ 'The membership loop keeps checking and completes the join once a Member row for this party appears.');
|
|
503
707
|
log('[%s] half-committed redemption, full error: %o', this.deps.label, error);
|
|
504
|
-
this.deps.pendingInvite?.clear();
|
|
708
|
+
this.deps.pendingInvite?.clear(invite);
|
|
505
709
|
// The idle passes that follow are the wait this warning already explained; the escalation's
|
|
506
710
|
// "waiting on the founder rows to replicate" would be a second, misleading warning.
|
|
507
711
|
this.idleEscalated = true;
|
|
@@ -514,7 +718,7 @@ export class StrandMembershipReconciler {
|
|
|
514
718
|
this.keyPair = strandMemberKeyPair(this.deps.partyMemberPrivateKey);
|
|
515
719
|
}
|
|
516
720
|
catch (error) {
|
|
517
|
-
this.finish(`the party membership key does not decode — nothing can be signed (${errorMessage(error)})
|
|
721
|
+
this.finish(`the party membership key does not decode — nothing can be signed (${errorMessage(error)})`, true);
|
|
518
722
|
return undefined;
|
|
519
723
|
}
|
|
520
724
|
return this.keyPair;
|
|
@@ -539,10 +743,22 @@ export class StrandMembershipReconciler {
|
|
|
539
743
|
+ 'replicate (or on a manager admission). The loop keeps retrying quietly.');
|
|
540
744
|
}
|
|
541
745
|
}
|
|
542
|
-
/**
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
746
|
+
/**
|
|
747
|
+
* Stop with a reason — the terminal and done paths' shared exit. `permanent` says whether
|
|
748
|
+
* a fresh invitation may reopen the loop ("Stops, and re-arming" in the module doc).
|
|
749
|
+
*/
|
|
750
|
+
finish(reason, permanent) {
|
|
751
|
+
this.halt(`membership reconciliation stopping: ${reason}`, permanent);
|
|
752
|
+
}
|
|
753
|
+
/** Latch the stop and disarm the timer; a permanent latch sticks even on an already-stopped loop. */
|
|
754
|
+
halt(reason, permanent) {
|
|
755
|
+
if (permanent)
|
|
756
|
+
this.permanentlyStopped = true;
|
|
757
|
+
if (this.stoppedFlag)
|
|
758
|
+
return;
|
|
759
|
+
this.stoppedFlag = true;
|
|
760
|
+
this.clearTimer();
|
|
761
|
+
log('[%s] membership reconciler stopped%s: %s%s', this.deps.label, this.doneFlag ? ' (done)' : '', reason, permanent ? '' : ' — a fresh invitation re-arms it');
|
|
546
762
|
}
|
|
547
763
|
}
|
|
548
764
|
//# sourceMappingURL=strand-membership-reconciler.js.map
|