@volter/supercode-teams 0.3.159 → 0.3.161
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/connector/index.mjs +2 -2
- package/live-sessions.mjs +12 -3
- package/machine-records.mjs +113 -45
- package/machine.mjs +75 -63
- package/mail/carrier.mjs +202 -157
- package/mail/change-stream.mjs +143 -0
- package/mail/delivery-status.mjs +42 -0
- package/mail/hooks.mjs +90 -15
- package/mail/reads.mjs +2 -1
- package/mail/relay-routing.mjs +41 -0
- package/mail/relay.mjs +120 -204
- package/mail/send.mjs +56 -32
- package/mail/sqlite-store.mjs +166 -16
- package/mail/sqlite.mjs +42 -4
- package/package.json +1 -1
- package/record-state.mjs +143 -0
package/mail/carrier.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// - a hand-over is waited for at most HANDOVER_BOUND_MS, and one mailbox's attempt never waits on another's;
|
|
15
15
|
// - each attempt is a line of the carrier's record.
|
|
16
16
|
import { render } from './envelope.mjs';
|
|
17
|
+
import { expireDelivery, paneWaitReason, terminalReceipt } from './delivery-status.mjs';
|
|
17
18
|
|
|
18
19
|
const FIRST_RETRY_MS = 2_000;
|
|
19
20
|
const LAST_RETRY_MS = 300_000;
|
|
@@ -25,6 +26,10 @@ const OFFLINE_CEILING_MS = 30 * 24 * 60 * 60_000;
|
|
|
25
26
|
// and the hand-over itself answers 'later' until it settles (MailSend.handOver), so a frozen door holds only its own
|
|
26
27
|
// mailbox's wakes, and only for this long
|
|
27
28
|
const HANDOVER_BOUND_MS = 150_000;
|
|
29
|
+
// Each target proceeds independently, with bounded simultaneous door calls.
|
|
30
|
+
// In-flight targets are excluded from the one next-due query until they settle.
|
|
31
|
+
const MAX_ACTIVE_TARGETS = 16;
|
|
32
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
28
33
|
|
|
29
34
|
const offline = (text) => String(text).includes('machine_offline');
|
|
30
35
|
|
|
@@ -35,195 +40,221 @@ export class MailCarrier {
|
|
|
35
40
|
* `typeTurn(pane, harness, text, ids)` a wake typed into a hooked session's pane; `askMachine(machine, request)` one
|
|
36
41
|
* request to another machine's mail door.
|
|
37
42
|
*/
|
|
38
|
-
constructor({ machine, store, live, handOver, typeTurn, askMachine, log = () => {} }) {
|
|
39
|
-
Object.assign(this, { machine, store, live, handOver, typeTurn, askMachine, log });
|
|
43
|
+
constructor({ machine, store, live, handOver, typeTurn, typeUserTurns, paneOf, askMachine, log = () => {} }) {
|
|
44
|
+
Object.assign(this, { machine, store, live, handOver, typeTurn, typeUserTurns, paneOf, askMachine, log });
|
|
40
45
|
this.stopped = false;
|
|
41
|
-
this.
|
|
42
|
-
this.
|
|
43
|
-
this.
|
|
44
|
-
this.carrying = new Set(); // machines with a carry in flight
|
|
45
|
-
this.remote = new Map(); // machine → Map(`${address}\0${id}` → { address, id }): wakes for another machine
|
|
46
|
+
this.active = new Map(); // kind + target → the door call in progress
|
|
47
|
+
this.timer = null;
|
|
48
|
+
this.timerAt = null;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
/**
|
|
49
|
-
*
|
|
50
|
-
* bound. */
|
|
51
|
+
/** Writes refresh their target's persisted due row before notifying this
|
|
52
|
+
* scheduler. Only the startup/five-minute reconciliation walks all targets. */
|
|
51
53
|
start() {
|
|
52
|
-
this.onWake = (
|
|
54
|
+
this.onWake = () => this.#schedule();
|
|
53
55
|
this.store.on('wake', this.onWake);
|
|
56
|
+
this.onLive = () => {
|
|
57
|
+
if (this.stopped) return;
|
|
58
|
+
try { this.store.wakeIdleSessions(this.live.sessions().filter((row) => row.status === 'idle').map((row) => row.address)); }
|
|
59
|
+
catch (error) { this.log(`carrier: idle waits could not be resumed: ${error.message}`); }
|
|
60
|
+
};
|
|
61
|
+
this.live.on('changed', this.onLive);
|
|
54
62
|
this.#sweep();
|
|
55
63
|
this.sweeper = setInterval(() => this.#sweep(), LAST_RETRY_MS);
|
|
56
64
|
this.sweeper.unref?.();
|
|
57
65
|
return this;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
#sweep() {
|
|
69
|
+
if (this.stopped) return;
|
|
70
|
+
try { this.store.reconcileDeliverySchedule(); this.#schedule(); }
|
|
71
|
+
catch (error) { this.log(`carrier: its delivery schedule could not be reconciled: ${error.message}`); }
|
|
72
|
+
}
|
|
62
73
|
|
|
63
|
-
stop() {
|
|
74
|
+
stop() {
|
|
75
|
+
this.stopped = true;
|
|
76
|
+
this.store.off('wake', this.onWake);
|
|
77
|
+
this.live.off('changed', this.onLive);
|
|
78
|
+
clearInterval(this.sweeper); clearTimeout(this.timer);
|
|
79
|
+
this.timer = null; this.timerAt = null;
|
|
80
|
+
}
|
|
64
81
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
82
|
+
#next() { return this.store.nextDeliveryTarget([...this.active.values()]); }
|
|
83
|
+
|
|
84
|
+
/** There is one due timer. A changed earlier deadline replaces it immediately,
|
|
85
|
+
* and a removed last delivery disarms it. Very distant dates are clamped to
|
|
86
|
+
* Node's timer range and queried again when that bound ends. */
|
|
87
|
+
#schedule() {
|
|
88
|
+
if (this.stopped) return;
|
|
89
|
+
let next;
|
|
90
|
+
try { next = this.active.size < MAX_ACTIVE_TARGETS ? this.#next() : null; }
|
|
91
|
+
catch (error) { this.log(`carrier: its next deadline could not be read: ${error.message}`); return; }
|
|
92
|
+
const at = next?.due_at_ms ?? null;
|
|
93
|
+
if (this.timer && this.timerAt === at) return;
|
|
94
|
+
clearTimeout(this.timer); this.timer = null; this.timerAt = at;
|
|
95
|
+
if (at == null) return;
|
|
96
|
+
this.timer = setTimeout(() => {
|
|
97
|
+
this.timer = null; this.timerAt = null;
|
|
98
|
+
try { this.#dispatch(); }
|
|
99
|
+
catch (error) { this.log(`carrier: its due target could not be read: ${error.message}`); }
|
|
100
|
+
}, Math.min(MAX_TIMER_MS, Math.max(0, at - Date.now())));
|
|
101
|
+
this.timer.unref?.();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#dispatch() {
|
|
105
|
+
if (this.stopped) return;
|
|
106
|
+
while (this.active.size < MAX_ACTIVE_TARGETS) {
|
|
107
|
+
const next = this.#next();
|
|
108
|
+
if (!next || next.due_at_ms > Date.now()) break;
|
|
109
|
+
const key = `${next.kind}\0${next.target}`;
|
|
110
|
+
this.active.set(key, { kind: next.kind, target: next.target });
|
|
111
|
+
let failed = false;
|
|
112
|
+
void (next.kind === 'mailbox' ? this.#local(next.target) : this.#carry(next.target))
|
|
113
|
+
.catch((error) => {
|
|
114
|
+
failed = true;
|
|
115
|
+
this.log(`carrier: ${next.target}'s attempt could not finish: ${error.message}`);
|
|
116
|
+
// A failed store operation must not create an immediate retry loop.
|
|
117
|
+
// An ordinary write may schedule sooner; reconciliation is the backstop.
|
|
118
|
+
if (!this.stopped) this.store.delayDeliveryTarget(next.kind, next.target, Date.now() + LAST_RETRY_MS);
|
|
119
|
+
})
|
|
120
|
+
.finally(() => {
|
|
121
|
+
this.active.delete(key);
|
|
122
|
+
if (!this.stopped) {
|
|
123
|
+
if (!failed) this.store.refreshDeliveryTarget(next.kind, next.target);
|
|
124
|
+
this.#schedule();
|
|
125
|
+
}
|
|
126
|
+
}).catch((error) => this.log(`carrier: ${next.target}'s schedule could not be kept: ${error.message}`));
|
|
74
127
|
}
|
|
75
|
-
|
|
76
|
-
if (this.timers.has(key)) return;
|
|
77
|
-
const state = this.store.wakeState(address, id);
|
|
78
|
-
const timer = setTimeout(() => { this.timers.delete(key); void this.#local(address); }, Math.max(0, (state.next_at_ms ?? 0) - Date.now()));
|
|
79
|
-
timer.unref?.();
|
|
80
|
-
this.timers.set(key, timer);
|
|
128
|
+
this.#schedule();
|
|
81
129
|
}
|
|
82
130
|
|
|
83
131
|
/** The wakes of one mailbox here that are due, attempted (mail_watch.rs `tick`, its local half). */
|
|
84
132
|
async #local(address) {
|
|
85
133
|
if (this.stopped) return;
|
|
86
|
-
|
|
87
|
-
this.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
else {
|
|
102
|
-
try {
|
|
103
|
-
const typed = await this.typeTurn(session.pane, address.split(':')[2], render(envelope), [id]);
|
|
104
|
-
// held: the daemon's queue for the session types it once the composer frees; nothing asks it again
|
|
105
|
-
carried = typed?.delivered ? { kind: 'delivered' } : typed?.queued ? { kind: 'filed' } : { kind: 'later', reason: 'its pane was not at its prompt' };
|
|
106
|
-
} catch (error) { carried = { kind: 'later', reason: error.message }; }
|
|
107
|
-
}
|
|
108
|
-
if (this.stopped) return;
|
|
109
|
-
this.#record({ to: address, id, path: `${session.door}-pane`, carried });
|
|
110
|
-
this.#settle(address, id, carried);
|
|
111
|
-
}
|
|
112
|
-
return;
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
let due = this.store.dueWakes(address, now);
|
|
136
|
+
if (!due.length) return;
|
|
137
|
+
// a session in a daemon pane whose mail is typed there: a hooked one (its hook shows it the rest), and one with no
|
|
138
|
+
// hook and no relay (Grok), whose mail that found its composer busy waits here until it is free
|
|
139
|
+
const session = this.live.sessions().find((row) => row.address === address);
|
|
140
|
+
const pane = session ? this.paneOf(session) : null;
|
|
141
|
+
const users = due.filter((id) => this.store.findIn(address, id)?.envelope.kind === 'user');
|
|
142
|
+
if (users.length) {
|
|
143
|
+
if (pane) await this.typeUserTurns(address, pane);
|
|
144
|
+
if (this.stopped) return;
|
|
145
|
+
for (const id of users) {
|
|
146
|
+
const found = this.store.findIn(address, id);
|
|
147
|
+
if (!found || found.state === 'read') this.#settle(address, id, { kind: 'filed' });
|
|
148
|
+
else this.store.setWakeState(address, id, { attempts: 0, next_at_ms: Date.now() + (pane ? FIRST_RETRY_MS : LAST_RETRY_MS), last_error: 'its user turn waits for an empty composer' });
|
|
113
149
|
}
|
|
150
|
+
due = due.filter((id) => !users.includes(id));
|
|
151
|
+
}
|
|
152
|
+
// A terminal queue owns its accepted paste. Follow its durable outcome
|
|
153
|
+
// instead of sending another input or treating a queue entry as delivery.
|
|
154
|
+
due = due.filter((id) => {
|
|
155
|
+
const found = this.store.findIn(address, id);
|
|
156
|
+
if (!found || (found.state === 'read' && this.store.claimOf(address, id)?.recipient === true)) {
|
|
157
|
+
this.#settle(address, id, { kind: 'filed' });
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
const receipt = terminalReceipt(this.store, address, id);
|
|
161
|
+
if (!receipt) return true;
|
|
162
|
+
this.#settle(address, id, receipt.delivered ? { kind: 'delivered' }
|
|
163
|
+
: receipt.refused ? { kind: 'refused', reason: receipt.reason }
|
|
164
|
+
: { kind: 'pending', reason: receipt.reason });
|
|
165
|
+
return false;
|
|
166
|
+
});
|
|
167
|
+
if ((session?.door === 'hook' || session?.door === 'pane') && pane) {
|
|
114
168
|
for (const id of due) {
|
|
115
|
-
const
|
|
169
|
+
const stored = this.store.findIn(address, id);
|
|
170
|
+
const envelope = stored ? this.store.body(stored) : null;
|
|
116
171
|
let carried;
|
|
117
|
-
|
|
118
|
-
// never delivered twice; a read by anyone else does not count
|
|
119
|
-
if (!found || (found.state === 'read' && this.store.claimOf(address, id)?.recipient === true)) carried = { kind: 'filed' };
|
|
172
|
+
if (!envelope) carried = { kind: 'filed' };
|
|
120
173
|
else {
|
|
121
174
|
try {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
carried = answer?.outcome === 'delivered' ? { kind: 'delivered' } : answer?.outcome === 'filed' ? { kind: 'filed' } : { kind: 'later', reason: answer?.detail ?? 'not handed over' };
|
|
128
|
-
} catch (error) { carried = { kind: 'later', reason: `this machine's daemon did not hand it over: ${error.message}` }; }
|
|
175
|
+
const typed = await this.typeTurn(pane, address.split(':')[2], render(envelope), [id], address);
|
|
176
|
+
// held: the daemon's queue for the session types it once the composer frees; nothing asks it again
|
|
177
|
+
carried = typed?.delivered ? { kind: 'delivered' } : typed?.queued ? { kind: 'pending', reason: typed.reason }
|
|
178
|
+
: typed?.refused === true ? { kind: 'refused', reason: typed.reason } : { kind: 'later', reason: paneWaitReason(typed) };
|
|
179
|
+
} catch (error) { carried = { kind: 'later', reason: error.message }; }
|
|
129
180
|
}
|
|
130
181
|
if (this.stopped) return;
|
|
131
|
-
this.#record({ to: address, id, path:
|
|
182
|
+
this.#record({ to: address, id, path: `${session.door}-pane`, carried });
|
|
132
183
|
this.#settle(address, id, carried);
|
|
133
184
|
}
|
|
134
|
-
|
|
135
|
-
this.running.delete(address);
|
|
136
|
-
this.#rewake(address);
|
|
185
|
+
return;
|
|
137
186
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (at == null) {
|
|
160
|
-
at = Infinity;
|
|
161
|
-
for (const { address, id } of this.#remoteWakes(machine)) at = Math.min(at, this.store.wakeState(address, id).next_at_ms ?? 0);
|
|
162
|
-
if (!Number.isFinite(at)) return;
|
|
187
|
+
for (const id of due) {
|
|
188
|
+
const found = this.store.findIn(address, id);
|
|
189
|
+
let carried;
|
|
190
|
+
// its recipient has it already (its own read, or a hand-over by an attempt that ended before it was settled):
|
|
191
|
+
// never delivered twice; a read by anyone else does not count
|
|
192
|
+
if (!found || (found.state === 'read' && this.store.claimOf(address, id)?.recipient === true)) carried = { kind: 'filed' };
|
|
193
|
+
else {
|
|
194
|
+
try {
|
|
195
|
+
let bound;
|
|
196
|
+
const answer = await Promise.race([
|
|
197
|
+
this.handOver(address, id),
|
|
198
|
+
new Promise((settle) => { bound = setTimeout(() => settle({ outcome: 'later', detail: `its hand-over was still running after ${HANDOVER_BOUND_MS / 1000} s` }), HANDOVER_BOUND_MS); bound.unref?.(); }),
|
|
199
|
+
]).finally(() => clearTimeout(bound));
|
|
200
|
+
carried = answer?.outcome === 'delivered' ? { kind: 'delivered' } : answer?.outcome === 'filed' ? { kind: 'filed' }
|
|
201
|
+
: answer?.outcome === 'waiting' ? { kind: 'waiting', reason: answer.detail }
|
|
202
|
+
: answer?.outcome === 'pending' ? { kind: 'pending', reason: answer.detail } : { kind: 'later', reason: answer?.detail ?? 'not handed over' };
|
|
203
|
+
} catch (error) { carried = { kind: 'later', reason: `this machine's daemon did not hand it over: ${error.message}` }; }
|
|
204
|
+
}
|
|
205
|
+
if (this.stopped) return;
|
|
206
|
+
this.#record({ to: address, id, path: 'door', carried });
|
|
207
|
+
this.#settle(address, id, carried);
|
|
163
208
|
}
|
|
164
|
-
const timer = setTimeout(() => { this.machines.delete(machine); void this.#carry(machine); }, Math.max(0, at - Date.now()));
|
|
165
|
-
timer.unref?.();
|
|
166
|
-
this.machines.set(machine, timer);
|
|
167
209
|
}
|
|
168
210
|
|
|
169
|
-
|
|
170
|
-
* wake whose message is gone, or that is gone itself, is let go. */
|
|
171
|
-
#remoteWakes(machine) {
|
|
172
|
-
const items = [];
|
|
173
|
-
for (const [key, item] of this.remote.get(machine) ?? []) {
|
|
174
|
-
const present = this.store.hasWake(item.address, item.id);
|
|
175
|
-
const found = present ? this.store.findIn(item.address, item.id) : null;
|
|
176
|
-
if (!found) { this.remote.get(machine).delete(key); if (present) this.store.removeWake(item.address, item.id); continue; }
|
|
177
|
-
const envelope = this.store.body(found);
|
|
178
|
-
items.push({ ...item, filed: Number(envelope?.created_at_ms) || 0 });
|
|
179
|
-
}
|
|
180
|
-
return items.sort((a, b) => a.filed - b.filed);
|
|
181
|
-
}
|
|
211
|
+
// ---- another machine's mail ------------------------------------------------------------------------------------
|
|
182
212
|
|
|
183
|
-
/** One
|
|
184
|
-
*
|
|
213
|
+
/** One offline probe establishes reachability. Once the machine answers,
|
|
214
|
+
* remaining batches obey each message's own retry deadline. */
|
|
185
215
|
async #carry(machine) {
|
|
186
216
|
if (this.stopped) return;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
let wait = this.store.machineWait(machine);
|
|
219
|
+
// Earlier candidates kept an online-draining wait. Its existence must not
|
|
220
|
+
// release newly assigned failure deadlines on this owner or after restart.
|
|
221
|
+
if (wait?.online_draining) { this.#setMachineWait(machine, null); wait = null; }
|
|
222
|
+
const offlineExpired = wait && now - (wait.offline_since_ms ?? now) >= OFFLINE_CEILING_MS;
|
|
223
|
+
const items = this.store.remoteWakes(machine, now, { probing: Boolean(wait), limit: wait && !offlineExpired ? 1 : 256 });
|
|
224
|
+
if (!items.length) return;
|
|
225
|
+
if (wait) {
|
|
226
|
+
// a machine away longer than the ceiling is not coming back for this mail: it expires, saying so
|
|
227
|
+
if (offlineExpired) {
|
|
228
|
+
const reason = `${machine} has been offline for ${OFFLINE_CEILING_MS / 86_400_000} days (since epoch ms ${wait.offline_since_ms}): ${wait.last_error ?? 'not linked'}`;
|
|
229
|
+
for (const item of items) this.#expire(item.address, item.id, this.store.wakeState(item.address, item.id).attempts ?? 0, reason);
|
|
230
|
+
if (!this.store.remoteWakes(machine, now, { probing: true, limit: 1 }).length) this.#setMachineWait(machine, null);
|
|
231
|
+
this.#line({ t: now, machine, outcome: 'expired', messages: items.length, reason });
|
|
232
|
+
return;
|
|
203
233
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (carried.kind === 'offline') { offlineAnswer = carried.reason; break; }
|
|
217
|
-
carriedHere++;
|
|
218
|
-
this.#settle(item.address, item.id, carried);
|
|
234
|
+
if ((wait.next_probe_ms ?? 0) > now) return;
|
|
235
|
+
}
|
|
236
|
+
let carriedHere = 0, offlineAnswer = null, called = false;
|
|
237
|
+
for (const item of items) {
|
|
238
|
+
if (!wait && (this.store.wakeState(item.address, item.id).next_at_ms ?? 0) > now) continue;
|
|
239
|
+
const found = this.store.findIn(item.address, item.id);
|
|
240
|
+
let carried;
|
|
241
|
+
// carried already (this carrier's own record): never carried twice
|
|
242
|
+
if (!found || (found.state === 'read' && this.store.claimOf(item.address, item.id)?.recipient === true)) carried = { kind: 'filed' };
|
|
243
|
+
else {
|
|
244
|
+
called = true;
|
|
245
|
+
carried = await this.#carryOne(machine, item.address, this.store.body(found));
|
|
219
246
|
}
|
|
220
|
-
if (
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
this.carrying.delete(machine);
|
|
225
|
-
this.#scheduleMachine(machine);
|
|
247
|
+
if (this.stopped) return;
|
|
248
|
+
if (carried.kind === 'offline') { offlineAnswer = carried.reason; break; }
|
|
249
|
+
carriedHere++;
|
|
250
|
+
this.#settle(item.address, item.id, carried);
|
|
226
251
|
}
|
|
252
|
+
if (called) this.#line({ t: now, machine, outcome: offlineAnswer ? 'offline' : 'carried', waiting: items.length, carried: carriedHere, reason: offlineAnswer });
|
|
253
|
+
if (offlineAnswer) this.#setMachineWait(machine, { machine, offline_since_ms: wait?.offline_since_ms ?? now, next_probe_ms: now + LAST_RETRY_MS, probes: (wait?.probes ?? 0) + 1, last_error: offlineAnswer });
|
|
254
|
+
// A refusal still proves that the machine answered. Clearing its wait
|
|
255
|
+
// restores due-row selection; this attempt's failure keeps its backoff,
|
|
256
|
+
// and unattempted due mail advances without probing that failure again.
|
|
257
|
+
else if (wait || called) this.#setMachineWait(machine, null);
|
|
227
258
|
}
|
|
228
259
|
|
|
229
260
|
/** One message carried to its machine's mail door, filed there with a wake. */
|
|
@@ -242,7 +273,7 @@ export class MailCarrier {
|
|
|
242
273
|
}
|
|
243
274
|
|
|
244
275
|
#setMachineWait(machine, wait) {
|
|
245
|
-
|
|
276
|
+
this.store.setMachineWait(machine, wait);
|
|
246
277
|
}
|
|
247
278
|
|
|
248
279
|
// ---- what waits, and what expired (`message waiting`, `message expired`) -----------------------------------------
|
|
@@ -255,7 +286,8 @@ export class MailCarrier {
|
|
|
255
286
|
for (const { address, id } of this.store.allWakes()) {
|
|
256
287
|
const machine = address.split(':')[1];
|
|
257
288
|
if (machine !== this.machine && !waits.has(machine)) waits.set(machine, this.store.machineWait(machine));
|
|
258
|
-
const
|
|
289
|
+
const heldWait = machine !== this.machine ? waits.get(machine) : null;
|
|
290
|
+
const wait = heldWait?.online_draining ? null : heldWait;
|
|
259
291
|
{
|
|
260
292
|
const state = this.store.wakeState(address, id);
|
|
261
293
|
if (!wait && !(state.attempts > 0) && state.last_error == null) continue;
|
|
@@ -288,9 +320,25 @@ export class MailCarrier {
|
|
|
288
320
|
return;
|
|
289
321
|
}
|
|
290
322
|
if (carried.kind === 'offline') return;
|
|
323
|
+
if (carried.kind === 'pending') {
|
|
324
|
+
const state = this.store.wakeState(address, id);
|
|
325
|
+
this.store.setWakeState(address, id, { ...state, next_at_ms: Date.now() + FIRST_RETRY_MS, last_error: carried.reason });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (carried.kind === 'waiting') {
|
|
329
|
+
const state = this.store.wakeState(address, id);
|
|
330
|
+
this.store.setWakeState(address, id, { ...state, waiting_for_idle: true, next_at_ms: Date.now() + LAST_RETRY_MS });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
291
333
|
const now = Date.now();
|
|
292
334
|
const state = this.store.wakeState(address, id);
|
|
335
|
+
const envelope = this.store.findIn(address, id)?.envelope;
|
|
336
|
+
if (envelope?.pool_reply_failure_notice || envelope?.delivery_failure_notice) {
|
|
337
|
+
this.store.setWakeState(address, id, { ...state, next_at_ms: now + LAST_RETRY_MS, last_error: carried.reason });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
293
340
|
const attempts = (state.attempts ?? 0) + 1;
|
|
341
|
+
if (carried.kind === 'refused') { this.#expire(address, id, attempts, carried.reason); return; }
|
|
294
342
|
const since = state.failing_since_ms ?? now;
|
|
295
343
|
const lifetime = address.split(':')[1] === this.machine ? LOCAL_WAKE_LIFETIME_MS : REMOTE_WAKE_LIFETIME_MS;
|
|
296
344
|
if (attempts >= WAKE_ATTEMPTS && now - since >= lifetime) { this.#expire(address, id, attempts, carried.reason); return; }
|
|
@@ -300,10 +348,7 @@ export class MailCarrier {
|
|
|
300
348
|
|
|
301
349
|
/** End a wake undelivered: the message stays filed and unread, and the expiry is recorded. */
|
|
302
350
|
#expire(address, id, attempts, reason) {
|
|
303
|
-
|
|
304
|
-
const envelope = found ? this.store.body(found) : null;
|
|
305
|
-
const expiry = { id, to: address, from: envelope?.from ?? '', subject: envelope?.subject ?? null, filed_at_ms: Number(envelope?.created_at_ms) || 0, expired_at_ms: Date.now(), attempts, reason };
|
|
306
|
-
try { this.store.expire(address, id, expiry); } catch (error) { this.log(`carrier: the expiry of ${id} was not recorded: ${error.message}`); }
|
|
351
|
+
expireDelivery(this.store, this.machine, address, id, attempts, reason);
|
|
307
352
|
}
|
|
308
353
|
|
|
309
354
|
/** One local delivery attempt in the carrier's record. */
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Durable owner-side row changes. SQL triggers record each mutation in its
|
|
2
|
+
// transaction; a commit event merely wakes readers of that persisted sequence.
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const MAX_CHANGE_ROWS = 100_000;
|
|
6
|
+
const PAGE = 256;
|
|
7
|
+
|
|
8
|
+
const ROWS = [
|
|
9
|
+
['machine_record_heads', 'record-head', (row) => `json_object('role', ${row}.role, 'machine', ${row}.machine)`, (row) => `${row}.value`],
|
|
10
|
+
['machine_record_rows', 'record-row', (row) => `json_object('role', ${row}.role, 'machine', ${row}.machine, 'section', ${row}.section, 'key', ${row}.key, 'position', ${row}.position)`, (row) => `${row}.value`],
|
|
11
|
+
['messages', 'message', (row) => `json_object('address', ${row}.mailbox, 'id', ${row}.id)`,
|
|
12
|
+
(row) => `json_object('state', ${row}.state, 'body_expired_at_ms', ${row}.body_expired_at_ms)`],
|
|
13
|
+
['deliveries', 'delivery', (row) => `json_object('address', ${row}.mailbox, 'id', ${row}.message)`,
|
|
14
|
+
(row) => `json_object('due_at_ms', ${row}.due_at_ms, 'attempts', ${row}.attempts)`],
|
|
15
|
+
['claims', 'claim', (row) => `json_object('address', ${row}.mailbox, 'id', ${row}.message)`],
|
|
16
|
+
['subscriptions', 'subscription', (row) => `json_object('address', ${row}.mailbox, 'id', ${row}.message)`],
|
|
17
|
+
['mail_records', 'record', (row) => `json_object('collection', ${row}.collection, 'key', ${row}.key)`],
|
|
18
|
+
['thread_markers', 'thread_marker', (row) => `json_object('thread', ${row}.thread, 'marker', ${row}.marker)`],
|
|
19
|
+
['sent_messages', 'sent', (row) => `json_object('sender_key', ${row}.sender_key, 'id', ${row}.id, 'recipient', ${row}.recipient)`],
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const CHANGE_SCHEMA = `
|
|
23
|
+
CREATE TABLE IF NOT EXISTS machine_changes (
|
|
24
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
at_ms INTEGER NOT NULL,
|
|
26
|
+
topic TEXT NOT NULL,
|
|
27
|
+
key TEXT NOT NULL CHECK (json_valid(key)),
|
|
28
|
+
operation TEXT NOT NULL CHECK (operation IN ('insert', 'update', 'delete')),
|
|
29
|
+
value TEXT CHECK (value IS NULL OR json_valid(value))
|
|
30
|
+
) STRICT;
|
|
31
|
+
CREATE INDEX IF NOT EXISTS machine_changes_topic ON machine_changes(topic, sequence);
|
|
32
|
+
CREATE TRIGGER IF NOT EXISTS machine_changes_bound AFTER INSERT ON machine_changes BEGIN
|
|
33
|
+
DELETE FROM machine_changes WHERE sequence <= NEW.sequence - ${MAX_CHANGE_ROWS};
|
|
34
|
+
END;
|
|
35
|
+
${ROWS.flatMap(([table, topic, key, value]) => ['insert', 'update', 'delete'].map((operation) => {
|
|
36
|
+
const row = operation === 'delete' ? 'OLD' : 'NEW';
|
|
37
|
+
return `CREATE TRIGGER IF NOT EXISTS ${table}_change_${operation} AFTER ${operation.toUpperCase()} ON ${table} BEGIN
|
|
38
|
+
INSERT INTO machine_changes(at_ms, topic, key, operation, value)
|
|
39
|
+
VALUES (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER), '${topic}', ${key(row)}, '${operation}', ${value && operation !== 'delete' ? value(row) : 'NULL'});
|
|
40
|
+
END;`;
|
|
41
|
+
})).join('\n')}
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
export class ChangeStream {
|
|
45
|
+
constructor(database) {
|
|
46
|
+
this.database = database;
|
|
47
|
+
this.id = database.metadata('change_stream_id');
|
|
48
|
+
if (!this.id) { this.id = randomUUID(); database.setMetadata('change_stream_id', this.id); }
|
|
49
|
+
this.readers = new Set();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
cursor() {
|
|
53
|
+
const row = this.database.statement("SELECT seq FROM sqlite_sequence WHERE name = 'machine_changes'").get();
|
|
54
|
+
return { id: this.id, sequence: row?.seq ?? 0 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A cursor is for this database, not merely a number that another fresh
|
|
58
|
+
* database may reach. A pruned gap is explicit and never a partial replay. */
|
|
59
|
+
read(after, { topics = [], addresses = [], limit = PAGE } = {}) {
|
|
60
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > PAGE) throw new Error('a change page has 1–256 rows');
|
|
61
|
+
if (![topics, addresses].every((items) => Array.isArray(items) && items.every((item) => typeof item === 'string'))) throw new Error('change filters must be string arrays');
|
|
62
|
+
const head = this.cursor();
|
|
63
|
+
const oldest = this.database.statement('SELECT min(sequence) AS sequence FROM machine_changes').get().sequence ?? head.sequence + 1;
|
|
64
|
+
let reason = null;
|
|
65
|
+
if (!after) reason = 'no cursor';
|
|
66
|
+
else if (after.id !== this.id) reason = 'the cursor names a different database';
|
|
67
|
+
else if (!Number.isSafeInteger(after.sequence) || after.sequence < 0 || after.sequence > head.sequence) reason = 'the cursor sequence is invalid';
|
|
68
|
+
else if (after.sequence < oldest - 1) reason = 'the retained change window no longer contains this cursor';
|
|
69
|
+
if (reason) return { cursor: head, reset: true, reason, changes: [], more: false };
|
|
70
|
+
const rows = this.database.statement(`SELECT sequence, at_ms, topic, key, operation, value FROM machine_changes
|
|
71
|
+
WHERE sequence > ? AND (json_array_length(?) = 0 OR topic IN (SELECT value FROM json_each(?)))
|
|
72
|
+
AND (json_array_length(?) = 0 OR json_extract(key, '$.address') IN (SELECT value FROM json_each(?)))
|
|
73
|
+
ORDER BY sequence LIMIT ?`).all(after.sequence, JSON.stringify(topics), JSON.stringify(topics), JSON.stringify(addresses), JSON.stringify(addresses), limit + 1);
|
|
74
|
+
const more = rows.length > limit;
|
|
75
|
+
if (more) rows.pop();
|
|
76
|
+
return {
|
|
77
|
+
cursor: { id: this.id, sequence: more ? rows.at(-1).sequence : head.sequence }, reset: false, more,
|
|
78
|
+
changes: rows.map(({ key, value, ...row }) => ({ ...row, key: JSON.parse(key), value: value == null ? null : JSON.parse(value) })),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Owner-generated changes (for example record row deltas) share the sequence
|
|
83
|
+
* with mail, and can be committed with the state that they describe. */
|
|
84
|
+
append(topic, key, value, operation = 'update') {
|
|
85
|
+
const row = this.database.statement('INSERT INTO machine_changes(at_ms, topic, key, operation, value) VALUES (?, ?, ?, ?, ?)')
|
|
86
|
+
.run(Date.now(), topic, JSON.stringify(key), operation, value == null ? null : JSON.stringify(value));
|
|
87
|
+
return { id: this.id, sequence: Number(row.lastInsertRowid) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Subscribe before the first read. A synchronous commit between page reads
|
|
91
|
+
* marks the drain dirty; reconnects resume from the last delivered cursor. */
|
|
92
|
+
follow(write, { after = null, topics = [], addresses = [], initial = false } = {}) {
|
|
93
|
+
let cursor = after, closed = false, scheduled = false, first = true;
|
|
94
|
+
const stop = () => {
|
|
95
|
+
if (closed) return;
|
|
96
|
+
closed = true; this.database.off('commit', wake); this.readers.delete(stop);
|
|
97
|
+
};
|
|
98
|
+
const drain = () => {
|
|
99
|
+
scheduled = false;
|
|
100
|
+
if (closed || this.database.closed) return stop();
|
|
101
|
+
try {
|
|
102
|
+
const page = this.read(cursor, { topics, addresses });
|
|
103
|
+
cursor = page.cursor;
|
|
104
|
+
if (page.reset || page.changes.length || (first && initial)) {
|
|
105
|
+
const resumed = write(page);
|
|
106
|
+
if (resumed?.id === this.id && Number.isSafeInteger(resumed.sequence)) cursor = resumed;
|
|
107
|
+
}
|
|
108
|
+
first = false;
|
|
109
|
+
if (page.more) wake();
|
|
110
|
+
} catch (error) { stop(); try { write({ error: error.message }); } catch { /* its reader is gone */ } }
|
|
111
|
+
};
|
|
112
|
+
const wake = () => {
|
|
113
|
+
if (closed || scheduled) return;
|
|
114
|
+
scheduled = true; setImmediate(drain);
|
|
115
|
+
};
|
|
116
|
+
this.database.on('commit', wake); this.readers.add(stop); wake();
|
|
117
|
+
return stop;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A long-poll predicate is evaluated after installing the durable stream,
|
|
121
|
+
* closing the old read/listen gap. A cursor reset asks the predicate for its
|
|
122
|
+
* current snapshot. Stop resolves false before the database is closed. */
|
|
123
|
+
wait(predicate, { topics = [], addresses = [], timeoutMs = 0 } = {}) {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
let timer, settled = false;
|
|
126
|
+
const finish = (value, error = null) => {
|
|
127
|
+
if (settled) return;
|
|
128
|
+
settled = true; clearTimeout(timer); stop(); this.readers.delete(ended);
|
|
129
|
+
error ? reject(error) : resolve(value);
|
|
130
|
+
};
|
|
131
|
+
const check = () => {
|
|
132
|
+
try { if (predicate()) finish(true); } catch (error) { finish(false, error); }
|
|
133
|
+
};
|
|
134
|
+
const ended = () => finish(false);
|
|
135
|
+
const stop = this.follow((page) => page.error ? finish(false, new Error(page.error)) : check(), { after: this.cursor(), topics, addresses });
|
|
136
|
+
this.readers.add(ended);
|
|
137
|
+
timer = setTimeout(() => finish(false), Math.max(0, timeoutMs));
|
|
138
|
+
check();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
stop() { for (const stop of [...this.readers]) stop(); }
|
|
143
|
+
}
|