@serfab/cadre-core 1.13.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cadre-invite-protocol.d.ts +348 -0
- package/dist/cadre-invite-protocol.js +605 -0
- package/dist/cadre-invite-protocol.js.map +1 -0
- package/dist/cadre-node.d.ts +523 -180
- package/dist/cadre-node.js +921 -293
- package/dist/cadre-node.js.map +1 -1
- package/dist/claim-proof.d.ts +45 -0
- package/dist/claim-proof.js +132 -0
- package/dist/claim-proof.js.map +1 -0
- package/dist/control-authorization.d.ts +80 -19
- package/dist/control-authorization.js +55 -13
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +514 -97
- package/dist/control-database.js +1033 -195
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +142 -0
- package/dist/control-protocol-guard.js +293 -0
- package/dist/control-protocol-guard.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +801 -1067
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +4 -0
- package/dist/control-stream.js +22 -7
- package/dist/control-stream.js.map +1 -1
- package/dist/delegate-admission.d.ts +1 -1
- package/dist/delegate-admission.js +1 -1
- package/dist/device-token.d.ts +11 -9
- package/dist/device-token.js +14 -12
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +15 -0
- package/dist/ed25519-key.js +28 -1
- package/dist/ed25519-key.js.map +1 -1
- package/dist/index.d.ts +14 -9
- package/dist/index.js +27 -9
- package/dist/index.js.map +1 -1
- package/dist/link-budget.d.ts +16 -29
- package/dist/link-budget.js +16 -31
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +190 -151
- package/dist/membership-connection-gater.js +235 -173
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +19 -0
- package/dist/node-claim-payload.js +120 -0
- package/dist/node-claim-payload.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +22 -0
- package/dist/owner-anchor-sync.js +143 -0
- package/dist/owner-anchor-sync.js.map +1 -0
- package/dist/peer-authorization.d.ts +113 -18
- package/dist/peer-authorization.js +225 -29
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-dial.d.ts +19 -0
- package/dist/peer-dial.js +22 -1
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +26 -2
- package/dist/peer-join-backfill.js +54 -12
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +13 -9
- package/dist/peer-record.js +16 -12
- package/dist/peer-record.js.map +1 -1
- package/dist/pending-join-runner.d.ts +66 -58
- package/dist/pending-join-runner.js +136 -145
- package/dist/pending-join-runner.js.map +1 -1
- package/dist/push-fanout.d.ts +8 -2
- package/dist/push-fanout.js +8 -2
- package/dist/push-fanout.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +147 -122
- package/dist/seed-bootstrap.js +284 -269
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +98 -37
- package/dist/seed-trust-policy.js +188 -2
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +24 -26
- package/dist/strand-addr-protocol.js +19 -26
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-formation-protocol.js +1 -1
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-revocation-enforcer.d.ts +11 -11
- package/dist/strand-revocation-enforcer.js +11 -11
- package/dist/strand-wake-protocol.d.ts +13 -16
- package/dist/strand-wake-protocol.js +13 -20
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/trusted-owner-store.d.ts +35 -5
- package/dist/trusted-owner-store.js +50 -8
- package/dist/trusted-owner-store.js.map +1 -1
- package/dist/types.d.ts +302 -87
- package/dist/types.js.map +1 -1
- package/package.json +8 -7
- package/src/cadre-invite-protocol.ts +864 -0
- package/src/cadre-node.ts +9381 -8686
- package/src/claim-proof.ts +147 -0
- package/src/control-authorization.ts +203 -117
- package/src/control-cohort.ts +1 -1
- package/src/control-database.ts +1216 -218
- package/src/control-protocol-guard.ts +348 -0
- package/src/control-schema.ts +812 -1078
- package/src/control-stream.ts +26 -7
- package/src/delegate-admission.ts +1 -1
- package/src/device-token.ts +14 -10
- package/src/ed25519-key.ts +28 -1
- package/src/index.ts +84 -10
- package/src/link-budget.ts +16 -32
- package/src/membership-connection-gater.ts +637 -570
- package/src/node-claim-payload.ts +125 -0
- package/src/owner-anchor-sync.ts +171 -0
- package/src/peer-authorization.ts +258 -29
- package/src/peer-dial.ts +22 -1
- package/src/peer-join-backfill.ts +56 -11
- package/src/peer-record.ts +16 -11
- package/src/pending-join-runner.ts +729 -735
- package/src/push-fanout.ts +8 -2
- package/src/seed-bootstrap.ts +344 -321
- package/src/seed-trust-policy.ts +353 -140
- package/src/strand-addr-protocol.ts +26 -35
- package/src/strand-formation-protocol.ts +1 -1
- package/src/strand-revocation-enforcer.ts +11 -11
- package/src/strand-wake-protocol.ts +13 -23
- package/src/trusted-owner-store.ts +242 -171
- package/src/types.ts +2471 -2242
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The pending-join retry loop: carries on a join this party asked for through another party's
|
|
3
|
-
* invitation (`CadreControl.
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
6
|
-
* inviter is offline".
|
|
3
|
+
* invitation (`CadreControl.JoinRequest`, one row per invitation, with its outcome in
|
|
4
|
+
* `JoinSuccess` / `JoinFailure`) in the background, on every owner machine of the party, until
|
|
5
|
+
* the join works, the invitation is used up or expires, or the user dismisses it. The behaviour
|
|
6
|
+
* is described in `docs/strands.md` → "Joining while the inviter is offline".
|
|
7
7
|
*
|
|
8
8
|
* `CadreNode` owns every side effect and hands this module plain functions
|
|
9
|
-
* ({@link PendingJoinRunnerDeps}): read the
|
|
9
|
+
* ({@link PendingJoinRunnerDeps}): read the joins, run one attempt (its own `formStrand`), write
|
|
10
10
|
* an outcome, say whether this machine is an owner, and the clock. This module owns the policy:
|
|
11
11
|
* when to attempt, how to read an attempt's failure, and how to settle an outcome write that
|
|
12
12
|
* another machine's write beat.
|
|
13
13
|
*
|
|
14
|
-
* Only owner machines run it, because every
|
|
14
|
+
* Only owner machines run it, because every pending-join write is owner-signed. Letting a
|
|
15
15
|
* machine without an owner key finish a join is `blocked/decide-non-owner-machine-completes-a-pending-join`;
|
|
16
16
|
* that changes {@link PendingJoinRunnerDeps.isOwner} and the write functions, not this loop.
|
|
17
17
|
*/
|
|
@@ -28,7 +28,7 @@ export const PENDING_JOIN_POLL_MS = 30000;
|
|
|
28
28
|
/** Ceiling on the wait between two attempts of one join on one machine, before jitter. */
|
|
29
29
|
export const PENDING_JOIN_MAX_BACKOFF_MS = 10 * 60000;
|
|
30
30
|
/**
|
|
31
|
-
* The longest a join is tried for. A
|
|
31
|
+
* The longest a join is tried for. A request's `ExpiresAt` is the earlier of this and the
|
|
32
32
|
* invitation's own expiration, which the inviter chose.
|
|
33
33
|
*/
|
|
34
34
|
export const MAX_PENDING_JOIN_MS = 30 * 24 * 3600000;
|
|
@@ -40,21 +40,21 @@ const RETRY_JITTER = 0.2;
|
|
|
40
40
|
const MAX_OUTCOME_WRITE_ROUNDS = 3;
|
|
41
41
|
/**
|
|
42
42
|
* A `joined` outcome replaces anything still live, since a join that happened cannot be undone
|
|
43
|
-
* by another machine's failure. A failure replaces a pending
|
|
44
|
-
* re-issue of a
|
|
45
|
-
*
|
|
43
|
+
* by another machine's failure. A failure replaces a pending request of the same ask: only a
|
|
44
|
+
* re-issue of a request written alone writes one, unchanged, so it decides nothing. Any other
|
|
45
|
+
* live join wins, and a fresh request is then decided again by the loop.
|
|
46
46
|
*/
|
|
47
|
-
function resolveLostWrite(live, next) {
|
|
47
|
+
function resolveLostWrite(live, expected, next) {
|
|
48
48
|
if (live === null)
|
|
49
49
|
return 'gone';
|
|
50
|
-
if (live.
|
|
50
|
+
if (live.outcome?.kind === 'joined')
|
|
51
51
|
return 'adopt';
|
|
52
|
-
if (next.
|
|
52
|
+
if (next.kind === 'joined')
|
|
53
53
|
return 'rewrite';
|
|
54
|
-
return live.
|
|
54
|
+
return live.outcome === null && live.RequestedAt === expected.RequestedAt ? 'rewrite' : 'adopt';
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
|
-
* The
|
|
57
|
+
* The request a `requestJoin` writes: tried until the invitation's own expiration or
|
|
58
58
|
* {@link MAX_PENDING_JOIN_MS} from now, whichever comes first.
|
|
59
59
|
*/
|
|
60
60
|
export function requestedPendingJoin(id, encodedInvitation, disclosure, invitationExpiresAt, now) {
|
|
@@ -64,23 +64,17 @@ export function requestedPendingJoin(id, encodedInvitation, disclosure, invitati
|
|
|
64
64
|
Disclosure: canonicalJson(disclosure),
|
|
65
65
|
RequestedAt: now,
|
|
66
66
|
ExpiresAt: Math.min(invitationExpiresAt, now + MAX_PENDING_JOIN_MS),
|
|
67
|
-
Outcome: null,
|
|
68
|
-
OutcomeAt: null,
|
|
69
|
-
StrandId: null,
|
|
70
|
-
MembershipInvite: null,
|
|
71
|
-
FailureCode: null,
|
|
72
|
-
FailureReason: null,
|
|
73
67
|
};
|
|
74
68
|
}
|
|
75
|
-
/** A
|
|
76
|
-
export function parseStoredDisclosure(
|
|
77
|
-
const parsed = JSON.parse(
|
|
69
|
+
/** A request's stored disclosure, as the next attempt sends it. Owner-signed, so anything but an object is a bug. */
|
|
70
|
+
export function parseStoredDisclosure(join) {
|
|
71
|
+
const parsed = JSON.parse(join.Disclosure);
|
|
78
72
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
79
|
-
throw new Error(`
|
|
73
|
+
throw new Error(`JoinRequest ${join.Id}: the stored disclosure is not a JSON object`);
|
|
80
74
|
}
|
|
81
75
|
return parsed;
|
|
82
76
|
}
|
|
83
|
-
/** A `
|
|
77
|
+
/** A `JoinSuccess` row's `MembershipInvite` column, or null when it is not the `{inviteKey, invitePrivateKey}` JSON the loop writes. */
|
|
84
78
|
export function parseStoredMembershipInvite(text) {
|
|
85
79
|
let parsed;
|
|
86
80
|
try {
|
|
@@ -95,61 +89,51 @@ export function parseStoredMembershipInvite(text) {
|
|
|
95
89
|
return typeof inviteKey === 'string' && typeof invitePrivateKey === 'string' ? { inviteKey, invitePrivateKey } : null;
|
|
96
90
|
}
|
|
97
91
|
/**
|
|
98
|
-
* The membership invitations
|
|
92
|
+
* The membership invitations joined requests carry that this process has not staged yet, so a
|
|
99
93
|
* closed strand joined on one owner machine can be seated by whichever machine launches it
|
|
100
|
-
* first.
|
|
94
|
+
* first. Joins older than {@link MEMBERSHIP_INVITE_TTL_MS} are skipped: their invitation is dead.
|
|
101
95
|
* `alreadyStaged` holds invite keys; a malformed column is skipped and logged.
|
|
102
96
|
*/
|
|
103
|
-
export function membershipInvitesToStage(
|
|
97
|
+
export function membershipInvitesToStage(joins, alreadyStaged, now) {
|
|
104
98
|
const toStage = [];
|
|
105
|
-
for (const
|
|
106
|
-
|
|
99
|
+
for (const join of joins) {
|
|
100
|
+
const { outcome } = join;
|
|
101
|
+
if (outcome?.kind !== 'joined' || outcome.MembershipInvite === null)
|
|
107
102
|
continue;
|
|
108
|
-
if (now >=
|
|
103
|
+
if (now >= outcome.RecordedAt + MEMBERSHIP_INVITE_TTL_MS)
|
|
109
104
|
continue;
|
|
110
|
-
const invite = parseStoredMembershipInvite(
|
|
105
|
+
const invite = parseStoredMembershipInvite(outcome.MembershipInvite);
|
|
111
106
|
if (!invite) {
|
|
112
|
-
log('pending join %s: its MembershipInvite column does not parse; not staged',
|
|
107
|
+
log('pending join %s: its MembershipInvite column does not parse; not staged', join.Id);
|
|
113
108
|
continue;
|
|
114
109
|
}
|
|
115
110
|
if (!alreadyStaged.has(invite.inviteKey)) {
|
|
116
|
-
toStage.push({ rowId:
|
|
111
|
+
toStage.push({ rowId: join.Id, strandId: outcome.StrandId, invite });
|
|
117
112
|
}
|
|
118
113
|
}
|
|
119
114
|
return toStage;
|
|
120
115
|
}
|
|
121
|
-
/** The
|
|
122
|
-
function fieldsOf(
|
|
123
|
-
const { StampId: _stampId, ...fields } =
|
|
116
|
+
/** The join without its stamp, as a rewrite takes it. */
|
|
117
|
+
function fieldsOf(join) {
|
|
118
|
+
const { StampId: _stampId, ...fields } = join;
|
|
124
119
|
return fields;
|
|
125
120
|
}
|
|
126
|
-
function
|
|
121
|
+
function joinedOutcome(result, now) {
|
|
127
122
|
const invite = result.membershipInvite;
|
|
128
123
|
return {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
OutcomeAt: now,
|
|
124
|
+
kind: 'joined',
|
|
125
|
+
RecordedAt: now,
|
|
132
126
|
StrandId: result.strandId,
|
|
133
127
|
MembershipInvite: invite ? JSON.stringify({ inviteKey: invite.inviteKey, invitePrivateKey: invite.invitePrivateKey }) : null,
|
|
134
|
-
FailureCode: null,
|
|
135
|
-
FailureReason: null,
|
|
136
128
|
};
|
|
137
129
|
}
|
|
138
|
-
function
|
|
139
|
-
return {
|
|
140
|
-
...fieldsOf(row),
|
|
141
|
-
Outcome: 'failed',
|
|
142
|
-
OutcomeAt: now,
|
|
143
|
-
StrandId: null,
|
|
144
|
-
MembershipInvite: null,
|
|
145
|
-
FailureCode: code,
|
|
146
|
-
FailureReason: reason,
|
|
147
|
-
};
|
|
130
|
+
function failedOutcome(code, reason, now) {
|
|
131
|
+
return { kind: 'failed', RecordedAt: now, Code: code, Reason: reason };
|
|
148
132
|
}
|
|
149
133
|
function errorText(error) {
|
|
150
134
|
return error instanceof Error ? error.message : String(error);
|
|
151
135
|
}
|
|
152
|
-
/** A fraction in [0, 1) fixed by `key` (32-bit FNV-1a): the same machine staggers the same
|
|
136
|
+
/** A fraction in [0, 1) fixed by `key` (32-bit FNV-1a): the same machine staggers the same join the same way every time. */
|
|
153
137
|
function stableFraction(key) {
|
|
154
138
|
let hash = 0x811c9dc5;
|
|
155
139
|
for (let i = 0; i < key.length; i++) {
|
|
@@ -162,7 +146,7 @@ export class PendingJoinRunner {
|
|
|
162
146
|
constructor(deps) {
|
|
163
147
|
this.deps = deps;
|
|
164
148
|
this.tracked = new Map();
|
|
165
|
-
/**
|
|
149
|
+
/** Joins whose latest write by this machine committed with no control connection: id → the request stamp written. */
|
|
166
150
|
this.writtenAlone = new Map();
|
|
167
151
|
this.dueQueue = [];
|
|
168
152
|
this.running = false;
|
|
@@ -187,8 +171,8 @@ export class PendingJoinRunner {
|
|
|
187
171
|
* Stop scheduling. Clears every timer and starts no new attempt.
|
|
188
172
|
*
|
|
189
173
|
* NOTE: an attempt already in flight is not awaited, because `formStrand` takes no abort
|
|
190
|
-
* signal. An approval that lands while the node stops can lose its local records; the
|
|
191
|
-
* stays pending, and the next start's attempt answers `token-spent`, which fails
|
|
174
|
+
* signal. An approval that lands while the node stops can lose its local records; the request
|
|
175
|
+
* then stays pending, and the next start's attempt answers `token-spent`, which fails it after
|
|
192
176
|
* its confirming attempt. Honest and bounded; if it shows up in practice, give `formStrand` a
|
|
193
177
|
* signal and abort it here.
|
|
194
178
|
*/
|
|
@@ -209,44 +193,49 @@ export class PendingJoinRunner {
|
|
|
209
193
|
void this.pass();
|
|
210
194
|
}
|
|
211
195
|
/**
|
|
212
|
-
* Try `
|
|
213
|
-
* after the attempt. Joins an attempt already running for
|
|
196
|
+
* Try `join` on this machine at once, outside the background slots, and report the status
|
|
197
|
+
* after the attempt. Joins an attempt already running for it.
|
|
214
198
|
*/
|
|
215
|
-
async attemptNow(
|
|
216
|
-
const entry = this.adopt(
|
|
217
|
-
if (entry.row.
|
|
199
|
+
async attemptNow(join) {
|
|
200
|
+
const entry = this.adopt(join);
|
|
201
|
+
if (entry.row.outcome === null) {
|
|
218
202
|
await this.startAttempt(entry);
|
|
219
203
|
}
|
|
220
204
|
return this.describe(entry.row, entry);
|
|
221
205
|
}
|
|
222
|
-
/** This machine's view of `
|
|
223
|
-
statusOf(
|
|
224
|
-
return this.describe(
|
|
206
|
+
/** This machine's view of `join`: its own state, with this machine's attempt state laid over a pending one. */
|
|
207
|
+
statusOf(join) {
|
|
208
|
+
return this.describe(join, this.tracked.get(join.Id));
|
|
225
209
|
}
|
|
226
|
-
/**
|
|
227
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Record a pending-join write this machine made, for {@link reissueWritesMadeAlone}. A write
|
|
212
|
+
* made alone marks the join. A request write made connected clears the mark, since it
|
|
213
|
+
* carried the request; an outcome write made connected does not, since the request it names
|
|
214
|
+
* may still be unknown to the other machines.
|
|
215
|
+
*/
|
|
216
|
+
noteWritten(join, write) {
|
|
228
217
|
if (this.deps.isAlone()) {
|
|
229
|
-
this.writtenAlone.set(
|
|
218
|
+
this.writtenAlone.set(join.Id, join.StampId);
|
|
230
219
|
}
|
|
231
|
-
else {
|
|
232
|
-
this.writtenAlone.delete(
|
|
220
|
+
else if (write === 'request') {
|
|
221
|
+
this.writtenAlone.delete(join.Id);
|
|
233
222
|
}
|
|
234
223
|
}
|
|
235
|
-
/** Stop tracking a
|
|
224
|
+
/** Stop tracking a join the user dismissed. An attempt in flight finishes; its outcome write then finds the request gone. */
|
|
236
225
|
forgetRow(id) {
|
|
237
226
|
const entry = this.tracked.get(id);
|
|
238
227
|
if (entry)
|
|
239
228
|
this.forget(entry);
|
|
240
229
|
}
|
|
241
230
|
/**
|
|
242
|
-
* Re-write, with identical content under a fresh stamp, each
|
|
231
|
+
* Re-write, with identical content under a fresh stamp, each join this machine last wrote while
|
|
243
232
|
* it had no control connection, so the other machines receive it. Called on the control
|
|
244
|
-
* connection growth edge. A
|
|
233
|
+
* connection growth edge. A join someone wrote since is left alone.
|
|
245
234
|
*
|
|
246
|
-
* NOTE: in memory only, so a
|
|
247
|
-
* reaches the party only with that
|
|
235
|
+
* NOTE: in memory only, so a join written alone by a process that stopped before it reconnected
|
|
236
|
+
* reaches the party only with that join's next write. A sweep of every join this machine wrote,
|
|
248
237
|
* on the first connection after start, would cover that, at the cost of one permanent
|
|
249
|
-
* `Revocation` tombstone per
|
|
238
|
+
* `Revocation` tombstone per join per start. Revisit if other machines are seen missing such joins.
|
|
250
239
|
*/
|
|
251
240
|
async reissueWritesMadeAlone() {
|
|
252
241
|
if (this.writtenAlone.size === 0)
|
|
@@ -262,15 +251,15 @@ export class PendingJoinRunner {
|
|
|
262
251
|
this.writtenAlone.delete(id);
|
|
263
252
|
continue;
|
|
264
253
|
}
|
|
265
|
-
const written = await this.deps.
|
|
266
|
-
this.noteWritten(written);
|
|
254
|
+
const written = await this.deps.rewrite(stampId, fieldsOf(live));
|
|
255
|
+
this.noteWritten(written, 'request');
|
|
267
256
|
const entry = this.tracked.get(id);
|
|
268
257
|
if (entry?.row.StampId === stampId)
|
|
269
258
|
entry.row = written;
|
|
270
|
-
log('pending join %s: re-issued the
|
|
259
|
+
log('pending join %s: re-issued the join written while alone', id);
|
|
271
260
|
}
|
|
272
261
|
catch (error) {
|
|
273
|
-
log('pending join %s: re-issuing the
|
|
262
|
+
log('pending join %s: re-issuing the join written while alone failed; kept for the next connection: %s', id, errorText(error));
|
|
274
263
|
}
|
|
275
264
|
}
|
|
276
265
|
}
|
|
@@ -317,23 +306,23 @@ export class PendingJoinRunner {
|
|
|
317
306
|
this.pauseAttempts();
|
|
318
307
|
return;
|
|
319
308
|
}
|
|
320
|
-
const
|
|
309
|
+
const joins = await this.deps.readRows();
|
|
321
310
|
if (!this.running)
|
|
322
311
|
return;
|
|
323
|
-
this.deps.observeRows(
|
|
324
|
-
const live = new Set(
|
|
312
|
+
this.deps.observeRows(joins);
|
|
313
|
+
const live = new Set(joins.map((join) => join.Id));
|
|
325
314
|
for (const entry of [...this.tracked.values()]) {
|
|
326
315
|
if (!live.has(entry.row.Id) && !entry.attempt)
|
|
327
316
|
this.forget(entry);
|
|
328
317
|
}
|
|
329
|
-
for (const
|
|
318
|
+
for (const join of joins) {
|
|
330
319
|
if (!this.running)
|
|
331
320
|
return;
|
|
332
321
|
try {
|
|
333
|
-
await this.consider(
|
|
322
|
+
await this.consider(join);
|
|
334
323
|
}
|
|
335
324
|
catch (error) {
|
|
336
|
-
log('pending join %s: this pass could not handle it; the next one retries: %s',
|
|
325
|
+
log('pending join %s: this pass could not handle it; the next one retries: %s', join.Id, errorText(error));
|
|
337
326
|
}
|
|
338
327
|
}
|
|
339
328
|
}
|
|
@@ -347,12 +336,12 @@ export class PendingJoinRunner {
|
|
|
347
336
|
entry.queued = false;
|
|
348
337
|
this.dueQueue.length = 0;
|
|
349
338
|
}
|
|
350
|
-
/** Bring one read
|
|
351
|
-
async consider(
|
|
352
|
-
const entry = this.adopt(
|
|
339
|
+
/** Bring one read join into this machine's schedule. */
|
|
340
|
+
async consider(join) {
|
|
341
|
+
const entry = this.adopt(join);
|
|
353
342
|
if (entry.attempt)
|
|
354
343
|
return;
|
|
355
|
-
if (entry.row.
|
|
344
|
+
if (entry.row.outcome !== null) {
|
|
356
345
|
this.emitIfChanged(entry);
|
|
357
346
|
await this.ageOut(entry);
|
|
358
347
|
return;
|
|
@@ -367,28 +356,28 @@ export class PendingJoinRunner {
|
|
|
367
356
|
this.emitIfChanged(entry);
|
|
368
357
|
}
|
|
369
358
|
/**
|
|
370
|
-
* The tracked entry for `
|
|
371
|
-
* starts this machine's counting over; a finished
|
|
359
|
+
* The tracked entry for `join`, updated to it. A join asked for again (`RequestedAt` changed)
|
|
360
|
+
* starts this machine's counting over; a finished join stops its schedule.
|
|
372
361
|
*/
|
|
373
|
-
adopt(
|
|
374
|
-
const entry = this.tracked.get(
|
|
362
|
+
adopt(join) {
|
|
363
|
+
const entry = this.tracked.get(join.Id);
|
|
375
364
|
if (!entry) {
|
|
376
|
-
const fresh = { row, failures: 0, queued: false, trying: false, spentOnce: false };
|
|
377
|
-
this.tracked.set(
|
|
365
|
+
const fresh = { row: join, failures: 0, queued: false, trying: false, spentOnce: false };
|
|
366
|
+
this.tracked.set(join.Id, fresh);
|
|
378
367
|
return fresh;
|
|
379
368
|
}
|
|
380
|
-
if (entry.row.StampId === row.
|
|
369
|
+
if (entry.row.StampId === join.StampId && entry.row.outcome?.kind === join.outcome?.kind)
|
|
381
370
|
return entry;
|
|
382
|
-
if (
|
|
371
|
+
if (join.outcome !== null || entry.row.RequestedAt !== join.RequestedAt) {
|
|
383
372
|
this.cancelTimer(entry);
|
|
384
373
|
entry.failures = 0;
|
|
385
374
|
entry.spentOnce = false;
|
|
386
375
|
entry.lastError = undefined;
|
|
387
376
|
entry.nextAttemptAt = undefined;
|
|
388
377
|
}
|
|
389
|
-
if (
|
|
378
|
+
if (join.outcome?.kind === 'joined')
|
|
390
379
|
entry.joinedHere = undefined;
|
|
391
|
-
entry.row =
|
|
380
|
+
entry.row = join;
|
|
392
381
|
return entry;
|
|
393
382
|
}
|
|
394
383
|
scheduleAttempt(entry, delayMs) {
|
|
@@ -396,8 +385,8 @@ export class PendingJoinRunner {
|
|
|
396
385
|
return;
|
|
397
386
|
this.cancelTimer(entry);
|
|
398
387
|
const now = this.now();
|
|
399
|
-
// A
|
|
400
|
-
// and clamping to a past expiry would retry a failing write with no wait at all.
|
|
388
|
+
// A request already past its expiry keeps the full delay: its due attempt is an outcome
|
|
389
|
+
// write, and clamping to a past expiry would retry a failing write with no wait at all.
|
|
401
390
|
const at = now < entry.row.ExpiresAt ? Math.min(now + delayMs, entry.row.ExpiresAt) : now + delayMs;
|
|
402
391
|
entry.nextAttemptAt = at;
|
|
403
392
|
entry.timer = this.scheduler.setTimeout(() => {
|
|
@@ -448,7 +437,7 @@ export class PendingJoinRunner {
|
|
|
448
437
|
.catch((error) => {
|
|
449
438
|
entry.trying = false;
|
|
450
439
|
log('pending join %s: attempt failed on this machine: %s', entry.row.Id, errorText(error));
|
|
451
|
-
if (entry.row.
|
|
440
|
+
if (entry.row.outcome === null && this.tracked.get(entry.row.Id) === entry) {
|
|
452
441
|
this.retryLater(entry, { code: 'local', reason: errorText(error) });
|
|
453
442
|
}
|
|
454
443
|
})
|
|
@@ -459,7 +448,7 @@ export class PendingJoinRunner {
|
|
|
459
448
|
return run;
|
|
460
449
|
}
|
|
461
450
|
async attemptOnce(entry) {
|
|
462
|
-
if (entry.row.
|
|
451
|
+
if (entry.row.outcome !== null)
|
|
463
452
|
return;
|
|
464
453
|
if (entry.joinedHere) {
|
|
465
454
|
await this.writeOutcome(entry, entry.joinedHere);
|
|
@@ -467,7 +456,7 @@ export class PendingJoinRunner {
|
|
|
467
456
|
}
|
|
468
457
|
if (this.now() >= entry.row.ExpiresAt) {
|
|
469
458
|
const reason = `No attempt starts after ${new Date(entry.row.ExpiresAt).toISOString()}`;
|
|
470
|
-
await this.writeOutcome(entry,
|
|
459
|
+
await this.writeOutcome(entry, failedOutcome('expired', reason, this.now()));
|
|
471
460
|
return;
|
|
472
461
|
}
|
|
473
462
|
entry.trying = true;
|
|
@@ -484,22 +473,22 @@ export class PendingJoinRunner {
|
|
|
484
473
|
}
|
|
485
474
|
entry.trying = false;
|
|
486
475
|
log('pending join %s: approved, strand %s', entry.row.Id, result.strandId);
|
|
487
|
-
entry.joinedHere =
|
|
476
|
+
entry.joinedHere = joinedOutcome(result, this.now());
|
|
488
477
|
await this.writeOutcome(entry, entry.joinedHere);
|
|
489
478
|
}
|
|
490
|
-
/** Read a failed attempt: retry, confirm a spent token, or fail the
|
|
479
|
+
/** Read a failed attempt: retry, confirm a spent token, or fail the join. */
|
|
491
480
|
async onAttemptFailed(entry, error) {
|
|
492
481
|
const now = this.now();
|
|
493
482
|
if (error instanceof FormationRejectedError && error.code === 'token-spent') {
|
|
494
483
|
await this.onSpent(entry, error);
|
|
495
484
|
}
|
|
496
485
|
else if (error instanceof FormationPostApprovalError) {
|
|
497
|
-
log('pending join %s: approved, then a step on this machine failed; failing the
|
|
498
|
-
await this.writeOutcome(entry,
|
|
486
|
+
log('pending join %s: approved, then a step on this machine failed; failing the join', entry.row.Id);
|
|
487
|
+
await this.writeOutcome(entry, failedOutcome('local', error.message, now));
|
|
499
488
|
}
|
|
500
489
|
else if (error instanceof FormationRejectedError && !error.retryable) {
|
|
501
|
-
log('pending join %s: refused (%s); failing the
|
|
502
|
-
await this.writeOutcome(entry,
|
|
490
|
+
log('pending join %s: refused (%s); failing the join', entry.row.Id, error.code);
|
|
491
|
+
await this.writeOutcome(entry, failedOutcome(error.code, error.reason, now));
|
|
503
492
|
}
|
|
504
493
|
else if (error instanceof FormationRejectedError) {
|
|
505
494
|
this.retryLater(entry, { code: error.code, reason: error.reason });
|
|
@@ -508,15 +497,15 @@ export class PendingJoinRunner {
|
|
|
508
497
|
this.retryLater(entry, { code: 'unreachable', reason: error.message });
|
|
509
498
|
}
|
|
510
499
|
else {
|
|
511
|
-
// Retrying is safe: nothing is attempted after the
|
|
500
|
+
// Retrying is safe: nothing is attempted after the request's expiry.
|
|
512
501
|
log('pending join %s: attempt threw an unexpected error; retrying: %s', entry.row.Id, errorText(error));
|
|
513
502
|
this.retryLater(entry, { code: 'local', reason: errorText(error) });
|
|
514
503
|
}
|
|
515
504
|
}
|
|
516
505
|
/**
|
|
517
506
|
* A `token-spent` answer may mean another owner machine of this party won the same
|
|
518
|
-
* invitation. Adopt its outcome if the
|
|
519
|
-
* long enough for that machine's `joined` write to arrive, before failing the
|
|
507
|
+
* invitation. Adopt its outcome if the join already shows one; otherwise confirm once, after
|
|
508
|
+
* long enough for that machine's `joined` write to arrive, before failing the join.
|
|
520
509
|
*/
|
|
521
510
|
async onSpent(entry, error) {
|
|
522
511
|
const live = await this.deps.readRow(entry.row.Id);
|
|
@@ -525,13 +514,13 @@ export class PendingJoinRunner {
|
|
|
525
514
|
return;
|
|
526
515
|
}
|
|
527
516
|
this.adopt(live);
|
|
528
|
-
if (live.
|
|
517
|
+
if (live.outcome !== null) {
|
|
529
518
|
this.emitIfChanged(entry);
|
|
530
519
|
return;
|
|
531
520
|
}
|
|
532
521
|
if (entry.spentOnce) {
|
|
533
|
-
log('pending join %s: token spent again after the confirming wait; failing the
|
|
534
|
-
await this.writeOutcome(entry,
|
|
522
|
+
log('pending join %s: token spent again after the confirming wait; failing the join', entry.row.Id);
|
|
523
|
+
await this.writeOutcome(entry, failedOutcome('token-spent', error.reason, this.now()));
|
|
535
524
|
return;
|
|
536
525
|
}
|
|
537
526
|
entry.spentOnce = true;
|
|
@@ -551,29 +540,30 @@ export class PendingJoinRunner {
|
|
|
551
540
|
return Math.round(capped * (1 + (this.random() * 2 - 1) * RETRY_JITTER));
|
|
552
541
|
}
|
|
553
542
|
/**
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
* recreated; a join this machine made is still remembered machine-locally, as
|
|
557
|
-
* `formStrand` is.
|
|
543
|
+
* Record `next` on the join this machine last read. When another write got there first, settle
|
|
544
|
+
* by {@link resolveLostWrite} from the live join the refusal carries. A join the user dismissed
|
|
545
|
+
* meanwhile is not recreated; a join this machine made is still remembered machine-locally, as
|
|
546
|
+
* every `formStrand` join is.
|
|
558
547
|
*/
|
|
559
548
|
async writeOutcome(entry, next) {
|
|
560
549
|
let expected = entry.row;
|
|
561
550
|
for (let round = 0; round < MAX_OUTCOME_WRITE_ROUNDS; round++) {
|
|
551
|
+
let live;
|
|
562
552
|
try {
|
|
563
|
-
const written = await this.deps.
|
|
564
|
-
this.noteWritten(written);
|
|
553
|
+
const written = await this.deps.record(expected, next);
|
|
554
|
+
this.noteWritten(written, 'outcome');
|
|
565
555
|
this.settle(written);
|
|
566
|
-
log('pending join %s: recorded %s
|
|
556
|
+
log('pending join %s: recorded %s', entry.row.Id, next.kind === 'failed' ? `failed (${next.Code})` : 'joined');
|
|
567
557
|
return;
|
|
568
558
|
}
|
|
569
559
|
catch (error) {
|
|
570
560
|
if (!(error instanceof PendingJoinChangedError))
|
|
571
561
|
throw error;
|
|
562
|
+
live = error.live;
|
|
572
563
|
}
|
|
573
|
-
const
|
|
574
|
-
const resolution = resolveLostWrite(live, next);
|
|
564
|
+
const resolution = resolveLostWrite(live, expected, next);
|
|
575
565
|
if (resolution === 'gone') {
|
|
576
|
-
log('pending join %s: dismissed while this machine was recording %s; not recreated',
|
|
566
|
+
log('pending join %s: dismissed while this machine was recording %s; not recreated', entry.row.Id, next.kind);
|
|
577
567
|
this.forget(entry);
|
|
578
568
|
return;
|
|
579
569
|
}
|
|
@@ -583,16 +573,16 @@ export class PendingJoinRunner {
|
|
|
583
573
|
}
|
|
584
574
|
expected = live;
|
|
585
575
|
}
|
|
586
|
-
log('pending join %s: another machine kept
|
|
576
|
+
log('pending join %s: another machine kept changing the join; the next pass decides', entry.row.Id);
|
|
587
577
|
}
|
|
588
|
-
settle(
|
|
589
|
-
const entry = this.adopt(
|
|
590
|
-
if (
|
|
578
|
+
settle(join) {
|
|
579
|
+
const entry = this.adopt(join);
|
|
580
|
+
if (join.outcome !== null)
|
|
591
581
|
entry.joinedHere = undefined;
|
|
592
582
|
this.emitIfChanged(entry);
|
|
593
583
|
}
|
|
594
584
|
/**
|
|
595
|
-
* Remove a finished
|
|
585
|
+
* Remove a finished join {@link MEMBERSHIP_INVITE_TTL_MS} after its outcome: the membership
|
|
596
586
|
* invitation it carries is dead by then, and an app that was not running has seen the strand
|
|
597
587
|
* through `strand:discovered`. Only while connected, like every write nothing waits on.
|
|
598
588
|
*
|
|
@@ -601,8 +591,8 @@ export class PendingJoinRunner {
|
|
|
601
591
|
* user asks again; revisit if that is ever seen.
|
|
602
592
|
*/
|
|
603
593
|
async ageOut(entry) {
|
|
604
|
-
const
|
|
605
|
-
if (
|
|
594
|
+
const recordedAt = entry.row.outcome?.RecordedAt;
|
|
595
|
+
if (recordedAt === undefined || this.now() < recordedAt + MEMBERSHIP_INVITE_TTL_MS || this.deps.isAlone())
|
|
606
596
|
return;
|
|
607
597
|
await this.deps.remove(entry.row.Id);
|
|
608
598
|
log('pending join %s: removed, %d days after its outcome', entry.row.Id, MEMBERSHIP_INVITE_TTL_MS / (24 * 3600000));
|
|
@@ -623,20 +613,21 @@ export class PendingJoinRunner {
|
|
|
623
613
|
entry.lastEmitted = key;
|
|
624
614
|
this.deps.emit(status);
|
|
625
615
|
}
|
|
626
|
-
describe(
|
|
616
|
+
describe(join, entry) {
|
|
627
617
|
const base = {
|
|
628
|
-
id:
|
|
629
|
-
sAppId: this.deps.sAppIdOf(
|
|
630
|
-
requestedAt:
|
|
631
|
-
expiresAt:
|
|
618
|
+
id: join.Id,
|
|
619
|
+
sAppId: this.deps.sAppIdOf(join),
|
|
620
|
+
requestedAt: join.RequestedAt,
|
|
621
|
+
expiresAt: join.ExpiresAt,
|
|
632
622
|
};
|
|
633
|
-
|
|
634
|
-
|
|
623
|
+
const { outcome } = join;
|
|
624
|
+
if (outcome?.kind === 'joined') {
|
|
625
|
+
return { ...base, state: 'joined', strandId: outcome.StrandId };
|
|
635
626
|
}
|
|
636
|
-
if (
|
|
637
|
-
return { ...base, state: 'failed', failure: { code:
|
|
627
|
+
if (outcome?.kind === 'failed') {
|
|
628
|
+
return { ...base, state: 'failed', failure: { code: outcome.Code, reason: outcome.Reason } };
|
|
638
629
|
}
|
|
639
|
-
const local = entry && entry.row.
|
|
630
|
+
const local = entry && entry.row.outcome === null && entry.row.RequestedAt === join.RequestedAt ? entry : undefined;
|
|
640
631
|
if (local?.trying) {
|
|
641
632
|
return { ...base, state: 'trying' };
|
|
642
633
|
}
|