@volter/twin-upstash 0.1.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/LICENSE +202 -0
- package/README.md +202 -0
- package/api/src/fetch.ts +54 -0
- package/api/src/generated/surface.gen.json +1 -0
- package/api/src/generated/ui.gen.json +1 -0
- package/api/src/index.ts +19 -0
- package/api/src/key-gate.ts +30 -0
- package/api/src/manifest.ts +103 -0
- package/api/src/screens/developer-api.tsx +106 -0
- package/api/src/screens/qstash.tsx +99 -0
- package/api/src/screens/session.tsx +125 -0
- package/api/src/screens/teams.tsx +114 -0
- package/api/src/semantics/backups.ts +90 -0
- package/api/src/semantics/index.ts +191 -0
- package/api/src/semantics/shared.ts +42 -0
- package/api/src/semantics/teams.ts +108 -0
- package/api/src/semantics/time.ts +40 -0
- package/dist/api/src/fetch.d.ts +15 -0
- package/dist/api/src/fetch.js +44 -0
- package/dist/api/src/fetch.ts +54 -0
- package/dist/api/src/generated/surface.gen.json +1 -0
- package/dist/api/src/generated/ui.gen.json +1 -0
- package/dist/api/src/index.ts +19 -0
- package/dist/api/src/key-gate.d.ts +3 -0
- package/dist/api/src/key-gate.js +30 -0
- package/dist/api/src/key-gate.ts +30 -0
- package/dist/api/src/manifest.d.ts +2 -0
- package/dist/api/src/manifest.js +81 -0
- package/dist/api/src/manifest.ts +103 -0
- package/dist/api/src/screens/developer-api.d.ts +3 -0
- package/dist/api/src/screens/developer-api.js +101 -0
- package/dist/api/src/screens/developer-api.tsx +106 -0
- package/dist/api/src/screens/qstash.d.ts +3 -0
- package/dist/api/src/screens/qstash.js +92 -0
- package/dist/api/src/screens/qstash.tsx +99 -0
- package/dist/api/src/screens/session.d.ts +9 -0
- package/dist/api/src/screens/session.js +118 -0
- package/dist/api/src/screens/session.tsx +125 -0
- package/dist/api/src/screens/teams.d.ts +3 -0
- package/dist/api/src/screens/teams.js +99 -0
- package/dist/api/src/screens/teams.tsx +114 -0
- package/dist/api/src/semantics/backups.d.ts +7 -0
- package/dist/api/src/semantics/backups.js +75 -0
- package/dist/api/src/semantics/backups.ts +90 -0
- package/dist/api/src/semantics/index.d.ts +10 -0
- package/dist/api/src/semantics/index.js +191 -0
- package/dist/api/src/semantics/index.ts +191 -0
- package/dist/api/src/semantics/shared.d.ts +21 -0
- package/dist/api/src/semantics/shared.js +34 -0
- package/dist/api/src/semantics/shared.ts +42 -0
- package/dist/api/src/semantics/teams.d.ts +13 -0
- package/dist/api/src/semantics/teams.js +100 -0
- package/dist/api/src/semantics/teams.ts +108 -0
- package/dist/api/src/semantics/time.d.ts +2 -0
- package/dist/api/src/semantics/time.js +34 -0
- package/dist/api/src/semantics/time.ts +40 -0
- package/dist/qstash/src/doors.d.ts +6 -0
- package/dist/qstash/src/doors.js +33 -0
- package/dist/qstash/src/doors.ts +51 -0
- package/dist/qstash/src/egress.d.ts +7 -0
- package/dist/qstash/src/egress.js +66 -0
- package/dist/qstash/src/egress.ts +58 -0
- package/dist/qstash/src/fetch.d.ts +7 -0
- package/dist/qstash/src/fetch.js +48 -0
- package/dist/qstash/src/fetch.ts +46 -0
- package/dist/qstash/src/generated/surface.gen.json +1 -0
- package/dist/qstash/src/generated/ui.gen.json +1 -0
- package/dist/qstash/src/index.ts +35 -0
- package/dist/qstash/src/manifest.d.ts +10 -0
- package/dist/qstash/src/manifest.js +105 -0
- package/dist/qstash/src/manifest.ts +134 -0
- package/dist/qstash/src/semantics/account.d.ts +29 -0
- package/dist/qstash/src/semantics/account.js +91 -0
- package/dist/qstash/src/semantics/account.ts +98 -0
- package/dist/qstash/src/semantics/delivery.d.ts +17 -0
- package/dist/qstash/src/semantics/delivery.js +274 -0
- package/dist/qstash/src/semantics/delivery.ts +264 -0
- package/dist/qstash/src/semantics/dlq.d.ts +4 -0
- package/dist/qstash/src/semantics/dlq.js +51 -0
- package/dist/qstash/src/semantics/dlq.ts +61 -0
- package/dist/qstash/src/semantics/index.d.ts +2 -0
- package/dist/qstash/src/semantics/index.js +10 -0
- package/dist/qstash/src/semantics/index.ts +13 -0
- package/dist/qstash/src/semantics/keys.d.ts +2 -0
- package/dist/qstash/src/semantics/keys.js +9 -0
- package/dist/qstash/src/semantics/keys.ts +14 -0
- package/dist/qstash/src/semantics/messages.d.ts +74 -0
- package/dist/qstash/src/semantics/messages.js +233 -0
- package/dist/qstash/src/semantics/messages.ts +249 -0
- package/dist/qstash/src/semantics/queues.d.ts +2 -0
- package/dist/qstash/src/semantics/queues.js +60 -0
- package/dist/qstash/src/semantics/queues.ts +66 -0
- package/dist/qstash/src/semantics/schedules.d.ts +19 -0
- package/dist/qstash/src/semantics/schedules.js +125 -0
- package/dist/qstash/src/semantics/schedules.ts +132 -0
- package/dist/qstash/src/semantics/shared.d.ts +45 -0
- package/dist/qstash/src/semantics/shared.js +115 -0
- package/dist/qstash/src/semantics/shared.ts +121 -0
- package/dist/qstash/src/semantics/urlgroups.d.ts +2 -0
- package/dist/qstash/src/semantics/urlgroups.js +58 -0
- package/dist/qstash/src/semantics/urlgroups.ts +69 -0
- package/dist/qstash/src/semantics/workflows.d.ts +44 -0
- package/dist/qstash/src/semantics/workflows.js +379 -0
- package/dist/qstash/src/semantics/workflows.ts +401 -0
- package/dist/qstash/src/signing.d.ts +4 -0
- package/dist/qstash/src/signing.js +16 -0
- package/dist/qstash/src/signing.ts +19 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +35 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.js +124 -0
- package/dist/src/manifest.d.ts +14 -0
- package/dist/src/manifest.js +8 -0
- package/dist/src/upstash-budget.d.ts +85 -0
- package/dist/src/upstash-budget.js +440 -0
- package/dist/src/upstash-capabilities.d.ts +4 -0
- package/dist/src/upstash-capabilities.js +1286 -0
- package/dist/src/upstash-conformance.d.ts +7 -0
- package/dist/src/upstash-conformance.js +119 -0
- package/dist/src/upstash-connector.d.ts +115 -0
- package/dist/src/upstash-connector.js +309 -0
- package/dist/src/upstash-lua.d.ts +140 -0
- package/dist/src/upstash-lua.js +1229 -0
- package/dist/src/upstash-server.d.ts +29 -0
- package/dist/src/upstash-server.js +81 -0
- package/dist/src/upstash-store.d.ts +114 -0
- package/dist/src/upstash-store.js +1663 -0
- package/dist/src/upstash-twin.d.ts +73 -0
- package/dist/src/upstash-twin.js +437 -0
- package/package.json +59 -0
- package/qstash/src/doors.ts +51 -0
- package/qstash/src/egress.ts +58 -0
- package/qstash/src/fetch.ts +46 -0
- package/qstash/src/generated/surface.gen.json +1 -0
- package/qstash/src/generated/ui.gen.json +1 -0
- package/qstash/src/index.ts +35 -0
- package/qstash/src/manifest.ts +134 -0
- package/qstash/src/semantics/account.ts +98 -0
- package/qstash/src/semantics/delivery.ts +264 -0
- package/qstash/src/semantics/dlq.ts +61 -0
- package/qstash/src/semantics/index.ts +13 -0
- package/qstash/src/semantics/keys.ts +14 -0
- package/qstash/src/semantics/messages.ts +249 -0
- package/qstash/src/semantics/queues.ts +66 -0
- package/qstash/src/semantics/schedules.ts +132 -0
- package/qstash/src/semantics/shared.ts +121 -0
- package/qstash/src/semantics/urlgroups.ts +69 -0
- package/qstash/src/semantics/workflows.ts +401 -0
- package/qstash/src/signing.ts +19 -0
- package/src/cli.ts +36 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/index.ts +203 -0
- package/src/manifest.ts +26 -0
- package/src/upstash-budget.ts +486 -0
- package/src/upstash-capabilities.ts +1418 -0
- package/src/upstash-conformance.ts +131 -0
- package/src/upstash-connector.ts +340 -0
- package/src/upstash-lua.ts +1120 -0
- package/src/upstash-server.ts +103 -0
- package/src/upstash-store.ts +1437 -0
- package/src/upstash-twin.ts +465 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { accountOf } from "./account.js";
|
|
2
|
+
import { acceptToUrlGroup, ensureQueue, storeMessage } from "./messages.js";
|
|
3
|
+
import { nextFire } from "./schedules.js";
|
|
4
|
+
import { failRun, failureCallSettled, rawSteps, timeOut, workflowHeaders } from "./workflows.js";
|
|
5
|
+
import { b64, iso, multiValue, nowMs } from "./shared.js";
|
|
6
|
+
import { post } from "../egress.js";
|
|
7
|
+
import { sign } from "../signing.js";
|
|
8
|
+
/** The World's word on what a destination answers, stored by `POST /_twin/destinations` (../doors.ts). */
|
|
9
|
+
export const DESTINATION = '_qstash_destination';
|
|
10
|
+
/** What a destination received, read at `GET /_twin/deliveries?to=` (../doors.ts). */
|
|
11
|
+
export const DELIVERY = '_qstash_delivery';
|
|
12
|
+
const DAY_MS = 86_400_000;
|
|
13
|
+
const DLQ_RETENTION_MS = 7 * DAY_MS;
|
|
14
|
+
const PENDING = new Set(['CREATED', 'ACTIVE', 'RETRY']);
|
|
15
|
+
/** The answer the World has stated for a delivery: of the statements whose URL prefixes the delivery's and whose `when`
|
|
16
|
+
* headers (if any) it carries, one that names headers before one that does not, then the longest prefix. */
|
|
17
|
+
function stated(ctx, url, sent) {
|
|
18
|
+
const holds = (d) => url.startsWith(String(d.url)) && Object.entries((d.when ?? {})).every(([k, v]) => sent[k.toLowerCase()] === v);
|
|
19
|
+
const hit = ctx.rowsRaw(DESTINATION).filter(holds)
|
|
20
|
+
.sort((a, b) => Number(Object.keys(b.when ?? {}).length > 0) - Number(Object.keys(a.when ?? {}).length > 0) || String(b.url).length - String(a.url).length)[0];
|
|
21
|
+
return hit ? { status: Number(hit.status), body: String(hit.body ?? ''), headers: (hit.headers ?? {}) } : undefined;
|
|
22
|
+
}
|
|
23
|
+
/** QStash's backoff for retry n, in milliseconds. */
|
|
24
|
+
const backoffMs = (n) => Math.round(Math.min(86_400, Math.exp(2.5 * n)) * 1000);
|
|
25
|
+
/** A `Retry-After` answer in milliseconds: seconds, a duration (`6m5s`) or a date, capped at a day. */
|
|
26
|
+
function retryAfterMs(headers, at) {
|
|
27
|
+
const raw = headers['retry-after']?.trim();
|
|
28
|
+
return raw ? retryAfterValue(raw, at) : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** A `Retry-After` value in milliseconds (https://upstash.com/docs/qstash/features/retry, Retry-After Headers). */
|
|
31
|
+
function retryAfterValue(raw, at) {
|
|
32
|
+
const d = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(raw);
|
|
33
|
+
const sec = /^\d+$/.test(raw) ? Number(raw) : d && raw !== '' ? Number(d[1] ?? 0) * 3600 + Number(d[2] ?? 0) * 60 + Number(d[3] ?? 0) : Number.isNaN(Date.parse(raw)) ? undefined : Math.max(0, (Date.parse(raw) - at) / 1000);
|
|
34
|
+
return sec === undefined ? undefined : Math.round(Math.min(86_400, sec) * 1000);
|
|
35
|
+
}
|
|
36
|
+
/** An `Upstash-Retry-Delay` expression's value in milliseconds: numbers, `retried`, `+ - * /`, parentheses and `pow sqrt
|
|
37
|
+
* abs exp floor ceil round min max` (the retry page's Custom Retry Delay); anything else is undefined. */
|
|
38
|
+
export function retryDelayMs(expr, retried) {
|
|
39
|
+
const src = expr.replace(/\s+/g, '');
|
|
40
|
+
let i = 0;
|
|
41
|
+
const fns = { pow: Math.pow, sqrt: Math.sqrt, abs: Math.abs, exp: Math.exp, floor: Math.floor, ceil: Math.ceil, round: Math.round, min: Math.min, max: Math.max };
|
|
42
|
+
const fail = () => { throw new Error('retryDelay'); };
|
|
43
|
+
const primary = () => {
|
|
44
|
+
if (src[i] === '(') {
|
|
45
|
+
i += 1;
|
|
46
|
+
const v = sum();
|
|
47
|
+
if (src[i] !== ')')
|
|
48
|
+
fail();
|
|
49
|
+
i += 1;
|
|
50
|
+
return v;
|
|
51
|
+
}
|
|
52
|
+
const num = /^\d+(\.\d+)?/.exec(src.slice(i));
|
|
53
|
+
if (num) {
|
|
54
|
+
i += num[0].length;
|
|
55
|
+
return Number(num[0]);
|
|
56
|
+
}
|
|
57
|
+
const id = /^[a-z]+/.exec(src.slice(i));
|
|
58
|
+
if (!id)
|
|
59
|
+
return fail();
|
|
60
|
+
i += id[0].length;
|
|
61
|
+
if (id[0] === 'retried')
|
|
62
|
+
return retried;
|
|
63
|
+
const fn = fns[id[0]];
|
|
64
|
+
if (!fn || src[i] !== '(')
|
|
65
|
+
return fail();
|
|
66
|
+
i += 1;
|
|
67
|
+
const args = [sum()];
|
|
68
|
+
while (src[i] === ',') {
|
|
69
|
+
i += 1;
|
|
70
|
+
args.push(sum());
|
|
71
|
+
}
|
|
72
|
+
if (src[i] !== ')')
|
|
73
|
+
fail();
|
|
74
|
+
i += 1;
|
|
75
|
+
return fn(...args);
|
|
76
|
+
};
|
|
77
|
+
const product = () => { let v = primary(); while (src[i] === '*' || src[i] === '/') {
|
|
78
|
+
const op = src[i];
|
|
79
|
+
i += 1;
|
|
80
|
+
const r = primary();
|
|
81
|
+
v = op === '*' ? v * r : v / r;
|
|
82
|
+
} return v; };
|
|
83
|
+
const sum = () => { let v = product(); while (src[i] === '+' || src[i] === '-') {
|
|
84
|
+
const op = src[i];
|
|
85
|
+
i += 1;
|
|
86
|
+
const r = product();
|
|
87
|
+
v = op === '+' ? v + r : v - r;
|
|
88
|
+
} return v; };
|
|
89
|
+
try {
|
|
90
|
+
const v = sum();
|
|
91
|
+
return i === src.length && Number.isFinite(v) && v >= 0 ? v : undefined;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** A queue delivers in order, as many at a time as its parallelism ("The number of parallel consumers consuming from the
|
|
98
|
+
* queue", the spec's Queue), and none while it is paused ("Pausing a queue stops the delivery of enqueued messages", the
|
|
99
|
+
* spec's pause). A message whose queue was deleted goes as a queue of one would. */
|
|
100
|
+
function queueLets(ctx, m, all) {
|
|
101
|
+
const q = ctx.rowsRaw('Queue').find((r) => r.name === m.queueName);
|
|
102
|
+
if (q?.paused === true)
|
|
103
|
+
return false;
|
|
104
|
+
const ahead = all.filter((o) => o.queueName === m.queueName && PENDING.has(String(o.state)) && Number(o._ordinal) < Number(m._ordinal)).length;
|
|
105
|
+
return ahead < Math.max(1, Number(q?.parallelism ?? 1));
|
|
106
|
+
}
|
|
107
|
+
/** Whether a pending message may go now: its queue lets it (queueLets), and it is the first undelivered of its run. */
|
|
108
|
+
function eligible(ctx, m, all) {
|
|
109
|
+
if (m.state !== 'CREATED' && m.state !== 'RETRY')
|
|
110
|
+
return false;
|
|
111
|
+
if (typeof m.queueName === 'string' && !queueLets(ctx, m, all))
|
|
112
|
+
return false;
|
|
113
|
+
if (typeof m._run === 'string') {
|
|
114
|
+
const run = ctx.rowsRaw('WorkflowRun').find((r) => r.workflowRunId === m._run);
|
|
115
|
+
if (!run || run.workflowState !== 'RUN_STARTED')
|
|
116
|
+
return false;
|
|
117
|
+
if (all.some((o) => o._run === m._run && PENDING.has(String(o.state)) && Number(o._seq) < Number(m._seq)))
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
function nextDue(ctx, until) {
|
|
123
|
+
const due = [];
|
|
124
|
+
for (const s of ctx.rowsRaw('Schedule')) {
|
|
125
|
+
if (typeof s.nextScheduleTime === 'number' && s.nextScheduleTime <= until)
|
|
126
|
+
due.push({ when: s.nextScheduleTime, order: 0, act: (at) => fire(at, s) });
|
|
127
|
+
}
|
|
128
|
+
for (const w of ctx.rowsRaw('Waiter')) {
|
|
129
|
+
const when = Number(w.deadline) * 1000;
|
|
130
|
+
if (when <= until)
|
|
131
|
+
due.push({ when, order: 1, act: (at) => timeOut(at, w) });
|
|
132
|
+
}
|
|
133
|
+
const all = ctx.rowsRaw('Message');
|
|
134
|
+
for (const m of all) {
|
|
135
|
+
const when = Number(m._due);
|
|
136
|
+
if (when > until)
|
|
137
|
+
continue;
|
|
138
|
+
if (m.state === 'CANCEL_REQUESTED')
|
|
139
|
+
due.push({ when, order: 2 + Number(m._ordinal) / 1e9, act: (at) => cancelled(at, m) });
|
|
140
|
+
else if (eligible(ctx, m, all))
|
|
141
|
+
due.push({ when, order: 3 + Number(m._ordinal) / 1e9, act: (at, report) => attempt(at, m, report) });
|
|
142
|
+
}
|
|
143
|
+
for (const resource of ['DLQMessage', 'WorkflowDLQMessage']) {
|
|
144
|
+
for (const e of ctx.rowsRaw(resource)) {
|
|
145
|
+
const when = Number(e._filed_at ?? e.createdAt) + DLQ_RETENTION_MS;
|
|
146
|
+
if (when <= until)
|
|
147
|
+
due.push({ when, order: 4, act: (at) => at.write(resource, String(e.id), { deleted: true }, 'dlq.expired').then(() => undefined) });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return due.sort((a, b) => a.when - b.when || a.order - b.order)[0];
|
|
151
|
+
}
|
|
152
|
+
/** Run everything due by the World's instant, in order, each at its own moment. */
|
|
153
|
+
export async function catchUp(ctx) {
|
|
154
|
+
const until = nowMs(ctx);
|
|
155
|
+
const report = { delivered: [], retried: [], failed: [] };
|
|
156
|
+
// a message held behind another (the one before it in its queue or its run) goes once that one has left, so the World
|
|
157
|
+
// clock never runs back: each move happens at its own due time or at the last move's, whichever is later
|
|
158
|
+
let last = 0;
|
|
159
|
+
for (let n = 0; n < 5000; n += 1) {
|
|
160
|
+
const next = nextDue(ctx, until);
|
|
161
|
+
if (!next)
|
|
162
|
+
break;
|
|
163
|
+
last = Math.max(last, next.when);
|
|
164
|
+
await next.act(await ctx.at(iso(last)), report);
|
|
165
|
+
}
|
|
166
|
+
return report;
|
|
167
|
+
}
|
|
168
|
+
/** A schedule's cron has fired: its message is published at that moment (a paused schedule's is not), and it is next
|
|
169
|
+
* due at the cron's following minute. */
|
|
170
|
+
async function fire(ctx, s) {
|
|
171
|
+
const when = Number(s.nextScheduleTime);
|
|
172
|
+
if (s.isPaused !== true) {
|
|
173
|
+
const { 'upstash-cron': _cron, 'upstash-schedule-id': _id, 'upstash-queue-name': _q, ...headers } = (s._published ?? {});
|
|
174
|
+
const queue = typeof s._queue === 'string' ? s._queue : undefined;
|
|
175
|
+
if (queue)
|
|
176
|
+
await ensureQueue(ctx, queue);
|
|
177
|
+
const destination = String(s.destination);
|
|
178
|
+
if (/^https?:\/\//.test(destination))
|
|
179
|
+
await storeMessage(ctx, { url: destination, headers, body: String(s.body ?? ''), owner: String(s._owner), scheduleId: String(s.scheduleId), queue });
|
|
180
|
+
else
|
|
181
|
+
await acceptToUrlGroup(ctx, { destination, headers, body: String(s.body ?? ''), queue }, String(s._owner));
|
|
182
|
+
}
|
|
183
|
+
const next = nextFire(String(s.cron), when);
|
|
184
|
+
await ctx.write('Schedule', String(s.id), { lastScheduleTime: when, ...(next !== undefined ? { nextScheduleTime: next } : {}) }, 'schedule.fire');
|
|
185
|
+
}
|
|
186
|
+
/** A cancelled message reaches its next delivery time: it is CANCELLED, never delivered. */
|
|
187
|
+
async function cancelled(ctx, m) {
|
|
188
|
+
const refused = ctx.legal('Message', 'state', 'delete_v2_messages_messageid', m.state, 'CANCELLED', String(m.id), 'time');
|
|
189
|
+
if (refused)
|
|
190
|
+
throw new Error(refused.message);
|
|
191
|
+
await ctx.write('Message', String(m.id), { state: 'CANCELLED' }, 'message.cancelled');
|
|
192
|
+
}
|
|
193
|
+
/** Move a message, as its machine allows. */
|
|
194
|
+
async function move(ctx, m, to, actor, fields) {
|
|
195
|
+
const refused = ctx.legal('Message', 'state', 'delete_v2_messages_messageid', m.state, to, String(m.id), actor);
|
|
196
|
+
if (refused)
|
|
197
|
+
throw new Error(refused.message);
|
|
198
|
+
await ctx.write('Message', String(m.id), { ...fields, state: to }, `message.${to.toLowerCase()}`);
|
|
199
|
+
m.state = to;
|
|
200
|
+
}
|
|
201
|
+
/** One delivery attempt of a message, at the moment it is due. */
|
|
202
|
+
async function attempt(ctx, m, report) {
|
|
203
|
+
const when = nowMs(ctx);
|
|
204
|
+
const tried = Number(m._attempts ?? 0);
|
|
205
|
+
await move(ctx, m, 'ACTIVE', 'time', { _attempts: tried });
|
|
206
|
+
const published = (m._published ?? {});
|
|
207
|
+
const step = typeof m._run === 'string';
|
|
208
|
+
const body = step ? JSON.stringify(rawSteps(ctx, m._run, Number(m._seq))) : String(m.body ?? '');
|
|
209
|
+
const base = published['upstash-workflow-runid'] !== undefined ? workflowHeaders(published) : {
|
|
210
|
+
...(typeof m._content_type === 'string' ? { 'content-type': m._content_type } : {}),
|
|
211
|
+
...Object.fromEntries(Object.entries(published).filter(([k]) => k.startsWith('upstash-forward-')).map(([k, v]) => [k.slice('upstash-forward-'.length), v])),
|
|
212
|
+
};
|
|
213
|
+
const url = String(m.url);
|
|
214
|
+
const key = accountOf(ctx, String(m._owner ?? 'world')).current;
|
|
215
|
+
const headers = {
|
|
216
|
+
...base, 'upstash-message-id': String(m.messageId), 'upstash-retried': String(tried),
|
|
217
|
+
'upstash-signature': sign(body, url, Math.floor(when / 1000), `jwt_${String(m.messageId)}_${tried}`, key),
|
|
218
|
+
};
|
|
219
|
+
const method = String(m.method ?? 'POST');
|
|
220
|
+
const outcome = stated(ctx, url, headers) ?? await post(url, method, headers, body);
|
|
221
|
+
await ctx.record(DELIVERY, { to: url, message_id: m.messageId, retried: tried, at: ctx.occurredAt, n: ctx.rowsRaw(DELIVERY).length, method, headers, body, status: outcome.status }, `delivery:${String(m.messageId)}:${tried}`);
|
|
222
|
+
const n = tried + 1;
|
|
223
|
+
const response = { _response_status: outcome.status, _response_body: outcome.body, _response_headers: outcome.headers };
|
|
224
|
+
if (outcome.status >= 200 && outcome.status < 300) {
|
|
225
|
+
await move(ctx, m, 'DELIVERED', 'vendor', { _attempts: n, ...response });
|
|
226
|
+
report.delivered.push({ messageId: m.messageId, url, status: outcome.status });
|
|
227
|
+
if (typeof m._callback_of === 'string')
|
|
228
|
+
await failureCallSettled(ctx, m._callback_of, true);
|
|
229
|
+
else if (typeof m.callback === 'string')
|
|
230
|
+
await callback(ctx, m, String(m.callback), outcome, n, undefined);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const nonRetryable = outcome.status === 489 && outcome.headers['upstash-nonretryable-error'] === 'true';
|
|
234
|
+
if (n <= Number(m.maxRetries ?? 3) && !nonRetryable) {
|
|
235
|
+
const run = step ? ctx.rowsRaw('WorkflowRun').find((r) => r.workflowRunId === m._run) : undefined;
|
|
236
|
+
const expr = typeof m._retry_delay === 'string' ? m._retry_delay : typeof run?._retry_delay === 'string' ? run._retry_delay : undefined;
|
|
237
|
+
const wait = retryAfterMs(outcome.headers, when) ?? (expr !== undefined ? retryDelayMs(expr, tried) : undefined) ?? backoffMs(n);
|
|
238
|
+
await move(ctx, m, 'RETRY', 'vendor', { _attempts: n, _due: when + wait, ...response });
|
|
239
|
+
report.retried.push({ messageId: m.messageId, url, status: outcome.status, retried: n, nextDeliveryTime: when + wait });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
await move(ctx, m, 'FAILED', 'vendor', { _attempts: n, ...response });
|
|
243
|
+
report.failed.push({ messageId: m.messageId, url, status: outcome.status });
|
|
244
|
+
if (typeof m._callback_of === 'string') {
|
|
245
|
+
await failureCallSettled(ctx, m._callback_of, false);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (step) {
|
|
249
|
+
await failRun(ctx, { ...m, _attempts: n }, outcome);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const dlqId = `${when}-${ctx.rowsRaw('DLQMessage', { withDeleted: true }).length}`;
|
|
253
|
+
const own = ctx.own(m);
|
|
254
|
+
await ctx.write('DLQMessage', dlqId, {
|
|
255
|
+
...Object.fromEntries(Object.entries(own).filter(([k]) => !k.startsWith('_') && k !== 'state')),
|
|
256
|
+
dlqId, responseStatus: outcome.status, responseHeader: multiValue(outcome.headers), responseBody: outcome.body,
|
|
257
|
+
_filed_at: when, _published: published, _message_id: m.messageId, _owner: m._owner ?? 'world', ...(m._redact ? { _redact: m._redact } : {}),
|
|
258
|
+
}, 'dlq.create');
|
|
259
|
+
if (typeof m.failureCallback === 'string')
|
|
260
|
+
await callback(ctx, m, String(m.failureCallback), outcome, n, dlqId);
|
|
261
|
+
if (typeof m.callback === 'string')
|
|
262
|
+
await callback(ctx, m, String(m.callback), outcome, n, undefined);
|
|
263
|
+
}
|
|
264
|
+
/** A callback: "QStash calls your callback URL with the response"; its body is the callbacks page's (`status`, `header`,
|
|
265
|
+
* base64 `body`, `retried`, `maxRetries`, `sourceMessageId`, `url`, `method`, `sourceHeader`, base64 `sourceBody`,
|
|
266
|
+
* `notBefore`, `createdAt`, `callerIP`), and a failure callback's adds the `dlqId`. It is a message of its own. */
|
|
267
|
+
async function callback(ctx, m, url, o, attempts, dlqId) {
|
|
268
|
+
const body = JSON.stringify({
|
|
269
|
+
status: o.status, header: multiValue(o.headers), body: b64(o.body), retried: attempts - 1, maxRetries: Number(m.maxRetries ?? 3),
|
|
270
|
+
...(dlqId ? { dlqId } : {}), sourceMessageId: m.messageId, url: m.url, method: m.method ?? 'POST', sourceHeader: m.header ?? {},
|
|
271
|
+
sourceBody: b64(String(m.body ?? '')), notBefore: m.notBefore, createdAt: m.createdAt, callerIP: '127.0.0.1',
|
|
272
|
+
});
|
|
273
|
+
await storeMessage(ctx, { url, headers: { 'content-type': 'application/json' }, body, owner: String(m._owner ?? 'world'), callbackOf: String(m.messageId) });
|
|
274
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The QStash lane's catch-up (docs/contributing/architecture.md, "Moves no API call makes": time): before the lane answers
|
|
2
|
+
// any request on a writable World, everything the World clock has made due happens, in order, each at the moment it fell
|
|
3
|
+
// due: a schedule's cron fires and publishes its message, a wait times out, a message is delivered (or retried, or
|
|
4
|
+
// failed into the DLQ, its callbacks published), a cancelled message is marked CANCELLED, and a DLQ entry past its
|
|
5
|
+
// retention is removed. There is no scheduler: the World clock is read, never watched.
|
|
6
|
+
//
|
|
7
|
+
// Delivery (https://upstash.com/docs/qstash/howto/debug-logs, /features/retry, /features/callbacks, /features/dlq):
|
|
8
|
+
// - A message is delivered when due: its publish instant, plus `Upstash-Delay`, or its `Upstash-Not-Before`. Messages
|
|
9
|
+
// of one queue go one at a time in order ("The next message will wait for retries of the current one"), and a
|
|
10
|
+
// workflow run's messages go in the order the run made them.
|
|
11
|
+
// - The destination receives the body and the publish's `Content-Type`, every `Upstash-Forward-*` header with its prefix
|
|
12
|
+
// stripped, `Upstash-Message-Id`, `Upstash-Retried` ("0" first, then how many retries so far) and an
|
|
13
|
+
// `Upstash-Signature` signed with the account's current key (signing.ts); a workflow's delivery carries the run's steps
|
|
14
|
+
// and headers (workflows.ts).
|
|
15
|
+
// - "If your API does not respond with a success status code (2XX), we retry the request": retry n waits
|
|
16
|
+
// `min(86400, e ** (2.5*n))` seconds, or what the destination's `Retry-After` asks (seconds, a duration or a date, capped
|
|
17
|
+
// at a day), or the message's `Upstash-Retry-Delay` expression (milliseconds, `retried` from 0). A 489 with
|
|
18
|
+
// `Upstash-NonRetryable-Error: true` is not retried.
|
|
19
|
+
// - "If all retries are exhausted, the task has FAILED and the message will be moved to the DLQ", and its
|
|
20
|
+
// `Upstash-Failure-Callback` is called; its `Upstash-Callback` is called with the answer once delivered or failed. A
|
|
21
|
+
// callback is a message of its own, delivered, signed and retried the same way, and never called back itself.
|
|
22
|
+
// - DLQ retention: "Messages are deleted when their retention period expires" (/features/dlq; the pricing page's Pay as
|
|
23
|
+
// You Go column: DLQ 7 days; the Workflow DLQ "Pay-as-you-go: 1 week", https://upstash.com/docs/workflow/features/dlq).
|
|
24
|
+
//
|
|
25
|
+
// What a destination answers is the World's: a destination the World has stated an answer for (the destinations door,
|
|
26
|
+
// ../doors.ts) answers that, and any other is asked over HTTP where the World lets the request out (egress.ts); a
|
|
27
|
+
// sealed World refuses it, which is a failed attempt. Where the documentation stops and the lane decides: the account's
|
|
28
|
+
// plan is Pay as You Go; a callback is called once when the message is delivered or fails, not after every retry.
|
|
29
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
30
|
+
import { accountOf } from './account.ts';
|
|
31
|
+
import { acceptToUrlGroup, ensureQueue, storeMessage } from './messages.ts';
|
|
32
|
+
import { nextFire } from './schedules.ts';
|
|
33
|
+
import { failRun, failureCallSettled, rawSteps, timeOut, workflowHeaders } from './workflows.ts';
|
|
34
|
+
import { b64, iso, multiValue, nowMs, type Row } from './shared.ts';
|
|
35
|
+
import { post, type Outcome } from '../egress.ts';
|
|
36
|
+
import { sign } from '../signing.ts';
|
|
37
|
+
|
|
38
|
+
/** The World's word on what a destination answers, stored by `POST /_twin/destinations` (../doors.ts). */
|
|
39
|
+
export const DESTINATION = '_qstash_destination';
|
|
40
|
+
/** What a destination received, read at `GET /_twin/deliveries?to=` (../doors.ts). */
|
|
41
|
+
export const DELIVERY = '_qstash_delivery';
|
|
42
|
+
|
|
43
|
+
const DAY_MS = 86_400_000;
|
|
44
|
+
const DLQ_RETENTION_MS = 7 * DAY_MS;
|
|
45
|
+
const PENDING = new Set(['CREATED', 'ACTIVE', 'RETRY']);
|
|
46
|
+
|
|
47
|
+
/** The answer the World has stated for a delivery: of the statements whose URL prefixes the delivery's and whose `when`
|
|
48
|
+
* headers (if any) it carries, one that names headers before one that does not, then the longest prefix. */
|
|
49
|
+
function stated(ctx: SemanticsContext, url: string, sent: Record<string, string>): Outcome | undefined {
|
|
50
|
+
const holds = (d: Row): boolean => url.startsWith(String(d.url)) && Object.entries((d.when ?? {}) as Record<string, string>).every(([k, v]) => sent[k.toLowerCase()] === v);
|
|
51
|
+
const hit = ctx.rowsRaw(DESTINATION).filter(holds)
|
|
52
|
+
.sort((a, b) => Number(Object.keys(b.when ?? {}).length > 0) - Number(Object.keys(a.when ?? {}).length > 0) || String(b.url).length - String(a.url).length)[0];
|
|
53
|
+
return hit ? { status: Number(hit.status), body: String(hit.body ?? ''), headers: (hit.headers ?? {}) as Record<string, string> } : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** QStash's backoff for retry n, in milliseconds. */
|
|
57
|
+
const backoffMs = (n: number): number => Math.round(Math.min(86_400, Math.exp(2.5 * n)) * 1000);
|
|
58
|
+
|
|
59
|
+
/** A `Retry-After` answer in milliseconds: seconds, a duration (`6m5s`) or a date, capped at a day. */
|
|
60
|
+
function retryAfterMs(headers: Record<string, string>, at: number): number | undefined {
|
|
61
|
+
const raw = headers['retry-after']?.trim();
|
|
62
|
+
return raw ? retryAfterValue(raw, at) : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A `Retry-After` value in milliseconds (https://upstash.com/docs/qstash/features/retry, Retry-After Headers). */
|
|
66
|
+
function retryAfterValue(raw: string, at: number): number | undefined {
|
|
67
|
+
const d = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(raw);
|
|
68
|
+
const sec = /^\d+$/.test(raw) ? Number(raw) : d && raw !== '' ? Number(d[1] ?? 0) * 3600 + Number(d[2] ?? 0) * 60 + Number(d[3] ?? 0) : Number.isNaN(Date.parse(raw)) ? undefined : Math.max(0, (Date.parse(raw) - at) / 1000);
|
|
69
|
+
return sec === undefined ? undefined : Math.round(Math.min(86_400, sec) * 1000);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** An `Upstash-Retry-Delay` expression's value in milliseconds: numbers, `retried`, `+ - * /`, parentheses and `pow sqrt
|
|
73
|
+
* abs exp floor ceil round min max` (the retry page's Custom Retry Delay); anything else is undefined. */
|
|
74
|
+
export function retryDelayMs(expr: string, retried: number): number | undefined {
|
|
75
|
+
const src = expr.replace(/\s+/g, '');
|
|
76
|
+
let i = 0;
|
|
77
|
+
const fns: Record<string, (...a: number[]) => number> = { pow: Math.pow, sqrt: Math.sqrt, abs: Math.abs, exp: Math.exp, floor: Math.floor, ceil: Math.ceil, round: Math.round, min: Math.min, max: Math.max };
|
|
78
|
+
const fail = (): never => { throw new Error('retryDelay'); };
|
|
79
|
+
const primary = (): number => {
|
|
80
|
+
if (src[i] === '(') { i += 1; const v = sum(); if (src[i] !== ')') fail(); i += 1; return v; }
|
|
81
|
+
const num = /^\d+(\.\d+)?/.exec(src.slice(i));
|
|
82
|
+
if (num) { i += num[0].length; return Number(num[0]); }
|
|
83
|
+
const id = /^[a-z]+/.exec(src.slice(i));
|
|
84
|
+
if (!id) return fail();
|
|
85
|
+
i += id[0].length;
|
|
86
|
+
if (id[0] === 'retried') return retried;
|
|
87
|
+
const fn = fns[id[0]];
|
|
88
|
+
if (!fn || src[i] !== '(') return fail();
|
|
89
|
+
i += 1;
|
|
90
|
+
const args = [sum()];
|
|
91
|
+
while (src[i] === ',') { i += 1; args.push(sum()); }
|
|
92
|
+
if (src[i] !== ')') fail();
|
|
93
|
+
i += 1;
|
|
94
|
+
return fn(...args);
|
|
95
|
+
};
|
|
96
|
+
const product = (): number => { let v = primary(); while (src[i] === '*' || src[i] === '/') { const op = src[i]; i += 1; const r = primary(); v = op === '*' ? v * r : v / r; } return v; };
|
|
97
|
+
const sum = (): number => { let v = product(); while (src[i] === '+' || src[i] === '-') { const op = src[i]; i += 1; const r = product(); v = op === '+' ? v + r : v - r; } return v; };
|
|
98
|
+
try { const v = sum(); return i === src.length && Number.isFinite(v) && v >= 0 ? v : undefined; } catch { return undefined; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A queue delivers in order, as many at a time as its parallelism ("The number of parallel consumers consuming from the
|
|
102
|
+
* queue", the spec's Queue), and none while it is paused ("Pausing a queue stops the delivery of enqueued messages", the
|
|
103
|
+
* spec's pause). A message whose queue was deleted goes as a queue of one would. */
|
|
104
|
+
function queueLets(ctx: SemanticsContext, m: Row, all: Row[]): boolean {
|
|
105
|
+
const q = ctx.rowsRaw('Queue').find((r) => r.name === m.queueName);
|
|
106
|
+
if (q?.paused === true) return false;
|
|
107
|
+
const ahead = all.filter((o) => o.queueName === m.queueName && PENDING.has(String(o.state)) && Number(o._ordinal) < Number(m._ordinal)).length;
|
|
108
|
+
return ahead < Math.max(1, Number(q?.parallelism ?? 1));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether a pending message may go now: its queue lets it (queueLets), and it is the first undelivered of its run. */
|
|
112
|
+
function eligible(ctx: SemanticsContext, m: Row, all: Row[]): boolean {
|
|
113
|
+
if (m.state !== 'CREATED' && m.state !== 'RETRY') return false;
|
|
114
|
+
if (typeof m.queueName === 'string' && !queueLets(ctx, m, all)) return false;
|
|
115
|
+
if (typeof m._run === 'string') {
|
|
116
|
+
const run = ctx.rowsRaw('WorkflowRun').find((r) => r.workflowRunId === m._run);
|
|
117
|
+
if (!run || run.workflowState !== 'RUN_STARTED') return false;
|
|
118
|
+
if (all.some((o) => o._run === m._run && PENDING.has(String(o.state)) && Number(o._seq) < Number(m._seq))) return false;
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What a catch-up did: the deliveries it made, by outcome (the drain door answers it). */
|
|
124
|
+
export type CatchUpReport = { delivered: Row[]; retried: Row[]; failed: Row[] };
|
|
125
|
+
|
|
126
|
+
type Due = { when: number; order: number; act: (at: SemanticsContext, report: CatchUpReport) => Promise<void> };
|
|
127
|
+
|
|
128
|
+
function nextDue(ctx: SemanticsContext, until: number): Due | undefined {
|
|
129
|
+
const due: Due[] = [];
|
|
130
|
+
for (const s of ctx.rowsRaw('Schedule')) {
|
|
131
|
+
if (typeof s.nextScheduleTime === 'number' && s.nextScheduleTime <= until) due.push({ when: s.nextScheduleTime, order: 0, act: (at) => fire(at, s) });
|
|
132
|
+
}
|
|
133
|
+
for (const w of ctx.rowsRaw('Waiter')) {
|
|
134
|
+
const when = Number(w.deadline) * 1000;
|
|
135
|
+
if (when <= until) due.push({ when, order: 1, act: (at) => timeOut(at, w) });
|
|
136
|
+
}
|
|
137
|
+
const all = ctx.rowsRaw('Message');
|
|
138
|
+
for (const m of all) {
|
|
139
|
+
const when = Number(m._due);
|
|
140
|
+
if (when > until) continue;
|
|
141
|
+
if (m.state === 'CANCEL_REQUESTED') due.push({ when, order: 2 + Number(m._ordinal) / 1e9, act: (at) => cancelled(at, m) });
|
|
142
|
+
else if (eligible(ctx, m, all)) due.push({ when, order: 3 + Number(m._ordinal) / 1e9, act: (at, report) => attempt(at, m, report) });
|
|
143
|
+
}
|
|
144
|
+
for (const resource of ['DLQMessage', 'WorkflowDLQMessage']) {
|
|
145
|
+
for (const e of ctx.rowsRaw(resource)) {
|
|
146
|
+
const when = Number(e._filed_at ?? e.createdAt) + DLQ_RETENTION_MS;
|
|
147
|
+
if (when <= until) due.push({ when, order: 4, act: (at) => at.write(resource, String(e.id), { deleted: true }, 'dlq.expired').then(() => undefined) });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return due.sort((a, b) => a.when - b.when || a.order - b.order)[0];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Run everything due by the World's instant, in order, each at its own moment. */
|
|
154
|
+
export async function catchUp(ctx: SemanticsContext): Promise<CatchUpReport> {
|
|
155
|
+
const until = nowMs(ctx);
|
|
156
|
+
const report: CatchUpReport = { delivered: [], retried: [], failed: [] };
|
|
157
|
+
// a message held behind another (the one before it in its queue or its run) goes once that one has left, so the World
|
|
158
|
+
// clock never runs back: each move happens at its own due time or at the last move's, whichever is later
|
|
159
|
+
let last = 0;
|
|
160
|
+
for (let n = 0; n < 5000; n += 1) {
|
|
161
|
+
const next = nextDue(ctx, until);
|
|
162
|
+
if (!next) break;
|
|
163
|
+
last = Math.max(last, next.when);
|
|
164
|
+
await next.act(await ctx.at(iso(last)), report);
|
|
165
|
+
}
|
|
166
|
+
return report;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A schedule's cron has fired: its message is published at that moment (a paused schedule's is not), and it is next
|
|
170
|
+
* due at the cron's following minute. */
|
|
171
|
+
async function fire(ctx: SemanticsContext, s: Row): Promise<void> {
|
|
172
|
+
const when = Number(s.nextScheduleTime);
|
|
173
|
+
if (s.isPaused !== true) {
|
|
174
|
+
const { 'upstash-cron': _cron, 'upstash-schedule-id': _id, 'upstash-queue-name': _q, ...headers } = (s._published ?? {}) as Record<string, string>;
|
|
175
|
+
const queue = typeof s._queue === 'string' ? s._queue : undefined;
|
|
176
|
+
if (queue) await ensureQueue(ctx, queue);
|
|
177
|
+
const destination = String(s.destination);
|
|
178
|
+
if (/^https?:\/\//.test(destination)) await storeMessage(ctx, { url: destination, headers, body: String(s.body ?? ''), owner: String(s._owner), scheduleId: String(s.scheduleId), queue });
|
|
179
|
+
else await acceptToUrlGroup(ctx, { destination, headers, body: String(s.body ?? ''), queue }, String(s._owner));
|
|
180
|
+
}
|
|
181
|
+
const next = nextFire(String(s.cron), when);
|
|
182
|
+
await ctx.write('Schedule', String(s.id), { lastScheduleTime: when, ...(next !== undefined ? { nextScheduleTime: next } : {}) }, 'schedule.fire');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A cancelled message reaches its next delivery time: it is CANCELLED, never delivered. */
|
|
186
|
+
async function cancelled(ctx: SemanticsContext, m: Row): Promise<void> {
|
|
187
|
+
const refused = ctx.legal('Message', 'state', 'delete_v2_messages_messageid', m.state, 'CANCELLED', String(m.id), 'time');
|
|
188
|
+
if (refused) throw new Error(refused.message);
|
|
189
|
+
await ctx.write('Message', String(m.id), { state: 'CANCELLED' }, 'message.cancelled');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Move a message, as its machine allows. */
|
|
193
|
+
async function move(ctx: SemanticsContext, m: Row, to: string, actor: 'time' | 'vendor', fields: Row): Promise<void> {
|
|
194
|
+
const refused = ctx.legal('Message', 'state', 'delete_v2_messages_messageid', m.state, to, String(m.id), actor);
|
|
195
|
+
if (refused) throw new Error(refused.message);
|
|
196
|
+
await ctx.write('Message', String(m.id), { ...fields, state: to }, `message.${to.toLowerCase()}`);
|
|
197
|
+
m.state = to;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One delivery attempt of a message, at the moment it is due. */
|
|
201
|
+
async function attempt(ctx: SemanticsContext, m: Row, report: CatchUpReport): Promise<void> {
|
|
202
|
+
const when = nowMs(ctx);
|
|
203
|
+
const tried = Number(m._attempts ?? 0);
|
|
204
|
+
await move(ctx, m, 'ACTIVE', 'time', { _attempts: tried });
|
|
205
|
+
const published = (m._published ?? {}) as Record<string, string>;
|
|
206
|
+
const step = typeof m._run === 'string';
|
|
207
|
+
const body = step ? JSON.stringify(rawSteps(ctx, m._run as string, Number(m._seq))) : String(m.body ?? '');
|
|
208
|
+
const base = published['upstash-workflow-runid'] !== undefined ? workflowHeaders(published) : {
|
|
209
|
+
...(typeof m._content_type === 'string' ? { 'content-type': m._content_type } : {}),
|
|
210
|
+
...Object.fromEntries(Object.entries(published).filter(([k]) => k.startsWith('upstash-forward-')).map(([k, v]) => [k.slice('upstash-forward-'.length), v])),
|
|
211
|
+
};
|
|
212
|
+
const url = String(m.url);
|
|
213
|
+
const key = accountOf(ctx, String(m._owner ?? 'world')).current;
|
|
214
|
+
const headers: Record<string, string> = {
|
|
215
|
+
...base, 'upstash-message-id': String(m.messageId), 'upstash-retried': String(tried),
|
|
216
|
+
'upstash-signature': sign(body, url, Math.floor(when / 1000), `jwt_${String(m.messageId)}_${tried}`, key),
|
|
217
|
+
};
|
|
218
|
+
const method = String(m.method ?? 'POST');
|
|
219
|
+
const outcome = stated(ctx, url, headers) ?? await post(url, method, headers, body);
|
|
220
|
+
await ctx.record(DELIVERY, { to: url, message_id: m.messageId, retried: tried, at: ctx.occurredAt, n: ctx.rowsRaw(DELIVERY).length, method, headers, body, status: outcome.status }, `delivery:${String(m.messageId)}:${tried}`);
|
|
221
|
+
const n = tried + 1;
|
|
222
|
+
const response = { _response_status: outcome.status, _response_body: outcome.body, _response_headers: outcome.headers };
|
|
223
|
+
if (outcome.status >= 200 && outcome.status < 300) {
|
|
224
|
+
await move(ctx, m, 'DELIVERED', 'vendor', { _attempts: n, ...response });
|
|
225
|
+
report.delivered.push({ messageId: m.messageId, url, status: outcome.status });
|
|
226
|
+
if (typeof m._callback_of === 'string') await failureCallSettled(ctx, m._callback_of, true);
|
|
227
|
+
else if (typeof m.callback === 'string') await callback(ctx, m, String(m.callback), outcome, n, undefined);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const nonRetryable = outcome.status === 489 && outcome.headers['upstash-nonretryable-error'] === 'true';
|
|
231
|
+
if (n <= Number(m.maxRetries ?? 3) && !nonRetryable) {
|
|
232
|
+
const run = step ? ctx.rowsRaw('WorkflowRun').find((r) => r.workflowRunId === m._run) : undefined;
|
|
233
|
+
const expr = typeof m._retry_delay === 'string' ? m._retry_delay : typeof run?._retry_delay === 'string' ? run._retry_delay : undefined;
|
|
234
|
+
const wait = retryAfterMs(outcome.headers, when) ?? (expr !== undefined ? retryDelayMs(expr, tried) : undefined) ?? backoffMs(n);
|
|
235
|
+
await move(ctx, m, 'RETRY', 'vendor', { _attempts: n, _due: when + wait, ...response });
|
|
236
|
+
report.retried.push({ messageId: m.messageId, url, status: outcome.status, retried: n, nextDeliveryTime: when + wait });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await move(ctx, m, 'FAILED', 'vendor', { _attempts: n, ...response });
|
|
240
|
+
report.failed.push({ messageId: m.messageId, url, status: outcome.status });
|
|
241
|
+
if (typeof m._callback_of === 'string') { await failureCallSettled(ctx, m._callback_of, false); return; }
|
|
242
|
+
if (step) { await failRun(ctx, { ...m, _attempts: n }, outcome); return; }
|
|
243
|
+
const dlqId = `${when}-${ctx.rowsRaw('DLQMessage', { withDeleted: true }).length}`;
|
|
244
|
+
const own = ctx.own(m);
|
|
245
|
+
await ctx.write('DLQMessage', dlqId, {
|
|
246
|
+
...Object.fromEntries(Object.entries(own).filter(([k]) => !k.startsWith('_') && k !== 'state')),
|
|
247
|
+
dlqId, responseStatus: outcome.status, responseHeader: multiValue(outcome.headers), responseBody: outcome.body,
|
|
248
|
+
_filed_at: when, _published: published, _message_id: m.messageId, _owner: m._owner ?? 'world', ...(m._redact ? { _redact: m._redact } : {}),
|
|
249
|
+
}, 'dlq.create');
|
|
250
|
+
if (typeof m.failureCallback === 'string') await callback(ctx, m, String(m.failureCallback), outcome, n, dlqId);
|
|
251
|
+
if (typeof m.callback === 'string') await callback(ctx, m, String(m.callback), outcome, n, undefined);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A callback: "QStash calls your callback URL with the response"; its body is the callbacks page's (`status`, `header`,
|
|
255
|
+
* base64 `body`, `retried`, `maxRetries`, `sourceMessageId`, `url`, `method`, `sourceHeader`, base64 `sourceBody`,
|
|
256
|
+
* `notBefore`, `createdAt`, `callerIP`), and a failure callback's adds the `dlqId`. It is a message of its own. */
|
|
257
|
+
async function callback(ctx: SemanticsContext, m: Row, url: string, o: Outcome, attempts: number, dlqId: string | undefined): Promise<void> {
|
|
258
|
+
const body = JSON.stringify({
|
|
259
|
+
status: o.status, header: multiValue(o.headers), body: b64(o.body), retried: attempts - 1, maxRetries: Number(m.maxRetries ?? 3),
|
|
260
|
+
...(dlqId ? { dlqId } : {}), sourceMessageId: m.messageId, url: m.url, method: m.method ?? 'POST', sourceHeader: m.header ?? {},
|
|
261
|
+
sourceBody: b64(String(m.body ?? '')), notBefore: m.notBefore, createdAt: m.createdAt, callerIP: '127.0.0.1',
|
|
262
|
+
});
|
|
263
|
+
await storeMessage(ctx, { url, headers: { 'content-type': 'application/json' }, body, owner: String(m._owner ?? 'world'), callbackOf: String(m.messageId) });
|
|
264
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { ensureQueue, ownerFor, storeMessage } from "./messages.js";
|
|
2
|
+
import { idsOf, workflowDlq } from "./workflows.js";
|
|
3
|
+
import { matchesFilters, MESSAGE_FIELDS } from "./shared.js";
|
|
4
|
+
import { redacted } from "./messages.js";
|
|
5
|
+
const DLQ_FIELDS = [...MESSAGE_FIELDS, 'dlqId', 'responseStatus', 'responseHeader', 'responseBody', 'responseBodyBase64'];
|
|
6
|
+
export function dlqView(ctx, e) {
|
|
7
|
+
const own = ctx.own(e);
|
|
8
|
+
return redacted(Object.fromEntries(DLQ_FIELDS.filter((k) => own[k] !== undefined && own[k] !== null).map((k) => [k, own[k]])), e);
|
|
9
|
+
}
|
|
10
|
+
/** QStash's DLQ fields its list and bulk actions filter on, by query parameter (the spec's list and retry parameters). */
|
|
11
|
+
const FILTERS = { url: 'url', topicName: 'topicName', scheduleId: 'scheduleId', queueName: 'queueName', responseStatus: 'responseStatus', label: 'labels', flowControlKey: 'flowControlKey', messageId: 'messageId', callerIp: 'callerIP' };
|
|
12
|
+
/** The account's entries a request names: by `dlqIds`, else by its filters (all of them when it names none), oldest first. */
|
|
13
|
+
function selected(ctx) {
|
|
14
|
+
const owner = ownerFor(ctx);
|
|
15
|
+
const mine = ctx.rowsRaw('DLQMessage').filter((e) => e._owner === owner);
|
|
16
|
+
const ids = idsOf(ctx, 'dlqIds');
|
|
17
|
+
return (ids.length ? mine.filter((e) => ids.includes(String(e.dlqId))) : mine.filter((e) => matchesFilters(ctx, e, FILTERS, '_filed_at')))
|
|
18
|
+
.sort((a, b) => Number(a._filed_at) - Number(b._filed_at));
|
|
19
|
+
}
|
|
20
|
+
/** GET /v2/dlq: QStash's failed messages, the latest to fail first, `{messages}` ("If no cursor is returned, you have reached the
|
|
21
|
+
* end"); with `source=workflow`, the Workflow DLQ (the spec patch, from @upstash/workflow's `client.dlq.list`). */
|
|
22
|
+
const list = async (ctx) => {
|
|
23
|
+
if (new URL(ctx.call.request.url).searchParams.get('source') === 'workflow')
|
|
24
|
+
return workflowDlq(ctx);
|
|
25
|
+
const count = Number(new URL(ctx.call.request.url).searchParams.get('count') ?? 0);
|
|
26
|
+
const all = selected(ctx).reverse();
|
|
27
|
+
return ctx.reply({ messages: (count > 0 ? all.slice(0, count) : all).map((e) => dlqView(ctx, e)) });
|
|
28
|
+
};
|
|
29
|
+
/** POST /v2/dlq/retry: each entry the request names (by `dlqIds`, by its filters, or all of them) is republished as a new
|
|
30
|
+
* message (its destination, body and headers, in its queue) and leaves the DLQ; answered 201 `{responses: [{messageId}]}`.
|
|
31
|
+
* An id the DLQ does not hold answers 404 (the spec's 404). */
|
|
32
|
+
const retry = async (ctx) => {
|
|
33
|
+
const ids = idsOf(ctx, 'dlqIds');
|
|
34
|
+
const entries = selected(ctx);
|
|
35
|
+
const missing = ids.find((id) => !entries.some((e) => e.dlqId === id));
|
|
36
|
+
if (missing)
|
|
37
|
+
return ctx.refuse({ status: 404, message: `DLQ message ${missing} not found` });
|
|
38
|
+
const responses = [];
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
const published = (e._published ?? {});
|
|
41
|
+
const queue = typeof e.queueName === 'string' ? e.queueName : undefined;
|
|
42
|
+
if (queue)
|
|
43
|
+
await ensureQueue(ctx, queue);
|
|
44
|
+
const { 'upstash-delay': _delay, 'upstash-not-before': _notBefore, ...headers } = published;
|
|
45
|
+
const messageId = await storeMessage(ctx, { url: String(e.url), headers, body: String(e.body ?? ''), owner: ownerFor(ctx), queue });
|
|
46
|
+
await ctx.write('DLQMessage', String(e.id), { deleted: true }, 'dlq.retried');
|
|
47
|
+
responses.push({ messageId });
|
|
48
|
+
}
|
|
49
|
+
return ctx.reply({ responses }, 201);
|
|
50
|
+
};
|
|
51
|
+
export const dlqSemantics = { get_v2_dlq: list, post_v2_dlq_retry: retry };
|