@groundnuty/macf-channel-server 0.2.47 → 0.2.48
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/collision.d.ts +28 -3
- package/dist/collision.d.ts.map +1 -1
- package/dist/collision.js +78 -18
- package/dist/collision.js.map +1 -1
- package/dist/delivery/driver.d.ts +45 -0
- package/dist/delivery/driver.d.ts.map +1 -0
- package/dist/delivery/driver.js +34 -0
- package/dist/delivery/driver.js.map +1 -0
- package/dist/delivery/in-memory-store.d.ts +24 -0
- package/dist/delivery/in-memory-store.d.ts.map +1 -0
- package/dist/delivery/in-memory-store.js +60 -0
- package/dist/delivery/in-memory-store.js.map +1 -0
- package/dist/delivery/inbox-drain-driver.d.ts +150 -0
- package/dist/delivery/inbox-drain-driver.d.ts.map +1 -0
- package/dist/delivery/inbox-drain-driver.js +109 -0
- package/dist/delivery/inbox-drain-driver.js.map +1 -0
- package/dist/delivery/inbox-ticker.d.ts +45 -0
- package/dist/delivery/inbox-ticker.d.ts.map +1 -0
- package/dist/delivery/inbox-ticker.js +68 -0
- package/dist/delivery/inbox-ticker.js.map +1 -0
- package/dist/delivery/inbox.d.ts +57 -0
- package/dist/delivery/inbox.d.ts.map +1 -0
- package/dist/delivery/inbox.js +19 -0
- package/dist/delivery/inbox.js.map +1 -0
- package/dist/delivery/message-id.d.ts +10 -0
- package/dist/delivery/message-id.d.ts.map +1 -0
- package/dist/delivery/message-id.js +33 -0
- package/dist/delivery/message-id.js.map +1 -0
- package/dist/delivery/outbox-ticker.d.ts +50 -0
- package/dist/delivery/outbox-ticker.d.ts.map +1 -0
- package/dist/delivery/outbox-ticker.js +49 -0
- package/dist/delivery/outbox-ticker.js.map +1 -0
- package/dist/delivery/outbox.d.ts +105 -0
- package/dist/delivery/outbox.d.ts.map +1 -0
- package/dist/delivery/outbox.js +103 -0
- package/dist/delivery/outbox.js.map +1 -0
- package/dist/https.d.ts +24 -0
- package/dist/https.d.ts.map +1 -1
- package/dist/https.js +70 -4
- package/dist/https.js.map +1 -1
- package/dist/notify-peer.d.ts +70 -0
- package/dist/notify-peer.d.ts.map +1 -1
- package/dist/notify-peer.js +142 -29
- package/dist/notify-peer.js.map +1 -1
- package/dist/server.js +100 -2
- package/dist/server.js.map +1 -1
- package/dist/shutdown.d.ts +18 -0
- package/dist/shutdown.d.ts.map +1 -1
- package/dist/shutdown.js +18 -1
- package/dist/shutdown.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grace window before an undrained entry is considered a genuine orphan
|
|
3
|
+
* (constraint 1). Deliberately >= the ticker's own tick interval — the
|
|
4
|
+
* accept()→onNotify()→markProcessed() sequence on the ORIGINAL receive path
|
|
5
|
+
* completes in well under a second in practice, so an interval comfortably
|
|
6
|
+
* larger than one tick cycle can never mistake a mid-flight entry for an
|
|
7
|
+
* orphan.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_INBOX_DRAIN_GRACE_MS = 30_000;
|
|
10
|
+
/**
|
|
11
|
+
* Attempt cap (constraint 3). Small + finite — unlike the outbox's
|
|
12
|
+
* wall-clock-only retry budget (Decision 4 explicitly rejects an attempt
|
|
13
|
+
* count there, because restart gaps make attempt-count meaningless across
|
|
14
|
+
* process lifetimes), THIS driver's retries are only ever in-process (a
|
|
15
|
+
* fresh process gets a fresh, empty attempts `Map` + — today — a fresh,
|
|
16
|
+
* empty in-memory inbox), so a small attempt cap is the right complementary
|
|
17
|
+
* bound to the TTL below: it stops hammering a persistently-broken
|
|
18
|
+
* `onNotify` path quickly, without waiting a full day to give up.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_INBOX_MAX_ATTEMPTS = 5;
|
|
21
|
+
/**
|
|
22
|
+
* Wall-clock TTL (constraint 3), mirroring the outbox's `DEFAULT_TTL_MS`
|
|
23
|
+
* (`outbox.ts`) — same 24h budget, same rationale (an 8h-class outage fits
|
|
24
|
+
* comfortably; anything longer legitimately downgrades to the give-up path).
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_INBOX_TTL_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
/**
|
|
28
|
+
* Drive one orphan-drain tick: for every undrained inbox entry, either skip
|
|
29
|
+
* it (too young — constraint 1; or already exhausted — constraint 3), give
|
|
30
|
+
* it up (TTL exceeded — constraint 3), or re-fire `onNotify` (success →
|
|
31
|
+
* `markProcessed`, constraint 2; failure → bump the attempt counter,
|
|
32
|
+
* constraint 3). Callable directly by tests with an explicit `now` + a
|
|
33
|
+
* fresh `attempts` Map, or wrapped in `inbox-ticker.ts`'s periodic timer.
|
|
34
|
+
*/
|
|
35
|
+
export async function driveInboxOnce(deps) {
|
|
36
|
+
const { inbox, onNotify, attempts, logger } = deps;
|
|
37
|
+
const now = deps.now ?? Date.now;
|
|
38
|
+
const graceMs = deps.graceMs ?? DEFAULT_INBOX_DRAIN_GRACE_MS;
|
|
39
|
+
const maxAttempts = deps.maxAttempts ?? DEFAULT_INBOX_MAX_ATTEMPTS;
|
|
40
|
+
const ttlMs = deps.ttlMs ?? DEFAULT_INBOX_TTL_MS;
|
|
41
|
+
const t = now();
|
|
42
|
+
const undrained = await inbox.undrained();
|
|
43
|
+
let attempted = 0;
|
|
44
|
+
let drained = 0;
|
|
45
|
+
let failed = 0;
|
|
46
|
+
let gaveUp = 0;
|
|
47
|
+
for (const entry of undrained) {
|
|
48
|
+
const priorAttempts = attempts.get(entry.id) ?? 0;
|
|
49
|
+
// Constraint 3 (already exhausted): don't re-evaluate age at all once
|
|
50
|
+
// capped — permanently given-up for THIS driver; only the future
|
|
51
|
+
// surface-as-text path (constraint 4) is left to deal with it.
|
|
52
|
+
if (priorAttempts >= maxAttempts) {
|
|
53
|
+
gaveUp++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const age = t - entry.receivedAt;
|
|
57
|
+
// Constraint 3 (TTL): checked before the grace window — an entry old
|
|
58
|
+
// enough to exceed the TTL is never "too young to be an orphan"; TTL
|
|
59
|
+
// (default 24h) is always far larger than the grace window (default
|
|
60
|
+
// 30s), so the two checks never actually compete in practice.
|
|
61
|
+
if (age > ttlMs) {
|
|
62
|
+
attempts.set(entry.id, maxAttempts); // pin capped so future ticks short-circuit above
|
|
63
|
+
gaveUp++;
|
|
64
|
+
logger?.warn('inbox_drain_gave_up', {
|
|
65
|
+
id: entry.id,
|
|
66
|
+
reason: 'ttl_exceeded',
|
|
67
|
+
age_ms: age,
|
|
68
|
+
ttl_ms: ttlMs,
|
|
69
|
+
});
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// Constraint 1 (grace-before-drain): too young to be a genuine orphan —
|
|
73
|
+
// may still be mid-flight in the ORIGINAL accept()→onNotify()→
|
|
74
|
+
// markProcessed() sequence. Leave it for a later tick.
|
|
75
|
+
if (age < graceMs) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
attempted++;
|
|
79
|
+
try {
|
|
80
|
+
// See InboxDriveDeps.onNotify's doc comment for why this cast is sound.
|
|
81
|
+
await onNotify(entry.payload);
|
|
82
|
+
// Constraint 2: markProcessed ONLY after a confirmed onNotify success.
|
|
83
|
+
await inbox.markProcessed(entry.id);
|
|
84
|
+
attempts.delete(entry.id);
|
|
85
|
+
drained++;
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
failed++;
|
|
89
|
+
const nextAttempts = priorAttempts + 1;
|
|
90
|
+
attempts.set(entry.id, nextAttempts);
|
|
91
|
+
logger?.warn('inbox_drain_refire_failed', {
|
|
92
|
+
id: entry.id,
|
|
93
|
+
attempt: nextAttempts,
|
|
94
|
+
max_attempts: maxAttempts,
|
|
95
|
+
error: err instanceof Error ? err.message : String(err),
|
|
96
|
+
});
|
|
97
|
+
if (nextAttempts >= maxAttempts) {
|
|
98
|
+
gaveUp++;
|
|
99
|
+
logger?.warn('inbox_drain_gave_up', {
|
|
100
|
+
id: entry.id,
|
|
101
|
+
reason: 'max_attempts_exceeded',
|
|
102
|
+
attempts: nextAttempts,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { attempted, drained, failed, gaveUp };
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=inbox-drain-driver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inbox-drain-driver.js","sourceRoot":"","sources":["../../src/delivery/inbox-drain-driver.ts"],"names":[],"mappings":"AAgEA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,4BAA4B,GAAG,MAAM,CAAC;AAEnD;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,CAAC,CAAC;AAE5C;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAuDxD;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAoB;IACvD,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IACnD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IACjC,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,4BAA4B,CAAC;IAC7D,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,IAAI,0BAA0B,CAAC;IACnE,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,oBAAoB,CAAC;IACjD,MAAM,CAAC,GAAG,GAAG,EAAE,CAAC;IAEhB,MAAM,SAAS,GAAG,MAAM,KAAK,CAAC,SAAS,EAAE,CAAC;IAC1C,IAAI,SAAS,GAAG,CAAC,CAAC;IAClB,IAAI,OAAO,GAAG,CAAC,CAAC;IAChB,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,IAAI,MAAM,GAAG,CAAC,CAAC;IAEf,KAAK,MAAM,KAAK,IAAI,SAAS,EAAE,CAAC;QAC9B,MAAM,aAAa,GAAG,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC;QAElD,sEAAsE;QACtE,iEAAiE;QACjE,+DAA+D;QAC/D,IAAI,aAAa,IAAI,WAAW,EAAE,CAAC;YACjC,MAAM,EAAE,CAAC;YACT,SAAS;QACX,CAAC;QAED,MAAM,GAAG,GAAG,CAAC,GAAG,KAAK,CAAC,UAAU,CAAC;QAEjC,qEAAqE;QACrE,qEAAqE;QACrE,oEAAoE;QACpE,8DAA8D;QAC9D,IAAI,GAAG,GAAG,KAAK,EAAE,CAAC;YAChB,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,EAAE,WAAW,CAAC,CAAC,CAAC,iDAAiD;YACtF,MAAM,EAAE,CAAC;YACT,MAAM,EAAE,IAAI,CAAC,qBAAqB,EAAE;gBAClC,EAAE,EAAE,KAAK,CAAC,EAAE;gBACZ,MAAM,EAAE,cAAc;gBACtB,MAAM,EAAE,GAAG;gBACX,MAAM,EAAE,KAAK;aACd,CAAC,CAAC;YACH,SAAS;QACX,CAAC;QAED,wEAAwE;QACxE,+DAA+D;QAC/D,uDAAuD;QACvD,IAAI,GAAG,GAAG,OAAO,EAAE,CAAC;YAClB,SAAS;QACX,CAAC;QAED,SAAS,EAAE,CAAC;QACZ,IAAI,CAAC;YACH,wEAAwE;YACxE,MAAM,QAAQ,CAAC,KAAK,CAAC,OAAwB,CAAC,CAAC;YAC/C,uEAAuE;YACvE,MAAM,KAAK,CAAC,aAAa,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YACpC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;YAC1B,OAAO,EAAE,CAAC;QACZ,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,EAAE,CAAC;YACT,MAAM,YAAY,GAAG,aAAa,GAAG,CAAC,CAAC;YACvC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,EAAE,YAAY,CAAC,CAAC;YACrC,MAAM,EAAE,IAAI,CAAC,2BAA2B,EAAE;gBACxC,EAAE,EAAE,KAAK,CAAC,EAAE;gBACZ,OAAO,EAAE,YAAY;gBACrB,YAAY,EAAE,WAAW;gBACzB,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;aACxD,CAAC,CAAC;YACH,IAAI,YAAY,IAAI,WAAW,EAAE,CAAC;gBAChC,MAAM,EAAE,CAAC;gBACT,MAAM,EAAE,IAAI,CAAC,qBAAqB,EAAE;oBAClC,EAAE,EAAE,KAAK,CAAC,EAAE;oBACZ,MAAM,EAAE,uBAAuB;oBAC/B,QAAQ,EAAE,YAAY;iBACvB,CAAC,CAAC;YACL,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;AAChD,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DR-038 Decision 5 follow-on (groundnuty/macf#744) — the mechanism that
|
|
3
|
+
* DRIVES `inbox-drain-driver.ts`'s `driveInboxOnce()` periodically over the
|
|
4
|
+
* channel-server's lifetime, so the receiver's own in-process inbox orphans
|
|
5
|
+
* (an entry whose original `onNotify` threw between `inbox.accept()` and
|
|
6
|
+
* `inbox.markProcessed()`) get re-fired without waiting for a restart or the
|
|
7
|
+
* plugin's separate, short-lived startup-drain (which can't see this
|
|
8
|
+
* process's own in-memory orphans).
|
|
9
|
+
*
|
|
10
|
+
* Exact same lifecycle shape as `outbox-ticker.ts` (`start()` / `stop()` /
|
|
11
|
+
* `tickNow()`, unref()'d interval, tick body never throws past the timer) —
|
|
12
|
+
* intentionally, so the two DR-038 tickers read as one family in
|
|
13
|
+
* `server.ts` + `shutdown.ts`.
|
|
14
|
+
*
|
|
15
|
+
* `server.ts` calls `tickNow()` once at startup (mirroring the outbox
|
|
16
|
+
* ticker's own startup tick) BEFORE starting the recurring interval, so any
|
|
17
|
+
* orphan already undrained when this process comes up gets an immediate
|
|
18
|
+
* first re-fire attempt rather than waiting a full tick interval.
|
|
19
|
+
*/
|
|
20
|
+
import type { Logger, NotifyPayload } from '@groundnuty/macf-core';
|
|
21
|
+
import type { Inbox } from './inbox.js';
|
|
22
|
+
/** Default retry-drive cadence. Same cadence as the outbox ticker. */
|
|
23
|
+
export declare const DEFAULT_INBOX_TICK_INTERVAL_MS = 30000;
|
|
24
|
+
export interface InboxTicker {
|
|
25
|
+
/** Start the periodic drive tick (no-op if already started). */
|
|
26
|
+
readonly start: () => void;
|
|
27
|
+
/** Clear the interval (idempotent; safe before start). */
|
|
28
|
+
readonly stop: () => void;
|
|
29
|
+
/** Fire one drive tick immediately, outside the interval schedule. */
|
|
30
|
+
readonly tickNow: () => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export declare function createInboxTicker(opts: {
|
|
33
|
+
readonly inbox: Inbox;
|
|
34
|
+
/** The SAME `onNotify` callback passed to `createHttpsServer`. */
|
|
35
|
+
readonly onNotify: (payload: NotifyPayload) => Promise<void>;
|
|
36
|
+
readonly logger: Logger;
|
|
37
|
+
/** Cadence in ms; default 30s. */
|
|
38
|
+
readonly intervalMs?: number;
|
|
39
|
+
readonly graceMs?: number;
|
|
40
|
+
readonly maxAttempts?: number;
|
|
41
|
+
readonly ttlMs?: number;
|
|
42
|
+
/** Injectable epoch-ms clock (testing). Default `Date.now`. */
|
|
43
|
+
readonly now?: () => number;
|
|
44
|
+
}): InboxTicker;
|
|
45
|
+
//# sourceMappingURL=inbox-ticker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inbox-ticker.d.ts","sourceRoot":"","sources":["../../src/delivery/inbox-ticker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACnE,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAQxC,sEAAsE;AACtE,eAAO,MAAM,8BAA8B,QAAS,CAAC;AAErD,MAAM,WAAW,WAAW;IAC1B,gEAAgE;IAChE,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAC;IAC3B,0DAA0D;IAC1D,QAAQ,CAAC,IAAI,EAAE,MAAM,IAAI,CAAC;IAC1B,sEAAsE;IACtE,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,kEAAkE;IAClE,QAAQ,CAAC,QAAQ,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,kCAAkC;IAClC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,+DAA+D;IAC/D,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B,GAAG,WAAW,CA+Dd"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { driveInboxOnce, DEFAULT_INBOX_DRAIN_GRACE_MS, DEFAULT_INBOX_MAX_ATTEMPTS, DEFAULT_INBOX_TTL_MS, } from './inbox-drain-driver.js';
|
|
2
|
+
/** Default retry-drive cadence. Same cadence as the outbox ticker. */
|
|
3
|
+
export const DEFAULT_INBOX_TICK_INTERVAL_MS = 30_000;
|
|
4
|
+
export function createInboxTicker(opts) {
|
|
5
|
+
const { inbox, onNotify, logger } = opts;
|
|
6
|
+
const intervalMs = opts.intervalMs ?? DEFAULT_INBOX_TICK_INTERVAL_MS;
|
|
7
|
+
const graceMs = opts.graceMs ?? DEFAULT_INBOX_DRAIN_GRACE_MS;
|
|
8
|
+
const maxAttempts = opts.maxAttempts ?? DEFAULT_INBOX_MAX_ATTEMPTS;
|
|
9
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_INBOX_TTL_MS;
|
|
10
|
+
const now = opts.now ?? Date.now;
|
|
11
|
+
// Process-lifetime attempt counter (DR-038 Decision 5 follow-on constraint
|
|
12
|
+
// 3) — never persisted, never touches the InboxStore/InboxEntry schema,
|
|
13
|
+
// reset to empty on every fresh process. This IS the "driver-locally"
|
|
14
|
+
// bookkeeping the design calls for: the ticker owns the one long-lived Map
|
|
15
|
+
// instance and hands it to `driveInboxOnce` unchanged on every tick so
|
|
16
|
+
// attempt counts actually accumulate across ticks.
|
|
17
|
+
const attempts = new Map();
|
|
18
|
+
let timer = null;
|
|
19
|
+
async function tickNow() {
|
|
20
|
+
try {
|
|
21
|
+
const summary = await driveInboxOnce({
|
|
22
|
+
inbox,
|
|
23
|
+
onNotify,
|
|
24
|
+
attempts,
|
|
25
|
+
logger,
|
|
26
|
+
now,
|
|
27
|
+
graceMs,
|
|
28
|
+
maxAttempts,
|
|
29
|
+
ttlMs,
|
|
30
|
+
});
|
|
31
|
+
if (summary.attempted > 0 || summary.gaveUp > 0) {
|
|
32
|
+
logger.info('inbox_drain_tick', {
|
|
33
|
+
attempted: summary.attempted,
|
|
34
|
+
drained: summary.drained,
|
|
35
|
+
failed: summary.failed,
|
|
36
|
+
gave_up: summary.gaveUp,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
// Best-effort: a driveInboxOnce failure (store I/O error, etc.) must
|
|
42
|
+
// never escape a timer callback and become an uncaughtException — same
|
|
43
|
+
// posture as outbox-ticker.ts's tick(). The next tick retries.
|
|
44
|
+
logger.warn('inbox_drain_tick_failed', {
|
|
45
|
+
error: err instanceof Error ? err.message : String(err),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
start() {
|
|
51
|
+
if (timer !== null)
|
|
52
|
+
return;
|
|
53
|
+
timer = setInterval(() => {
|
|
54
|
+
void tickNow();
|
|
55
|
+
}, intervalMs);
|
|
56
|
+
// Never let the drain-drive interval keep the process alive.
|
|
57
|
+
timer.unref();
|
|
58
|
+
},
|
|
59
|
+
stop() {
|
|
60
|
+
if (timer !== null) {
|
|
61
|
+
clearInterval(timer);
|
|
62
|
+
timer = null;
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
tickNow,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=inbox-ticker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inbox-ticker.js","sourceRoot":"","sources":["../../src/delivery/inbox-ticker.ts"],"names":[],"mappings":"AAqBA,OAAO,EACL,cAAc,EACd,4BAA4B,EAC5B,0BAA0B,EAC1B,oBAAoB,GACrB,MAAM,yBAAyB,CAAC;AAEjC,sEAAsE;AACtE,MAAM,CAAC,MAAM,8BAA8B,GAAG,MAAM,CAAC;AAWrD,MAAM,UAAU,iBAAiB,CAAC,IAYjC;IACC,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IACzC,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,IAAI,8BAA8B,CAAC;IACrE,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,4BAA4B,CAAC;IAC7D,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,IAAI,0BAA0B,CAAC;IACnE,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,oBAAoB,CAAC;IACjD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IACjC,2EAA2E;IAC3E,wEAAwE;IACxE,sEAAsE;IACtE,2EAA2E;IAC3E,uEAAuE;IACvE,mDAAmD;IACnD,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC3C,IAAI,KAAK,GAA0C,IAAI,CAAC;IAExD,KAAK,UAAU,OAAO;QACpB,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,cAAc,CAAC;gBACnC,KAAK;gBACL,QAAQ;gBACR,QAAQ;gBACR,MAAM;gBACN,GAAG;gBACH,OAAO;gBACP,WAAW;gBACX,KAAK;aACN,CAAC,CAAC;YACH,IAAI,OAAO,CAAC,SAAS,GAAG,CAAC,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBAChD,MAAM,CAAC,IAAI,CAAC,kBAAkB,EAAE;oBAC9B,SAAS,EAAE,OAAO,CAAC,SAAS;oBAC5B,OAAO,EAAE,OAAO,CAAC,OAAO;oBACxB,MAAM,EAAE,OAAO,CAAC,MAAM;oBACtB,OAAO,EAAE,OAAO,CAAC,MAAM;iBACxB,CAAC,CAAC;YACL,CAAC;QACH,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,qEAAqE;YACrE,uEAAuE;YACvE,+DAA+D;YAC/D,MAAM,CAAC,IAAI,CAAC,yBAAyB,EAAE;gBACrC,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;aACxD,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,OAAO;QACL,KAAK;YACH,IAAI,KAAK,KAAK,IAAI;gBAAE,OAAO;YAC3B,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE;gBACvB,KAAK,OAAO,EAAE,CAAC;YACjB,CAAC,EAAE,UAAU,CAAC,CAAC;YACf,6DAA6D;YAC7D,KAAK,CAAC,KAAK,EAAE,CAAC;QAChB,CAAC;QACD,IAAI;YACF,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;gBACnB,aAAa,CAAC,KAAK,CAAC,CAAC;gBACrB,KAAK,GAAG,IAAI,CAAC;YACf,CAAC;QACH,CAAC;QACD,OAAO;KACR,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DR-038 Decisions 1, 3, 5 — the receiver-side effectively-once logic:
|
|
3
|
+
* persist-THEN-ack ordering (Decision 3), and the store-backed primitives
|
|
4
|
+
* the inbox drain (Decision 5) needs.
|
|
5
|
+
*
|
|
6
|
+
* Runtime-agnostic over an injected `InboxStore` (the pluggable driver
|
|
7
|
+
* interface, hoisted to `@groundnuty/macf-core`'s `delivery/store.ts` —
|
|
8
|
+
* macf#741). The drain TRIGGER itself — hooking `accept` into the HTTP
|
|
9
|
+
* `/notify` / `message/send` handler (on-receipt), and hooking `undrained`
|
|
10
|
+
* + `markProcessed` into the plugin's SessionStart `startup_check`
|
|
11
|
+
* (on-startup) — is a follow-on slice. This module provides the store-
|
|
12
|
+
* backed primitives both triggers call into, with the dedup-by-id property
|
|
13
|
+
* that makes the two triggers safe to run overlapping (Decision 5: "no
|
|
14
|
+
* periodic drain is needed if receipt + startup cover it").
|
|
15
|
+
*/
|
|
16
|
+
import type { InboxStore, InboxEntry } from '@groundnuty/macf-core';
|
|
17
|
+
export interface InboxAcceptResult {
|
|
18
|
+
/**
|
|
19
|
+
* `true` when this `id` was genuinely new — now durably persisted for
|
|
20
|
+
* the first time. `false` when it was a redelivery: the existing
|
|
21
|
+
* persisted entry is left untouched (idempotent no-op per Decision 1's
|
|
22
|
+
* "idempotent receipt").
|
|
23
|
+
*/
|
|
24
|
+
readonly wasNew: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface InboxDeps {
|
|
27
|
+
readonly store: InboxStore;
|
|
28
|
+
/** Injectable epoch-ms clock (testing). Default `Date.now`. */
|
|
29
|
+
readonly now?: () => number;
|
|
30
|
+
}
|
|
31
|
+
export interface Inbox {
|
|
32
|
+
/**
|
|
33
|
+
* Persist-THEN-ack (DR-038 Decision 3). The caller — the HTTP handler at
|
|
34
|
+
* the `/notify` or `message/send` boundary — MUST `await` this and
|
|
35
|
+
* return its HTTP 200 only AFTER it resolves. Returning 200 before this
|
|
36
|
+
* resolves would make the ACK mean "received" instead of "durably
|
|
37
|
+
* yours," reproducing the exact silent-fallback shape (success at the
|
|
38
|
+
* boundary, semantic durability absent — see `silent-fallback-hazards.md`)
|
|
39
|
+
* Decision 3 exists to avoid.
|
|
40
|
+
*
|
|
41
|
+
* A re-delivered `id` dedups here (`wasNew: false`) rather than
|
|
42
|
+
* re-persisting or re-processing — the caller still returns 200 per
|
|
43
|
+
* Decision 3 ("a lost-in-transit 200 is safe: the sender re-sends the
|
|
44
|
+
* same id → the receiver dedups... → re-returns 200. Idempotent by
|
|
45
|
+
* construction.").
|
|
46
|
+
*/
|
|
47
|
+
readonly accept: (id: string, payload: unknown) => Promise<InboxAcceptResult>;
|
|
48
|
+
/**
|
|
49
|
+
* Persisted-but-not-processed entries — DR-038 Decision 5's drain input,
|
|
50
|
+
* read by both the on-receipt and on-startup triggers.
|
|
51
|
+
*/
|
|
52
|
+
readonly undrained: () => Promise<InboxEntry[]>;
|
|
53
|
+
/** Mark an entry processed once the drain has handled it — won't be re-drained. */
|
|
54
|
+
readonly markProcessed: (id: string) => Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
export declare function createInbox(deps: InboxDeps): Inbox;
|
|
57
|
+
//# sourceMappingURL=inbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inbox.d.ts","sourceRoot":"","sources":["../../src/delivery/inbox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAEpE,MAAM,WAAW,iBAAiB;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAC3B,+DAA+D;IAC/D,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,KAAK;IACpB;;;;;;;;;;;;;;OAcG;IACH,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAE9E;;;OAGG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC;IAEhD,mFAAmF;IACnF,QAAQ,CAAC,aAAa,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvD;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,GAAG,KAAK,CAkBlD"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function createInbox(deps) {
|
|
2
|
+
const { store } = deps;
|
|
3
|
+
const now = deps.now ?? Date.now;
|
|
4
|
+
return {
|
|
5
|
+
async accept(id, payload) {
|
|
6
|
+
const entry = {
|
|
7
|
+
id,
|
|
8
|
+
payload,
|
|
9
|
+
receivedAt: now(),
|
|
10
|
+
processed: false,
|
|
11
|
+
};
|
|
12
|
+
const wasNew = await store.persist(entry);
|
|
13
|
+
return { wasNew };
|
|
14
|
+
},
|
|
15
|
+
undrained: () => store.listUndrained(),
|
|
16
|
+
markProcessed: (id) => store.markProcessed(id),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=inbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inbox.js","sourceRoot":"","sources":["../../src/delivery/inbox.ts"],"names":[],"mappings":"AA6DA,MAAM,UAAU,WAAW,CAAC,IAAe;IACzC,MAAM,EAAE,KAAK,EAAE,GAAG,IAAI,CAAC;IACvB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IAEjC,OAAO;QACL,KAAK,CAAC,MAAM,CAAC,EAAU,EAAE,OAAgB;YACvC,MAAM,KAAK,GAAe;gBACxB,EAAE;gBACF,OAAO;gBACP,UAAU,EAAE,GAAG,EAAE;gBACjB,SAAS,EAAE,KAAK;aACjB,CAAC;YACF,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;YAC1C,OAAO,EAAE,MAAM,EAAE,CAAC;QACpB,CAAC;QACD,SAAS,EAAE,GAA0B,EAAE,CAAC,KAAK,CAAC,aAAa,EAAE;QAC7D,aAAa,EAAE,CAAC,EAAU,EAAiB,EAAE,CAAC,KAAK,CAAC,aAAa,CAAC,EAAE,CAAC;KACtE,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint a fresh message-id for a NEW outbox entry. Call this exactly ONCE per
|
|
3
|
+
* logical message, at enqueue time — never again for the same message on
|
|
4
|
+
* retry (the persisted id is what gets reused). `randomUUID()` is Node's
|
|
5
|
+
* CSPRNG-backed UUIDv4 generator (no weak-PRNG concern; matches the
|
|
6
|
+
* `no-weak-prng` invariant already enforced elsewhere in this package, e.g.
|
|
7
|
+
* `notify-peer.ts` / `https.ts`).
|
|
8
|
+
*/
|
|
9
|
+
export declare function mintMessageId(): string;
|
|
10
|
+
//# sourceMappingURL=message-id.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"message-id.d.ts","sourceRoot":"","sources":["../../src/delivery/message-id.ts"],"names":[],"mappings":"AAsBA;;;;;;;GAOG;AACH,wBAAgB,aAAa,IAAI,MAAM,CAEtC"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DR-038 Decision 2 — the message-id contract for the effectively-once
|
|
3
|
+
* delivery layer.
|
|
4
|
+
*
|
|
5
|
+
* A UUIDv4, minted ONCE at outbox-enqueue, persisted with the entry BEFORE
|
|
6
|
+
* the first send, and reused VERBATIM on every retry — including across a
|
|
7
|
+
* sender restart (the id is stable because it's persisted, never
|
|
8
|
+
* regenerated). This is the whole receiver-side dedup key (Decision 1):
|
|
9
|
+
* `InboxStore.persist()` dedups by this id, which is what makes
|
|
10
|
+
* `message/send` safe to retry without the operation itself being
|
|
11
|
+
* idempotent.
|
|
12
|
+
*
|
|
13
|
+
* NOT a content-hash of the payload: two legitimately-identical messages
|
|
14
|
+
* (a peer sending "ping" twice, deliberately) would wrongly dedup-collide to
|
|
15
|
+
* one under a content-hash scheme. NOT a ULID: its only edge over UUIDv4 is
|
|
16
|
+
* a lexicographic time-ordering hint, but DR-038 explicitly scopes
|
|
17
|
+
* guaranteed ordering OUT of this delivery layer (see the DR's "Boundaries"
|
|
18
|
+
* section), and both `OutboxEntry` and `InboxEntry` already carry their own
|
|
19
|
+
* timestamp field for drain-ordering — so the id itself needn't encode time.
|
|
20
|
+
*/
|
|
21
|
+
import { randomUUID } from 'node:crypto';
|
|
22
|
+
/**
|
|
23
|
+
* Mint a fresh message-id for a NEW outbox entry. Call this exactly ONCE per
|
|
24
|
+
* logical message, at enqueue time — never again for the same message on
|
|
25
|
+
* retry (the persisted id is what gets reused). `randomUUID()` is Node's
|
|
26
|
+
* CSPRNG-backed UUIDv4 generator (no weak-PRNG concern; matches the
|
|
27
|
+
* `no-weak-prng` invariant already enforced elsewhere in this package, e.g.
|
|
28
|
+
* `notify-peer.ts` / `https.ts`).
|
|
29
|
+
*/
|
|
30
|
+
export function mintMessageId() {
|
|
31
|
+
return randomUUID();
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=message-id.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"message-id.js","sourceRoot":"","sources":["../../src/delivery/message-id.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa;IAC3B,OAAO,UAAU,EAAE,CAAC;AACtB,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DR-038 Decision 4 — the mechanism that DRIVES the persisted outbox's retry
|
|
3
|
+
* loop over the channel-server's lifetime.
|
|
4
|
+
*
|
|
5
|
+
* `outbox.ts`'s `driveOnce()` is a pure, callable-on-demand function; nothing
|
|
6
|
+
* in Slice A ticks it periodically. Two triggers combine to give the full
|
|
7
|
+
* restart-survival + eventual-retry contract:
|
|
8
|
+
*
|
|
9
|
+
* 1. `notify_peer`'s own enqueue path calls `driveOnce()` once immediately
|
|
10
|
+
* after enqueueing (see `notify-peer.ts`) — the reachable-peer common
|
|
11
|
+
* case is delivered inline, same UX as pre-DR-038 fire-and-forget.
|
|
12
|
+
* 2. THIS periodic, unref()'d ticker — mirrors `alive-ticker.ts` /
|
|
13
|
+
* `registry-heartbeat.ts`'s exact lifecycle shape (`start()` / `stop()`,
|
|
14
|
+
* never pins the event loop, tick body never throws past the timer) —
|
|
15
|
+
* is what drives backed-off RETRIES for an unreachable peer, and
|
|
16
|
+
* eventually TTL-expiry dead-lettering, WITHOUT requiring another
|
|
17
|
+
* `notify_peer` call to happen to trigger the next attempt. Without
|
|
18
|
+
* this, a message to a peer that's down for hours would sit in the
|
|
19
|
+
* outbox forever unless some other notify_peer call happened to touch
|
|
20
|
+
* the same store and incidentally drive it — that's not a retry
|
|
21
|
+
* schedule, that's luck.
|
|
22
|
+
*
|
|
23
|
+
* `server.ts` also calls `tickNow()` once at startup (DR-038 Decision 4:
|
|
24
|
+
* "resumed on sender startup") BEFORE starting the recurring interval, so any
|
|
25
|
+
* entry already overdue when the process comes up (a message enqueued by a
|
|
26
|
+
* prior process instance, surviving via a durable store driver once DR-008
|
|
27
|
+
* lands) gets its first post-restart attempt immediately rather than waiting
|
|
28
|
+
* a full tick interval.
|
|
29
|
+
*/
|
|
30
|
+
import type { Logger } from '@groundnuty/macf-core';
|
|
31
|
+
import type { Outbox } from './outbox.js';
|
|
32
|
+
/** Default retry-drive cadence. Deliberately coarser than the alive-ticker's
|
|
33
|
+
* 60s — this is a backstop for the backoff schedule (1s..5min), not a
|
|
34
|
+
* liveness signal; a shorter interval would mostly find nothing due. */
|
|
35
|
+
export declare const DEFAULT_OUTBOX_TICK_INTERVAL_MS = 30000;
|
|
36
|
+
export interface OutboxTicker {
|
|
37
|
+
/** Start the periodic drive tick (no-op if already started). */
|
|
38
|
+
readonly start: () => void;
|
|
39
|
+
/** Clear the interval (idempotent; safe before start). */
|
|
40
|
+
readonly stop: () => void;
|
|
41
|
+
/** Fire one drive tick immediately, outside the interval schedule. */
|
|
42
|
+
readonly tickNow: () => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
export declare function createOutboxTicker(opts: {
|
|
45
|
+
readonly outbox: Outbox;
|
|
46
|
+
readonly logger: Logger;
|
|
47
|
+
/** Cadence in ms; default 30s. */
|
|
48
|
+
readonly intervalMs?: number;
|
|
49
|
+
}): OutboxTicker;
|
|
50
|
+
//# sourceMappingURL=outbox-ticker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outbox-ticker.d.ts","sourceRoot":"","sources":["../../src/delivery/outbox-ticker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,uBAAuB,CAAC;AACpD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C;;wEAEwE;AACxE,eAAO,MAAM,+BAA+B,QAAS,CAAC;AAEtD,MAAM,WAAW,YAAY;IAC3B,gEAAgE;IAChE,QAAQ,CAAC,KAAK,EAAE,MAAM,IAAI,CAAC;IAC3B,0DAA0D;IAC1D,QAAQ,CAAC,IAAI,EAAE,MAAM,IAAI,CAAC;IAC1B,sEAAsE;IACtE,QAAQ,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IACvC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,kCAAkC;IAClC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B,GAAG,YAAY,CA2Cf"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Default retry-drive cadence. Deliberately coarser than the alive-ticker's
|
|
2
|
+
* 60s — this is a backstop for the backoff schedule (1s..5min), not a
|
|
3
|
+
* liveness signal; a shorter interval would mostly find nothing due. */
|
|
4
|
+
export const DEFAULT_OUTBOX_TICK_INTERVAL_MS = 30_000;
|
|
5
|
+
export function createOutboxTicker(opts) {
|
|
6
|
+
const { outbox, logger } = opts;
|
|
7
|
+
const intervalMs = opts.intervalMs ?? DEFAULT_OUTBOX_TICK_INTERVAL_MS;
|
|
8
|
+
let timer = null;
|
|
9
|
+
async function tickNow() {
|
|
10
|
+
try {
|
|
11
|
+
const summary = await outbox.driveOnce();
|
|
12
|
+
if (summary.attempted > 0 || summary.deadLettered > 0) {
|
|
13
|
+
logger.info('outbox_drive_tick', {
|
|
14
|
+
attempted: summary.attempted,
|
|
15
|
+
acked: summary.acked,
|
|
16
|
+
failed: summary.failed,
|
|
17
|
+
dead_lettered: summary.deadLettered,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
// Best-effort: a driveOnce failure (store I/O error, etc.) must never
|
|
23
|
+
// escape a timer callback and become an uncaughtException — same
|
|
24
|
+
// posture as alive-ticker.ts's tick(). The next tick retries.
|
|
25
|
+
logger.warn('outbox_drive_tick_failed', {
|
|
26
|
+
error: err instanceof Error ? err.message : String(err),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
start() {
|
|
32
|
+
if (timer !== null)
|
|
33
|
+
return;
|
|
34
|
+
timer = setInterval(() => {
|
|
35
|
+
void tickNow();
|
|
36
|
+
}, intervalMs);
|
|
37
|
+
// Never let the retry-drive interval keep the process alive.
|
|
38
|
+
timer.unref();
|
|
39
|
+
},
|
|
40
|
+
stop() {
|
|
41
|
+
if (timer !== null) {
|
|
42
|
+
clearInterval(timer);
|
|
43
|
+
timer = null;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
tickNow,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=outbox-ticker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outbox-ticker.js","sourceRoot":"","sources":["../../src/delivery/outbox-ticker.ts"],"names":[],"mappings":"AAgCA;;wEAEwE;AACxE,MAAM,CAAC,MAAM,+BAA+B,GAAG,MAAM,CAAC;AAWtD,MAAM,UAAU,kBAAkB,CAAC,IAKlC;IACC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAChC,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,IAAI,+BAA+B,CAAC;IACtE,IAAI,KAAK,GAA0C,IAAI,CAAC;IAExD,KAAK,UAAU,OAAO;QACpB,IAAI,CAAC;YACH,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,SAAS,EAAE,CAAC;YACzC,IAAI,OAAO,CAAC,SAAS,GAAG,CAAC,IAAI,OAAO,CAAC,YAAY,GAAG,CAAC,EAAE,CAAC;gBACtD,MAAM,CAAC,IAAI,CAAC,mBAAmB,EAAE;oBAC/B,SAAS,EAAE,OAAO,CAAC,SAAS;oBAC5B,KAAK,EAAE,OAAO,CAAC,KAAK;oBACpB,MAAM,EAAE,OAAO,CAAC,MAAM;oBACtB,aAAa,EAAE,OAAO,CAAC,YAAY;iBACpC,CAAC,CAAC;YACL,CAAC;QACH,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,sEAAsE;YACtE,iEAAiE;YACjE,8DAA8D;YAC9D,MAAM,CAAC,IAAI,CAAC,0BAA0B,EAAE;gBACtC,KAAK,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC;aACxD,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,OAAO;QACL,KAAK;YACH,IAAI,KAAK,KAAK,IAAI;gBAAE,OAAO;YAC3B,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE;gBACvB,KAAK,OAAO,EAAE,CAAC;YACjB,CAAC,EAAE,UAAU,CAAC,CAAC;YACf,6DAA6D;YAC7D,KAAK,CAAC,KAAK,EAAE,CAAC;QAChB,CAAC;QACD,IAAI;YACF,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;gBACnB,aAAa,CAAC,KAAK,CAAC,CAAC;gBACrB,KAAK,GAAG,IAAI,CAAC;YACf,CAAC;QACH,CAAC;QACD,OAAO;KACR,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DR-038 Decisions 1, 2, 4 — the sender-side effectively-once logic:
|
|
3
|
+
* persist-then-send, stable message-id reuse across retries, exponential-
|
|
4
|
+
* backoff retry bounded by a wall-clock TTL, and dead-letter escalation on
|
|
5
|
+
* TTL expiry.
|
|
6
|
+
*
|
|
7
|
+
* Runtime-agnostic over an injected `OutboxStore` (the pluggable driver
|
|
8
|
+
* interface, hoisted to `@groundnuty/macf-core`'s `delivery/store.ts` —
|
|
9
|
+
* macf#741), an injected `send` transport seam, and an injected clock — so
|
|
10
|
+
* the whole retry loop is deterministically testable without real time or
|
|
11
|
+
* real network I/O, and so a driver swap (in-memory here, VM disk-spool /
|
|
12
|
+
* K8s PVC-broker per DR-008) never touches this file.
|
|
13
|
+
*
|
|
14
|
+
* Retries are driven off the PERSISTED outbox, not an in-memory queue:
|
|
15
|
+
* `driveOnce` re-reads `store.listPending()` on every call. This is the
|
|
16
|
+
* crux of restart-survival (Decision 4) — a fresh `createOutbox()` instance
|
|
17
|
+
* over the SAME store (simulating a sender restart) sees every entry a
|
|
18
|
+
* prior process instance hadn't finished with, and its very first
|
|
19
|
+
* `driveOnce` picks up anything already past its `nextAttemptAt` — which is
|
|
20
|
+
* also what naturally implements the DR-038 "Open Questions" lean ("one
|
|
21
|
+
* immediate attempt on startup... then re-enter backoff") without any
|
|
22
|
+
* startup-specific code path: an overdue entry is simply due right away.
|
|
23
|
+
*/
|
|
24
|
+
import type { Logger, OutboxStore, OutboxEntry } from '@groundnuty/macf-core';
|
|
25
|
+
/**
|
|
26
|
+
* Result of one send attempt. `ack: true` means durable-ACK per DR-038
|
|
27
|
+
* Decision 3 (an HTTP 200 the *receiver* only returns after its own
|
|
28
|
+
* persist-then-ACK). Anything else — `ack: false`, or the `send` promise
|
|
29
|
+
* rejecting — is treated uniformly as a delivery failure (timeout, 5xx,
|
|
30
|
+
* connection-refused: the dead-peer case) and the entry stays in the
|
|
31
|
+
* outbox for retry.
|
|
32
|
+
*/
|
|
33
|
+
export interface OutboxSendResult {
|
|
34
|
+
readonly ack: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The injected transport seam. A real implementation dispatches the actual
|
|
38
|
+
* `message/send` (or legacy notify_peer POST) call; this module never
|
|
39
|
+
* touches the network directly — that keeps the effectively-once logic
|
|
40
|
+
* testable with a fake and reusable across transports.
|
|
41
|
+
*/
|
|
42
|
+
export type OutboxSendFn = (target: string, id: string, payload: unknown) => Promise<OutboxSendResult>;
|
|
43
|
+
/** Default exponential-backoff base (DR-038 Decision 4: "~1s → … → 5min"). */
|
|
44
|
+
export declare const DEFAULT_BACKOFF_BASE_MS = 1000;
|
|
45
|
+
/** Default exponential-backoff cap (DR-038 Decision 4: "~1s → … → 5min"). */
|
|
46
|
+
export declare const DEFAULT_BACKOFF_CAP_MS: number;
|
|
47
|
+
/**
|
|
48
|
+
* Default wall-clock TTL bounding the retry budget. DR-038 Decision 4
|
|
49
|
+
* proposes 24h ("the 8h outage fits comfortably; a multi-day VM outage
|
|
50
|
+
* would dead-letter, correctly escalating to GitHub" — Open Questions).
|
|
51
|
+
*/
|
|
52
|
+
export declare const DEFAULT_TTL_MS: number;
|
|
53
|
+
/**
|
|
54
|
+
* `baseMs * 2^attemptCount`, capped at `capMs`. `attemptCount` is the number
|
|
55
|
+
* of PRIOR failed attempts (0 on the first failure → backoff = `baseMs`).
|
|
56
|
+
* A wall-clock TTL bounds the retry budget (see `DEFAULT_TTL_MS`), NOT an
|
|
57
|
+
* attempt-count ceiling — an attempt count is meaningless across restart
|
|
58
|
+
* gaps of varying length (DR-038 Decision 4).
|
|
59
|
+
*/
|
|
60
|
+
export declare function computeBackoffMs(attemptCount: number, baseMs?: number, capMs?: number): number;
|
|
61
|
+
export interface OutboxDeps {
|
|
62
|
+
readonly store: OutboxStore;
|
|
63
|
+
readonly send: OutboxSendFn;
|
|
64
|
+
/** Injectable epoch-ms clock (testing). Default `Date.now`. */
|
|
65
|
+
readonly now?: () => number;
|
|
66
|
+
/**
|
|
67
|
+
* Invoked once per TTL-expired entry, AFTER `store.deadLetter(id)` has
|
|
68
|
+
* already run. This is the decision-layer escalation hook DR-038
|
|
69
|
+
* Decision 4 requires ("a `gh issue` to the operator/reporter... a
|
|
70
|
+
* decision-layer action... NOT a store-driver method") — this slice does
|
|
71
|
+
* NOT implement the GitHub call itself; a later wiring slice supplies a
|
|
72
|
+
* real callback. A throw from this callback is caught + logged, never
|
|
73
|
+
* allowed to abort the rest of the drive tick.
|
|
74
|
+
*/
|
|
75
|
+
readonly onDeadLetter?: (entry: OutboxEntry) => void | Promise<void>;
|
|
76
|
+
readonly logger?: Logger;
|
|
77
|
+
readonly backoffBaseMs?: number;
|
|
78
|
+
readonly backoffCapMs?: number;
|
|
79
|
+
readonly ttlMs?: number;
|
|
80
|
+
}
|
|
81
|
+
/** Per-tick counters, mainly for test assertions + operator diagnostics. */
|
|
82
|
+
export interface OutboxDriveSummary {
|
|
83
|
+
readonly attempted: number;
|
|
84
|
+
readonly acked: number;
|
|
85
|
+
readonly failed: number;
|
|
86
|
+
readonly deadLettered: number;
|
|
87
|
+
}
|
|
88
|
+
export interface Outbox {
|
|
89
|
+
/**
|
|
90
|
+
* Persist a NEW message BEFORE any send attempt (Decision 1: persist-
|
|
91
|
+
* then-send). Mints a fresh message-id (Decision 2) and returns it.
|
|
92
|
+
*/
|
|
93
|
+
readonly enqueue: (target: string, payload: unknown) => Promise<string>;
|
|
94
|
+
/**
|
|
95
|
+
* Drive one retry tick: for every pending entry whose `nextAttemptAt <=
|
|
96
|
+
* now`, either dead-letter it (TTL expired — checked BEFORE attempting
|
|
97
|
+
* another send) or attempt a send (ack → `markAcked`; failure →
|
|
98
|
+
* `reschedule` with the next backoff). Callable directly by tests with an
|
|
99
|
+
* explicit `now`, or wrapped in an operator-driven loop with injected
|
|
100
|
+
* sleep between ticks.
|
|
101
|
+
*/
|
|
102
|
+
readonly driveOnce: (now?: number) => Promise<OutboxDriveSummary>;
|
|
103
|
+
}
|
|
104
|
+
export declare function createOutbox(deps: OutboxDeps): Outbox;
|
|
105
|
+
//# sourceMappingURL=outbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outbox.d.ts","sourceRoot":"","sources":["../../src/delivery/outbox.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAG9E;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;CACvB;AAED;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG,CACzB,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,OAAO,KACb,OAAO,CAAC,gBAAgB,CAAC,CAAC;AAE/B,8EAA8E;AAC9E,eAAO,MAAM,uBAAuB,OAAQ,CAAC;AAE7C,6EAA6E;AAC7E,eAAO,MAAM,sBAAsB,QAAgB,CAAC;AAEpD;;;;GAIG;AACH,eAAO,MAAM,cAAc,QAAsB,CAAC;AAElD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,YAAY,EAAE,MAAM,EACpB,MAAM,GAAE,MAAgC,EACxC,KAAK,GAAE,MAA+B,GACrC,MAAM,CAGR;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B,+DAA+D;IAC/D,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B;;;;;;;;OAQG;IACH,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,4EAA4E;AAC5E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,MAAM;IACrB;;;OAGG;IACH,QAAQ,CAAC,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACxE;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,CAAC,GAAG,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,kBAAkB,CAAC,CAAC;CACnE;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAmFrD"}
|