@jarenjs/db 0.34.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/ARCHITECTURE.md +397 -0
- package/README.md +218 -0
- package/dist/types/algebra.d.ts +133 -0
- package/dist/types/app.d.ts +49 -0
- package/dist/types/capture.d.ts +85 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/dag-job.d.ts +40 -0
- package/dist/types/ddl.d.ts +170 -0
- package/dist/types/dialect.d.ts +130 -0
- package/dist/types/dialects/sqlite.d.ts +9 -0
- package/dist/types/driver.d.ts +128 -0
- package/dist/types/drivers/bun.d.ts +47 -0
- package/dist/types/drivers/node.d.ts +37 -0
- package/dist/types/drivers/wasm.d.ts +65 -0
- package/dist/types/emit-model.d.ts +44 -0
- package/dist/types/emit.d.ts +72 -0
- package/dist/types/entity.d.ts +23 -0
- package/dist/types/errors.d.ts +165 -0
- package/dist/types/graph.d.ts +28 -0
- package/dist/types/index.d.ts +35 -0
- package/dist/types/jobs.d.ts +134 -0
- package/dist/types/live.d.ts +62 -0
- package/dist/types/migrate.d.ts +163 -0
- package/dist/types/model.d.ts +36 -0
- package/dist/types/patch-sql.d.ts +37 -0
- package/dist/types/plan.d.ts +119 -0
- package/dist/types/profile.d.ts +80 -0
- package/dist/types/query.d.ts +100 -0
- package/dist/types/residual.d.ts +50 -0
- package/dist/types/store.d.ts +53 -0
- package/dist/types/tracker.d.ts +43 -0
- package/dist/types/typed.d.ts +15 -0
- package/dist/types/types.d.ts +26 -0
- package/dist/types/udf.d.ts +70 -0
- package/dist/types/window.d.ts +52 -0
- package/docs/JOBS-FORMAT.md +218 -0
- package/docs/LIVE-FORMAT.md +348 -0
- package/docs/MIGRATION-FORMAT.md +302 -0
- package/docs/MODEL-FORMAT.md +928 -0
- package/package.json +81 -0
- package/schemas/jaren-migration.draft-07.schema.json +144 -0
- package/schemas/jaren-migration.schema.json +144 -0
- package/schemas/jaren-model.draft-07.schema.json +149 -0
- package/schemas/jaren-model.schema.json +149 -0
- package/src/algebra.js +105 -0
- package/src/app.js +108 -0
- package/src/capture.js +584 -0
- package/src/cli.js +264 -0
- package/src/dag-job.js +86 -0
- package/src/ddl.js +588 -0
- package/src/dialect.js +297 -0
- package/src/dialects/sqlite.js +175 -0
- package/src/driver.js +419 -0
- package/src/drivers/bun.js +101 -0
- package/src/drivers/node.js +93 -0
- package/src/drivers/wasm.js +178 -0
- package/src/emit-model.js +208 -0
- package/src/emit.js +393 -0
- package/src/entity.js +367 -0
- package/src/errors.js +173 -0
- package/src/graph.js +101 -0
- package/src/index.js +64 -0
- package/src/jobs.js +507 -0
- package/src/live.js +899 -0
- package/src/migrate.js +1411 -0
- package/src/model.js +476 -0
- package/src/patch-sql.js +150 -0
- package/src/plan.js +1038 -0
- package/src/profile.js +131 -0
- package/src/query.js +1010 -0
- package/src/residual.js +91 -0
- package/src/store.js +1422 -0
- package/src/tracker.js +776 -0
- package/src/typed.js +19 -0
- package/src/types.js +36 -0
- package/src/udf.js +132 -0
- package/src/window.js +125 -0
- package/types/app.d.ts +36 -0
- package/types/bun.d.ts +9 -0
- package/types/index.d.ts +592 -0
- package/types/node.d.ts +15 -0
- package/types/typed.d.ts +108 -0
- package/types/wasm.d.ts +5 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file @jarenjs/db — document storage over SQLite through two seams:
|
|
4
|
+
* a driver (how a connection is made: `@jarenjs/db/node`, `/bun`, or
|
|
5
|
+
* `/wasm` with an injected handle) and a dialect (how SQL is spelled).
|
|
6
|
+
* This root subpath never touches a runtime builtin — a browser
|
|
7
|
+
* bundler resolves it clean; the bindings live behind their own
|
|
8
|
+
* subpaths and load their builtin lazily inside `open()`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { openStore, normalizeModel, MODEL_VERSION } from './store.js';
|
|
12
|
+
export { createDialect } from './dialect.js';
|
|
13
|
+
export { sqliteDialect } from './dialects/sqlite.js';
|
|
14
|
+
export {
|
|
15
|
+
SQLITE_FLOOR, chain, toPromise, isThenable, compareVersions,
|
|
16
|
+
openConnection, wrapStatement, lazyOpen,
|
|
17
|
+
} from './driver.js';
|
|
18
|
+
export {
|
|
19
|
+
planCollection, compileIndexPath, schemaTypeAt, KEY_COLUMN, DOC_COLUMN,
|
|
20
|
+
normalizeDeclaredSql, comparableDeclaredSql,
|
|
21
|
+
} from './ddl.js';
|
|
22
|
+
export {
|
|
23
|
+
planQuery, assertDecidedKind, entityShape, entityPathRef,
|
|
24
|
+
planEntityPredicate, planEntityQuery,
|
|
25
|
+
} from './plan.js';
|
|
26
|
+
export { emitPlan, createEntityPredicateEmitters, emitEntityPlan } from './emit.js';
|
|
27
|
+
export { mergeEntityRow, parseGraphRow } from './graph.js';
|
|
28
|
+
export { selectPlan, conjoin, assertNoSqlText, PLAN_VERSION } from './algebra.js';
|
|
29
|
+
export { typeOfPath, isNumericType } from './types.js';
|
|
30
|
+
export { compileSetResidual, compileRowResidual, sequenceResult } from './residual.js';
|
|
31
|
+
export { deterministicFragment, registerFragment } from './udf.js';
|
|
32
|
+
export {
|
|
33
|
+
createQueryEngine, createQueryState, createEntityQueryEngine,
|
|
34
|
+
createLoadEngine, INCLUDE_DEPTH_DEFAULT,
|
|
35
|
+
} from './query.js';
|
|
36
|
+
export {
|
|
37
|
+
normalizeProfile, SAFE_PROFILE, translateProfilePredicate,
|
|
38
|
+
applyMandatoryPredicate, applyRowBound,
|
|
39
|
+
} from './profile.js';
|
|
40
|
+
export { translatePatch } from './patch-sql.js';
|
|
41
|
+
export { normalizeEntities, explainMapping } from './model.js';
|
|
42
|
+
export { planEntity, planJoinTable } from './ddl.js';
|
|
43
|
+
export { entityCore } from './entity.js';
|
|
44
|
+
export { entityEmitModel } from './emit-model.js';
|
|
45
|
+
export {
|
|
46
|
+
parseChangeset, translateOperations, keyToken, createCaptureEngine,
|
|
47
|
+
CHANGES_TABLE, DEFAULT_RETENTION,
|
|
48
|
+
} from './capture.js';
|
|
49
|
+
export {
|
|
50
|
+
createTracker, deepFreeze, BATCH_PARAM_BUDGET, BATCH_ROW_BOUND,
|
|
51
|
+
} from './tracker.js';
|
|
52
|
+
export {
|
|
53
|
+
planMigration, planModelMigration, migrate, migrationStatus, shapeHash,
|
|
54
|
+
migrationChecksum, createModelShape, schemaShapeOf, compareShapeToModel,
|
|
55
|
+
MIGRATION_VERSION, HISTORY_TABLE,
|
|
56
|
+
} from './migrate.js';
|
|
57
|
+
export { DbCompileError, DbRuntimeError, DB_CODES } from './errors.js';
|
|
58
|
+
export { classifyLiveQuery, createLiveRegistry, diffRows, LIVE_DEFAULTS } from './live.js';
|
|
59
|
+
export { createSortedWindow, compareCodepoint } from './window.js';
|
|
60
|
+
export {
|
|
61
|
+
createJobEngine, JOBS_TABLE, JOB_CHECKPOINTS_TABLE, JOB_DEFAULTS,
|
|
62
|
+
describeValue, serializeResult,
|
|
63
|
+
} from './jobs.js';
|
|
64
|
+
export { createDagJobRunner } from './dag-job.js';
|
package/src/jobs.js
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The durable job queue (JOBS-FORMAT): enqueue, the
|
|
4
|
+
* single-statement guarded claim (§3 — one statement is one
|
|
5
|
+
* transaction, so no double-claim without any distributed lock),
|
|
6
|
+
* retry with exponential backoff and jitter (§4), recovery as
|
|
7
|
+
* re-claim of expired leases (§5), polling workers with same-process
|
|
8
|
+
* wake-on-enqueue (§6), and the per-job flow checkpoint store the DAG
|
|
9
|
+
* composition binds (§7) — completion marks the job done and records
|
|
10
|
+
* the result in ONE guarded transaction.
|
|
11
|
+
*
|
|
12
|
+
* Every worker transition is guarded by `state='leased' AND
|
|
13
|
+
* lease_owner=?`: execution is at-least-once, completion is
|
|
14
|
+
* exactly-once. `now` and `random` are injectable — the runtime
|
|
15
|
+
* defaults are the clock and `Math.random`; every test injects.
|
|
16
|
+
*
|
|
17
|
+
* The worker LIFECYCLE holds two invariants that a long-running process
|
|
18
|
+
* depends on, and neither is a detail:
|
|
19
|
+
*
|
|
20
|
+
* - **No handler value can break the loop.** A handler is host code and
|
|
21
|
+
* may resolve with something JSON cannot express, or reject with a
|
|
22
|
+
* value whose own `message` throws when read. Both are normalized
|
|
23
|
+
* totally, and `runOne` is isolated inside the loop, so the worst a
|
|
24
|
+
* single job can do is fail its own attempt. A rejected claim-execute
|
|
25
|
+
* loop would stop draining the queue silently.
|
|
26
|
+
* - **Shutdown is bounded.** Handlers receive an `AbortSignal` and
|
|
27
|
+
* `stop()` takes a deadline, so a handler that never settles cannot
|
|
28
|
+
* hold `stop()` — and therefore `store.close()`, and therefore the
|
|
29
|
+
* database file — open forever.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { chain } from './driver.js';
|
|
33
|
+
|
|
34
|
+
export const JOBS_TABLE = '_jaren_jobs';
|
|
35
|
+
export const JOB_CHECKPOINTS_TABLE = '_jaren_job_checkpoints';
|
|
36
|
+
|
|
37
|
+
/** §4 defaults, all overridable per worker. */
|
|
38
|
+
export const JOB_DEFAULTS = Object.freeze({
|
|
39
|
+
maxAttempts: 5,
|
|
40
|
+
leaseMs: 30_000,
|
|
41
|
+
pollInterval: 500,
|
|
42
|
+
backoffBase: 1_000,
|
|
43
|
+
backoffCap: 60_000,
|
|
44
|
+
/** How long `stop()` waits for in-flight handlers after signalling
|
|
45
|
+
* abort, before it stops waiting and reports what is still running.
|
|
46
|
+
* Bounded on purpose: an unbounded wait makes one stuck handler
|
|
47
|
+
* indistinguishable from a hung process. */
|
|
48
|
+
stopGraceMs: 5_000,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A diagnostic string for ANY value, including ones that fight back — a
|
|
53
|
+
* getter that throws, a null-prototype object, a revoked proxy, a
|
|
54
|
+
* symbol. Total by construction: an error report is never the thing that
|
|
55
|
+
* fails.
|
|
56
|
+
* @param {any} value
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export function describeValue(value) {
|
|
60
|
+
try {
|
|
61
|
+
if (value === null || value === undefined) return String(value);
|
|
62
|
+
if (typeof value === 'symbol') return value.toString();
|
|
63
|
+
if (typeof value !== 'object') return String(value);
|
|
64
|
+
const message = /** @type {any} */ (value).message;
|
|
65
|
+
if (typeof message === 'string') return message;
|
|
66
|
+
return String(value);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return '[unreportable value]';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* JSON text for a job result, or `null` when the value cannot be
|
|
75
|
+
* expressed — a BigInt, a cycle, a `toJSON` that throws. The caller
|
|
76
|
+
* treats that as a failed attempt, never as a broken worker.
|
|
77
|
+
* @param {any} value
|
|
78
|
+
* @returns {{ text: string | null } | { reason: string }}
|
|
79
|
+
*/
|
|
80
|
+
export function serializeResult(value) {
|
|
81
|
+
if (value === undefined || value === null) return { text: null };
|
|
82
|
+
try {
|
|
83
|
+
const text = JSON.stringify(value);
|
|
84
|
+
if (text === undefined) return { reason: 'the result is not representable as JSON' };
|
|
85
|
+
return { text };
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
return { reason: `the result could not be serialized: ${describeValue(error)}` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const CREATE_JOBS = `CREATE TABLE IF NOT EXISTS "${JOBS_TABLE}" (
|
|
93
|
+
id TEXT PRIMARY KEY,
|
|
94
|
+
kind TEXT NOT NULL,
|
|
95
|
+
payload TEXT,
|
|
96
|
+
state TEXT NOT NULL DEFAULT 'pending',
|
|
97
|
+
run_at INTEGER NOT NULL,
|
|
98
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
99
|
+
max_attempts INTEGER NOT NULL,
|
|
100
|
+
lease_until INTEGER,
|
|
101
|
+
lease_owner TEXT,
|
|
102
|
+
last_error TEXT,
|
|
103
|
+
result TEXT,
|
|
104
|
+
created_at INTEGER NOT NULL,
|
|
105
|
+
updated_at INTEGER NOT NULL
|
|
106
|
+
);
|
|
107
|
+
CREATE INDEX IF NOT EXISTS "${JOBS_TABLE}_claim"
|
|
108
|
+
ON "${JOBS_TABLE}" (state, run_at);
|
|
109
|
+
CREATE TABLE IF NOT EXISTS "${JOB_CHECKPOINTS_TABLE}" (
|
|
110
|
+
run_id TEXT NOT NULL,
|
|
111
|
+
node_id TEXT NOT NULL,
|
|
112
|
+
value TEXT NOT NULL,
|
|
113
|
+
PRIMARY KEY (run_id, node_id)
|
|
114
|
+
);`;
|
|
115
|
+
|
|
116
|
+
/** Map a raw row to the frozen public record (§2). */
|
|
117
|
+
function publicJob(row) {
|
|
118
|
+
return Object.freeze({
|
|
119
|
+
id: row.id,
|
|
120
|
+
kind: row.kind,
|
|
121
|
+
payload: row.payload === null ? null : JSON.parse(row.payload),
|
|
122
|
+
state: row.state,
|
|
123
|
+
runAt: Number(row.run_at),
|
|
124
|
+
attempts: Number(row.attempts),
|
|
125
|
+
maxAttempts: Number(row.max_attempts),
|
|
126
|
+
leaseUntil: row.lease_until === null ? null : Number(row.lease_until),
|
|
127
|
+
leaseOwner: row.lease_owner,
|
|
128
|
+
lastError: row.last_error,
|
|
129
|
+
result: row.result === null ? null : JSON.parse(row.result),
|
|
130
|
+
createdAt: Number(row.created_at),
|
|
131
|
+
updatedAt: Number(row.updated_at),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The queue engine over one open connection.
|
|
137
|
+
* @param {{ connection: any, now?: () => number,
|
|
138
|
+
* random?: () => number,
|
|
139
|
+
* defaults?: Partial<typeof JOB_DEFAULTS> }} options
|
|
140
|
+
*/
|
|
141
|
+
export function createJobEngine(options) {
|
|
142
|
+
const { connection } = options;
|
|
143
|
+
const now = options.now ?? Date.now;
|
|
144
|
+
const random = options.random ?? Math.random;
|
|
145
|
+
const defaults = { ...JOB_DEFAULTS, ...options.defaults };
|
|
146
|
+
|
|
147
|
+
/** @type {Map<string, any>} */
|
|
148
|
+
const statements = new Map();
|
|
149
|
+
const prepared = (key, sql) => {
|
|
150
|
+
let statement = statements.get(key);
|
|
151
|
+
if (statement === undefined) {
|
|
152
|
+
statement = connection.prepare(sql);
|
|
153
|
+
statements.set(key, statement);
|
|
154
|
+
}
|
|
155
|
+
return statement;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** Same-process wake-on-enqueue (§6). */
|
|
159
|
+
const wakers = new Set();
|
|
160
|
+
const wakeAll = () => {
|
|
161
|
+
for (const wake of [...wakers]) wake();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const ready = connection.exec(CREATE_JOBS);
|
|
165
|
+
|
|
166
|
+
const enqueue = (kind, payload, enqueueOptions) => {
|
|
167
|
+
if (typeof kind !== 'string' || kind === '') {
|
|
168
|
+
throw new TypeError('enqueue: "kind" must be a non-empty string');
|
|
169
|
+
}
|
|
170
|
+
const id = enqueueOptions?.id ?? crypto.randomUUID();
|
|
171
|
+
const at = now();
|
|
172
|
+
return chain(prepared('enqueue', `INSERT INTO "${JOBS_TABLE}"
|
|
173
|
+
(id, kind, payload, state, run_at, max_attempts, created_at, updated_at)
|
|
174
|
+
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?)
|
|
175
|
+
ON CONFLICT (id) DO NOTHING`).run([
|
|
176
|
+
id, kind,
|
|
177
|
+
payload === undefined || payload === null ? null : JSON.stringify(payload),
|
|
178
|
+
enqueueOptions?.runAt ?? at,
|
|
179
|
+
enqueueOptions?.maxAttempts ?? defaults.maxAttempts,
|
|
180
|
+
at, at,
|
|
181
|
+
]), () => {
|
|
182
|
+
wakeAll();
|
|
183
|
+
return id;
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const get = (id) => chain(
|
|
188
|
+
prepared('get', `SELECT * FROM "${JOBS_TABLE}" WHERE id = ?`).get([id]),
|
|
189
|
+
(row) => (row === undefined ? undefined : publicJob(row)));
|
|
190
|
+
|
|
191
|
+
const counts = () => chain(
|
|
192
|
+
prepared('counts', `SELECT state, COUNT(*) AS n FROM "${JOBS_TABLE}" GROUP BY state`).all([]),
|
|
193
|
+
(states) => chain(
|
|
194
|
+
prepared('pendingKinds', `SELECT kind, COUNT(*) AS n FROM "${JOBS_TABLE}"
|
|
195
|
+
WHERE state IN ('pending', 'failed') GROUP BY kind`).all([]),
|
|
196
|
+
(kinds) => {
|
|
197
|
+
const out = {
|
|
198
|
+
pending: 0, leased: 0, done: 0, failed: 0, dead: 0,
|
|
199
|
+
/** @type {Record<string, number>} */
|
|
200
|
+
pendingKinds: {},
|
|
201
|
+
};
|
|
202
|
+
for (const row of states) out[row.state] = Number(row.n);
|
|
203
|
+
for (const row of kinds) out.pendingKinds[row.kind] = Number(row.n);
|
|
204
|
+
return out;
|
|
205
|
+
}));
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* §3: the single guarded claim; expired leases are claimable — the
|
|
209
|
+
* next claim IS crash recovery.
|
|
210
|
+
* @param {{ kinds: string[], owner: string, leaseMs?: number }} claimOptions
|
|
211
|
+
*/
|
|
212
|
+
const claim = (claimOptions) => {
|
|
213
|
+
const { kinds, owner } = claimOptions;
|
|
214
|
+
if (!Array.isArray(kinds) || kinds.length === 0
|
|
215
|
+
|| typeof owner !== 'string' || owner === '') {
|
|
216
|
+
throw new TypeError('claim: needs a non-empty "kinds" array and an "owner"');
|
|
217
|
+
}
|
|
218
|
+
const at = now();
|
|
219
|
+
const placeholders = kinds.map(() => '?').join(', ');
|
|
220
|
+
const statement = prepared(`claim:${kinds.length}`,
|
|
221
|
+
`UPDATE "${JOBS_TABLE}" SET state='leased', lease_owner=?, lease_until=?,
|
|
222
|
+
attempts = attempts + 1, updated_at=?
|
|
223
|
+
WHERE id = (SELECT id FROM "${JOBS_TABLE}"
|
|
224
|
+
WHERE (state='pending' OR state='failed'
|
|
225
|
+
OR (state='leased' AND lease_until < ?))
|
|
226
|
+
AND run_at <= ? AND kind IN (${placeholders})
|
|
227
|
+
ORDER BY run_at, created_at, id LIMIT 1)
|
|
228
|
+
RETURNING *`);
|
|
229
|
+
return chain(statement.get([
|
|
230
|
+
owner, at + (claimOptions.leaseMs ?? defaults.leaseMs), at, at, at, ...kinds,
|
|
231
|
+
]), (row) => (row === undefined ? undefined : publicJob(row)));
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** §4: the jittered exponential backoff. */
|
|
235
|
+
const backoffOf = (attempts, workerDefaults) => {
|
|
236
|
+
const base = workerDefaults?.backoffBase ?? defaults.backoffBase;
|
|
237
|
+
const cap = workerDefaults?.backoffCap ?? defaults.backoffCap;
|
|
238
|
+
return Math.round(Math.min(cap, base * 2 ** (attempts - 1)) * (0.5 + random() / 2));
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Complete a leased job — guarded, exactly-once (§3). A result JSON
|
|
243
|
+
* cannot express is refused HERE, before any write, so the caller can
|
|
244
|
+
* fail the attempt instead of the worker.
|
|
245
|
+
* @throws {TypeError} when the result is not representable
|
|
246
|
+
*/
|
|
247
|
+
const complete = (id, owner, result) => {
|
|
248
|
+
const serialized = serializeResult(result);
|
|
249
|
+
if (!('text' in serialized)) throw new TypeError(serialized.reason);
|
|
250
|
+
return chain(
|
|
251
|
+
prepared('complete', `UPDATE "${JOBS_TABLE}"
|
|
252
|
+
SET state='done', result=?, lease_until=NULL, updated_at=?
|
|
253
|
+
WHERE id=? AND state='leased' AND lease_owner=?`).run([
|
|
254
|
+
serialized.text, now(), id, owner,
|
|
255
|
+
]), (out) => Number(out.changes ?? 0) > 0);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** Fail a leased job: schedule the retry or dead-letter (§4). */
|
|
259
|
+
const fail = (id, owner, error, workerDefaults) => chain(get(id), (job) => {
|
|
260
|
+
if (job === undefined) return false;
|
|
261
|
+
const terminal = job.attempts >= job.maxAttempts;
|
|
262
|
+
const at = now();
|
|
263
|
+
const statement = terminal
|
|
264
|
+
? prepared('dead', `UPDATE "${JOBS_TABLE}"
|
|
265
|
+
SET state='dead', last_error=?, lease_until=NULL, updated_at=?
|
|
266
|
+
WHERE id=? AND state='leased' AND lease_owner=?`)
|
|
267
|
+
: prepared('retry', `UPDATE "${JOBS_TABLE}"
|
|
268
|
+
SET state='failed', last_error=?, lease_until=NULL, run_at=?, updated_at=?
|
|
269
|
+
WHERE id=? AND state='leased' AND lease_owner=?`);
|
|
270
|
+
// host code decides what it throws; reading it must not throw back
|
|
271
|
+
const message = describeValue(error);
|
|
272
|
+
const params = terminal
|
|
273
|
+
? [message, at, id, owner]
|
|
274
|
+
: [message, at + backoffOf(job.attempts, workerDefaults), at, id, owner];
|
|
275
|
+
return chain(statement.run(params), (out) => Number(out.changes ?? 0) > 0);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* §7: the flow checkpoint store bound to ONE claimed job. `save`
|
|
280
|
+
* refuses once the lease is lost (the stale run aborts fast);
|
|
281
|
+
* `complete` records the result, marks the job done and prunes the
|
|
282
|
+
* checkpoint rows in ONE guarded transaction — a failure leaves
|
|
283
|
+
* neither.
|
|
284
|
+
* @param {{ id: string, leaseOwner: string | null }} job
|
|
285
|
+
*/
|
|
286
|
+
const checkpointsFor = (job) => ({
|
|
287
|
+
load: (runId) => chain(
|
|
288
|
+
prepared('cpLoad', `SELECT node_id, value FROM "${JOB_CHECKPOINTS_TABLE}"
|
|
289
|
+
WHERE run_id = ?`).all([runId]),
|
|
290
|
+
(rows) => (rows.length === 0
|
|
291
|
+
? null
|
|
292
|
+
: {
|
|
293
|
+
values: Object.fromEntries(
|
|
294
|
+
rows.map((row) => [row.node_id, JSON.parse(row.value)])),
|
|
295
|
+
})),
|
|
296
|
+
save: (runId, nodeId, value) => connection.transaction(() => chain(
|
|
297
|
+
prepared('cpOwner', `SELECT lease_owner FROM "${JOBS_TABLE}"
|
|
298
|
+
WHERE id=? AND state='leased'`).get([runId]),
|
|
299
|
+
(row) => {
|
|
300
|
+
if (row === undefined || row.lease_owner !== job.leaseOwner) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`the lease on job '${runId}' was lost; the checkpoint is refused`);
|
|
303
|
+
}
|
|
304
|
+
return prepared('cpSave', `INSERT INTO "${JOB_CHECKPOINTS_TABLE}"
|
|
305
|
+
(run_id, node_id, value) VALUES (?, ?, ?)
|
|
306
|
+
ON CONFLICT (run_id, node_id) DO UPDATE SET value=excluded.value`)
|
|
307
|
+
.run([runId, nodeId, JSON.stringify(value)]);
|
|
308
|
+
})),
|
|
309
|
+
complete: (runId, result) => connection.transaction(() => chain(
|
|
310
|
+
complete(runId, /** @type {string} */ (job.leaseOwner), result),
|
|
311
|
+
(completed) => {
|
|
312
|
+
if (!completed) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`the lease on job '${runId}' was lost; the completion is refused`);
|
|
315
|
+
}
|
|
316
|
+
return prepared('cpPrune',
|
|
317
|
+
`DELETE FROM "${JOB_CHECKPOINTS_TABLE}" WHERE run_id = ?`).run([runId]);
|
|
318
|
+
})),
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
/** @type {Set<any>} */
|
|
322
|
+
const workers = new Set();
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* §6: N claim-execute loops over one handler registry.
|
|
326
|
+
* @param {{ handlers: Record<string, Function>, concurrency?: number,
|
|
327
|
+
* pollInterval?: number, leaseMs?: number, owner?: string,
|
|
328
|
+
* backoffBase?: number, backoffCap?: number }} workerOptions
|
|
329
|
+
*/
|
|
330
|
+
const createWorker = (workerOptions) => {
|
|
331
|
+
const handlers = workerOptions?.handlers;
|
|
332
|
+
if (handlers === null || typeof handlers !== 'object'
|
|
333
|
+
|| Object.keys(handlers).length === 0
|
|
334
|
+
|| Object.values(handlers).some((handler) => typeof handler !== 'function')) {
|
|
335
|
+
throw new TypeError(
|
|
336
|
+
'createWorker: "handlers" must be a non-empty object of functions');
|
|
337
|
+
}
|
|
338
|
+
const kinds = Object.keys(handlers);
|
|
339
|
+
const owner = workerOptions.owner ?? crypto.randomUUID();
|
|
340
|
+
const concurrency = workerOptions.concurrency ?? 1;
|
|
341
|
+
const pollInterval = workerOptions.pollInterval ?? defaults.pollInterval;
|
|
342
|
+
const stats = { claims: 0, completions: 0, failures: 0, polls: 0, wakes: 0 };
|
|
343
|
+
|
|
344
|
+
let running = false;
|
|
345
|
+
/** @type {Promise<void>[]} */
|
|
346
|
+
let loops = [];
|
|
347
|
+
/** @type {Set<{ timer: any, wake: () => void }>} */
|
|
348
|
+
const sleepers = new Set();
|
|
349
|
+
|
|
350
|
+
const sleep = () => new Promise((resolve) => {
|
|
351
|
+
/** @type {{ timer: any, wake: () => void }} */
|
|
352
|
+
const sleeper = {
|
|
353
|
+
timer: setTimeout(() => {
|
|
354
|
+
sleepers.delete(sleeper);
|
|
355
|
+
stats.polls += 1;
|
|
356
|
+
resolve(undefined);
|
|
357
|
+
}, pollInterval),
|
|
358
|
+
wake: () => {
|
|
359
|
+
clearTimeout(sleeper.timer);
|
|
360
|
+
sleepers.delete(sleeper);
|
|
361
|
+
stats.wakes += 1;
|
|
362
|
+
resolve(undefined);
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
sleepers.add(sleeper);
|
|
366
|
+
});
|
|
367
|
+
const onWake = () => {
|
|
368
|
+
for (const sleeper of [...sleepers]) sleeper.wake();
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
/** Aborted when `stop()` is called: the handler's cue to wind up. */
|
|
372
|
+
let shutdown = new AbortController();
|
|
373
|
+
/** In-flight handler count, so `stop()` can report what it left. */
|
|
374
|
+
let inFlight = 0;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Record one failed attempt. Reporting a failure must never itself
|
|
378
|
+
* fail the loop, so a storage error here is swallowed after the
|
|
379
|
+
* attempt count has already been incremented by the claim.
|
|
380
|
+
*/
|
|
381
|
+
const recordFailure = async (job, error) => {
|
|
382
|
+
stats.failures += 1;
|
|
383
|
+
try {
|
|
384
|
+
await Promise.resolve(fail(job.id, owner, error, workerOptions));
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
// the lease will expire and the job will be re-claimed (§5);
|
|
388
|
+
// a worker must not die because the failure write failed
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const runOne = async (job) => {
|
|
393
|
+
stats.claims += 1;
|
|
394
|
+
inFlight += 1;
|
|
395
|
+
try {
|
|
396
|
+
let result;
|
|
397
|
+
try {
|
|
398
|
+
result = await handlers[job.kind](job.payload,
|
|
399
|
+
{ job, checkpointsFor, signal: shutdown.signal });
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
await recordFailure(job, error);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
// a §7 handler may have completed transactionally already; the
|
|
407
|
+
// guarded update makes this a no-op then
|
|
408
|
+
await Promise.resolve(complete(job.id, owner, result ?? null));
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
// an unrepresentable result, or a storage failure at the
|
|
412
|
+
// completion write: this attempt failed, the worker did not
|
|
413
|
+
await recordFailure(job, error);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
stats.completions += 1;
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
inFlight -= 1;
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const loop = async () => {
|
|
424
|
+
while (running) {
|
|
425
|
+
let job;
|
|
426
|
+
try {
|
|
427
|
+
job = await Promise.resolve(claim({
|
|
428
|
+
kinds, owner, leaseMs: workerOptions.leaseMs }));
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
job = undefined; // a transient storage failure: back off to the poll
|
|
432
|
+
}
|
|
433
|
+
if (!running) return;
|
|
434
|
+
if (job === undefined) {
|
|
435
|
+
await sleep();
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
try {
|
|
439
|
+
await runOne(job);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
// `runOne` normalizes every handler outcome, so reaching here
|
|
443
|
+
// means the normalization itself broke. The loop still must not
|
|
444
|
+
// die: a stopped claim-execute loop drains nothing, silently.
|
|
445
|
+
stats.failures += 1;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const worker = {
|
|
451
|
+
stats: () => ({ ...stats, inFlight }),
|
|
452
|
+
start() {
|
|
453
|
+
if (running) throw new TypeError('the worker is already started');
|
|
454
|
+
running = true;
|
|
455
|
+
shutdown = new AbortController();
|
|
456
|
+
loops = Array.from({ length: concurrency }, () => loop());
|
|
457
|
+
return worker;
|
|
458
|
+
},
|
|
459
|
+
/**
|
|
460
|
+
* Stop claiming, signal in-flight handlers to abort, and wait for
|
|
461
|
+
* the loops — but only up to `graceMs`. A handler that ignores its
|
|
462
|
+
* signal cannot hold the process open; the resolved record says so
|
|
463
|
+
* instead, and the lease expiry (§5) lets another worker re-claim.
|
|
464
|
+
* @param {{ graceMs?: number }} [stopOptions]
|
|
465
|
+
* @returns {Promise<{ drained: boolean, inFlight: number }>}
|
|
466
|
+
*/
|
|
467
|
+
async stop(stopOptions) {
|
|
468
|
+
running = false;
|
|
469
|
+
shutdown.abort();
|
|
470
|
+
onWake();
|
|
471
|
+
const graceMs = stopOptions?.graceMs
|
|
472
|
+
?? workerOptions.stopGraceMs ?? defaults.stopGraceMs;
|
|
473
|
+
/** @type {any} */
|
|
474
|
+
let timer;
|
|
475
|
+
const drained = await Promise.race([
|
|
476
|
+
Promise.all(loops).then(() => true),
|
|
477
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(false), graceMs); }),
|
|
478
|
+
]);
|
|
479
|
+
clearTimeout(timer);
|
|
480
|
+
if (drained) loops = [];
|
|
481
|
+
wakers.delete(onWake);
|
|
482
|
+
workers.delete(worker);
|
|
483
|
+
return { drained: drained === true, inFlight };
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
wakers.add(onWake);
|
|
487
|
+
workers.add(worker);
|
|
488
|
+
return worker;
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
ready,
|
|
493
|
+
enqueue,
|
|
494
|
+
get,
|
|
495
|
+
counts,
|
|
496
|
+
claim,
|
|
497
|
+
complete,
|
|
498
|
+
fail,
|
|
499
|
+
checkpointsFor,
|
|
500
|
+
createWorker,
|
|
501
|
+
/** Stop every worker, bounded. Resolves to the per-worker outcome so
|
|
502
|
+
* `close()` can report a handler it could not wait out rather than
|
|
503
|
+
* hanging on it. */
|
|
504
|
+
stopAll: (stopOptions) =>
|
|
505
|
+
Promise.all([...workers].map((worker) => worker.stop(stopOptions))),
|
|
506
|
+
};
|
|
507
|
+
}
|