@softure-ai/jobs 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +110 -10
- package/dist/contract.d.ts +1 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/messages/en.d.ts +2 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +2 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +2 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/options.d.ts +3 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +4 -2
- package/dist/options.js.map +1 -1
- package/dist/schema.d.ts +287 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +21 -1
- package/dist/schema.js.map +1 -1
- package/dist/server/claim.d.ts +2 -1
- package/dist/server/claim.d.ts.map +1 -1
- package/dist/server/claim.js +22 -14
- package/dist/server/claim.js.map +1 -1
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/schedules.d.ts +8 -0
- package/dist/server/schedules.d.ts.map +1 -1
- package/dist/server/schedules.js +12 -1
- package/dist/server/schedules.js.map +1 -1
- package/dist/server/sync-cursors.d.ts +23 -0
- package/dist/server/sync-cursors.d.ts.map +1 -0
- package/dist/server/sync-cursors.js +53 -0
- package/dist/server/sync-cursors.js.map +1 -0
- package/dist/server/sync-items.d.ts +45 -0
- package/dist/server/sync-items.d.ts.map +1 -0
- package/dist/server/sync-items.js +195 -0
- package/dist/server/sync-items.js.map +1 -0
- package/dist/sync.d.ts +65 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +10 -0
- package/dist/sync.js.map +1 -0
- package/dist/testing/index.d.ts +9 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +8 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/worker/health.d.ts +30 -0
- package/dist/worker/health.d.ts.map +1 -0
- package/dist/worker/health.js +53 -0
- package/dist/worker/health.js.map +1 -0
- package/dist/worker/index.d.ts +1 -0
- package/dist/worker/index.d.ts.map +1 -1
- package/dist/worker/index.js +3 -2
- package/dist/worker/index.js.map +1 -1
- package/dist/worker/process.d.ts +8 -2
- package/dist/worker/process.d.ts.map +1 -1
- package/dist/worker/process.js +27 -3
- package/dist/worker/process.js.map +1 -1
- package/dist/worker/run-job-worker.d.ts +5 -0
- package/dist/worker/run-job-worker.d.ts.map +1 -1
- package/dist/worker/run-job-worker.js +18 -0
- package/dist/worker/run-job-worker.js.map +1 -1
- package/migrations/0003_add_schedule_failures.sql +8 -0
- package/migrations/0004_create_sync_state.sql +41 -0
- package/module.json +2 -2
- package/package.json +1 -1
- package/src/contract.ts +3 -1
- package/src/index.ts +14 -3
- package/src/messages/en.ts +2 -0
- package/src/options.ts +4 -2
- package/src/schema.ts +29 -1
- package/src/server/claim.ts +20 -14
- package/src/server/index.ts +9 -0
- package/src/server/schedules.ts +18 -1
- package/src/server/sync-cursors.ts +67 -0
- package/src/server/sync-items.ts +236 -0
- package/src/sync.ts +59 -0
- package/src/testing/index.ts +18 -2
- package/src/worker/health.ts +64 -0
- package/src/worker/index.ts +3 -2
- package/src/worker/process.ts +32 -4
- package/src/worker/run-job-worker.ts +20 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The cursor of a polled source: where its next read starts. It moves only by compare-and-set, so a worker that read
|
|
2
|
+
// an older cursor cannot move it back over a newer one; a crash before the move re-reads the batch instead of skipping it.
|
|
3
|
+
import { err, ok } from "@softure-ai/core";
|
|
4
|
+
import { and, eq } from "drizzle-orm";
|
|
5
|
+
import { syncCursors } from "../schema.js";
|
|
6
|
+
/** A source name: lowercase letters, digits, `_ . : -`, starting with a letter, up to 127 characters. */
|
|
7
|
+
export const SYNC_SOURCE = /^[a-z][a-z0-9_.:-]{0,126}$/;
|
|
8
|
+
const MAX_CURSOR_LENGTH = 2000;
|
|
9
|
+
/** The source's cursor, or null before its first `advanceSyncCursor`. Throws on an invalid source name. */
|
|
10
|
+
export async function getSyncCursor(ctx, source) {
|
|
11
|
+
checkSyncSource(source);
|
|
12
|
+
const [row] = await ctx.db.select({ cursor: syncCursors.cursor }).from(syncCursors).where(eq(syncCursors.source, source));
|
|
13
|
+
return row?.cursor ?? null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Moves the source's cursor from `from` to `to`, after the batch read from `from` was handled. Refused with
|
|
17
|
+
* `jobs.sync_cursor_moved` when the stored cursor is no longer `from` (another worker moved it first): re-read from
|
|
18
|
+
* `getSyncCursor`. `ctx.db` may be a transaction, so the move commits with the batch's last writes. Throws on an
|
|
19
|
+
* invalid source name or cursor; database failures propagate.
|
|
20
|
+
*/
|
|
21
|
+
export async function advanceSyncCursor(ctx, input) {
|
|
22
|
+
checkSyncSource(input.source);
|
|
23
|
+
checkCursor(input.to);
|
|
24
|
+
if (input.from !== null)
|
|
25
|
+
checkCursor(input.from);
|
|
26
|
+
const now = ctx.clock.now();
|
|
27
|
+
if (input.from === null) {
|
|
28
|
+
const inserted = await ctx.db
|
|
29
|
+
.insert(syncCursors)
|
|
30
|
+
.values({ source: input.source, cursor: input.to, createdAt: now, updatedAt: now })
|
|
31
|
+
.onConflictDoNothing({ target: syncCursors.source })
|
|
32
|
+
.returning({ source: syncCursors.source });
|
|
33
|
+
return inserted.length === 1 ? ok() : err("jobs.sync_cursor_moved");
|
|
34
|
+
}
|
|
35
|
+
const updated = await ctx.db
|
|
36
|
+
.update(syncCursors)
|
|
37
|
+
.set({ cursor: input.to, updatedAt: now })
|
|
38
|
+
.where(and(eq(syncCursors.source, input.source), eq(syncCursors.cursor, input.from)))
|
|
39
|
+
.returning({ source: syncCursors.source });
|
|
40
|
+
return updated.length === 1 ? ok() : err("jobs.sync_cursor_moved");
|
|
41
|
+
}
|
|
42
|
+
/** Throws on a name the tables refuse: a typo in code, not a user error. */
|
|
43
|
+
export function checkSyncSource(source) {
|
|
44
|
+
if (!SYNC_SOURCE.test(source)) {
|
|
45
|
+
throw new RangeError(`@softure-ai/jobs: "${source}" is not a sync source name: lowercase letters, digits, _ . : and -, starting with a letter, up to 127 characters`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function checkCursor(cursor) {
|
|
49
|
+
if (cursor.length === 0 || cursor.length > MAX_CURSOR_LENGTH) {
|
|
50
|
+
throw new RangeError(`@softure-ai/jobs: a sync cursor must have 1 to ${String(MAX_CURSOR_LENGTH)} characters`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=sync-cursors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-cursors.js","sourceRoot":"","sources":["../../src/server/sync-cursors.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type SyncItemRow } from "../schema.js";
|
|
2
|
+
import type { SyncItem, SyncItemOutcome, SyncItemRunResult, SyncItemStatus } from "../sync.js";
|
|
3
|
+
import type { JobsContext } from "./context.js";
|
|
4
|
+
export interface ProcessSyncItemInput {
|
|
5
|
+
readonly source: string;
|
|
6
|
+
/** The item's id at the source. */
|
|
7
|
+
readonly itemId: string;
|
|
8
|
+
/** Runs of the callback before the item is `failed` for good; the first run counts. Default 5. */
|
|
9
|
+
readonly maxAttempts?: number;
|
|
10
|
+
}
|
|
11
|
+
/** What the callback receives: the module context with the item's transaction as `db`, and this attempt's number. */
|
|
12
|
+
export interface SyncItemContext extends JobsContext {
|
|
13
|
+
/** Starts at 1. */
|
|
14
|
+
readonly attempt: number;
|
|
15
|
+
}
|
|
16
|
+
export interface ListSyncItemsInput {
|
|
17
|
+
readonly source?: string;
|
|
18
|
+
readonly status?: SyncItemStatus;
|
|
19
|
+
/** Only items whose status, attempts or error changed at or after this time. */
|
|
20
|
+
readonly since?: Date;
|
|
21
|
+
/** Default 100, at most 1000. */
|
|
22
|
+
readonly limit?: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Handles one item of a polled source at most once. Runs `run` unless the ledger already has the item `done`,
|
|
26
|
+
* `skipped` or `failed` (then `skipped`, the callback untouched). `run` gets the item's transaction as `context.db`:
|
|
27
|
+
* write the item's effect through it and it commits together with the ledger's `done`. A returned failure rolls those
|
|
28
|
+
* writes back, counts the attempt and leaves the item `pending` for the next run, or `failed` once `maxAttempts` runs
|
|
29
|
+
* failed (or at once with `failSyncItem`). A throw is recorded the same way with `jobs.sync_item_threw` and then
|
|
30
|
+
* rethrown. A call outside the database (an API) is not rolled back: a crash between it and the commit repeats it on
|
|
31
|
+
* the next run, so keep it idempotent where the source allows. Throws on invalid input; database failures propagate.
|
|
32
|
+
*/
|
|
33
|
+
export declare function processSyncItem(ctx: JobsContext, input: ProcessSyncItemInput, run: (context: SyncItemContext) => Promise<SyncItemRunResult>): Promise<SyncItemOutcome>;
|
|
34
|
+
/**
|
|
35
|
+
* The ledger, most recently changed first: for an overview page or an MCP tool. Throws on an invalid filter.
|
|
36
|
+
*/
|
|
37
|
+
export declare function listSyncItems(ctx: JobsContext, input?: ListSyncItemsInput): Promise<SyncItem[]>;
|
|
38
|
+
/**
|
|
39
|
+
* Deletes ledger items that no `processSyncItem` call met for `retention.syncItemsDays`, in batches, whatever their
|
|
40
|
+
* status: the source no longer returns them. Returns the number deleted. The worker calls it once an hour with
|
|
41
|
+
* `pruneJobs`; an app without a worker calls it from the same cron.
|
|
42
|
+
*/
|
|
43
|
+
export declare function pruneSyncItems(ctx: JobsContext): Promise<number>;
|
|
44
|
+
export declare function toSyncItem(row: SyncItemRow): SyncItem;
|
|
45
|
+
//# sourceMappingURL=sync-items.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-items.d.ts","sourceRoot":"","sources":["../../src/server/sync-items.ts"],"names":[],"mappings":"AAKA,OAAO,EAAa,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3D,OAAO,KAAK,EAAE,QAAQ,EAAmB,eAAe,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAChH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAehD,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,mCAAmC;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,kGAAkG;IAClG,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,qHAAqH;AACrH,MAAM,WAAW,eAAgB,SAAQ,WAAW;IAClD,mBAAmB;IACnB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,cAAc,CAAC;IACjC,gFAAgF;IAChF,QAAQ,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC;IACtB,iCAAiC;IACjC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAWD;;;;;;;;GAQG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,WAAW,EAChB,KAAK,EAAE,oBAAoB,EAC3B,GAAG,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,GAC5D,OAAO,CAAC,eAAe,CAAC,CAsD1B;AAwBD;;GAEG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,WAAW,EAAE,KAAK,GAAE,kBAAuB,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CAoBzG;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAatE;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,WAAW,GAAG,QAAQ,CAarD"}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { and, desc, eq, gte, lt, sql } from "drizzle-orm";
|
|
2
|
+
import { syncItems } from "../schema.js";
|
|
3
|
+
import { getJobsOptions } from "./options.js";
|
|
4
|
+
import { checkSyncSource } from "./sync-cursors.js";
|
|
5
|
+
const SYNC_ITEM_THREW = "jobs.sync_item_threw";
|
|
6
|
+
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
7
|
+
const MAX_ITEM_ID_LENGTH = 500;
|
|
8
|
+
const MAX_RESULT_REF_LENGTH = 500;
|
|
9
|
+
const MAX_ERROR_LENGTH = 200;
|
|
10
|
+
const DEFAULT_LIST_LIMIT = 100;
|
|
11
|
+
const MAX_LIST_LIMIT = 1000;
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
/** Rows deleted per statement, so pruning a large ledger never holds one long lock. */
|
|
14
|
+
const PRUNE_BATCH = 1000;
|
|
15
|
+
/** Thrown inside the savepoint to roll back the callback's writes when it returned a failure. */
|
|
16
|
+
class RunFailed extends Error {
|
|
17
|
+
result;
|
|
18
|
+
constructor(result) {
|
|
19
|
+
super("sync item callback failed");
|
|
20
|
+
this.result = result;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Handles one item of a polled source at most once. Runs `run` unless the ledger already has the item `done`,
|
|
25
|
+
* `skipped` or `failed` (then `skipped`, the callback untouched). `run` gets the item's transaction as `context.db`:
|
|
26
|
+
* write the item's effect through it and it commits together with the ledger's `done`. A returned failure rolls those
|
|
27
|
+
* writes back, counts the attempt and leaves the item `pending` for the next run, or `failed` once `maxAttempts` runs
|
|
28
|
+
* failed (or at once with `failSyncItem`). A throw is recorded the same way with `jobs.sync_item_threw` and then
|
|
29
|
+
* rethrown. A call outside the database (an API) is not rolled back: a crash between it and the commit repeats it on
|
|
30
|
+
* the next run, so keep it idempotent where the source allows. Throws on invalid input; database failures propagate.
|
|
31
|
+
*/
|
|
32
|
+
export async function processSyncItem(ctx, input, run) {
|
|
33
|
+
checkSyncSource(input.source);
|
|
34
|
+
const maxAttempts = input.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
35
|
+
checkItemInput(input.itemId, maxAttempts);
|
|
36
|
+
const now = ctx.clock.now();
|
|
37
|
+
const settled = await ctx.db.transaction(async (tx) => {
|
|
38
|
+
const [seen] = await tx
|
|
39
|
+
.insert(syncItems)
|
|
40
|
+
.values({ source: input.source, itemId: input.itemId, status: "pending", attempts: 0, firstSeenAt: now, lastSeenAt: now, updatedAt: now })
|
|
41
|
+
.onConflictDoUpdate({
|
|
42
|
+
target: [syncItems.source, syncItems.itemId],
|
|
43
|
+
set: { lastSeenAt: sql `greatest(${syncItems.lastSeenAt}, excluded.last_seen_at)` },
|
|
44
|
+
})
|
|
45
|
+
.returning();
|
|
46
|
+
const row = requireRow(seen);
|
|
47
|
+
const where = and(eq(syncItems.source, input.source), eq(syncItems.itemId, input.itemId));
|
|
48
|
+
const finish = async (values) => {
|
|
49
|
+
const [updated] = await tx.update(syncItems).set({ ...values, updatedAt: now }).where(where).returning();
|
|
50
|
+
return toSyncItem(requireRow(updated));
|
|
51
|
+
};
|
|
52
|
+
if (row.status !== "pending")
|
|
53
|
+
return { outcome: { kind: "skipped", item: toSyncItem(row) } };
|
|
54
|
+
if (row.attempts >= maxAttempts) {
|
|
55
|
+
// maxAttempts was lowered below what the item already used: it has had its runs.
|
|
56
|
+
const error = row.errorCode ?? SYNC_ITEM_THREW;
|
|
57
|
+
return { outcome: { kind: "failed", error, item: await finish({ status: "failed", errorCode: error, finishedAt: now }) } };
|
|
58
|
+
}
|
|
59
|
+
const attempt = row.attempts + 1;
|
|
60
|
+
let result;
|
|
61
|
+
let thrown = null;
|
|
62
|
+
try {
|
|
63
|
+
result = await tx.transaction(async (savepoint) => {
|
|
64
|
+
const returned = await run({ ...ctx, db: savepoint, attempt });
|
|
65
|
+
if (!isOk(returned))
|
|
66
|
+
throw new RunFailed(returned);
|
|
67
|
+
checkResultRef(returned);
|
|
68
|
+
return returned;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof RunFailed) {
|
|
73
|
+
result = error.result;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
thrown = { error };
|
|
77
|
+
result = { ok: false, error: SYNC_ITEM_THREW };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const outcome = await record(result, { attempt, maxAttempts, now, finish });
|
|
81
|
+
return thrown === null ? { outcome } : { thrown: thrown.error };
|
|
82
|
+
});
|
|
83
|
+
if (settled.outcome === undefined)
|
|
84
|
+
throw settled.thrown;
|
|
85
|
+
return settled.outcome;
|
|
86
|
+
}
|
|
87
|
+
async function record(result, context) {
|
|
88
|
+
const { attempt, now, finish } = context;
|
|
89
|
+
if (isOk(result)) {
|
|
90
|
+
const completion = result.value ?? { status: "done" };
|
|
91
|
+
const item = completion.status === "done"
|
|
92
|
+
? await finish({ status: "done", attempts: attempt, errorCode: null, resultRef: completion.resultRef ?? null, finishedAt: now })
|
|
93
|
+
: await finish({ status: "skipped", attempts: attempt, errorCode: completion.reason === undefined ? null : toErrorCode(completion.reason), finishedAt: now });
|
|
94
|
+
return { kind: "handled", item };
|
|
95
|
+
}
|
|
96
|
+
// A callback that returned neither ok() nor an error (a JavaScript caller) counts as a throw.
|
|
97
|
+
const failure = result;
|
|
98
|
+
const error = toErrorCode(typeof failure?.error === "string" ? failure.error : SYNC_ITEM_THREW);
|
|
99
|
+
if (failure?.retry === false || attempt >= context.maxAttempts) {
|
|
100
|
+
return { kind: "failed", error, item: await finish({ status: "failed", attempts: attempt, errorCode: error, finishedAt: now }) };
|
|
101
|
+
}
|
|
102
|
+
return { kind: "retrying", error, item: await finish({ attempts: attempt, errorCode: error }) };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The ledger, most recently changed first: for an overview page or an MCP tool. Throws on an invalid filter.
|
|
106
|
+
*/
|
|
107
|
+
export async function listSyncItems(ctx, input = {}) {
|
|
108
|
+
const limit = input.limit ?? DEFAULT_LIST_LIMIT;
|
|
109
|
+
if (!(Number.isInteger(limit) && limit >= 1 && limit <= MAX_LIST_LIMIT)) {
|
|
110
|
+
throw new RangeError(`@softure-ai/jobs: listSyncItems limit must be an integer from 1 to ${String(MAX_LIST_LIMIT)}`);
|
|
111
|
+
}
|
|
112
|
+
if (input.source !== undefined)
|
|
113
|
+
checkSyncSource(input.source);
|
|
114
|
+
if (input.since !== undefined && Number.isNaN(input.since.getTime())) {
|
|
115
|
+
throw new RangeError("@softure-ai/jobs: listSyncItems since is an invalid date");
|
|
116
|
+
}
|
|
117
|
+
const filters = [];
|
|
118
|
+
if (input.source !== undefined)
|
|
119
|
+
filters.push(eq(syncItems.source, input.source));
|
|
120
|
+
if (input.status !== undefined)
|
|
121
|
+
filters.push(eq(syncItems.status, input.status));
|
|
122
|
+
if (input.since !== undefined)
|
|
123
|
+
filters.push(gte(syncItems.updatedAt, input.since));
|
|
124
|
+
const rows = await ctx.db
|
|
125
|
+
.select()
|
|
126
|
+
.from(syncItems)
|
|
127
|
+
.where(and(...filters))
|
|
128
|
+
.orderBy(desc(syncItems.updatedAt), syncItems.source, syncItems.itemId)
|
|
129
|
+
.limit(limit);
|
|
130
|
+
return rows.map(toSyncItem);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Deletes ledger items that no `processSyncItem` call met for `retention.syncItemsDays`, in batches, whatever their
|
|
134
|
+
* status: the source no longer returns them. Returns the number deleted. The worker calls it once an hour with
|
|
135
|
+
* `pruneJobs`; an app without a worker calls it from the same cron.
|
|
136
|
+
*/
|
|
137
|
+
export async function pruneSyncItems(ctx) {
|
|
138
|
+
const { retention } = getJobsOptions(ctx.config);
|
|
139
|
+
const before = new Date(ctx.clock.now().getTime() - retention.syncItemsDays * DAY_MS);
|
|
140
|
+
let deleted = 0;
|
|
141
|
+
for (;;) {
|
|
142
|
+
const batch = ctx.db.select({ source: syncItems.source, itemId: syncItems.itemId }).from(syncItems).where(lt(syncItems.lastSeenAt, before)).limit(PRUNE_BATCH);
|
|
143
|
+
const rows = await ctx.db
|
|
144
|
+
.delete(syncItems)
|
|
145
|
+
.where(sql `(${syncItems.source}, ${syncItems.itemId}) in (${batch})`)
|
|
146
|
+
.returning({ source: syncItems.source });
|
|
147
|
+
deleted += rows.length;
|
|
148
|
+
if (rows.length < PRUNE_BATCH)
|
|
149
|
+
return deleted;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export function toSyncItem(row) {
|
|
153
|
+
return {
|
|
154
|
+
source: row.source,
|
|
155
|
+
itemId: row.itemId,
|
|
156
|
+
status: row.status,
|
|
157
|
+
errorCode: row.errorCode,
|
|
158
|
+
attempts: row.attempts,
|
|
159
|
+
resultRef: row.resultRef,
|
|
160
|
+
firstSeenAt: row.firstSeenAt,
|
|
161
|
+
lastSeenAt: row.lastSeenAt,
|
|
162
|
+
updatedAt: row.updatedAt,
|
|
163
|
+
finishedAt: row.finishedAt,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function checkItemInput(itemId, maxAttempts) {
|
|
167
|
+
if (itemId.length === 0 || itemId.length > MAX_ITEM_ID_LENGTH) {
|
|
168
|
+
throw new RangeError(`@softure-ai/jobs: a sync item id must have 1 to ${String(MAX_ITEM_ID_LENGTH)} characters`);
|
|
169
|
+
}
|
|
170
|
+
if (!(Number.isInteger(maxAttempts) && maxAttempts >= 1 && maxAttempts <= 1000)) {
|
|
171
|
+
throw new RangeError("@softure-ai/jobs: processSyncItem maxAttempts must be an integer from 1 to 1000");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** Inside the savepoint, so a bad reference counts as a thrown attempt and rolls the callback's writes back. */
|
|
175
|
+
function checkResultRef(result) {
|
|
176
|
+
const completion = result.ok ? result.value : undefined;
|
|
177
|
+
if (completion?.status !== "done" || completion.resultRef === undefined)
|
|
178
|
+
return;
|
|
179
|
+
if (completion.resultRef.length === 0 || completion.resultRef.length > MAX_RESULT_REF_LENGTH) {
|
|
180
|
+
throw new RangeError(`@softure-ai/jobs: a sync item resultRef must have 1 to ${String(MAX_RESULT_REF_LENGTH)} characters`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function isOk(result) {
|
|
184
|
+
return typeof result === "object" && result !== null && result.ok === true;
|
|
185
|
+
}
|
|
186
|
+
function toErrorCode(error) {
|
|
187
|
+
const trimmed = error.slice(0, MAX_ERROR_LENGTH);
|
|
188
|
+
return (trimmed.includes(".") ? trimmed : SYNC_ITEM_THREW);
|
|
189
|
+
}
|
|
190
|
+
function requireRow(row) {
|
|
191
|
+
if (row === undefined)
|
|
192
|
+
throw new Error("@softure-ai/jobs: a sync item write returned no row");
|
|
193
|
+
return row;
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=sync-items.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-items.js","sourceRoot":"","sources":["../../src/server/sync-items.ts"],"names":[],"mappings":"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"}
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Err, ErrorCode, Ok } from "@softure-ai/core";
|
|
2
|
+
export declare const SYNC_ITEM_STATUSES: readonly ["pending", "done", "skipped", "failed"];
|
|
3
|
+
/** `pending`: seen, not handled yet (a failed attempt with attempts left); `done`, `skipped`, `failed` are final. */
|
|
4
|
+
export type SyncItemStatus = (typeof SYNC_ITEM_STATUSES)[number];
|
|
5
|
+
/** One item of the processed-item ledger. */
|
|
6
|
+
export interface SyncItem {
|
|
7
|
+
readonly source: string;
|
|
8
|
+
readonly itemId: string;
|
|
9
|
+
readonly status: SyncItemStatus;
|
|
10
|
+
/** The code of the last failed attempt, or why the item was skipped; never a message. */
|
|
11
|
+
readonly errorCode: string | null;
|
|
12
|
+
/** Runs of the callback so far. */
|
|
13
|
+
readonly attempts: number;
|
|
14
|
+
/** What handling the item produced in the app (a task id), when the callback named it. */
|
|
15
|
+
readonly resultRef: string | null;
|
|
16
|
+
readonly firstSeenAt: Date;
|
|
17
|
+
/** The last time `processSyncItem` met the item; pruning goes by it. */
|
|
18
|
+
readonly lastSeenAt: Date;
|
|
19
|
+
/** The last change of status, attempts or error code. */
|
|
20
|
+
readonly updatedAt: Date;
|
|
21
|
+
readonly finishedAt: Date | null;
|
|
22
|
+
}
|
|
23
|
+
/** How a callback ends an item well: handled (`ok()`, or `ok({ status: "done", resultRef })`) or deliberately skipped. */
|
|
24
|
+
export type SyncItemCompletion = {
|
|
25
|
+
readonly status: "done";
|
|
26
|
+
readonly resultRef?: string;
|
|
27
|
+
} | {
|
|
28
|
+
readonly status: "skipped";
|
|
29
|
+
readonly reason?: ErrorCode;
|
|
30
|
+
};
|
|
31
|
+
/** A callback's failure: tried again on the next run unless `retry` is false or the attempts are used up. */
|
|
32
|
+
export type SyncItemFailure = Err<ErrorCode> & {
|
|
33
|
+
readonly retry?: boolean;
|
|
34
|
+
};
|
|
35
|
+
/** What a `processSyncItem` callback returns. */
|
|
36
|
+
export type SyncItemRunResult = Ok<SyncItemCompletion | undefined> | SyncItemFailure;
|
|
37
|
+
/** What `processSyncItem` did with an item. */
|
|
38
|
+
export type SyncItemOutcome =
|
|
39
|
+
/** The callback ran and succeeded; `item.status` is `done` or `skipped`. */
|
|
40
|
+
{
|
|
41
|
+
readonly kind: "handled";
|
|
42
|
+
readonly item: SyncItem;
|
|
43
|
+
}
|
|
44
|
+
/** The callback failed; the item stays `pending` and the next run tries it again. */
|
|
45
|
+
| {
|
|
46
|
+
readonly kind: "retrying";
|
|
47
|
+
readonly error: string;
|
|
48
|
+
readonly item: SyncItem;
|
|
49
|
+
}
|
|
50
|
+
/** The callback failed for good (attempts used up, or `failSyncItem`); the item is `failed`. */
|
|
51
|
+
| {
|
|
52
|
+
readonly kind: "failed";
|
|
53
|
+
readonly error: string;
|
|
54
|
+
readonly item: SyncItem;
|
|
55
|
+
}
|
|
56
|
+
/** The callback did not run: the item was already `done`, `skipped` or `failed` (see `item.status`). */
|
|
57
|
+
| {
|
|
58
|
+
readonly kind: "skipped";
|
|
59
|
+
readonly item: SyncItem;
|
|
60
|
+
};
|
|
61
|
+
/** Ends an item as `skipped` (not relevant to the app), with an optional code saying why; never tried again. */
|
|
62
|
+
export declare function skipSyncItem(reason?: ErrorCode): Ok<SyncItemCompletion>;
|
|
63
|
+
/** A failure that is not retried: the item is `failed` at once with this code. */
|
|
64
|
+
export declare function failSyncItem<E extends ErrorCode>(error: E): SyncItemFailure;
|
|
65
|
+
//# sourceMappingURL=sync.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../src/sync.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AAE3D,eAAO,MAAM,kBAAkB,mDAAoD,CAAC;AAEpF,qHAAqH;AACrH,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjE,6CAA6C;AAC7C,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,yFAAyF;IACzF,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,mCAAmC;IACnC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,0FAA0F;IAC1F,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAC;IAC3B,wEAAwE;IACxE,QAAQ,CAAC,UAAU,EAAE,IAAI,CAAC;IAC1B,yDAAyD;IACzD,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,IAAI,GAAG,IAAI,CAAC;CAClC;AAED,0HAA0H;AAC1H,MAAM,MAAM,kBAAkB,GAC1B;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GACxD;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAA;CAAE,CAAC;AAEhE,6GAA6G;AAC7G,MAAM,MAAM,eAAe,GAAG,GAAG,CAAC,SAAS,CAAC,GAAG;IAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE5E,iDAAiD;AACjD,MAAM,MAAM,iBAAiB,GAAG,EAAE,CAAC,kBAAkB,GAAG,SAAS,CAAC,GAAG,eAAe,CAAC;AAErF,+CAA+C;AAC/C,MAAM,MAAM,eAAe;AACzB,4EAA4E;AAC1E;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE;AACvD,qFAAqF;GACnF;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE;AAChF,gGAAgG;GAC9F;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE;AAC9E,wGAAwG;GACtG;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC;AAE1D,gHAAgH;AAChH,wBAAgB,YAAY,CAAC,MAAM,CAAC,EAAE,SAAS,GAAG,EAAE,CAAC,kBAAkB,CAAC,CAEvE;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAAC,CAAC,SAAS,SAAS,EAAE,KAAK,EAAE,CAAC,GAAG,eAAe,CAE3E"}
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const SYNC_ITEM_STATUSES = ["pending", "done", "skipped", "failed"];
|
|
2
|
+
/** Ends an item as `skipped` (not relevant to the app), with an optional code saying why; never tried again. */
|
|
3
|
+
export function skipSyncItem(reason) {
|
|
4
|
+
return { ok: true, value: reason === undefined ? { status: "skipped" } : { status: "skipped", reason } };
|
|
5
|
+
}
|
|
6
|
+
/** A failure that is not retried: the item is `failed` at once with this code. */
|
|
7
|
+
export function failSyncItem(error) {
|
|
8
|
+
return { ok: false, error, retry: false };
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=sync.js.map
|
package/dist/sync.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync.js","sourceRoot":"","sources":["../src/sync.ts"],"names":[],"mappings":"AAIA,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,CAAU,CAAC;AA8CpF,gHAAgH;AAChH,MAAM,UAAU,YAAY,CAAC,MAAkB;IAC7C,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,CAAC;AAC3G,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,YAAY,CAAsB,KAAQ;IACxD,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;AAC5C,CAAC"}
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { Queryable } from "@softure-ai/db";
|
|
2
2
|
import type { Job } from "../contract.js";
|
|
3
|
+
import type { SyncItem } from "../sync.js";
|
|
3
4
|
export { runDueJobs, type RunDueJobsOptions, type RunDueJobsReport } from "../server/run-due.js";
|
|
4
5
|
/** Every job in the table, oldest first: for assertions. */
|
|
5
6
|
export declare function listJobs(db: Queryable): Promise<Job[]>;
|
|
7
|
+
export interface SyncState {
|
|
8
|
+
/** Every source's cursor, by source name. */
|
|
9
|
+
readonly cursors: Readonly<Record<string, string>>;
|
|
10
|
+
/** Every ledger item, by source and then item id. */
|
|
11
|
+
readonly items: SyncItem[];
|
|
12
|
+
}
|
|
13
|
+
/** The whole sync state, cursors and ledger: for assertions after a test ran its polling job (`runDueJobs`). */
|
|
14
|
+
export declare function listSyncState(db: Queryable): Promise<SyncState>;
|
|
6
15
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAC;AAI1C,OAAO,EAAE,UAAU,EAAE,KAAK,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAEjG,4DAA4D;AAC5D,wBAAsB,QAAQ,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,CAG5D"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAC;AAI1C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C,OAAO,EAAE,UAAU,EAAE,KAAK,iBAAiB,EAAE,KAAK,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAEjG,4DAA4D;AAC5D,wBAAsB,QAAQ,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,CAG5D;AAED,MAAM,WAAW,SAAS;IACxB,6CAA6C;IAC7C,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,qDAAqD;IACrD,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,CAAC;CAC5B;AAED,gHAAgH;AAChH,wBAAsB,aAAa,CAAC,EAAE,EAAE,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,CAIrE"}
|
package/dist/testing/index.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { asc } from "drizzle-orm";
|
|
2
|
-
import { jobs } from "../schema.js";
|
|
2
|
+
import { jobs, syncCursors, syncItems } from "../schema.js";
|
|
3
3
|
import { toJob } from "../server/job-row.js";
|
|
4
|
+
import { toSyncItem } from "../server/sync-items.js";
|
|
4
5
|
export { runDueJobs } from "../server/run-due.js";
|
|
5
6
|
/** Every job in the table, oldest first: for assertions. */
|
|
6
7
|
export async function listJobs(db) {
|
|
7
8
|
const rows = await db.select().from(jobs).orderBy(asc(jobs.id));
|
|
8
9
|
return rows.map(toJob);
|
|
9
10
|
}
|
|
11
|
+
/** The whole sync state, cursors and ledger: for assertions after a test ran its polling job (`runDueJobs`). */
|
|
12
|
+
export async function listSyncState(db) {
|
|
13
|
+
const cursors = await db.select().from(syncCursors).orderBy(asc(syncCursors.source));
|
|
14
|
+
const items = await db.select().from(syncItems).orderBy(asc(syncItems.source), asc(syncItems.itemId));
|
|
15
|
+
return { cursors: Object.fromEntries(cursors.map((row) => [row.source, row.cursor])), items: items.map(toSyncItem) };
|
|
16
|
+
}
|
|
10
17
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AAElC,OAAO,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AAElC,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC5D,OAAO,EAAE,KAAK,EAAE,MAAM,sBAAsB,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAGrD,OAAO,EAAE,UAAU,EAAiD,MAAM,sBAAsB,CAAC;AAEjG,4DAA4D;AAC5D,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,EAAa;IAC1C,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;IAChE,OAAO,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;AACzB,CAAC;AASD,gHAAgH;AAChH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,EAAa;IAC/C,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC;IACrF,MAAM,KAAK,GAAG,MAAM,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,GAAG,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC;IACtG,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,WAAW,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC;AACvH,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SoftureConfig } from "@softure-ai/core";
|
|
2
|
+
import type { JobsLogger } from "../server/logger.js";
|
|
3
|
+
export type JobWorkerHealth = {
|
|
4
|
+
ok: true;
|
|
5
|
+
ageMs: number;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
reason: "missing";
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
reason: "stale";
|
|
12
|
+
ageMs: number;
|
|
13
|
+
};
|
|
14
|
+
export interface CheckJobWorkerHealthInput {
|
|
15
|
+
readonly healthFile: string;
|
|
16
|
+
/** The oldest heartbeat that still counts; `getJobWorkerHealthMaxAgeMs(config)` by default in the process. */
|
|
17
|
+
readonly maxAgeMs: number;
|
|
18
|
+
/** Epoch ms to compare the file's modification time with; default `Date.now()`. */
|
|
19
|
+
readonly now?: number;
|
|
20
|
+
}
|
|
21
|
+
/** The default age limit: five poll intervals, at least 30 s. */
|
|
22
|
+
export declare function getJobWorkerHealthMaxAgeMs(config: SoftureConfig): number;
|
|
23
|
+
/** Whether the heartbeat file exists and is no older than `maxAgeMs`. Reads the file system only. */
|
|
24
|
+
export declare function checkJobWorkerHealth(input: CheckJobWorkerHealthInput): JobWorkerHealth;
|
|
25
|
+
/** Writes and removes the heartbeat file; a failure is logged once until a write succeeds again, never thrown. */
|
|
26
|
+
export declare function createHealthFile(healthFile: string, logger: JobsLogger): {
|
|
27
|
+
touch: () => void;
|
|
28
|
+
remove: () => void;
|
|
29
|
+
};
|
|
30
|
+
//# sourceMappingURL=health.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"health.d.ts","sourceRoot":"","sources":["../../src/worker/health.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAOtD,MAAM,MAAM,eAAe,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7I,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,8GAA8G;IAC9G,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,iEAAiE;AACjE,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,CAExE;AAED,qGAAqG;AACrG,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,yBAAyB,GAAG,eAAe,CAStF;AAED,kHAAkH;AAClH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,GAAG;IAAE,KAAK,EAAE,MAAM,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,IAAI,CAAA;CAAE,CAwBlH"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The worker's heartbeat: a file the loop touches after each round whose claims reached the database, and the check
|
|
2
|
+
// an image runs (`node worker.mjs --health`) to tell a running loop from a process that only stays alive.
|
|
3
|
+
import { rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { getJobsOptions } from "../server/options.js";
|
|
5
|
+
/** The age limit never drops below this, so a slow round (an hourly prune) does not read as a stuck loop. */
|
|
6
|
+
const MIN_HEALTH_MAX_AGE_MS = 30_000;
|
|
7
|
+
const POLL_INTERVALS_PER_MAX_AGE = 5;
|
|
8
|
+
/** The default age limit: five poll intervals, at least 30 s. */
|
|
9
|
+
export function getJobWorkerHealthMaxAgeMs(config) {
|
|
10
|
+
return Math.max(MIN_HEALTH_MAX_AGE_MS, POLL_INTERVALS_PER_MAX_AGE * getJobsOptions(config).pollIntervalMs);
|
|
11
|
+
}
|
|
12
|
+
/** Whether the heartbeat file exists and is no older than `maxAgeMs`. Reads the file system only. */
|
|
13
|
+
export function checkJobWorkerHealth(input) {
|
|
14
|
+
let mtimeMs;
|
|
15
|
+
try {
|
|
16
|
+
mtimeMs = statSync(input.healthFile).mtimeMs;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { ok: false, reason: "missing" };
|
|
20
|
+
}
|
|
21
|
+
const ageMs = Math.max(0, Math.round((input.now ?? Date.now()) - mtimeMs));
|
|
22
|
+
return ageMs > input.maxAgeMs ? { ok: false, reason: "stale", ageMs } : { ok: true, ageMs };
|
|
23
|
+
}
|
|
24
|
+
/** Writes and removes the heartbeat file; a failure is logged once until a write succeeds again, never thrown. */
|
|
25
|
+
export function createHealthFile(healthFile, logger) {
|
|
26
|
+
let hasFailed = false;
|
|
27
|
+
const report = (operation, error) => {
|
|
28
|
+
if (hasFailed)
|
|
29
|
+
return;
|
|
30
|
+
hasFailed = true;
|
|
31
|
+
logger.error(`could not ${operation} the worker's health file`, { healthFile, error });
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
touch: () => {
|
|
35
|
+
try {
|
|
36
|
+
writeFileSync(healthFile, new Date().toISOString());
|
|
37
|
+
hasFailed = false;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
report("write", error);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
remove: () => {
|
|
44
|
+
try {
|
|
45
|
+
rmSync(healthFile, { force: true });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
report("remove", error);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=health.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"health.js","sourceRoot":"","sources":["../../src/worker/health.ts"],"names":[],"mappings":"AAAA,oHAAoH;AACpH,0GAA0G;AAC1G,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAG1D,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtD,6GAA6G;AAC7G,MAAM,qBAAqB,GAAG,MAAM,CAAC;AACrC,MAAM,0BAA0B,GAAG,CAAC,CAAC;AAYrC,iEAAiE;AACjE,MAAM,UAAU,0BAA0B,CAAC,MAAqB;IAC9D,OAAO,IAAI,CAAC,GAAG,CAAC,qBAAqB,EAAE,0BAA0B,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC,cAAc,CAAC,CAAC;AAC7G,CAAC;AAED,qGAAqG;AACrG,MAAM,UAAU,oBAAoB,CAAC,KAAgC;IACnE,IAAI,OAAe,CAAC;IACpB,IAAI,CAAC;QACH,OAAO,GAAG,QAAQ,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,OAAO,CAAC;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IAC1C,CAAC;IACD,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAC3E,OAAO,KAAK,GAAG,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC9F,CAAC;AAED,kHAAkH;AAClH,MAAM,UAAU,gBAAgB,CAAC,UAAkB,EAAE,MAAkB;IACrE,IAAI,SAAS,GAAG,KAAK,CAAC;IACtB,MAAM,MAAM,GAAG,CAAC,SAAiB,EAAE,KAAc,EAAE,EAAE;QACnD,IAAI,SAAS;YAAE,OAAO;QACtB,SAAS,GAAG,IAAI,CAAC;QACjB,MAAM,CAAC,KAAK,CAAC,aAAa,SAAS,2BAA2B,EAAE,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,CAAC;IACzF,CAAC,CAAC;IACF,OAAO;QACL,KAAK,EAAE,GAAG,EAAE;YACV,IAAI,CAAC;gBACH,aAAa,CAAC,UAAU,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC;gBACpD,SAAS,GAAG,KAAK,CAAC;YACpB,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,MAAM,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;YACzB,CAAC;QACH,CAAC;QACD,MAAM,EAAE,GAAG,EAAE;YACX,IAAI,CAAC;gBACH,MAAM,CAAC,UAAU,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;YACtC,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,MAAM,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;YAC1B,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { checkJobWorkerHealth, getJobWorkerHealthMaxAgeMs, type CheckJobWorkerHealthInput, type JobWorkerHealth } from "./health.js";
|
|
1
2
|
export { runJobWorkerProcess, type RunJobWorkerProcessOptions } from "./process.js";
|
|
2
3
|
export { runJobWorker, type RunJobWorkerOptions } from "./run-job-worker.js";
|
|
3
4
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/worker/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,mBAAmB,EAAE,KAAK,0BAA0B,EAAE,MAAM,cAAc,CAAC;AACpF,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,qBAAqB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/worker/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,oBAAoB,EAAE,0BAA0B,EAAE,KAAK,yBAAyB,EAAE,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AACrI,OAAO,EAAE,mBAAmB,EAAE,KAAK,0BAA0B,EAAE,MAAM,cAAc,CAAC;AACpF,OAAO,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,MAAM,qBAAqB,CAAC"}
|
package/dist/worker/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// The worker of @softure-ai/jobs: the loop (`runJobWorker`)
|
|
2
|
-
// (`runJobWorkerProcess`).
|
|
1
|
+
// The worker of @softure-ai/jobs: the loop (`runJobWorker`), the process entry an app's `scripts/worker.ts` calls
|
|
2
|
+
// (`runJobWorkerProcess`) and the check of its heartbeat (`checkJobWorkerHealth`).
|
|
3
|
+
export { checkJobWorkerHealth, getJobWorkerHealthMaxAgeMs } from "./health.js";
|
|
3
4
|
export { runJobWorkerProcess } from "./process.js";
|
|
4
5
|
export { runJobWorker } from "./run-job-worker.js";
|
|
5
6
|
//# sourceMappingURL=index.js.map
|
package/dist/worker/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/worker/index.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/worker/index.ts"],"names":[],"mappings":"AAAA,kHAAkH;AAClH,mFAAmF;AACnF,OAAO,EAAE,oBAAoB,EAAE,0BAA0B,EAAwD,MAAM,aAAa,CAAC;AACrI,OAAO,EAAE,mBAAmB,EAAmC,MAAM,cAAc,CAAC;AACpF,OAAO,EAAE,YAAY,EAA4B,MAAM,qBAAqB,CAAC"}
|
package/dist/worker/process.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type Database } from "@softure-ai/db";
|
|
|
3
3
|
import type { JobHandlers } from "../handlers.js";
|
|
4
4
|
import { type JobsLogger } from "../server/logger.js";
|
|
5
5
|
import { type RunJobWorkerOptions } from "./run-job-worker.js";
|
|
6
|
-
export interface RunJobWorkerProcessOptions extends Partial<Pick<RunJobWorkerOptions, "queues" | "clock" | "workerId" | "random" | "heartbeatMs">> {
|
|
6
|
+
export interface RunJobWorkerProcessOptions extends Partial<Pick<RunJobWorkerOptions, "queues" | "clock" | "workerId" | "random" | "heartbeatMs" | "healthFile">> {
|
|
7
7
|
readonly config: SoftureConfig;
|
|
8
8
|
readonly handlers: JobHandlers;
|
|
9
9
|
readonly logger?: JobsLogger;
|
|
@@ -11,10 +11,16 @@ export interface RunJobWorkerProcessOptions extends Partial<Pick<RunJobWorkerOpt
|
|
|
11
11
|
readonly database?: Database;
|
|
12
12
|
/** Stops the worker instead of SIGTERM and SIGINT (tests). */
|
|
13
13
|
readonly signal?: AbortSignal;
|
|
14
|
+
/** The oldest heartbeat `--health` accepts; default `getJobWorkerHealthMaxAgeMs(config)` (five poll intervals, at least 30 s). */
|
|
15
|
+
readonly healthMaxAgeMs?: number;
|
|
16
|
+
/** The process arguments; default `process.argv.slice(2)`. `--health` checks `healthFile` instead of running. */
|
|
17
|
+
readonly argv?: readonly string[];
|
|
14
18
|
}
|
|
15
19
|
/**
|
|
16
20
|
* Runs the worker until SIGTERM or SIGINT, then stops gracefully. Resolves to the exit code and sets
|
|
17
|
-
* `process.exitCode`: 0 after a clean stop, 1 when it could not start or the database could not be opened.
|
|
21
|
+
* `process.exitCode`: 0 after a clean stop, 1 when it could not start or the database could not be opened. Started
|
|
22
|
+
* with `--health`, it opens nothing and checks the running worker's `healthFile` instead: 0 when its loop ran within
|
|
23
|
+
* `healthMaxAgeMs`, else 1.
|
|
18
24
|
*
|
|
19
25
|
* // scripts/worker.ts
|
|
20
26
|
* import { runJobWorkerProcess } from "@softure-ai/jobs/worker";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"process.d.ts","sourceRoot":"","sources":["../../src/worker/process.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"process.d.ts","sourceRoot":"","sources":["../../src/worker/process.ts"],"names":[],"mappings":"AAGA,OAAO,EAAiB,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACrE,OAAO,EAAuB,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AACpE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAqB,KAAK,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAGzE,OAAO,EAAgB,KAAK,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAO7E,MAAM,WAAW,0BACf,SAAQ,OAAO,CAAC,IAAI,CAAC,mBAAmB,EAAE,QAAQ,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,GAAG,aAAa,GAAG,YAAY,CAAC,CAAC;IACrH,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC;IAC/B,QAAQ,CAAC,MAAM,CAAC,EAAE,UAAU,CAAC;IAC7B,gFAAgF;IAChF,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAC7B,8DAA8D;IAC9D,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;IAC9B,kIAAkI;IAClI,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,iHAAiH;IACjH,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACnC;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,0BAA0B,GAAG,OAAO,CAAC,MAAM,CAAC,CAK9F"}
|