@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,735 +1,729 @@
|
|
|
1
|
-
/**
|
|
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".
|
|
7
|
-
*
|
|
8
|
-
* `CadreNode` owns every side effect and hands this module plain functions
|
|
9
|
-
* ({@link PendingJoinRunnerDeps}): read the
|
|
10
|
-
* an outcome, say whether this machine is an owner, and the clock. This module owns the policy:
|
|
11
|
-
* when to attempt, how to read an attempt's failure, and how to settle an outcome write that
|
|
12
|
-
* another machine's write beat.
|
|
13
|
-
*
|
|
14
|
-
* Only owner machines run it, because every
|
|
15
|
-
* machine without an owner key finish a join is `blocked/decide-non-owner-machine-completes-a-pending-join`;
|
|
16
|
-
* that changes {@link PendingJoinRunnerDeps.isOwner} and the write functions, not this loop.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import debug from 'debug';
|
|
20
|
-
import type {
|
|
21
|
-
FormStrandResult,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
linkRoundTripMs
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
attempt(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
attempt
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
*
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
*
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
private
|
|
247
|
-
|
|
248
|
-
private
|
|
249
|
-
|
|
250
|
-
private readonly
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
this.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
this.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
this.
|
|
373
|
-
|
|
374
|
-
this.pollTimer = undefined;
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
private
|
|
380
|
-
if (this.
|
|
381
|
-
this.
|
|
382
|
-
this.
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
this.
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
this.
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
if (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
entry.
|
|
492
|
-
|
|
493
|
-
entry.timer = undefined;
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
private
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
this.
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
await this.
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
if (error instanceof FormationRejectedError
|
|
584
|
-
|
|
585
|
-
} else if (error instanceof
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
log('pending join %s:
|
|
590
|
-
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
entry.
|
|
624
|
-
entry
|
|
625
|
-
this.
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
private
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
if (
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
if (local?.lastError && local.nextAttemptAt !== undefined) {
|
|
731
|
-
return { ...base, state: 'waiting', nextAttemptAt: local.nextAttemptAt, lastError: local.lastError };
|
|
732
|
-
}
|
|
733
|
-
return { ...base, state: 'pending' };
|
|
734
|
-
}
|
|
735
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The pending-join retry loop: carries on a join this party asked for through another party's
|
|
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
|
+
*
|
|
8
|
+
* `CadreNode` owns every side effect and hands this module plain functions
|
|
9
|
+
* ({@link PendingJoinRunnerDeps}): read the joins, run one attempt (its own `formStrand`), write
|
|
10
|
+
* an outcome, say whether this machine is an owner, and the clock. This module owns the policy:
|
|
11
|
+
* when to attempt, how to read an attempt's failure, and how to settle an outcome write that
|
|
12
|
+
* another machine's write beat.
|
|
13
|
+
*
|
|
14
|
+
* Only owner machines run it, because every pending-join write is owner-signed. Letting a
|
|
15
|
+
* machine without an owner key finish a join is `blocked/decide-non-owner-machine-completes-a-pending-join`;
|
|
16
|
+
* that changes {@link PendingJoinRunnerDeps.isOwner} and the write functions, not this loop.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import debug from 'debug';
|
|
20
|
+
import type {
|
|
21
|
+
FormStrandResult,
|
|
22
|
+
JoinOutcome,
|
|
23
|
+
PendingJoin,
|
|
24
|
+
PendingJoinStatus,
|
|
25
|
+
StrandFormationDisclosure,
|
|
26
|
+
StrandMembershipInvite
|
|
27
|
+
} from './types.js';
|
|
28
|
+
import { canonicalJson } from './canonical-json.js';
|
|
29
|
+
import { PendingJoinChangedError, type JoinRequestFields, type PendingJoinFields } from './control-database.js';
|
|
30
|
+
import { FormationPostApprovalError, FormationRejectedError, FormationUnreachableError } from './strand-formation-rejection.js';
|
|
31
|
+
import { formationDeadlines } from './strand-formation-deadlines.js';
|
|
32
|
+
import { MEMBERSHIP_INVITE_TTL_MS } from './strand-formation-manager.js';
|
|
33
|
+
import { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
|
|
34
|
+
|
|
35
|
+
const log = debug('sereus:cadre:pending-join');
|
|
36
|
+
|
|
37
|
+
/** How often an owner machine re-reads the party's pending joins (it also reads once right after start). */
|
|
38
|
+
export const PENDING_JOIN_POLL_MS = 30_000;
|
|
39
|
+
|
|
40
|
+
/** Ceiling on the wait between two attempts of one join on one machine, before jitter. */
|
|
41
|
+
export const PENDING_JOIN_MAX_BACKOFF_MS = 10 * 60_000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The longest a join is tried for. A request's `ExpiresAt` is the earlier of this and the
|
|
45
|
+
* invitation's own expiration, which the inviter chose.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_PENDING_JOIN_MS = 30 * 24 * 3600_000;
|
|
48
|
+
|
|
49
|
+
/** Background attempts one machine runs at once. An explicit `requestJoin` does not wait for a slot. */
|
|
50
|
+
const MAX_BACKGROUND_ATTEMPTS = 2;
|
|
51
|
+
|
|
52
|
+
/** Each retry delay is moved by up to this fraction either way, so machines that failed together do not retry together. */
|
|
53
|
+
const RETRY_JITTER = 0.2;
|
|
54
|
+
|
|
55
|
+
/** Re-read-and-rewrite rounds after another machine's write won, before the next pass decides instead. */
|
|
56
|
+
const MAX_OUTCOME_WRITE_ROUNDS = 3;
|
|
57
|
+
|
|
58
|
+
type AttemptError = NonNullable<PendingJoinStatus['lastError']>;
|
|
59
|
+
|
|
60
|
+
/** Which pending-join write this machine made: the request itself (an insert or a rewrite), or an outcome row for it. */
|
|
61
|
+
export type PendingJoinWrite = 'request' | 'outcome';
|
|
62
|
+
|
|
63
|
+
export interface PendingJoinRunnerDeps {
|
|
64
|
+
/** This machine's peer id; with the request id it fixes when this machine first tries a join it did not ask for. */
|
|
65
|
+
selfId: string;
|
|
66
|
+
/** `NetworkConfig.linkRoundTripMs`: the retry pace follows the formation deadlines it yields. */
|
|
67
|
+
linkRoundTripMs?: number;
|
|
68
|
+
/** Whether this machine may write pending joins now. Asked every pass. */
|
|
69
|
+
isOwner(): Promise<boolean>;
|
|
70
|
+
readRows(): Promise<PendingJoin[]>;
|
|
71
|
+
readRow(id: string): Promise<PendingJoin | null>;
|
|
72
|
+
/** One formation attempt for the request's invitation and disclosure (`CadreNode.formStrand`). */
|
|
73
|
+
attempt(join: PendingJoin): Promise<FormStrandResult>;
|
|
74
|
+
/** `ControlDatabase.recordJoinOutcome`, owner-signed: throws `PendingJoinChangedError` when the live join is not `expected`. */
|
|
75
|
+
record(expected: PendingJoin, outcome: JoinOutcome): Promise<PendingJoin>;
|
|
76
|
+
/** `ControlDatabase.rewritePendingJoin`, owner-signed: throws `PendingJoinChangedError` when the live request is not `expectedStampId`. */
|
|
77
|
+
rewrite(expectedStampId: string, next: PendingJoinFields): Promise<PendingJoin>;
|
|
78
|
+
/** `ControlDatabase.deletePendingJoin`, owner-signed. */
|
|
79
|
+
remove(id: string): Promise<boolean>;
|
|
80
|
+
/** Whether this machine has no control connection now, so a write it commits reaches no other machine. */
|
|
81
|
+
isAlone(): boolean;
|
|
82
|
+
/** The sApp the request's invitation names. */
|
|
83
|
+
sAppIdOf(join: PendingJoin): string;
|
|
84
|
+
/** Every pass's joins, read by an owner machine; `CadreNode` stages the membership invitations they carry. */
|
|
85
|
+
observeRows(joins: readonly PendingJoin[]): void;
|
|
86
|
+
emit(status: PendingJoinStatus): void;
|
|
87
|
+
now?: () => number;
|
|
88
|
+
random?: () => number;
|
|
89
|
+
scheduler?: TimeoutScheduler;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** This machine's memory of one join. Nothing here survives a restart; the rows are the durable state. */
|
|
93
|
+
interface TrackedJoin {
|
|
94
|
+
row: PendingJoin;
|
|
95
|
+
/** Retryable failures on this machine since the join was first seen or asked for again. */
|
|
96
|
+
failures: number;
|
|
97
|
+
timer?: unknown;
|
|
98
|
+
/** Due, waiting for a background attempt slot. */
|
|
99
|
+
queued: boolean;
|
|
100
|
+
/** Set while an attempt (or a re-run of its outcome write) is running. */
|
|
101
|
+
attempt?: Promise<void>;
|
|
102
|
+
trying: boolean;
|
|
103
|
+
nextAttemptAt?: number;
|
|
104
|
+
lastError?: AttemptError;
|
|
105
|
+
/** An earlier attempt here answered `token-spent`; the next such answer fails the join. */
|
|
106
|
+
spentOnce: boolean;
|
|
107
|
+
/** This machine's approved join, held until its outcome write lands, so a later pass rewrites it rather than attempting a spent token. */
|
|
108
|
+
joinedHere?: JoinOutcome;
|
|
109
|
+
lastEmitted?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What to do after an outcome write found that another write had changed or removed the join. */
|
|
113
|
+
type LostWriteResolution = 'gone' | 'adopt' | 'rewrite';
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A `joined` outcome replaces anything still live, since a join that happened cannot be undone
|
|
117
|
+
* by another machine's failure. A failure replaces a pending request of the same ask: only a
|
|
118
|
+
* re-issue of a request written alone writes one, unchanged, so it decides nothing. Any other
|
|
119
|
+
* live join wins, and a fresh request is then decided again by the loop.
|
|
120
|
+
*/
|
|
121
|
+
function resolveLostWrite(live: PendingJoin | null, expected: PendingJoin, next: JoinOutcome): LostWriteResolution {
|
|
122
|
+
if (live === null) return 'gone';
|
|
123
|
+
if (live.outcome?.kind === 'joined') return 'adopt';
|
|
124
|
+
if (next.kind === 'joined') return 'rewrite';
|
|
125
|
+
return live.outcome === null && live.RequestedAt === expected.RequestedAt ? 'rewrite' : 'adopt';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The request a `requestJoin` writes: tried until the invitation's own expiration or
|
|
130
|
+
* {@link MAX_PENDING_JOIN_MS} from now, whichever comes first.
|
|
131
|
+
*/
|
|
132
|
+
export function requestedPendingJoin(
|
|
133
|
+
id: string,
|
|
134
|
+
encodedInvitation: string,
|
|
135
|
+
disclosure: StrandFormationDisclosure,
|
|
136
|
+
invitationExpiresAt: number,
|
|
137
|
+
now: number
|
|
138
|
+
): JoinRequestFields {
|
|
139
|
+
return {
|
|
140
|
+
Id: id,
|
|
141
|
+
Invitation: encodedInvitation,
|
|
142
|
+
Disclosure: canonicalJson(disclosure),
|
|
143
|
+
RequestedAt: now,
|
|
144
|
+
ExpiresAt: Math.min(invitationExpiresAt, now + MAX_PENDING_JOIN_MS),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A request's stored disclosure, as the next attempt sends it. Owner-signed, so anything but an object is a bug. */
|
|
149
|
+
export function parseStoredDisclosure(join: PendingJoin): StrandFormationDisclosure {
|
|
150
|
+
const parsed: unknown = JSON.parse(join.Disclosure);
|
|
151
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
152
|
+
throw new Error(`JoinRequest ${join.Id}: the stored disclosure is not a JSON object`);
|
|
153
|
+
}
|
|
154
|
+
return parsed as StrandFormationDisclosure;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A `JoinSuccess` row's `MembershipInvite` column, or null when it is not the `{inviteKey, invitePrivateKey}` JSON the loop writes. */
|
|
158
|
+
export function parseStoredMembershipInvite(text: string): StrandMembershipInvite | null {
|
|
159
|
+
let parsed: unknown;
|
|
160
|
+
try {
|
|
161
|
+
parsed = JSON.parse(text);
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
166
|
+
const { inviteKey, invitePrivateKey } = parsed as Record<string, unknown>;
|
|
167
|
+
return typeof inviteKey === 'string' && typeof invitePrivateKey === 'string' ? { inviteKey, invitePrivateKey } : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The membership invitations joined requests carry that this process has not staged yet, so a
|
|
172
|
+
* closed strand joined on one owner machine can be seated by whichever machine launches it
|
|
173
|
+
* first. Joins older than {@link MEMBERSHIP_INVITE_TTL_MS} are skipped: their invitation is dead.
|
|
174
|
+
* `alreadyStaged` holds invite keys; a malformed column is skipped and logged.
|
|
175
|
+
*/
|
|
176
|
+
export function membershipInvitesToStage(
|
|
177
|
+
joins: readonly PendingJoin[],
|
|
178
|
+
alreadyStaged: ReadonlySet<string>,
|
|
179
|
+
now: number
|
|
180
|
+
): Array<{ rowId: string; strandId: string; invite: StrandMembershipInvite }> {
|
|
181
|
+
const toStage: Array<{ rowId: string; strandId: string; invite: StrandMembershipInvite }> = [];
|
|
182
|
+
for (const join of joins) {
|
|
183
|
+
const { outcome } = join;
|
|
184
|
+
if (outcome?.kind !== 'joined' || outcome.MembershipInvite === null) continue;
|
|
185
|
+
if (now >= outcome.RecordedAt + MEMBERSHIP_INVITE_TTL_MS) continue;
|
|
186
|
+
const invite = parseStoredMembershipInvite(outcome.MembershipInvite);
|
|
187
|
+
if (!invite) {
|
|
188
|
+
log('pending join %s: its MembershipInvite column does not parse; not staged', join.Id);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (!alreadyStaged.has(invite.inviteKey)) {
|
|
192
|
+
toStage.push({ rowId: join.Id, strandId: outcome.StrandId, invite });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return toStage;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The join without its stamp, as a rewrite takes it. */
|
|
199
|
+
function fieldsOf(join: PendingJoin): PendingJoinFields {
|
|
200
|
+
const { StampId: _stampId, ...fields } = join;
|
|
201
|
+
return fields;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function joinedOutcome(result: FormStrandResult, now: number): JoinOutcome {
|
|
205
|
+
const invite = result.membershipInvite;
|
|
206
|
+
return {
|
|
207
|
+
kind: 'joined',
|
|
208
|
+
RecordedAt: now,
|
|
209
|
+
StrandId: result.strandId,
|
|
210
|
+
MembershipInvite: invite ? JSON.stringify({ inviteKey: invite.inviteKey, invitePrivateKey: invite.invitePrivateKey }) : null,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function failedOutcome(code: string, reason: string, now: number): JoinOutcome {
|
|
215
|
+
return { kind: 'failed', RecordedAt: now, Code: code, Reason: reason };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function errorText(error: unknown): string {
|
|
219
|
+
return error instanceof Error ? error.message : String(error);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A fraction in [0, 1) fixed by `key` (32-bit FNV-1a): the same machine staggers the same join the same way every time. */
|
|
223
|
+
function stableFraction(key: string): number {
|
|
224
|
+
let hash = 0x811c9dc5;
|
|
225
|
+
for (let i = 0; i < key.length; i++) {
|
|
226
|
+
hash ^= key.charCodeAt(i);
|
|
227
|
+
hash = Math.imul(hash, 0x01000193);
|
|
228
|
+
}
|
|
229
|
+
return (hash >>> 0) / 0x1_0000_0000;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export class PendingJoinRunner {
|
|
233
|
+
private readonly tracked = new Map<string, TrackedJoin>();
|
|
234
|
+
/** Joins whose latest write by this machine committed with no control connection: id → the request stamp written. */
|
|
235
|
+
private readonly writtenAlone = new Map<string, string>();
|
|
236
|
+
private readonly dueQueue: TrackedJoin[] = [];
|
|
237
|
+
private readonly scheduler: TimeoutScheduler;
|
|
238
|
+
private readonly now: () => number;
|
|
239
|
+
private readonly random: () => number;
|
|
240
|
+
/** First retry delay, and the window a join this machine did not ask for is first tried in. */
|
|
241
|
+
private readonly baseDelayMs: number;
|
|
242
|
+
/** How long a `token-spent` answer waits before its confirming attempt: long enough for a sibling's `joined` write to replicate. */
|
|
243
|
+
private readonly spentConfirmDelayMs: number;
|
|
244
|
+
private running = false;
|
|
245
|
+
private pollTimer: unknown;
|
|
246
|
+
private passInFlight: Promise<void> | null = null;
|
|
247
|
+
private passAgain = false;
|
|
248
|
+
private backgroundAttempts = 0;
|
|
249
|
+
|
|
250
|
+
constructor(private readonly deps: PendingJoinRunnerDeps) {
|
|
251
|
+
this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
|
|
252
|
+
this.now = deps.now ?? Date.now;
|
|
253
|
+
this.random = deps.random ?? Math.random;
|
|
254
|
+
const deadlines = formationDeadlines(deps.linkRoundTripMs);
|
|
255
|
+
this.baseDelayMs = 2 * deadlines.dialMs;
|
|
256
|
+
this.spentConfirmDelayMs = 2 * deadlines.sessionMs;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Start passes: one at once, then every {@link PENDING_JOIN_POLL_MS}. */
|
|
260
|
+
start(): void {
|
|
261
|
+
if (this.running) return;
|
|
262
|
+
this.running = true;
|
|
263
|
+
this.schedulePass(0);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Stop scheduling. Clears every timer and starts no new attempt.
|
|
268
|
+
*
|
|
269
|
+
* NOTE: an attempt already in flight is not awaited, because `formStrand` takes no abort
|
|
270
|
+
* signal. An approval that lands while the node stops can lose its local records; the request
|
|
271
|
+
* then stays pending, and the next start's attempt answers `token-spent`, which fails it after
|
|
272
|
+
* its confirming attempt. Honest and bounded; if it shows up in practice, give `formStrand` a
|
|
273
|
+
* signal and abort it here.
|
|
274
|
+
*/
|
|
275
|
+
stop(): void {
|
|
276
|
+
this.running = false;
|
|
277
|
+
this.clearPollTimer();
|
|
278
|
+
for (const entry of this.tracked.values()) this.cancelTimer(entry);
|
|
279
|
+
this.tracked.clear();
|
|
280
|
+
this.dueQueue.length = 0;
|
|
281
|
+
this.writtenAlone.clear();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Run a pass now, for a change that may make one useful (this machine just became an owner). */
|
|
285
|
+
kick(): void {
|
|
286
|
+
if (!this.running) return;
|
|
287
|
+
this.clearPollTimer();
|
|
288
|
+
void this.pass();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Try `join` on this machine at once, outside the background slots, and report the status
|
|
293
|
+
* after the attempt. Joins an attempt already running for it.
|
|
294
|
+
*/
|
|
295
|
+
async attemptNow(join: PendingJoin): Promise<PendingJoinStatus> {
|
|
296
|
+
const entry = this.adopt(join);
|
|
297
|
+
if (entry.row.outcome === null) {
|
|
298
|
+
await this.startAttempt(entry);
|
|
299
|
+
}
|
|
300
|
+
return this.describe(entry.row, entry);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** This machine's view of `join`: its own state, with this machine's attempt state laid over a pending one. */
|
|
304
|
+
statusOf(join: PendingJoin): PendingJoinStatus {
|
|
305
|
+
return this.describe(join, this.tracked.get(join.Id));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Record a pending-join write this machine made, for {@link reissueWritesMadeAlone}. A write
|
|
310
|
+
* made alone marks the join. A request write made connected clears the mark, since it
|
|
311
|
+
* carried the request; an outcome write made connected does not, since the request it names
|
|
312
|
+
* may still be unknown to the other machines.
|
|
313
|
+
*/
|
|
314
|
+
noteWritten(join: PendingJoin, write: PendingJoinWrite): void {
|
|
315
|
+
if (this.deps.isAlone()) {
|
|
316
|
+
this.writtenAlone.set(join.Id, join.StampId);
|
|
317
|
+
} else if (write === 'request') {
|
|
318
|
+
this.writtenAlone.delete(join.Id);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Stop tracking a join the user dismissed. An attempt in flight finishes; its outcome write then finds the request gone. */
|
|
323
|
+
forgetRow(id: string): void {
|
|
324
|
+
const entry = this.tracked.get(id);
|
|
325
|
+
if (entry) this.forget(entry);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Re-write, with identical content under a fresh stamp, each join this machine last wrote while
|
|
330
|
+
* it had no control connection, so the other machines receive it. Called on the control
|
|
331
|
+
* connection growth edge. A join someone wrote since is left alone.
|
|
332
|
+
*
|
|
333
|
+
* NOTE: in memory only, so a join written alone by a process that stopped before it reconnected
|
|
334
|
+
* reaches the party only with that join's next write. A sweep of every join this machine wrote,
|
|
335
|
+
* on the first connection after start, would cover that, at the cost of one permanent
|
|
336
|
+
* `Revocation` tombstone per join per start. Revisit if other machines are seen missing such joins.
|
|
337
|
+
*/
|
|
338
|
+
async reissueWritesMadeAlone(): Promise<void> {
|
|
339
|
+
if (this.writtenAlone.size === 0) return;
|
|
340
|
+
if (!(await this.deps.isOwner())) {
|
|
341
|
+
this.writtenAlone.clear();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
for (const [id, stampId] of [...this.writtenAlone]) {
|
|
345
|
+
try {
|
|
346
|
+
const live = await this.deps.readRow(id);
|
|
347
|
+
if (!live || live.StampId !== stampId) {
|
|
348
|
+
this.writtenAlone.delete(id);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const written = await this.deps.rewrite(stampId, fieldsOf(live));
|
|
352
|
+
this.noteWritten(written, 'request');
|
|
353
|
+
const entry = this.tracked.get(id);
|
|
354
|
+
if (entry?.row.StampId === stampId) entry.row = written;
|
|
355
|
+
log('pending join %s: re-issued the join written while alone', id);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
log('pending join %s: re-issuing the join written while alone failed; kept for the next connection: %s', id, errorText(error));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private schedulePass(delayMs: number): void {
|
|
363
|
+
if (!this.running) return;
|
|
364
|
+
this.clearPollTimer();
|
|
365
|
+
this.pollTimer = this.scheduler.setTimeout(() => {
|
|
366
|
+
this.pollTimer = undefined;
|
|
367
|
+
void this.pass();
|
|
368
|
+
}, delayMs);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private clearPollTimer(): void {
|
|
372
|
+
if (this.pollTimer !== undefined) {
|
|
373
|
+
this.scheduler.clearTimeout(this.pollTimer);
|
|
374
|
+
this.pollTimer = undefined;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** One pass at a time; a kick during a pass runs one more after it. */
|
|
379
|
+
private pass(): Promise<void> {
|
|
380
|
+
if (this.passInFlight) {
|
|
381
|
+
this.passAgain = true;
|
|
382
|
+
return this.passInFlight;
|
|
383
|
+
}
|
|
384
|
+
const run = this.runPass()
|
|
385
|
+
.catch((error: unknown) => log('pending-join pass failed; the next one retries: %s', errorText(error)))
|
|
386
|
+
.finally(() => {
|
|
387
|
+
this.passInFlight = null;
|
|
388
|
+
if (this.passAgain && this.running) {
|
|
389
|
+
this.passAgain = false;
|
|
390
|
+
void this.pass();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
this.schedulePass(PENDING_JOIN_POLL_MS);
|
|
394
|
+
});
|
|
395
|
+
this.passInFlight = run;
|
|
396
|
+
return run;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private async runPass(): Promise<void> {
|
|
400
|
+
if (!this.running) return;
|
|
401
|
+
if (!(await this.deps.isOwner())) {
|
|
402
|
+
log('pending-join pass skipped: this machine is not an enrolled owner');
|
|
403
|
+
this.pauseAttempts();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const joins = await this.deps.readRows();
|
|
407
|
+
if (!this.running) return;
|
|
408
|
+
this.deps.observeRows(joins);
|
|
409
|
+
const live = new Set(joins.map((join) => join.Id));
|
|
410
|
+
for (const entry of [...this.tracked.values()]) {
|
|
411
|
+
if (!live.has(entry.row.Id) && !entry.attempt) this.forget(entry);
|
|
412
|
+
}
|
|
413
|
+
for (const join of joins) {
|
|
414
|
+
if (!this.running) return;
|
|
415
|
+
try {
|
|
416
|
+
await this.consider(join);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
log('pending join %s: this pass could not handle it; the next one retries: %s', join.Id, errorText(error));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Drop every scheduled attempt; the first pass that finds this machine an owner again schedules afresh. */
|
|
424
|
+
private pauseAttempts(): void {
|
|
425
|
+
for (const entry of this.tracked.values()) {
|
|
426
|
+
this.cancelTimer(entry);
|
|
427
|
+
entry.nextAttemptAt = undefined;
|
|
428
|
+
}
|
|
429
|
+
for (const entry of this.dueQueue) entry.queued = false;
|
|
430
|
+
this.dueQueue.length = 0;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Bring one read join into this machine's schedule. */
|
|
434
|
+
private async consider(join: PendingJoin): Promise<void> {
|
|
435
|
+
const entry = this.adopt(join);
|
|
436
|
+
if (entry.attempt) return;
|
|
437
|
+
if (entry.row.outcome !== null) {
|
|
438
|
+
this.emitIfChanged(entry);
|
|
439
|
+
await this.ageOut(entry);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (entry.joinedHere || this.now() >= entry.row.ExpiresAt) {
|
|
443
|
+
await this.startAttempt(entry);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
if (entry.timer === undefined && !entry.queued) {
|
|
447
|
+
this.scheduleAttempt(entry, Math.floor(stableFraction(`${this.deps.selfId}|${entry.row.Id}`) * this.baseDelayMs));
|
|
448
|
+
}
|
|
449
|
+
this.emitIfChanged(entry);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The tracked entry for `join`, updated to it. A join asked for again (`RequestedAt` changed)
|
|
454
|
+
* starts this machine's counting over; a finished join stops its schedule.
|
|
455
|
+
*/
|
|
456
|
+
private adopt(join: PendingJoin): TrackedJoin {
|
|
457
|
+
const entry = this.tracked.get(join.Id);
|
|
458
|
+
if (!entry) {
|
|
459
|
+
const fresh: TrackedJoin = { row: join, failures: 0, queued: false, trying: false, spentOnce: false };
|
|
460
|
+
this.tracked.set(join.Id, fresh);
|
|
461
|
+
return fresh;
|
|
462
|
+
}
|
|
463
|
+
if (entry.row.StampId === join.StampId && entry.row.outcome?.kind === join.outcome?.kind) return entry;
|
|
464
|
+
if (join.outcome !== null || entry.row.RequestedAt !== join.RequestedAt) {
|
|
465
|
+
this.cancelTimer(entry);
|
|
466
|
+
entry.failures = 0;
|
|
467
|
+
entry.spentOnce = false;
|
|
468
|
+
entry.lastError = undefined;
|
|
469
|
+
entry.nextAttemptAt = undefined;
|
|
470
|
+
}
|
|
471
|
+
if (join.outcome?.kind === 'joined') entry.joinedHere = undefined;
|
|
472
|
+
entry.row = join;
|
|
473
|
+
return entry;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private scheduleAttempt(entry: TrackedJoin, delayMs: number): void {
|
|
477
|
+
if (!this.running) return;
|
|
478
|
+
this.cancelTimer(entry);
|
|
479
|
+
const now = this.now();
|
|
480
|
+
// A request already past its expiry keeps the full delay: its due attempt is an outcome
|
|
481
|
+
// write, and clamping to a past expiry would retry a failing write with no wait at all.
|
|
482
|
+
const at = now < entry.row.ExpiresAt ? Math.min(now + delayMs, entry.row.ExpiresAt) : now + delayMs;
|
|
483
|
+
entry.nextAttemptAt = at;
|
|
484
|
+
entry.timer = this.scheduler.setTimeout(() => {
|
|
485
|
+
entry.timer = undefined;
|
|
486
|
+
this.enqueue(entry);
|
|
487
|
+
}, Math.max(0, at - this.now()));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
private cancelTimer(entry: TrackedJoin): void {
|
|
491
|
+
if (entry.timer !== undefined) {
|
|
492
|
+
this.scheduler.clearTimeout(entry.timer);
|
|
493
|
+
entry.timer = undefined;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private enqueue(entry: TrackedJoin): void {
|
|
498
|
+
if (!this.running || this.tracked.get(entry.row.Id) !== entry || entry.queued) return;
|
|
499
|
+
entry.queued = true;
|
|
500
|
+
this.dueQueue.push(entry);
|
|
501
|
+
this.pump();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Start due attempts while background slots are free. */
|
|
505
|
+
private pump(): void {
|
|
506
|
+
while (this.running && this.backgroundAttempts < MAX_BACKGROUND_ATTEMPTS && this.dueQueue.length > 0) {
|
|
507
|
+
const entry = this.dueQueue.shift()!;
|
|
508
|
+
entry.queued = false;
|
|
509
|
+
if (entry.attempt || this.tracked.get(entry.row.Id) !== entry) continue;
|
|
510
|
+
this.backgroundAttempts++;
|
|
511
|
+
void this.startAttempt(entry).finally(() => {
|
|
512
|
+
this.backgroundAttempts--;
|
|
513
|
+
this.pump();
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Run one attempt for `entry`, or join the one already running. Never rejects. */
|
|
519
|
+
private startAttempt(entry: TrackedJoin): Promise<void> {
|
|
520
|
+
if (entry.attempt) return entry.attempt;
|
|
521
|
+
this.cancelTimer(entry);
|
|
522
|
+
if (entry.queued) {
|
|
523
|
+
entry.queued = false;
|
|
524
|
+
this.dueQueue.splice(this.dueQueue.indexOf(entry), 1);
|
|
525
|
+
}
|
|
526
|
+
// NOTE: only a join is held for re-writing (`joinedHere`); a failed write of a final failure is
|
|
527
|
+
// retried by attempting again, so a `local` failure whose write failed ends up recorded as
|
|
528
|
+
// `token-spent`. If apps come to branch on that code, hold failures the same way.
|
|
529
|
+
const run = this.attemptOnce(entry)
|
|
530
|
+
.catch((error: unknown) => {
|
|
531
|
+
entry.trying = false;
|
|
532
|
+
log('pending join %s: attempt failed on this machine: %s', entry.row.Id, errorText(error));
|
|
533
|
+
if (entry.row.outcome === null && this.tracked.get(entry.row.Id) === entry) {
|
|
534
|
+
this.retryLater(entry, { code: 'local', reason: errorText(error) });
|
|
535
|
+
}
|
|
536
|
+
})
|
|
537
|
+
.finally(() => {
|
|
538
|
+
entry.attempt = undefined;
|
|
539
|
+
});
|
|
540
|
+
entry.attempt = run;
|
|
541
|
+
return run;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
private async attemptOnce(entry: TrackedJoin): Promise<void> {
|
|
545
|
+
if (entry.row.outcome !== null) return;
|
|
546
|
+
if (entry.joinedHere) {
|
|
547
|
+
await this.writeOutcome(entry, entry.joinedHere);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (this.now() >= entry.row.ExpiresAt) {
|
|
551
|
+
const reason = `No attempt starts after ${new Date(entry.row.ExpiresAt).toISOString()}`;
|
|
552
|
+
await this.writeOutcome(entry, failedOutcome('expired', reason, this.now()));
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
entry.trying = true;
|
|
556
|
+
entry.nextAttemptAt = undefined;
|
|
557
|
+
this.emitIfChanged(entry);
|
|
558
|
+
let result: FormStrandResult;
|
|
559
|
+
try {
|
|
560
|
+
result = await this.deps.attempt(entry.row);
|
|
561
|
+
} catch (error) {
|
|
562
|
+
entry.trying = false;
|
|
563
|
+
await this.onAttemptFailed(entry, error);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
entry.trying = false;
|
|
567
|
+
log('pending join %s: approved, strand %s', entry.row.Id, result.strandId);
|
|
568
|
+
entry.joinedHere = joinedOutcome(result, this.now());
|
|
569
|
+
await this.writeOutcome(entry, entry.joinedHere);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Read a failed attempt: retry, confirm a spent token, or fail the join. */
|
|
573
|
+
private async onAttemptFailed(entry: TrackedJoin, error: unknown): Promise<void> {
|
|
574
|
+
const now = this.now();
|
|
575
|
+
if (error instanceof FormationRejectedError && error.code === 'token-spent') {
|
|
576
|
+
await this.onSpent(entry, error);
|
|
577
|
+
} else if (error instanceof FormationPostApprovalError) {
|
|
578
|
+
log('pending join %s: approved, then a step on this machine failed; failing the join', entry.row.Id);
|
|
579
|
+
await this.writeOutcome(entry, failedOutcome('local', error.message, now));
|
|
580
|
+
} else if (error instanceof FormationRejectedError && !error.retryable) {
|
|
581
|
+
log('pending join %s: refused (%s); failing the join', entry.row.Id, error.code);
|
|
582
|
+
await this.writeOutcome(entry, failedOutcome(error.code, error.reason, now));
|
|
583
|
+
} else if (error instanceof FormationRejectedError) {
|
|
584
|
+
this.retryLater(entry, { code: error.code, reason: error.reason });
|
|
585
|
+
} else if (error instanceof FormationUnreachableError) {
|
|
586
|
+
this.retryLater(entry, { code: 'unreachable', reason: error.message });
|
|
587
|
+
} else {
|
|
588
|
+
// Retrying is safe: nothing is attempted after the request's expiry.
|
|
589
|
+
log('pending join %s: attempt threw an unexpected error; retrying: %s', entry.row.Id, errorText(error));
|
|
590
|
+
this.retryLater(entry, { code: 'local', reason: errorText(error) });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* A `token-spent` answer may mean another owner machine of this party won the same
|
|
596
|
+
* invitation. Adopt its outcome if the join already shows one; otherwise confirm once, after
|
|
597
|
+
* long enough for that machine's `joined` write to arrive, before failing the join.
|
|
598
|
+
*/
|
|
599
|
+
private async onSpent(entry: TrackedJoin, error: FormationRejectedError): Promise<void> {
|
|
600
|
+
const live = await this.deps.readRow(entry.row.Id);
|
|
601
|
+
if (!live) {
|
|
602
|
+
this.forget(entry);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
this.adopt(live);
|
|
606
|
+
if (live.outcome !== null) {
|
|
607
|
+
this.emitIfChanged(entry);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (entry.spentOnce) {
|
|
611
|
+
log('pending join %s: token spent again after the confirming wait; failing the join', entry.row.Id);
|
|
612
|
+
await this.writeOutcome(entry, failedOutcome('token-spent', error.reason, this.now()));
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
entry.spentOnce = true;
|
|
616
|
+
entry.lastError = { code: 'token-spent', reason: error.reason };
|
|
617
|
+
this.scheduleAttempt(entry, this.spentConfirmDelayMs);
|
|
618
|
+
this.emitIfChanged(entry);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private retryLater(entry: TrackedJoin, lastError: AttemptError): void {
|
|
622
|
+
entry.failures++;
|
|
623
|
+
entry.lastError = lastError;
|
|
624
|
+
this.scheduleAttempt(entry, this.backoffMs(entry.failures));
|
|
625
|
+
this.emitIfChanged(entry);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** `min(base × 2^(n−1), max)`, moved by up to ±{@link RETRY_JITTER}. */
|
|
629
|
+
private backoffMs(failures: number): number {
|
|
630
|
+
const capped = Math.min(this.baseDelayMs * 2 ** (failures - 1), PENDING_JOIN_MAX_BACKOFF_MS);
|
|
631
|
+
return Math.round(capped * (1 + (this.random() * 2 - 1) * RETRY_JITTER));
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Record `next` on the join this machine last read. When another write got there first, settle
|
|
636
|
+
* by {@link resolveLostWrite} from the live join the refusal carries. A join the user dismissed
|
|
637
|
+
* meanwhile is not recreated; a join this machine made is still remembered machine-locally, as
|
|
638
|
+
* every `formStrand` join is.
|
|
639
|
+
*/
|
|
640
|
+
private async writeOutcome(entry: TrackedJoin, next: JoinOutcome): Promise<void> {
|
|
641
|
+
let expected = entry.row;
|
|
642
|
+
for (let round = 0; round < MAX_OUTCOME_WRITE_ROUNDS; round++) {
|
|
643
|
+
let live: PendingJoin | null;
|
|
644
|
+
try {
|
|
645
|
+
const written = await this.deps.record(expected, next);
|
|
646
|
+
this.noteWritten(written, 'outcome');
|
|
647
|
+
this.settle(written);
|
|
648
|
+
log('pending join %s: recorded %s', entry.row.Id, next.kind === 'failed' ? `failed (${next.Code})` : 'joined');
|
|
649
|
+
return;
|
|
650
|
+
} catch (error) {
|
|
651
|
+
if (!(error instanceof PendingJoinChangedError)) throw error;
|
|
652
|
+
live = error.live;
|
|
653
|
+
}
|
|
654
|
+
const resolution = resolveLostWrite(live, expected, next);
|
|
655
|
+
if (resolution === 'gone') {
|
|
656
|
+
log('pending join %s: dismissed while this machine was recording %s; not recreated', entry.row.Id, next.kind);
|
|
657
|
+
this.forget(entry);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (resolution === 'adopt') {
|
|
661
|
+
this.settle(live!);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
expected = live!;
|
|
665
|
+
}
|
|
666
|
+
log('pending join %s: another machine kept changing the join; the next pass decides', entry.row.Id);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private settle(join: PendingJoin): void {
|
|
670
|
+
const entry = this.adopt(join);
|
|
671
|
+
if (join.outcome !== null) entry.joinedHere = undefined;
|
|
672
|
+
this.emitIfChanged(entry);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Remove a finished join {@link MEMBERSHIP_INVITE_TTL_MS} after its outcome: the membership
|
|
677
|
+
* invitation it carries is dead by then, and an app that was not running has seen the strand
|
|
678
|
+
* through `strand:discovered`. Only while connected, like every write nothing waits on.
|
|
679
|
+
*
|
|
680
|
+
* NOTE: `deletePendingJoin` removes whichever incarnation is live, so a `requestJoin` of the
|
|
681
|
+
* same invitation landing on another machine in the same instant can be removed with it. The
|
|
682
|
+
* user asks again; revisit if that is ever seen.
|
|
683
|
+
*/
|
|
684
|
+
private async ageOut(entry: TrackedJoin): Promise<void> {
|
|
685
|
+
const recordedAt = entry.row.outcome?.RecordedAt;
|
|
686
|
+
if (recordedAt === undefined || this.now() < recordedAt + MEMBERSHIP_INVITE_TTL_MS || this.deps.isAlone()) return;
|
|
687
|
+
await this.deps.remove(entry.row.Id);
|
|
688
|
+
log('pending join %s: removed, %d days after its outcome', entry.row.Id, MEMBERSHIP_INVITE_TTL_MS / (24 * 3600_000));
|
|
689
|
+
this.forget(entry);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private forget(entry: TrackedJoin): void {
|
|
693
|
+
this.cancelTimer(entry);
|
|
694
|
+
if (this.tracked.get(entry.row.Id) === entry) this.tracked.delete(entry.row.Id);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
private emitIfChanged(entry: TrackedJoin): void {
|
|
698
|
+
if (!this.running || this.tracked.get(entry.row.Id) !== entry) return;
|
|
699
|
+
const status = this.describe(entry.row, entry);
|
|
700
|
+
const key = JSON.stringify(status);
|
|
701
|
+
if (key === entry.lastEmitted) return;
|
|
702
|
+
entry.lastEmitted = key;
|
|
703
|
+
this.deps.emit(status);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private describe(join: PendingJoin, entry: TrackedJoin | undefined): PendingJoinStatus {
|
|
707
|
+
const base = {
|
|
708
|
+
id: join.Id,
|
|
709
|
+
sAppId: this.deps.sAppIdOf(join),
|
|
710
|
+
requestedAt: join.RequestedAt,
|
|
711
|
+
expiresAt: join.ExpiresAt,
|
|
712
|
+
};
|
|
713
|
+
const { outcome } = join;
|
|
714
|
+
if (outcome?.kind === 'joined') {
|
|
715
|
+
return { ...base, state: 'joined', strandId: outcome.StrandId };
|
|
716
|
+
}
|
|
717
|
+
if (outcome?.kind === 'failed') {
|
|
718
|
+
return { ...base, state: 'failed', failure: { code: outcome.Code, reason: outcome.Reason } };
|
|
719
|
+
}
|
|
720
|
+
const local = entry && entry.row.outcome === null && entry.row.RequestedAt === join.RequestedAt ? entry : undefined;
|
|
721
|
+
if (local?.trying) {
|
|
722
|
+
return { ...base, state: 'trying' };
|
|
723
|
+
}
|
|
724
|
+
if (local?.lastError && local.nextAttemptAt !== undefined) {
|
|
725
|
+
return { ...base, state: 'waiting', nextAttemptAt: local.nextAttemptAt, lastError: local.lastError };
|
|
726
|
+
}
|
|
727
|
+
return { ...base, state: 'pending' };
|
|
728
|
+
}
|
|
729
|
+
}
|