@sanoma-ai/workflows 0.11.12 → 0.11.13
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/CLOUD-IDENTITY.md +12 -4
- package/KMS-SECRETS.md +3 -1
- package/README.md +4 -2
- package/dist/bundled.d.ts +2 -2
- package/dist/chats.d.ts +3 -3
- package/dist/chats.js +34 -20
- package/dist/chats.js.map +1 -1
- package/dist/client.d.ts +17 -4
- package/dist/client.js +74 -63
- package/dist/client.js.map +1 -1
- package/dist/cloud-identity.js +14 -12
- package/dist/cloud-identity.js.map +1 -1
- package/dist/collection.d.ts +2 -2
- package/dist/collection.js +53 -42
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +11 -6
- package/dist/config.js +15 -13
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +2 -0
- package/dist/connection.js +112 -40
- package/dist/connection.js.map +1 -1
- package/dist/credentials.d.ts +1 -1
- package/dist/credentials.js +33 -29
- package/dist/credentials.js.map +1 -1
- package/dist/db/company.d.ts +1152 -0
- package/dist/db/company.js +118 -0
- package/dist/db/company.js.map +1 -0
- package/dist/db/dbos.d.ts +111 -0
- package/dist/db/dbos.js +15 -0
- package/dist/db/dbos.js.map +1 -0
- package/dist/db/identity.d.ts +367 -0
- package/dist/db/identity.js +48 -0
- package/dist/db/identity.js.map +1 -0
- package/dist/db/index.d.ts +6 -0
- package/dist/db/index.js +10 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/kms.d.ts +224 -0
- package/dist/db/kms.js +29 -0
- package/dist/db/kms.js.map +1 -0
- package/dist/db/migrate.d.ts +63 -0
- package/dist/db/migrate.js +41 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/wakeup.d.ts +271 -0
- package/dist/db/wakeup.js +34 -0
- package/dist/db/wakeup.js.map +1 -0
- package/dist/db.d.ts +42 -39
- package/dist/db.js +129 -169
- package/dist/db.js.map +1 -1
- package/dist/deadlines.js +8 -4
- package/dist/deadlines.js.map +1 -1
- package/dist/deployment-key.d.ts +4 -1
- package/dist/deployment-key.js +10 -3
- package/dist/deployment-key.js.map +1 -1
- package/dist/host-derive.d.ts +12 -3
- package/dist/host-derive.js +21 -8
- package/dist/host-derive.js.map +1 -1
- package/dist/identity-provisioning.d.ts +2 -2
- package/dist/identity-provisioning.js +75 -42
- package/dist/identity-provisioning.js.map +1 -1
- package/dist/identity-service.js +18 -15
- package/dist/identity-service.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/kms-secrets.d.ts +9 -0
- package/dist/kms-secrets.js +59 -27
- package/dist/kms-secrets.js.map +1 -1
- package/dist/ledger.d.ts +1 -1
- package/dist/ledger.js +22 -14
- package/dist/ledger.js.map +1 -1
- package/dist/op.d.ts +27 -6
- package/dist/op.js.map +1 -1
- package/dist/run-status.js +7 -3
- package/dist/run-status.js.map +1 -1
- package/dist/schedules.d.ts +1 -1
- package/dist/schedules.js +5 -7
- package/dist/schedules.js.map +1 -1
- package/dist/schema.d.ts +6 -5
- package/dist/schema.js +11 -7
- package/dist/schema.js.map +1 -1
- package/dist/shared.d.ts +6 -23
- package/dist/shared.js +6 -5
- package/dist/shared.js.map +1 -1
- package/dist/storage-provisioning.d.ts +2 -0
- package/dist/storage-provisioning.js +51 -50
- package/dist/storage-provisioning.js.map +1 -1
- package/dist/tokens.d.ts +2 -2
- package/dist/tokens.js +31 -20
- package/dist/tokens.js.map +1 -1
- package/dist/version.d.ts +4 -4
- package/dist/version.js +5 -5
- package/dist/version.js.map +1 -1
- package/dist/wakeup-service.d.ts +0 -4
- package/dist/wakeup-service.js +102 -93
- package/dist/wakeup-service.js.map +1 -1
- package/dist/worker.d.ts +7 -0
- package/dist/worker.js +26 -21
- package/dist/worker.js.map +1 -1
- package/drizzle/company/0000_tables.sql +104 -0
- package/drizzle/company/0001_wakeup_row.sql +2 -0
- package/drizzle/company/meta/0000_snapshot.json +652 -0
- package/drizzle/company/meta/0001_snapshot.json +652 -0
- package/drizzle/company/meta/_journal.json +20 -0
- package/drizzle/wakeup/0000_tables.sql +26 -0
- package/drizzle/wakeup/meta/0000_snapshot.json +200 -0
- package/drizzle/wakeup/meta/_journal.json +13 -0
- package/oxlint.json +2 -1
- package/package.json +11 -2
- package/src/bundled.ts +2 -2
- package/src/chats.ts +41 -42
- package/src/client.ts +81 -64
- package/src/cloud-identity.ts +23 -24
- package/src/collection.ts +58 -50
- package/src/config.ts +29 -19
- package/src/connection.ts +132 -102
- package/src/credentials.ts +37 -33
- package/src/db/company.ts +173 -0
- package/src/db/dbos.ts +17 -0
- package/src/db/identity.ts +61 -0
- package/src/db/index.ts +10 -0
- package/src/db/kms.ts +41 -0
- package/src/db/migrate.ts +70 -0
- package/src/db/wakeup.ts +41 -0
- package/src/db.ts +151 -182
- package/src/deadlines.ts +10 -4
- package/src/deployment-key.ts +11 -3
- package/src/host-derive.ts +26 -9
- package/src/identity-provisioning.ts +85 -57
- package/src/identity-service.ts +19 -19
- package/src/index.ts +3 -0
- package/src/kms-secrets.ts +64 -56
- package/src/ledger.ts +23 -18
- package/src/op.ts +28 -6
- package/src/run-status.ts +7 -6
- package/src/schedules.ts +6 -8
- package/src/schema.ts +11 -8
- package/src/shared.ts +6 -17
- package/src/storage-provisioning.ts +47 -52
- package/src/tokens.ts +43 -33
- package/src/version.ts +5 -5
- package/src/wakeup-service.ts +117 -131
- package/src/worker.ts +38 -21
package/src/wakeup-service.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { and, eq, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
3
|
+
import { type Db, databaseOn, openPool, underPreparationLock } from "./db.ts";
|
|
4
|
+
import { applyMigrations, WAKEUP_MIGRATIONS } from "./db/migrate.ts";
|
|
5
|
+
import { wakeupAccounts, wakeupInstances } from "./db/wakeup.ts";
|
|
3
6
|
import { info, warn } from "./log.ts";
|
|
4
7
|
import { generateSecret, tokenHash as hashOf } from "./secret.ts";
|
|
5
8
|
import { errorMessage } from "./shared.ts";
|
|
@@ -11,39 +14,9 @@ import { errorMessage } from "./shared.ts";
|
|
|
11
14
|
* before it stops, `PUT`s with that token when it wants waking, replacing what it asked before;
|
|
12
15
|
* the service then calls `GET {url}/wake`, retrying until an answer is a 2xx. It makes its two
|
|
13
16
|
* tables itself, in the database it is given (sanoma-ops's own, when it hosts it): never among
|
|
14
|
-
* `db.ts`'s, which every company's database gets.
|
|
17
|
+
* `db/company.ts`'s, which every company's database gets.
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
|
-
/** Where the accounts live: one per party that registers instances, its token's hash alone. */
|
|
18
|
-
export const WAKEUP_ACCOUNTS_TABLE = "sanoma_wakeup_accounts";
|
|
19
|
-
/** Where the instances live: each one's URL, its token's hash and its registered wake. */
|
|
20
|
-
export const WAKEUP_INSTANCES_TABLE = "sanoma_wakeup_instances";
|
|
21
|
-
|
|
22
|
-
const TABLES: readonly [what: string, ddl: string][] = [
|
|
23
|
-
[
|
|
24
|
-
"wakeup accounts",
|
|
25
|
-
`CREATE TABLE IF NOT EXISTS ${WAKEUP_ACCOUNTS_TABLE} (id uuid PRIMARY KEY, token_hash text UNIQUE NOT NULL, ` +
|
|
26
|
-
"label text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz)",
|
|
27
|
-
],
|
|
28
|
-
[
|
|
29
|
-
"wakeup instances",
|
|
30
|
-
`CREATE TABLE IF NOT EXISTS ${WAKEUP_INSTANCES_TABLE} (id uuid PRIMARY KEY, ` +
|
|
31
|
-
`account_id uuid NOT NULL REFERENCES ${WAKEUP_ACCOUNTS_TABLE} (id), url text NOT NULL, ` +
|
|
32
|
-
"token_hash text UNIQUE NOT NULL, at timestamptz, " +
|
|
33
|
-
"called_at timestamptz, attempts integer NOT NULL DEFAULT 0, next_attempt_at timestamptz, " +
|
|
34
|
-
"last_error text, created_at timestamptz NOT NULL DEFAULT now())",
|
|
35
|
-
],
|
|
36
|
-
// Each on its own, so one another process made first does not skip the next.
|
|
37
|
-
[
|
|
38
|
-
"wakeup instances",
|
|
39
|
-
`CREATE INDEX IF NOT EXISTS ${WAKEUP_INSTANCES_TABLE}_at ON ${WAKEUP_INSTANCES_TABLE} (at) WHERE at IS NOT NULL`,
|
|
40
|
-
],
|
|
41
|
-
[
|
|
42
|
-
"wakeup instances",
|
|
43
|
-
`CREATE INDEX IF NOT EXISTS ${WAKEUP_INSTANCES_TABLE}_account ON ${WAKEUP_INSTANCES_TABLE} (account_id)`,
|
|
44
|
-
],
|
|
45
|
-
];
|
|
46
|
-
|
|
47
20
|
/** How long a call to an instance's `/wake` may take. */
|
|
48
21
|
const CALL_TIMEOUT_MS = 10_000;
|
|
49
22
|
/** The longest the loop sleeps without looking again, whatever is due. */
|
|
@@ -173,54 +146,45 @@ export function instanceUrl(value: unknown): { url: string } | { error: string }
|
|
|
173
146
|
return { url: url.href.replace(/\/+$/, "") };
|
|
174
147
|
}
|
|
175
148
|
|
|
176
|
-
|
|
177
|
-
id: string;
|
|
178
|
-
account_id: string;
|
|
179
|
-
url: string;
|
|
180
|
-
at: Date | null;
|
|
181
|
-
called_at: Date | null;
|
|
182
|
-
attempts: number;
|
|
183
|
-
last_error: string | null;
|
|
184
|
-
}
|
|
149
|
+
type InstanceRow = typeof wakeupInstances.$inferSelect;
|
|
185
150
|
|
|
186
151
|
/** An instance as `GET` shows it to its account. */
|
|
187
152
|
const shownInstance = (row: InstanceRow) => ({
|
|
188
153
|
id: row.id,
|
|
189
154
|
url: row.url,
|
|
190
155
|
at: row.at?.toISOString() ?? null,
|
|
191
|
-
calledAt: row.
|
|
156
|
+
calledAt: row.calledAt?.toISOString() ?? null,
|
|
192
157
|
attempts: row.attempts,
|
|
193
|
-
lastError: row.
|
|
158
|
+
lastError: row.lastError,
|
|
194
159
|
});
|
|
195
160
|
|
|
196
161
|
/** A row waiting for its call: registered, not called since its `at`. */
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
162
|
+
const waiting = and(
|
|
163
|
+
isNotNull(wakeupInstances.at),
|
|
164
|
+
or(isNull(wakeupInstances.calledAt), lt(wakeupInstances.calledAt, wakeupInstances.at)),
|
|
165
|
+
);
|
|
166
|
+
/** The join to an account not revoked. */
|
|
167
|
+
const live = and(eq(wakeupAccounts.id, wakeupInstances.accountId), isNull(wakeupAccounts.revokedAt));
|
|
200
168
|
|
|
201
169
|
/**
|
|
202
|
-
* Runs `use` on a connection to `databaseUrl`
|
|
203
|
-
*
|
|
170
|
+
* Runs `use` on a connection to `databaseUrl` holding the preparation's lock, the database and the
|
|
171
|
+
* service's two tables made when missing: the service's own migrations folder, with its own journal
|
|
172
|
+
* table, since the database may be a company's too.
|
|
204
173
|
*/
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
174
|
+
function withTables<T>(databaseUrl: string, use: (db: Db) => Promise<T>): Promise<T> {
|
|
175
|
+
return underPreparationLock(databaseUrl, async (db) => {
|
|
176
|
+
await applyMigrations(db, WAKEUP_MIGRATIONS).catch((err: unknown) => {
|
|
177
|
+
throw new Error(`could not prepare the wakeup service tables: ${errorMessage(err)}`, { cause: err });
|
|
178
|
+
});
|
|
179
|
+
return use(db);
|
|
180
|
+
});
|
|
213
181
|
}
|
|
214
182
|
|
|
215
183
|
async function insertAccount(db: Db, label: string): Promise<{ id: string; token: string }> {
|
|
216
184
|
if (!label.trim()) throw new Error("An account's label must not be empty");
|
|
217
185
|
const id = randomUUID();
|
|
218
186
|
const token = newToken("swa_");
|
|
219
|
-
await db.
|
|
220
|
-
id,
|
|
221
|
-
hashOf(token),
|
|
222
|
-
label,
|
|
223
|
-
]);
|
|
187
|
+
await db.insert(wakeupAccounts).values({ id, tokenHash: hashOf(token), label });
|
|
224
188
|
return { id, token };
|
|
225
189
|
}
|
|
226
190
|
|
|
@@ -257,26 +221,26 @@ export async function startWakeupService({
|
|
|
257
221
|
}: WakeupServiceOptions): Promise<WakeupService> {
|
|
258
222
|
await withTables(databaseUrl, async () => {});
|
|
259
223
|
const pool = openPool(databaseUrl, "wakeup service");
|
|
260
|
-
const
|
|
224
|
+
const db = databaseOn(pool);
|
|
225
|
+
const loop = startLoop({ db, now, fetch, log });
|
|
261
226
|
|
|
262
227
|
/** The live account the request's token is, or a 401. */
|
|
263
228
|
async function account(request: Request): Promise<string | Response> {
|
|
264
229
|
const token = bearer(request);
|
|
265
230
|
if (!token?.startsWith("swa_")) return problem(401, "an account's token is needed");
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
return
|
|
231
|
+
const [row] = await db
|
|
232
|
+
.select({ id: wakeupAccounts.id })
|
|
233
|
+
.from(wakeupAccounts)
|
|
234
|
+
.where(and(eq(wakeupAccounts.tokenHash, hashOf(token)), isNull(wakeupAccounts.revokedAt)));
|
|
235
|
+
return row?.id ?? problem(401, "the token is no account's");
|
|
271
236
|
}
|
|
272
237
|
|
|
273
238
|
/** The account's instance `id`, or a 404, or a 403 when it is another account's. */
|
|
274
239
|
async function owned(accountId: string, id: string): Promise<InstanceRow | Response> {
|
|
275
240
|
if (!UUID.test(id)) return problem(404, "no such instance");
|
|
276
|
-
const
|
|
277
|
-
const row = rows[0];
|
|
241
|
+
const [row] = await db.select().from(wakeupInstances).where(eq(wakeupInstances.id, id));
|
|
278
242
|
if (!row) return problem(404, "no such instance");
|
|
279
|
-
return row.
|
|
243
|
+
return row.accountId === accountId ? row : problem(403, "the instance is another account's");
|
|
280
244
|
}
|
|
281
245
|
|
|
282
246
|
async function register(request: Request): Promise<Response> {
|
|
@@ -291,32 +255,39 @@ export async function startWakeupService({
|
|
|
291
255
|
return problem(400, "token must be an instance's token: swi_, then 43 or more base64url characters");
|
|
292
256
|
// The token names the instance: a new one is a new row, one of this account's is moved to the
|
|
293
257
|
// URL given, and another account's is refused (no row returned).
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
258
|
+
const id = randomUUID();
|
|
259
|
+
const sameUrl = eq(wakeupInstances.url, checked.url);
|
|
260
|
+
const [row] = await db
|
|
261
|
+
.insert(wakeupInstances)
|
|
262
|
+
.values({ id, accountId, url: checked.url, tokenHash: hashOf(token) })
|
|
263
|
+
.onConflictDoUpdate({
|
|
264
|
+
target: wakeupInstances.tokenHash,
|
|
265
|
+
set: {
|
|
266
|
+
url: checked.url,
|
|
267
|
+
at: sql`CASE WHEN ${sameUrl} THEN ${wakeupInstances.at} END`,
|
|
268
|
+
calledAt: sql`CASE WHEN ${sameUrl} THEN ${wakeupInstances.calledAt} END`,
|
|
269
|
+
attempts: sql`CASE WHEN ${sameUrl} THEN ${wakeupInstances.attempts} ELSE 0 END`,
|
|
270
|
+
nextAttemptAt: sql`CASE WHEN ${sameUrl} THEN ${wakeupInstances.nextAttemptAt} END`,
|
|
271
|
+
lastError: sql`CASE WHEN ${sameUrl} THEN ${wakeupInstances.lastError} END`,
|
|
272
|
+
},
|
|
273
|
+
setWhere: eq(wakeupInstances.accountId, accountId),
|
|
274
|
+
})
|
|
275
|
+
.returning({ id: wakeupInstances.id });
|
|
306
276
|
if (!row) {
|
|
307
277
|
return problem(409, "the token is another account's instance's");
|
|
308
278
|
}
|
|
309
|
-
return json(row.
|
|
279
|
+
return json(row.id === id ? 201 : 200, { id: row.id });
|
|
310
280
|
}
|
|
311
281
|
|
|
312
282
|
async function wake(request: Request): Promise<Response> {
|
|
313
283
|
const token = bearer(request);
|
|
314
284
|
if (!token?.startsWith("swi_")) return problem(401, "an instance's token is needed");
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
285
|
+
const [found] = await db
|
|
286
|
+
.select({ id: wakeupInstances.id })
|
|
287
|
+
.from(wakeupInstances)
|
|
288
|
+
.innerJoin(wakeupAccounts, live)
|
|
289
|
+
.where(eq(wakeupInstances.tokenHash, hashOf(token)));
|
|
290
|
+
const id = found?.id;
|
|
320
291
|
if (!id) return problem(401, "the token is no instance's");
|
|
321
292
|
const given = await jsonBody(request);
|
|
322
293
|
if (given instanceof Response) return given;
|
|
@@ -324,11 +295,10 @@ export async function startWakeupService({
|
|
|
324
295
|
if (typeof at !== "string" || !ISO_DATE_TIME.test(at) || Number.isNaN(Date.parse(at)))
|
|
325
296
|
return problem(400, "at must be an ISO 8601 date-time");
|
|
326
297
|
// Whatever it had is replaced: a wake that arrives for an older one starts a worker with nothing to do.
|
|
327
|
-
await
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
);
|
|
298
|
+
await db
|
|
299
|
+
.update(wakeupInstances)
|
|
300
|
+
.set({ at: new Date(at), calledAt: null, attempts: 0, nextAttemptAt: null, lastError: null })
|
|
301
|
+
.where(eq(wakeupInstances.id, id));
|
|
332
302
|
loop.poke();
|
|
333
303
|
return noContent();
|
|
334
304
|
}
|
|
@@ -350,19 +320,19 @@ export async function startWakeupService({
|
|
|
350
320
|
const row = await owned(accountId, id);
|
|
351
321
|
if (row instanceof Response) return row;
|
|
352
322
|
if (method === "GET") return json(200, shownInstance(row));
|
|
353
|
-
await
|
|
323
|
+
await db.delete(wakeupInstances).where(eq(wakeupInstances.id, id));
|
|
354
324
|
return noContent();
|
|
355
325
|
}
|
|
356
326
|
|
|
357
327
|
return {
|
|
358
328
|
routes: [{ path: "/wakeup", prefix: true, handle }],
|
|
359
|
-
mintAccount: (label) => insertAccount(
|
|
329
|
+
mintAccount: (label) => insertAccount(db, label),
|
|
360
330
|
async revokeAccount(id) {
|
|
361
331
|
if (!UUID.test(id)) return;
|
|
362
|
-
await
|
|
363
|
-
|
|
364
|
-
now()
|
|
365
|
-
|
|
332
|
+
await db
|
|
333
|
+
.update(wakeupAccounts)
|
|
334
|
+
.set({ revokedAt: now() })
|
|
335
|
+
.where(and(eq(wakeupAccounts.id, id), isNull(wakeupAccounts.revokedAt)));
|
|
366
336
|
},
|
|
367
337
|
async stop() {
|
|
368
338
|
await loop.stop();
|
|
@@ -371,11 +341,8 @@ export async function startWakeupService({
|
|
|
371
341
|
};
|
|
372
342
|
}
|
|
373
343
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
url: string;
|
|
377
|
-
at: Date;
|
|
378
|
-
}
|
|
344
|
+
/** A row due for its call: one waiting, so its `at` is set. */
|
|
345
|
+
type Due = Pick<InstanceRow, "id" | "url"> & { at: Date };
|
|
379
346
|
|
|
380
347
|
/**
|
|
381
348
|
* The one loop: calls every row that is due, at most `CONCURRENCY` at once, then sleeps until the
|
|
@@ -383,12 +350,12 @@ interface Due {
|
|
|
383
350
|
* `min(2^attempts s, MAX_BACKOFF_S)`, for as long as it takes: no row is ever dropped.
|
|
384
351
|
*/
|
|
385
352
|
function startLoop({
|
|
386
|
-
|
|
353
|
+
db,
|
|
387
354
|
now,
|
|
388
355
|
fetch,
|
|
389
356
|
log,
|
|
390
357
|
}: {
|
|
391
|
-
|
|
358
|
+
db: Db;
|
|
392
359
|
now: () => Date;
|
|
393
360
|
fetch: typeof globalThis.fetch;
|
|
394
361
|
log: { info(line: string): void; warn(line: string): void };
|
|
@@ -414,21 +381,22 @@ function startLoop({
|
|
|
414
381
|
if (stopping.signal.aborted) return;
|
|
415
382
|
// Recorded only on the registration called: one replaced (another `at`), or moved to another
|
|
416
383
|
// URL, is called again.
|
|
417
|
-
const same =
|
|
384
|
+
const same = and(eq(wakeupInstances.id, row.id), eq(wakeupInstances.at, row.at), eq(wakeupInstances.url, row.url));
|
|
418
385
|
if (error === undefined) {
|
|
419
|
-
await
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
);
|
|
386
|
+
await db
|
|
387
|
+
.update(wakeupInstances)
|
|
388
|
+
.set({ calledAt: now(), attempts: 0, nextAttemptAt: null, lastError: null })
|
|
389
|
+
.where(same);
|
|
424
390
|
log.info(`wakeup: called ${target}`);
|
|
425
391
|
} else {
|
|
426
|
-
await
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
392
|
+
await db
|
|
393
|
+
.update(wakeupInstances)
|
|
394
|
+
.set({
|
|
395
|
+
attempts: sql`${wakeupInstances.attempts} + 1`,
|
|
396
|
+
lastError: error,
|
|
397
|
+
nextAttemptAt: sql`${now().toISOString()}::timestamptz + least(power(2, ${wakeupInstances.attempts}), ${sql.raw(String(MAX_BACKOFF_S))}) * interval '1 second'`,
|
|
398
|
+
})
|
|
399
|
+
.where(same);
|
|
432
400
|
log.warn(`wakeup: calling ${target} failed (${error}); it is tried again`);
|
|
433
401
|
}
|
|
434
402
|
}
|
|
@@ -436,25 +404,43 @@ function startLoop({
|
|
|
436
404
|
/** Calls every due row; returns when the next one is due, or undefined with none waiting. */
|
|
437
405
|
async function round(): Promise<Date | undefined> {
|
|
438
406
|
// Taken for `CLAIM_S`, rows another service holds skipped: each due row is called by one service.
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
407
|
+
const at = now();
|
|
408
|
+
const due = db
|
|
409
|
+
.select({ id: wakeupInstances.id })
|
|
410
|
+
.from(wakeupInstances)
|
|
411
|
+
.innerJoin(wakeupAccounts, live)
|
|
412
|
+
.where(
|
|
413
|
+
and(
|
|
414
|
+
waiting,
|
|
415
|
+
lte(wakeupInstances.at, at),
|
|
416
|
+
or(isNull(wakeupInstances.nextAttemptAt), lte(wakeupInstances.nextAttemptAt, at)),
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
.orderBy(wakeupInstances.at)
|
|
420
|
+
.limit(ROUND_ROWS)
|
|
421
|
+
.for("update", { of: wakeupInstances, skipLocked: true });
|
|
422
|
+
const rows = await db
|
|
423
|
+
.update(wakeupInstances)
|
|
424
|
+
.set({ nextAttemptAt: new Date(at.getTime() + CLAIM_S * 1000) })
|
|
425
|
+
.where(inArray(wakeupInstances.id, due))
|
|
426
|
+
.returning({ id: wakeupInstances.id, url: wakeupInstances.url, at: wakeupInstances.at });
|
|
427
|
+
const queue = rows.flatMap((r): Due[] => (r.at ? [{ ...r, at: r.at }] : []));
|
|
447
428
|
const worker = async () => {
|
|
448
429
|
for (let row = queue.shift(); row && !stopping.signal.aborted; row = queue.shift()) {
|
|
449
430
|
await call(row).catch((err: unknown) => log.warn(`wakeup: could not record a call: ${errorMessage(err)}`));
|
|
450
431
|
}
|
|
451
432
|
};
|
|
452
433
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
453
|
-
const next = await
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
434
|
+
const [next] = await db
|
|
435
|
+
.select({
|
|
436
|
+
next: sql<Date | null>`min(greatest(${wakeupInstances.at}, coalesce(${wakeupInstances.nextAttemptAt}, ${wakeupInstances.at})))`.mapWith(
|
|
437
|
+
wakeupInstances.at,
|
|
438
|
+
),
|
|
439
|
+
})
|
|
440
|
+
.from(wakeupInstances)
|
|
441
|
+
.innerJoin(wakeupAccounts, live)
|
|
442
|
+
.where(waiting);
|
|
443
|
+
return next?.next ?? undefined;
|
|
458
444
|
}
|
|
459
445
|
|
|
460
446
|
const sleep = (ms: number) =>
|
package/src/worker.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { buildCtx, decideStart, isInfrastructureError, startHoldOf } from "./cal
|
|
|
4
4
|
import { dbosStatusesOf } from "./client.ts";
|
|
5
5
|
import { declaredWakeup, envWakeup, type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
|
|
6
6
|
import { configuredSecrets } from "./credentials.ts";
|
|
7
|
-
import { type Db, DBOS_DATABASE_OPTIONS,
|
|
7
|
+
import { type Db, DBOS_DATABASE_OPTIONS, lazyDb, prepareDatabase, stampVersion } from "./db.ts";
|
|
8
8
|
import {
|
|
9
9
|
deadlineRegistry,
|
|
10
10
|
type Deadlines,
|
|
@@ -23,9 +23,10 @@ import { dbosLogger, info, quietIfSandbox, setLogLevel, warn } from "./log.ts";
|
|
|
23
23
|
import { lineStartsOf, readSource } from "./ast.ts";
|
|
24
24
|
import { callsOf, outlineWithSource } from "./outline.ts";
|
|
25
25
|
import { currentRun, type Run, type RunArgs, type WorkerState, type WorkflowOutline } from "./run.ts";
|
|
26
|
+
import type { DatabasePreparation } from "./op.ts";
|
|
26
27
|
import { applyScheduled, nextScheduledFire, type ScheduledStarter, scheduledStarter } from "./schedules.ts";
|
|
27
28
|
import type { Source } from "./source.ts";
|
|
28
|
-
import { approverLabel,
|
|
29
|
+
import { approverLabel, errorMessage, WORKFLOW_ACTION } from "./shared.ts";
|
|
29
30
|
|
|
30
31
|
export interface Worker {
|
|
31
32
|
/**
|
|
@@ -102,6 +103,12 @@ export interface WorkerOptions {
|
|
|
102
103
|
* it off, on a database of its own, so no schedule is applied there to start a live run.
|
|
103
104
|
*/
|
|
104
105
|
schedules?: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* What else the worker prepares the database for at its start, after the runtime's own tables
|
|
108
|
+
* and the connectors' (`ConnectorSetup.prepare`): the launcher passes the staff login's, which
|
|
109
|
+
* no config names. Each gets the database's direct URL.
|
|
110
|
+
*/
|
|
111
|
+
prepare?: readonly DatabasePreparation[];
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
/**
|
|
@@ -129,7 +136,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
129
136
|
}
|
|
130
137
|
// The config's own, or a Postgres store made for this worker, which `stop` then closes.
|
|
131
138
|
const ledger = ownedLedger(resolved);
|
|
132
|
-
const secrets =
|
|
139
|
+
const secrets = lazyDb(resolved.databaseUrl, "secrets");
|
|
133
140
|
const storedSecrets = configuredSecrets(resolved.secrets, () => secrets.db());
|
|
134
141
|
let stopRule: { close(): void } | undefined;
|
|
135
142
|
let stopping: Promise<void> | undefined;
|
|
@@ -182,7 +189,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
182
189
|
() => resolved.connections.close(),
|
|
183
190
|
() => resolved.identityProvisioning.close(),
|
|
184
191
|
() => secrets.close(),
|
|
185
|
-
() => resolved.
|
|
192
|
+
() => resolved.connectionStore.close(),
|
|
186
193
|
() => ledger.close(),
|
|
187
194
|
async () => resolved.dataStore?.close(),
|
|
188
195
|
]) {
|
|
@@ -209,6 +216,9 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
209
216
|
// Before DBOS's pool connects, so its connections start with the database's idle-transaction
|
|
210
217
|
// timeout (`DBOS_DATABASE_OPTIONS`); once per process and database.
|
|
211
218
|
await prepareDatabase(resolved.databaseUrl);
|
|
219
|
+
// Then what the connectors and the launcher keep there (a login's tables): the app only reads them.
|
|
220
|
+
for (const prepare of [...resolved.preparations.values(), ...(options.prepare ?? [])])
|
|
221
|
+
await prepare(resolved.databaseUrl);
|
|
212
222
|
DBOS.setConfig({
|
|
213
223
|
name: resolved.appName,
|
|
214
224
|
systemDatabaseUrl: resolved.databaseUrl,
|
|
@@ -233,8 +243,8 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
233
243
|
);
|
|
234
244
|
}
|
|
235
245
|
}
|
|
236
|
-
//
|
|
237
|
-
if (isLatest) await
|
|
246
|
+
// Built-in runs move to the latest version only: a rollback's worker, unless promoted, leaves them be.
|
|
247
|
+
if (isLatest) await carryBuiltInRuns(resolved, () => secrets.db());
|
|
238
248
|
await DBOS.registerQueue(resolved.queueName);
|
|
239
249
|
// The schedules are the latest version's: an older worker leaves them as the newer set them.
|
|
240
250
|
if (isLatest && options.schedules !== false) {
|
|
@@ -282,7 +292,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
282
292
|
secrets.close(),
|
|
283
293
|
ledger.close(),
|
|
284
294
|
resolved.dataStore?.close(),
|
|
285
|
-
resolved.
|
|
295
|
+
resolved.connectionStore.close(),
|
|
286
296
|
]);
|
|
287
297
|
}
|
|
288
298
|
current = previous;
|
|
@@ -313,37 +323,44 @@ function outlineOf(wf: WorkflowDefinition<any, any>): WorkflowOutline {
|
|
|
313
323
|
}
|
|
314
324
|
|
|
315
325
|
/**
|
|
316
|
-
* Takes over the unfinished
|
|
317
|
-
* keeping each run's id, history and unread
|
|
318
|
-
* the merged code, a new version, which DBOS would never
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
* `
|
|
322
|
-
*
|
|
323
|
-
*
|
|
326
|
+
* Takes over the unfinished runs of the built-in workflows (apply, change, …) started on other
|
|
327
|
+
* versions whose code is this worker's (`builtInCodes`), keeping each run's id, history and unread
|
|
328
|
+
* messages: a deploy restarts the worker on the merged code, a new version, which DBOS would never
|
|
329
|
+
* give the run to, and the apply or change run that made the deploy is one of them. Each is stamped
|
|
330
|
+
* with this version (`stampVersion`), and a PENDING one queued again on the app's queue
|
|
331
|
+
* (`resumeWorkflows`); one stopped between the two is PENDING on this version, which DBOS's
|
|
332
|
+
* recovery takes. Not `rewindWorkflow`, which takes only a cancelled run and deletes its unread
|
|
333
|
+
* messages. It assumes one live worker per database, as a deploy does: an older one still running
|
|
334
|
+
* such a run runs it on until it stops. A failure is warned about, and the worker starts.
|
|
324
335
|
*/
|
|
325
|
-
async function
|
|
336
|
+
async function carryBuiltInRuns({ appName, version, builtInCodes, queueName }: ResolvedConfig, db: () => Promise<Db>) {
|
|
326
337
|
try {
|
|
327
338
|
const runs = await DBOS.listWorkflows({
|
|
328
|
-
workflowName:
|
|
339
|
+
workflowName: Object.keys(builtInCodes),
|
|
329
340
|
status: ["PENDING", "ENQUEUED"],
|
|
330
341
|
applicationName: appName,
|
|
331
|
-
attributes: { changeCode },
|
|
332
342
|
loadInput: false,
|
|
333
343
|
loadOutput: false,
|
|
334
344
|
});
|
|
335
345
|
const theirs = runs
|
|
336
|
-
.filter(
|
|
346
|
+
.filter(
|
|
347
|
+
(r) =>
|
|
348
|
+
r.applicationVersion &&
|
|
349
|
+
r.applicationVersion !== version &&
|
|
350
|
+
r.attributes?.builtInCode === builtInCodes[r.workflowName],
|
|
351
|
+
)
|
|
337
352
|
.map((r) => r.workflowID);
|
|
338
353
|
if (!theirs.length) return;
|
|
339
354
|
const carried = await stampVersion(await db(), theirs, version);
|
|
340
355
|
const pending = carried.filter((r) => r.pending).map((r) => r.id);
|
|
341
356
|
if (pending.length) await DBOS.resumeWorkflows(pending, { queueName });
|
|
342
357
|
if (carried.length) {
|
|
343
|
-
info(`carried ${carried.length}
|
|
358
|
+
info(`carried ${carried.length} built-in run(s) onto version ${version}: ${carried.map((r) => r.id).join(", ")}`);
|
|
344
359
|
}
|
|
345
360
|
} catch (err) {
|
|
346
|
-
warn(
|
|
361
|
+
warn(
|
|
362
|
+
`carrying the built-in runs of other versions onto ${version} failed, so they stay there: ${errorMessage(err)}`,
|
|
363
|
+
);
|
|
347
364
|
}
|
|
348
365
|
}
|
|
349
366
|
|