@real-life/trust-protocol 0.4.0 → 0.6.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/CHANGELOG.md +137 -0
- package/README.md +91 -24
- package/dist/adapters/vti-mediator.d.ts +36 -8
- package/dist/adapters/vti-mediator.d.ts.map +1 -1
- package/dist/adapters/vti-mediator.js +193 -29
- package/dist/adapters/vti-mediator.js.map +1 -1
- package/dist/codec.d.ts +2 -1
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +75 -26
- package/dist/codec.js.map +1 -1
- package/dist/engine.d.ts +77 -4
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +419 -31
- package/dist/engine.js.map +1 -1
- package/dist/entropy.d.ts +1 -1
- package/dist/entropy.d.ts.map +1 -1
- package/dist/entropy.js +1 -1
- package/dist/entropy.js.map +1 -1
- package/dist/events.d.ts +21 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js.map +1 -1
- package/dist/holder-api.d.ts +44 -2
- package/dist/holder-api.d.ts.map +1 -1
- package/dist/holder-api.js +40 -5
- package/dist/holder-api.js.map +1 -1
- package/dist/{holder-state-v1.d.ts → holder-state-v3.d.ts} +261 -201
- package/dist/holder-state-v3.d.ts.map +1 -0
- package/dist/holder-state-v3.js +2 -0
- package/dist/holder-state-v3.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -6
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/introduction/holder.d.ts.map +1 -1
- package/dist/introduction/holder.js +7 -0
- package/dist/introduction/holder.js.map +1 -1
- package/dist/invariants.d.ts.map +1 -1
- package/dist/invariants.js +70 -0
- package/dist/invariants.js.map +1 -1
- package/dist/membership/holder.d.ts +3 -5
- package/dist/membership/holder.d.ts.map +1 -1
- package/dist/membership/holder.js +165 -88
- package/dist/membership/holder.js.map +1 -1
- package/dist/membership.d.ts +31 -0
- package/dist/membership.d.ts.map +1 -1
- package/dist/membership.js +25 -2
- package/dist/membership.js.map +1 -1
- package/dist/message/holder.d.ts +49 -0
- package/dist/message/holder.d.ts.map +1 -0
- package/dist/message/holder.js +290 -0
- package/dist/message/holder.js.map +1 -0
- package/dist/message.d.ts +43 -0
- package/dist/message.d.ts.map +1 -0
- package/dist/message.js +51 -0
- package/dist/message.js.map +1 -0
- package/dist/receive.d.ts +4 -3
- package/dist/receive.d.ts.map +1 -1
- package/dist/receive.js +23 -4
- package/dist/receive.js.map +1 -1
- package/dist/results.d.ts +22 -3
- package/dist/results.d.ts.map +1 -1
- package/dist/results.js.map +1 -1
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +9 -6
- package/dist/schemas.js.map +1 -1
- package/dist/state.d.ts +88 -14
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +2 -1
- package/dist/state.js.map +1 -1
- package/dist/trust/continuity.d.ts.map +1 -1
- package/dist/trust/continuity.js +7 -1
- package/dist/trust/continuity.js.map +1 -1
- package/dist/trust/entries.d.ts.map +1 -1
- package/dist/trust/entries.js +103 -2
- package/dist/trust/entries.js.map +1 -1
- package/dist/views.d.ts +34 -2
- package/dist/views.d.ts.map +1 -1
- package/dist/views.js +19 -1
- package/dist/views.js.map +1 -1
- package/dist/wire.d.ts +47 -6
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js.map +1 -1
- package/package.json +6 -4
- package/schemas/{holder-state-v1.schema.json → holder-state-v3.schema.json} +300 -12
- package/dist/holder-state-v1.d.ts.map +0 -1
- package/dist/holder-state-v1.js +0 -2
- package/dist/holder-state-v1.js.map +0 -1
package/dist/engine.js
CHANGED
|
@@ -37,19 +37,26 @@
|
|
|
37
37
|
// act whose apply returns the identical state object and nothing else
|
|
38
38
|
// commits nothing only when lastWhen stays too. A human act always
|
|
39
39
|
// commits, because its journal entry is the result. Refusals and
|
|
40
|
-
// journal replays commit nothing, lastWhen included
|
|
40
|
+
// journal replays commit nothing, lastWhen included — but for a replay
|
|
41
|
+
// that finds one of its documents past its lifetime or the horizon:
|
|
42
|
+
// that give-up runs first as an operation of its own on the one commit
|
|
43
|
+
// path (resume's CommitOp, its timer carried to the replay's answer)
|
|
41
44
|
// - outbound of an act starts at attempt 1 with nextAttemptAt =
|
|
42
45
|
// when + ceil(backoff(1)); resume() re-emits only items that are due
|
|
43
46
|
// and pauses re-emission while the last step is awaiting-transport
|
|
44
47
|
// (the adapter holds the item) and never re-emits an accepted one. It
|
|
45
48
|
// gives up every non-terminal item (submitted, held or accepted) at
|
|
46
49
|
// first submission + horizonMs, never by attempt
|
|
47
|
-
// count
|
|
48
|
-
//
|
|
50
|
+
// count, or earlier at the sender lifetime its type declared
|
|
51
|
+
// (expiresAt, Delivery 7020); nextDueAt is the earliest due
|
|
52
|
+
// re-emission, horizon or lifetime
|
|
53
|
+
// - the holder's give-up is failed(expired-by-adapter-policy), at a
|
|
54
|
+
// lifetime failed(task-expired) (Delivery 6110); a later
|
|
49
55
|
// delivered report is late by that history, whatever the adapter says
|
|
50
56
|
// - replay of a human act returns the journaled events and the outbound
|
|
51
|
-
// of its documents that are not terminal (delivered, failed)
|
|
52
|
-
// current attempt
|
|
57
|
+
// of its documents that are not terminal (delivered, failed) and not
|
|
58
|
+
// due for the give-up, at their current attempt; one due for it is
|
|
59
|
+
// given up in a commit of its own first (6115: never handed out again). Idempotency is bounded by the journal's retention:
|
|
53
60
|
// max(P90D, retry.horizonMs), the key-retention of Delivery §7 that
|
|
54
61
|
// bounds effects and their acks; after it the same actId is a new act
|
|
55
62
|
// - KeyProvider, Log and Access rejections inside a preparation become
|
|
@@ -68,13 +75,48 @@
|
|
|
68
75
|
// stages again, which re-checks the effect cache at stage 4
|
|
69
76
|
// - an ack is stored sealed (StoredAck: envelope, digest, recipient) in
|
|
70
77
|
// its effect, so every re-send is byte-identical down to the envelope.
|
|
71
|
-
// It stays `sent: false` until
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
+
// It stays `sent: false` until a service takes it (report accepted or
|
|
79
|
+
// delivered: `sentBy: 'accepted'`), the adapter reports it failed
|
|
80
|
+
// (`'failed'`), or its horizon passes (effect commit + horizonMs:
|
|
81
|
+
// `'horizon'`, no event: acks have no trias) — Delivery RLTP-DEL-6330.
|
|
82
|
+
// An awaiting-transport report keeps it unsent and records `held`; a
|
|
83
|
+
// held ack is not re-offered by resume (the adapter has it) and stays
|
|
84
|
+
// in pendingTransport. Unsent and not held, it has the outbox's retry
|
|
85
|
+
// schedule: attempt 1 at the receive, re-offered by resume when due
|
|
86
|
+
// (attempt + 1, backoff), counted into nextDueAt. Sent or not, it stays
|
|
87
|
+
// with its effect until the effect expires (RLTP-DEL-6335). A
|
|
88
|
+
// duplicate's re-send carries attempt + 1 and does not move the
|
|
89
|
+
// schedule; it commits the answer it makes due (6330), else nothing
|
|
90
|
+
// beyond lastWhen
|
|
91
|
+
// - custody (Delivery RLTP-DEL-6120 to 6135): pendingTransport(when) is
|
|
92
|
+
// the set the holder keeps for transport — every outbox item neither
|
|
93
|
+
// terminal nor accepted, held ones included, and every unsent ack —
|
|
94
|
+
// each with its deadline (notAfter: the horizon from the first
|
|
95
|
+
// submission, or the type's sender lifetime if earlier) and without
|
|
96
|
+
// those whose deadline has passed at `when`, whether or not a resume
|
|
97
|
+
// has recorded the give-up yet. It answers only after a resume of this
|
|
98
|
+
// engine has completed (a resume that throws withdraws that again), and
|
|
99
|
+
// its entries are issued to this engine: mayHandOver(entry, when)
|
|
100
|
+
// accepts only an entry this engine issued, re-reads the store and says
|
|
101
|
+
// yes only while the engine is open and the entry is still pending
|
|
102
|
+
// there with the same envelope before its deadline. handOver(entry,
|
|
103
|
+
// when, start) is the same check with the deposit's start inside the
|
|
104
|
+
// queue: nothing of this engine runs between the check and the start.
|
|
105
|
+
// A duplicate makes an answer due, stored with the ack (answers,
|
|
106
|
+
// answerDue: kept across a lock): a `resend` entry numbered by it,
|
|
107
|
+
// beside the ack's retries and independent of their state, deadline the
|
|
108
|
+
// effect's retention; the begun hand-over ends it (a commit): it is one
|
|
109
|
+
// hand-over with no retries (Delivery 6330), so a deposit the wire
|
|
110
|
+
// refuses is not tried again — the sender's next redelivery asks anew;
|
|
111
|
+
// a duplicate while one is due is answered by it (the same bytes); a resume that throws, its settling
|
|
112
|
+
// included, withdraws the authority until one completes. The check is
|
|
113
|
+
// this engine's: a second engine over the same store commits through
|
|
114
|
+
// CAS but is not serialized with this one's hand-overs, so one opening
|
|
115
|
+
// per store at a time is the app's to keep (its writer lock). close() ends the
|
|
116
|
+
// engine: every later operation rejects with InfraError('closed'),
|
|
117
|
+
// mayHandOver answers false, and no entry issued before authorizes
|
|
118
|
+
// anything. An operation already running when close() is called
|
|
119
|
+
// completes
|
|
78
120
|
// - outbound of one commit, in order: outbox re-emissions, released
|
|
79
121
|
// acks, new documents
|
|
80
122
|
// - resume runs duties after the outbox, ordered by dueAt (absent: due
|
|
@@ -348,6 +390,7 @@ function renameAcks(effects, renames) {
|
|
|
348
390
|
}
|
|
349
391
|
return out ?? effects;
|
|
350
392
|
}
|
|
393
|
+
const closedError = () => new InfraError('closed', 'the holder was closed');
|
|
351
394
|
const checkWhen = (when) => {
|
|
352
395
|
if (!Number.isSafeInteger(when) || when < 0)
|
|
353
396
|
throw new TypeError(`when must be non-negative integer milliseconds, got ${String(when)}`);
|
|
@@ -388,6 +431,7 @@ class HolderEngine {
|
|
|
388
431
|
#ownRetries;
|
|
389
432
|
#keepKeys;
|
|
390
433
|
#retain;
|
|
434
|
+
#accepted;
|
|
391
435
|
#deadlines;
|
|
392
436
|
#builtins;
|
|
393
437
|
#check;
|
|
@@ -396,6 +440,12 @@ class HolderEngine {
|
|
|
396
440
|
#chain = Promise.resolve();
|
|
397
441
|
/** a commit whose outcome is unknown, settled before the next operation */
|
|
398
442
|
#unsettled = null;
|
|
443
|
+
/** close() was called: nothing more runs, nothing issued authorizes */
|
|
444
|
+
#closed = false;
|
|
445
|
+
/** a resume of this engine completed (and none threw since) */
|
|
446
|
+
#resumed = false;
|
|
447
|
+
/** the transport entries this engine issued (RLTP-DEL-6130) */
|
|
448
|
+
#issued = new WeakSet();
|
|
399
449
|
constructor(ports, crypto, modules, opts = {}) {
|
|
400
450
|
if (opts.check !== undefined && typeof opts.check !== 'function')
|
|
401
451
|
throw new TypeError('check must be a function');
|
|
@@ -442,6 +492,7 @@ class HolderEngine {
|
|
|
442
492
|
const ownRetries = new Set();
|
|
443
493
|
const keepKeys = [];
|
|
444
494
|
const retain = [];
|
|
495
|
+
const accepted = [];
|
|
445
496
|
const deadlines = [];
|
|
446
497
|
const names = new Set();
|
|
447
498
|
for (const m of modules) {
|
|
@@ -530,6 +581,11 @@ class HolderEngine {
|
|
|
530
581
|
throw new TypeError(`deadlines (module ${m.name}) must be a function`);
|
|
531
582
|
deadlines.push(m.deadlines);
|
|
532
583
|
}
|
|
584
|
+
if (m.accepted !== undefined) {
|
|
585
|
+
if (typeof m.accepted !== 'function')
|
|
586
|
+
throw new TypeError(`accepted (module ${m.name}) must be a function`);
|
|
587
|
+
accepted.push(m.accepted);
|
|
588
|
+
}
|
|
533
589
|
}
|
|
534
590
|
this.#commands = commands;
|
|
535
591
|
this.#tasks = tasks;
|
|
@@ -541,6 +597,7 @@ class HolderEngine {
|
|
|
541
597
|
this.#keepKeys = Object.freeze(keepKeys);
|
|
542
598
|
this.#deadlines = Object.freeze(deadlines);
|
|
543
599
|
this.#retain = Object.freeze(retain);
|
|
600
|
+
this.#accepted = Object.freeze(accepted);
|
|
544
601
|
this.#builtins = Object.freeze({ tick: this.#tickDef(), resume: this.#resumeDef(), report: this.#reportDef(), receive: this.#receiveDef() });
|
|
545
602
|
this.registry = Object.freeze({
|
|
546
603
|
commands: Object.freeze([...commands.keys()]),
|
|
@@ -586,7 +643,14 @@ class HolderEngine {
|
|
|
586
643
|
}
|
|
587
644
|
// ── the queue ────────────────────────────────────────────────────────
|
|
588
645
|
#enqueue(fn) {
|
|
589
|
-
|
|
646
|
+
if (this.#closed)
|
|
647
|
+
return Promise.reject(closedError());
|
|
648
|
+
const p = this.#chain.then(async () => {
|
|
649
|
+
if (this.#closed)
|
|
650
|
+
throw closedError();
|
|
651
|
+
await this.#settle();
|
|
652
|
+
return fn();
|
|
653
|
+
});
|
|
590
654
|
this.#chain = p.then(() => undefined, () => undefined);
|
|
591
655
|
return p;
|
|
592
656
|
}
|
|
@@ -615,6 +679,11 @@ class HolderEngine {
|
|
|
615
679
|
// ── the four steps ───────────────────────────────────────────────────
|
|
616
680
|
/** `input` is already bound (bindInput); `inputHash` its journal hash for human acts. */
|
|
617
681
|
async #run(def, op, input, when, actId, inputHash) {
|
|
682
|
+
/** a replay's give-up work (below): its timer, carried to the replay's answer */
|
|
683
|
+
let replayWake;
|
|
684
|
+
/** give-ups a replay ran: each moves at least one of its finitely many
|
|
685
|
+
* journaled documents to failed, so they are bounded by that number */
|
|
686
|
+
let gaveUp = 0;
|
|
618
687
|
for (let attempt = 1; attempt <= this.#policy.commitAttempts; attempt++) {
|
|
619
688
|
// 1 — snapshot, when
|
|
620
689
|
const { state, version } = await this.#read();
|
|
@@ -623,9 +692,35 @@ class HolderEngine {
|
|
|
623
692
|
const rec = state.acts[actId];
|
|
624
693
|
if (rec.inputHash !== inputHash)
|
|
625
694
|
return { ok: false, code: 'actid-conflict' };
|
|
626
|
-
const live = rec.outboundDigests.filter((d) => { const t = triasOf(this.#itemOf(state, d)); return t !== 'delivered' && t !== 'failed'; });
|
|
627
695
|
const events = structuredClone(rec.events);
|
|
628
|
-
|
|
696
|
+
// the act's documents are handed out again only while live; one past
|
|
697
|
+
// its sender lifetime or the horizon is given up first, in an
|
|
698
|
+
// operation of its own on the engine's one commit path (as resume's
|
|
699
|
+
// give-up: Delivery 6110, 6115), and is never handed out — the
|
|
700
|
+
// journal's events are not announced again
|
|
701
|
+
const live = [];
|
|
702
|
+
const expired = [];
|
|
703
|
+
for (const d of rec.outboundDigests) {
|
|
704
|
+
const it = this.#itemOf(state, d);
|
|
705
|
+
const t = triasOf(it);
|
|
706
|
+
if (t === 'delivered' || t === 'failed')
|
|
707
|
+
continue;
|
|
708
|
+
if (this.#giveUp(it, at))
|
|
709
|
+
expired.push(d);
|
|
710
|
+
else
|
|
711
|
+
live.push(d);
|
|
712
|
+
}
|
|
713
|
+
if (expired.length) {
|
|
714
|
+
const g = await this.#run(this.#giveUpDef(), { kind: 'resume' }, expired, at);
|
|
715
|
+
if (!isHalt(g) && g.ok && g.nextDueAt !== undefined)
|
|
716
|
+
replayWake = replayWake === undefined ? g.nextDueAt : Math.min(replayWake, g.nextDueAt);
|
|
717
|
+
// re-read: the act's documents as the give-up left them, outside
|
|
718
|
+
// the CAS budget (no conflict happened); bounded by the act's documents
|
|
719
|
+
if (gaveUp++ <= rec.outboundDigests.length)
|
|
720
|
+
attempt--;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
return { ok: true, outbound: live.map((d) => this.#outboundOf(state, d)), events: freezeDeep(events), ...(replayWake !== undefined ? { nextDueAt: replayWake } : {}) };
|
|
629
724
|
}
|
|
630
725
|
// 2 — prepare, bound to this snapshot
|
|
631
726
|
const ctx = Object.freeze({
|
|
@@ -679,12 +774,16 @@ class HolderEngine {
|
|
|
679
774
|
freezeDeep(events);
|
|
680
775
|
const outbound = drafts.map((o) => ({ digest: o.digest, envelope: o.envelope, type: o.type, threadId: o.threadId, recipient: o.recipient, attempt: 1 }));
|
|
681
776
|
const outbox = { ...r.state.outbox };
|
|
682
|
-
for (const o of outbound) {
|
|
777
|
+
for (const [i, o] of outbound.entries()) {
|
|
683
778
|
if (Object.hasOwn(outbox, o.digest))
|
|
684
779
|
throw new Error(`engine: outbound ${o.digest} is already in the outbox`);
|
|
780
|
+
const life = drafts[i].expiresAt;
|
|
781
|
+
if (life !== undefined && (!Number.isSafeInteger(life) || life <= at))
|
|
782
|
+
throw new Error(`engine: outbound ${o.digest} expires at ${life}, not after ${at}`);
|
|
685
783
|
outbox[o.digest] = {
|
|
686
784
|
type: o.type, threadId: o.threadId, recipient: o.recipient, envelope: o.envelope, attempt: 1,
|
|
687
785
|
history: [{ status: 'submitted', at, attempt: 1 }], nextAttemptAt: dueAt(at, this.#policy.backoff(1)),
|
|
786
|
+
...(life !== undefined ? { expiresAt: life } : {}),
|
|
688
787
|
};
|
|
689
788
|
}
|
|
690
789
|
const duties = { ...r.state.duties };
|
|
@@ -819,6 +918,50 @@ class HolderEngine {
|
|
|
819
918
|
}
|
|
820
919
|
return { state: cur, events };
|
|
821
920
|
}
|
|
921
|
+
/** The give-up of the outbox items named (a replay found them due, see
|
|
922
|
+
* #run): as resume's, the failed step, the status event, the failure
|
|
923
|
+
* handlers in the same transaction; items no longer due are left alone. */
|
|
924
|
+
#giveUpDef() {
|
|
925
|
+
const giveUp = (it, when) => this.#giveUp(it, when);
|
|
926
|
+
const failed = (st, digests, when) => this.#failed(st, digests, when);
|
|
927
|
+
return {
|
|
928
|
+
human: false,
|
|
929
|
+
apply(state, _p, digests, when) {
|
|
930
|
+
const outbox = { ...state.outbox };
|
|
931
|
+
const events = [];
|
|
932
|
+
const gave = [];
|
|
933
|
+
for (const d of digests) {
|
|
934
|
+
const it = state.outbox[d];
|
|
935
|
+
const t = triasOf(it);
|
|
936
|
+
if (t === 'delivered' || t === 'failed')
|
|
937
|
+
continue;
|
|
938
|
+
const why = giveUp(it, when);
|
|
939
|
+
if (!why)
|
|
940
|
+
continue;
|
|
941
|
+
const { nextAttemptAt: _, ...rest } = it;
|
|
942
|
+
outbox[d] = { ...rest, history: [...rest.history, { status: 'failed', at: when, reason: why }] };
|
|
943
|
+
events.push({ type: 'delivery.status', digest: d, threadId: it.threadId, status: 'failed' });
|
|
944
|
+
gave.push(d);
|
|
945
|
+
}
|
|
946
|
+
if (!gave.length)
|
|
947
|
+
return { state: state };
|
|
948
|
+
const f = failed({ ...state, outbox }, gave, when);
|
|
949
|
+
return { state: f.state, events: [...events, ...f.events] };
|
|
950
|
+
},
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/** Why a non-terminal outbox item is given up at `when`, or null: its
|
|
954
|
+
* sender lifetime, where it ends no later than the horizon
|
|
955
|
+
* (failed(task-expired), Delivery 6110), else the horizon
|
|
956
|
+
* (failed(expired-by-adapter-policy)). */
|
|
957
|
+
#giveUp(it, when) {
|
|
958
|
+
const first = it.history.length ? it.history[0].at : when;
|
|
959
|
+
const expiry = Math.min(first + this.#policy.horizonMs, Number.MAX_SAFE_INTEGER);
|
|
960
|
+
const life = it.expiresAt;
|
|
961
|
+
if (life !== undefined && when >= life && life <= expiry)
|
|
962
|
+
return 'task-expired';
|
|
963
|
+
return when >= expiry ? 'expired-by-adapter-policy' : null;
|
|
964
|
+
}
|
|
822
965
|
#itemOf(state, digest) {
|
|
823
966
|
if (!Object.hasOwn(state.outbox, digest))
|
|
824
967
|
throw new Error(`engine: journaled outbound ${digest} is missing from the outbox`);
|
|
@@ -882,8 +1025,9 @@ class HolderEngine {
|
|
|
882
1025
|
if (!t.deactivated)
|
|
883
1026
|
live.add(t.rkid);
|
|
884
1027
|
// and the rkids a module still needs (a deactivated prior whose continuity flow is open)
|
|
1028
|
+
// (at this tick's time: a claim that ends now ends in this tick)
|
|
885
1029
|
for (const k of keepers())
|
|
886
|
-
for (const rkid of k(held))
|
|
1030
|
+
for (const rkid of k(held, when))
|
|
887
1031
|
live.add(rkid);
|
|
888
1032
|
const tombstones = [...held.addresses.tombstones];
|
|
889
1033
|
let gone = false;
|
|
@@ -935,6 +1079,7 @@ class HolderEngine {
|
|
|
935
1079
|
const sweeps = () => this.#sweeps;
|
|
936
1080
|
const failed = (st, digests, when) => this.#failed(st, digests, when);
|
|
937
1081
|
const ownRetries = () => this.#ownRetries;
|
|
1082
|
+
const giveUp = (it, when) => this.#giveUp(it, when);
|
|
938
1083
|
return {
|
|
939
1084
|
human: false,
|
|
940
1085
|
// the due duties' and the sweeps' preparations, against the snapshot apply will see
|
|
@@ -963,17 +1108,20 @@ class HolderEngine {
|
|
|
963
1108
|
const trias = triasOf(it);
|
|
964
1109
|
if (trias === 'delivered' || trias === 'failed')
|
|
965
1110
|
continue;
|
|
966
|
-
// the horizon applies to every non-terminal item: held, accepted or
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1111
|
+
// the horizon applies to every non-terminal item: held, accepted or
|
|
1112
|
+
// not; a sender lifetime the type declares ends it earlier, as
|
|
1113
|
+
// failed(task-expired), never handed out again (Delivery 6110, 6115)
|
|
1114
|
+
const why = giveUp(it, when);
|
|
1115
|
+
if (why) {
|
|
970
1116
|
const { nextAttemptAt: _, ...rest } = it;
|
|
971
|
-
outbox[digest] = { ...rest, history: [...it.history, { status: 'failed', at: when, reason:
|
|
1117
|
+
outbox[digest] = { ...rest, history: [...it.history, { status: 'failed', at: when, reason: why }] };
|
|
972
1118
|
events.push({ type: 'delivery.status', digest, threadId: it.threadId, status: 'failed' });
|
|
973
1119
|
gaveUp.push(digest);
|
|
974
1120
|
continue;
|
|
975
1121
|
}
|
|
976
|
-
|
|
1122
|
+
const first = it.history.length ? it.history[0].at : when;
|
|
1123
|
+
const expiry = Math.min(first + p.horizonMs, Number.MAX_SAFE_INTEGER);
|
|
1124
|
+
due(it.expiresAt !== undefined ? Math.min(it.expiresAt, expiry) : expiry);
|
|
977
1125
|
// never re-emitted: an accepted item (the service has it) and one the
|
|
978
1126
|
// adapter holds (awaiting-transport, until it reports again)
|
|
979
1127
|
if (trias === 'accepted' || it.history[it.history.length - 1]?.status === 'awaiting-transport')
|
|
@@ -1102,11 +1250,14 @@ class HolderEngine {
|
|
|
1102
1250
|
const expiry = Math.min(a.at + p.horizonMs, Number.MAX_SAFE_INTEGER);
|
|
1103
1251
|
effects ??= { ...cur.effects };
|
|
1104
1252
|
if (when >= expiry) {
|
|
1105
|
-
const { nextAttemptAt: _, ...rest } = a;
|
|
1253
|
+
const { nextAttemptAt: _, held: _h, ...rest } = a;
|
|
1106
1254
|
effects[d] = { ...cur.effects[d], ack: { ...rest, sent: true, sentBy: 'horizon' } };
|
|
1107
1255
|
continue;
|
|
1108
1256
|
}
|
|
1109
1257
|
due(expiry);
|
|
1258
|
+
// held: the adapter has it (awaiting-transport); pendingTransport offers it
|
|
1259
|
+
if (a.held !== undefined)
|
|
1260
|
+
continue;
|
|
1110
1261
|
if (a.nextAttemptAt !== undefined && a.nextAttemptAt > when) {
|
|
1111
1262
|
due(a.nextAttemptAt);
|
|
1112
1263
|
continue;
|
|
@@ -1131,21 +1282,57 @@ class HolderEngine {
|
|
|
1131
1282
|
},
|
|
1132
1283
|
};
|
|
1133
1284
|
}
|
|
1285
|
+
/** an answer's hand-over began: it is no longer due (handOver) */
|
|
1286
|
+
#answeredDef() {
|
|
1287
|
+
return {
|
|
1288
|
+
human: false,
|
|
1289
|
+
apply(state, _p, input) {
|
|
1290
|
+
const eff = Object.keys(state.effects).find((d) => state.effects[d].ack?.digest === input.digest);
|
|
1291
|
+
if (eff === undefined)
|
|
1292
|
+
return { state: state };
|
|
1293
|
+
const a = state.effects[eff].ack;
|
|
1294
|
+
if (a.answerDue !== true || a.answers !== input.n)
|
|
1295
|
+
return { state: state };
|
|
1296
|
+
const { answerDue: _d, ...rest } = a;
|
|
1297
|
+
return { state: { ...state, effects: { ...state.effects, [eff]: { ...state.effects[eff], ack: rest } } } };
|
|
1298
|
+
},
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1134
1301
|
#reportDef() {
|
|
1135
1302
|
const failed = (st, digests, when) => this.#failed(st, digests, when);
|
|
1136
1303
|
return {
|
|
1137
1304
|
human: false,
|
|
1138
1305
|
apply(state, _p, report, when) {
|
|
1139
1306
|
if (!Object.hasOwn(state.outbox, report.digest)) {
|
|
1140
|
-
// an ack (outbox-less
|
|
1307
|
+
// an ack (outbox-less, no trias; Delivery RLTP-DEL-6330): sent once
|
|
1308
|
+
// a service took it or the adapter gave it up; awaiting-transport
|
|
1309
|
+
// records that the adapter holds it and ends nothing
|
|
1141
1310
|
const eff = Object.keys(state.effects).find((d) => state.effects[d].ack?.digest === report.digest);
|
|
1142
1311
|
if (eff === undefined)
|
|
1143
1312
|
return { ok: false, code: 'unknown-digest' };
|
|
1144
1313
|
const e = state.effects[eff];
|
|
1145
|
-
|
|
1314
|
+
const a = e.ack;
|
|
1315
|
+
if (a.sent)
|
|
1146
1316
|
return { state: state };
|
|
1147
|
-
const { nextAttemptAt: _, ...
|
|
1148
|
-
|
|
1317
|
+
const { nextAttemptAt: _, held: _h, ...rest } = a;
|
|
1318
|
+
let ack;
|
|
1319
|
+
switch (report.status) {
|
|
1320
|
+
case 'awaiting-transport':
|
|
1321
|
+
if (a.held === report.reason)
|
|
1322
|
+
return { state: state };
|
|
1323
|
+
ack = { ...rest, held: report.reason };
|
|
1324
|
+
break;
|
|
1325
|
+
case 'accepted':
|
|
1326
|
+
case 'delivered':
|
|
1327
|
+
ack = { ...rest, sent: true, sentBy: 'accepted' };
|
|
1328
|
+
break;
|
|
1329
|
+
case 'failed':
|
|
1330
|
+
ack = { ...rest, sent: true, sentBy: 'failed' };
|
|
1331
|
+
break;
|
|
1332
|
+
default:
|
|
1333
|
+
throw new TypeError(`unknown report status ${String(report.status)}`);
|
|
1334
|
+
}
|
|
1335
|
+
return { state: { ...state, effects: { ...state.effects, [eff]: { ...e, ack } } } };
|
|
1149
1336
|
}
|
|
1150
1337
|
const it = state.outbox[report.digest];
|
|
1151
1338
|
const trias = triasOf(it);
|
|
@@ -1199,6 +1386,7 @@ class HolderEngine {
|
|
|
1199
1386
|
#receiveDef() {
|
|
1200
1387
|
const tasks = () => this.#tasks;
|
|
1201
1388
|
const acks = () => this.#acks;
|
|
1389
|
+
const acceptedHooks = () => this.#accepted;
|
|
1202
1390
|
const policy = () => this.#policy;
|
|
1203
1391
|
return {
|
|
1204
1392
|
human: false,
|
|
@@ -1211,8 +1399,14 @@ class HolderEngine {
|
|
|
1211
1399
|
case 'duplicate': {
|
|
1212
1400
|
// the stored ack, byte-identical; a terminal document has none
|
|
1213
1401
|
// (its outbound attempt is one past the schedule's, which it does not move)
|
|
1402
|
+
// and an answer due (Delivery RLTP-DEL-6330): a hand-over of its
|
|
1403
|
+
// own, kept across a lock until it begins; while one is due, a
|
|
1404
|
+
// further duplicate is answered by it (the same bytes)
|
|
1214
1405
|
const a = state.effects[plan.digest].ack;
|
|
1215
|
-
|
|
1406
|
+
const next = a && a.answerDue === undefined
|
|
1407
|
+
? { ...state, effects: { ...state.effects, [plan.digest]: { ...state.effects[plan.digest], ack: { ...a, answers: (a.answers ?? 0) + 1, answerDue: true } } } }
|
|
1408
|
+
: state;
|
|
1409
|
+
return { state: next, release: a ? [ackOutbound(a, a.attempt + 1)] : [], detail: { disposition: 'duplicate-known', digest: plan.digest } };
|
|
1216
1410
|
}
|
|
1217
1411
|
case 'ack': {
|
|
1218
1412
|
// terminal: the sender-status update and its cache entry, no ack;
|
|
@@ -1228,6 +1422,13 @@ class HolderEngine {
|
|
|
1228
1422
|
next = r.state;
|
|
1229
1423
|
events.push(...(r.events ?? []));
|
|
1230
1424
|
}
|
|
1425
|
+
// a verified ack is an accepted document of its issuer too: the
|
|
1426
|
+
// modules' accepted hooks, in the same transaction
|
|
1427
|
+
for (const hook of acceptedHooks()) {
|
|
1428
|
+
next = hook(next, plan.doc, plan.arrival);
|
|
1429
|
+
if (next.effects !== state.effects)
|
|
1430
|
+
throw new Error('engine: an accepted hook wrote the effect cache');
|
|
1431
|
+
}
|
|
1231
1432
|
return {
|
|
1232
1433
|
state: { ...next, effects: { ...next.effects, [plan.digest]: cached(null) } },
|
|
1233
1434
|
events,
|
|
@@ -1253,8 +1454,15 @@ class HolderEngine {
|
|
|
1253
1454
|
}
|
|
1254
1455
|
if (r.state.effects !== state.effects)
|
|
1255
1456
|
throw new Error(`engine: the handler of ${plan.type} wrote the effect cache; the core writes it`);
|
|
1457
|
+
// the modules' accepted hooks, in the same transaction
|
|
1458
|
+
let after = r.state;
|
|
1459
|
+
for (const hook of acceptedHooks()) {
|
|
1460
|
+
after = hook(after, plan.doc, plan.arrival);
|
|
1461
|
+
if (after.effects !== state.effects)
|
|
1462
|
+
throw new Error('engine: an accepted hook wrote the effect cache');
|
|
1463
|
+
}
|
|
1256
1464
|
return {
|
|
1257
|
-
state: { ...
|
|
1465
|
+
state: { ...after, effects: { ...state.effects, [plan.digest]: cached({ ...plan.ack, at: when, attempt: 1, nextAttemptAt: dueAt(when, policy().backoff(1)), sent: false }) } },
|
|
1258
1466
|
...(r.outbound ? { outbound: r.outbound } : {}),
|
|
1259
1467
|
...(r.events ? { events: r.events } : {}),
|
|
1260
1468
|
...(r.duties ? { duties: r.duties } : {}),
|
|
@@ -1314,12 +1522,16 @@ class HolderEngine {
|
|
|
1314
1522
|
catch (e) {
|
|
1315
1523
|
return Promise.reject(e);
|
|
1316
1524
|
}
|
|
1525
|
+
// fail closed: a resume that throws anywhere — the settling of an
|
|
1526
|
+
// earlier commit included — leaves nothing to go out until one completes
|
|
1317
1527
|
return this.#enqueue(async () => {
|
|
1528
|
+
this.#resumed = false;
|
|
1318
1529
|
const r = await this.#run(this.#builtins.resume, { kind: 'resume' }, null, when);
|
|
1319
1530
|
if (isHalt(r) || !r.ok)
|
|
1320
1531
|
throw new Error('engine: resume does not refuse');
|
|
1532
|
+
this.#resumed = true;
|
|
1321
1533
|
return { outbound: r.outbound, events: r.events, ...(r.nextDueAt !== undefined ? { nextDueAt: r.nextDueAt } : {}) };
|
|
1322
|
-
});
|
|
1534
|
+
}).catch((e) => { this.#resumed = false; throw e; });
|
|
1323
1535
|
}
|
|
1324
1536
|
report(report, when) {
|
|
1325
1537
|
let bound;
|
|
@@ -1377,6 +1589,182 @@ class HolderEngine {
|
|
|
1377
1589
|
return Object.freeze({ state, commitSeq: state.commitSeq });
|
|
1378
1590
|
});
|
|
1379
1591
|
}
|
|
1592
|
+
pendingTransport(when) {
|
|
1593
|
+
try {
|
|
1594
|
+
checkWhen(when);
|
|
1595
|
+
}
|
|
1596
|
+
catch (e) {
|
|
1597
|
+
return Promise.reject(e);
|
|
1598
|
+
}
|
|
1599
|
+
return this.#enqueue(async () => {
|
|
1600
|
+
if (!this.#resumed)
|
|
1601
|
+
return Object.freeze({ ok: false, code: 'not-resumed' });
|
|
1602
|
+
const { state } = await this.#read();
|
|
1603
|
+
// the holder's time never runs back: an earlier `when` reads at lastWhen
|
|
1604
|
+
const at = Math.max(when, state.lastWhen);
|
|
1605
|
+
const entries = transportEntries(state, at, this.#policy.horizonMs).map((e) => {
|
|
1606
|
+
const f = freezeDeep(e);
|
|
1607
|
+
this.#issued.add(f);
|
|
1608
|
+
return f;
|
|
1609
|
+
});
|
|
1610
|
+
return Object.freeze({ ok: true, commitSeq: state.commitSeq, at, entries: Object.freeze(entries) });
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
mayHandOver(entry, when) {
|
|
1614
|
+
try {
|
|
1615
|
+
checkWhen(when);
|
|
1616
|
+
}
|
|
1617
|
+
catch (e) {
|
|
1618
|
+
return Promise.reject(e);
|
|
1619
|
+
}
|
|
1620
|
+
if (this.#closed || entry === null || typeof entry !== 'object' || !this.#issued.has(entry))
|
|
1621
|
+
return Promise.resolve(false);
|
|
1622
|
+
return this.#enqueue(() => this.#mayGo(entry, when)).catch((e) => {
|
|
1623
|
+
if (e instanceof InfraError && e.code === 'closed')
|
|
1624
|
+
return false;
|
|
1625
|
+
throw e;
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
handOver(entry, when, start) {
|
|
1629
|
+
try {
|
|
1630
|
+
checkWhen(when);
|
|
1631
|
+
}
|
|
1632
|
+
catch (e) {
|
|
1633
|
+
return Promise.reject(e);
|
|
1634
|
+
}
|
|
1635
|
+
if (typeof start !== 'function')
|
|
1636
|
+
return Promise.reject(new TypeError('handOver needs a start function'));
|
|
1637
|
+
if (this.#closed || entry === null || typeof entry !== 'object' || !this.#issued.has(entry))
|
|
1638
|
+
return Promise.resolve(false);
|
|
1639
|
+
return this.#enqueue(async () => {
|
|
1640
|
+
if (!this.#resumed)
|
|
1641
|
+
return false;
|
|
1642
|
+
const { state } = await this.#read();
|
|
1643
|
+
// no await from here to start(): close() and every other operation
|
|
1644
|
+
// of this engine come before the check or after the start
|
|
1645
|
+
if (!this.#passes(state, entry, when))
|
|
1646
|
+
return false;
|
|
1647
|
+
if (start() !== true)
|
|
1648
|
+
return false;
|
|
1649
|
+
// an answer's hand-over has begun: it is no longer due (a later
|
|
1650
|
+
// duplicate grants the next); a commit that fails leaves it due, and
|
|
1651
|
+
// it may go out once more — the receiver absorbs it
|
|
1652
|
+
if (entry.resend !== undefined) {
|
|
1653
|
+
try {
|
|
1654
|
+
await this.#run(this.#answeredDef(), { kind: 'report', status: 'answered' }, { digest: entry.digest, n: entry.resend }, Math.max(when, state.lastWhen));
|
|
1655
|
+
}
|
|
1656
|
+
catch { /* stays due */ }
|
|
1657
|
+
}
|
|
1658
|
+
return true;
|
|
1659
|
+
}).catch((e) => {
|
|
1660
|
+
if (e instanceof InfraError && e.code === 'closed')
|
|
1661
|
+
return false;
|
|
1662
|
+
throw e;
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
/** the gate's check, inside the queue: open, resumed, still pending in
|
|
1666
|
+
* the store as issued, before its deadline */
|
|
1667
|
+
async #mayGo(entry, when) {
|
|
1668
|
+
if (!this.#resumed)
|
|
1669
|
+
return false;
|
|
1670
|
+
const { state } = await this.#read();
|
|
1671
|
+
return this.#passes(state, entry, when);
|
|
1672
|
+
}
|
|
1673
|
+
/** synchronous: open, resumed, the entry pending in `state` as issued,
|
|
1674
|
+
* before its deadline at max(when, lastWhen) */
|
|
1675
|
+
#passes(state, entry, when) {
|
|
1676
|
+
if (this.#closed || !this.#resumed)
|
|
1677
|
+
return false;
|
|
1678
|
+
const now = transportEntryOf(state, entry.kind, entry.digest, Math.max(when, state.lastWhen), this.#policy.horizonMs, entry.resend);
|
|
1679
|
+
return now !== null && now.resend === entry.resend && jcs(now.envelope) === jcs(entry.envelope);
|
|
1680
|
+
}
|
|
1681
|
+
close() {
|
|
1682
|
+
this.#closed = true;
|
|
1683
|
+
return this.#chain.then(() => undefined, () => undefined);
|
|
1684
|
+
}
|
|
1685
|
+
get closed() { return this.#closed; }
|
|
1686
|
+
}
|
|
1687
|
+
/** The send deadline of an outbox item: the horizon from its first
|
|
1688
|
+
* submission, or the sender lifetime its type declared if that is
|
|
1689
|
+
* earlier (#giveUp's two bounds as one instant). */
|
|
1690
|
+
const notAfterOf = (it, horizonMs) => {
|
|
1691
|
+
if (!it.history.length)
|
|
1692
|
+
return null;
|
|
1693
|
+
const expiry = Math.min(it.history[0].at + horizonMs, Number.MAX_SAFE_INTEGER);
|
|
1694
|
+
return it.expiresAt !== undefined ? Math.min(it.expiresAt, expiry) : expiry;
|
|
1695
|
+
};
|
|
1696
|
+
/** The transport entry of one pending document or ack at `when`, or null:
|
|
1697
|
+
* terminal, accepted, sent, past its deadline, or absent (RLTP-DEL-6135:
|
|
1698
|
+
* absent means stop). */
|
|
1699
|
+
function transportEntryOf(state, kind, digest, when, horizonMs, resend) {
|
|
1700
|
+
if (kind === 'document') {
|
|
1701
|
+
if (!Object.hasOwn(state.outbox, digest))
|
|
1702
|
+
return null;
|
|
1703
|
+
const it = state.outbox[digest];
|
|
1704
|
+
const trias = triasOf(it);
|
|
1705
|
+
if (trias !== 'submitted')
|
|
1706
|
+
return null;
|
|
1707
|
+
const notAfter = notAfterOf(it, horizonMs);
|
|
1708
|
+
if (notAfter === null || when >= notAfter)
|
|
1709
|
+
return null;
|
|
1710
|
+
const last = it.history[it.history.length - 1];
|
|
1711
|
+
return structuredClone({
|
|
1712
|
+
digest, kind, envelope: it.envelope, type: it.type, threadId: it.threadId, recipient: it.recipient, attempt: it.attempt, since: it.history[0].at, notAfter,
|
|
1713
|
+
...(last.status === 'awaiting-transport' ? { held: last.reason } : it.nextAttemptAt !== undefined ? { nextAttemptAt: it.nextAttemptAt } : {}),
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
for (const d of Object.keys(state.effects)) {
|
|
1717
|
+
const a = state.effects[d].ack;
|
|
1718
|
+
if (a === null || a.digest !== digest)
|
|
1719
|
+
continue;
|
|
1720
|
+
if (resend !== undefined) {
|
|
1721
|
+
// a duplicate's answer: its own hand-over, apart from the retries and
|
|
1722
|
+
// whatever state they are in, while due and the effect keeps the ack (6335)
|
|
1723
|
+
if (a.answerDue !== true || a.answers !== resend || when >= state.effects[d].expiresAt)
|
|
1724
|
+
return null;
|
|
1725
|
+
return structuredClone({
|
|
1726
|
+
digest, kind, envelope: a.envelope, type: ACK_TYPE, threadId: a.threadId, recipient: a.recipient, attempt: a.attempt, since: a.at,
|
|
1727
|
+
notAfter: state.effects[d].expiresAt, resend,
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
const notAfter = Math.min(a.at + horizonMs, Number.MAX_SAFE_INTEGER);
|
|
1731
|
+
if (a.sent || when >= notAfter)
|
|
1732
|
+
return null;
|
|
1733
|
+
return structuredClone({
|
|
1734
|
+
digest, kind, envelope: a.envelope, type: ACK_TYPE, threadId: a.threadId, recipient: a.recipient, attempt: a.attempt, since: a.at, notAfter,
|
|
1735
|
+
...(a.held !== undefined ? { held: a.held } : a.nextAttemptAt !== undefined ? { nextAttemptAt: a.nextAttemptAt } : {}),
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
return null;
|
|
1739
|
+
}
|
|
1740
|
+
/** Everything the holder keeps for transport at `when`: the outbox items
|
|
1741
|
+
* by due time, then digest, then the unsent acks by effect digest. */
|
|
1742
|
+
function transportEntries(state, when, horizonMs) {
|
|
1743
|
+
const out = [];
|
|
1744
|
+
const order = Object.keys(state.outbox).sort((a, b) => ((state.outbox[a].nextAttemptAt ?? 0) - (state.outbox[b].nextAttemptAt ?? 0)) || (a < b ? -1 : a > b ? 1 : 0));
|
|
1745
|
+
for (const d of order) {
|
|
1746
|
+
const e = transportEntryOf(state, 'document', d, when, horizonMs);
|
|
1747
|
+
if (e)
|
|
1748
|
+
out.push(e);
|
|
1749
|
+
}
|
|
1750
|
+
for (const d of Object.keys(state.effects).sort()) {
|
|
1751
|
+
const a = state.effects[d].ack;
|
|
1752
|
+
if (a === null)
|
|
1753
|
+
continue;
|
|
1754
|
+
const e = transportEntryOf(state, 'ack', a.digest, when, horizonMs);
|
|
1755
|
+
if (e)
|
|
1756
|
+
out.push(e);
|
|
1757
|
+
}
|
|
1758
|
+
// the duplicates' answers due, each its own entry beside the retries
|
|
1759
|
+
for (const d of Object.keys(state.effects).sort()) {
|
|
1760
|
+
const a = state.effects[d].ack;
|
|
1761
|
+
if (a === null || a.answerDue !== true)
|
|
1762
|
+
continue;
|
|
1763
|
+
const e = transportEntryOf(state, 'ack', a.digest, when, horizonMs, a.answers);
|
|
1764
|
+
if (e)
|
|
1765
|
+
out.push(e);
|
|
1766
|
+
}
|
|
1767
|
+
return out;
|
|
1380
1768
|
}
|
|
1381
1769
|
/**
|
|
1382
1770
|
* Build an engine over the ports and modules: resolves and probes
|