pipework 0.84.0 → 0.85.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/CHANGELOG.md +14 -0
- package/dist/async/jobs/index.d.ts +1 -1
- package/dist/async/jobs/index.d.ts.map +1 -1
- package/dist/async/jobs/queue.d.ts +20 -0
- package/dist/async/jobs/queue.d.ts.map +1 -1
- package/dist/async/jobs/queue.js +52 -10
- package/dist/async/jobs/queue.js.map +1 -1
- package/dist/async/steps/graph.d.ts +3 -0
- package/dist/async/steps/graph.d.ts.map +1 -0
- package/dist/async/steps/graph.js +850 -0
- package/dist/async/steps/graph.js.map +1 -0
- package/dist/async/steps/hash.d.ts +8 -0
- package/dist/async/steps/hash.d.ts.map +1 -0
- package/dist/async/steps/hash.js +40 -0
- package/dist/async/steps/hash.js.map +1 -0
- package/dist/async/steps/index.d.ts +4 -0
- package/dist/async/steps/index.d.ts.map +1 -0
- package/dist/async/steps/index.js +3 -0
- package/dist/async/steps/index.js.map +1 -0
- package/dist/async/steps/namespace.d.ts +7 -0
- package/dist/async/steps/namespace.d.ts.map +1 -0
- package/dist/async/steps/namespace.js +7 -0
- package/dist/async/steps/namespace.js.map +1 -0
- package/dist/async/steps/table.d.ts +155 -0
- package/dist/async/steps/table.d.ts.map +1 -0
- package/dist/async/steps/table.js +86 -0
- package/dist/async/steps/table.js.map +1 -0
- package/dist/async/steps/types.d.ts +164 -0
- package/dist/async/steps/types.d.ts.map +1 -0
- package/dist/async/steps/types.js +2 -0
- package/dist/async/steps/types.js.map +1 -0
- package/dist/core/surface/types.d.ts +7 -0
- package/dist/core/surface/types.d.ts.map +1 -1
- package/dist/core/surface/worker.d.ts.map +1 -1
- package/dist/core/surface/worker.js +19 -5
- package/dist/core/surface/worker.js.map +1 -1
- package/dist/data/migrate/internal-definitions.d.ts.map +1 -1
- package/dist/data/migrate/internal-definitions.js +5 -0
- package/dist/data/migrate/internal-definitions.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/test/savepoints.d.ts.map +1 -1
- package/dist/test/savepoints.js +28 -7
- package/dist/test/savepoints.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
// Step graphs over a jobs Queue. A graph is defined once; each request names the
|
|
2
|
+
// results it wants for an item, and only the steps whose result is not already
|
|
3
|
+
// recorded for their inputs run, each as an ordinary queued job.
|
|
4
|
+
//
|
|
5
|
+
// A result's identity is its content key: sha256 of (graph, step, version, the
|
|
6
|
+
// element, the hash of every need). A changed input changes the keys that depend
|
|
7
|
+
// on it and nothing else, so invalidation needs no bookkeeping.
|
|
8
|
+
//
|
|
9
|
+
// Run state changes only under a row lock on the run (`advance` and every writer
|
|
10
|
+
// take it before touching tasks), so steps finishing together serialize.
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { sql } from '../../data/db/sql.js';
|
|
13
|
+
import { schema } from '../../data/schema/namespace.js';
|
|
14
|
+
import { fitting } from '../../request/di/index.js';
|
|
15
|
+
import { pipeRaw } from '../../data/db/db.js';
|
|
16
|
+
import { getContext, runInContext } from '../../request/context/store.js';
|
|
17
|
+
import { createJobContext } from '../../request/context/create.js';
|
|
18
|
+
import { bindTransaction } from '../../request/context/guard.js';
|
|
19
|
+
import { propagateLocals } from '../../request/context/run-in-transaction.js';
|
|
20
|
+
import { parentTraceRef } from '../../trace/entry/parent-trace.js';
|
|
21
|
+
import { serializeForTrace } from '../../trace/capture/bounded-serializer.js';
|
|
22
|
+
import { getBaseLogger } from '../../core/logging/index.js';
|
|
23
|
+
import { canonicalJson, inputHash, sha256Hex, valueHash } from './hash.js';
|
|
24
|
+
import { SINGLE, registerStepTables } from './table.js';
|
|
25
|
+
const RUN = 'pipework_step_run';
|
|
26
|
+
const TASK = 'pipework_step_task';
|
|
27
|
+
const RESULT = 'pipework_step_result';
|
|
28
|
+
const NAME_RE = /^[A-Za-z][A-Za-z0-9_.-]*$/;
|
|
29
|
+
const MS_PER_DAY = 86_400_000;
|
|
30
|
+
function fail(graph, message) {
|
|
31
|
+
throw new Error(`[pipework] steps.define("${graph}"): ${message}\n`);
|
|
32
|
+
}
|
|
33
|
+
function toJson(value) {
|
|
34
|
+
return value === undefined ? null : JSON.parse(JSON.stringify(value));
|
|
35
|
+
}
|
|
36
|
+
function stepDefinitionChecks(config) {
|
|
37
|
+
const graph = config.name;
|
|
38
|
+
if (typeof graph !== 'string' || !NAME_RE.test(graph)) {
|
|
39
|
+
throw new Error(`[pipework] steps.define: graph name "${String(graph)}" must start with a letter and use letters, digits, "_", "." or "-".\n`);
|
|
40
|
+
}
|
|
41
|
+
const inputs = new Set(config.inputs ?? []);
|
|
42
|
+
const names = Object.keys(config.steps);
|
|
43
|
+
if (names.length === 0)
|
|
44
|
+
fail(graph, 'no steps.');
|
|
45
|
+
for (const name of names) {
|
|
46
|
+
const def = config.steps[name];
|
|
47
|
+
if (!NAME_RE.test(name))
|
|
48
|
+
fail(graph, `step name "${name}" must start with a letter and use letters, digits, "_", "." or "-".`);
|
|
49
|
+
if (inputs.has(name))
|
|
50
|
+
fail(graph, `"${name}" is both a request input and a step.`);
|
|
51
|
+
if (!Number.isInteger(def.version))
|
|
52
|
+
fail(graph, `step "${name}" needs an integer version.`);
|
|
53
|
+
for (const need of def.needs ?? []) {
|
|
54
|
+
if (!inputs.has(need) && config.steps[need] === undefined) {
|
|
55
|
+
fail(graph, `step "${name}" needs "${need}", which is neither a step nor a declared input (steps: ${names.join(', ')}; inputs: ${[...inputs].join(', ') || '(none)'}).`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (def.person === true) {
|
|
59
|
+
if (def.forEach !== undefined)
|
|
60
|
+
fail(graph, `step "${name}" is a person step and cannot have forEach.`);
|
|
61
|
+
if (def.run !== undefined)
|
|
62
|
+
fail(graph, `step "${name}" is a person step and cannot have run: its value comes from decide() or autoAccept.`);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
if (def.run === undefined)
|
|
66
|
+
fail(graph, `step "${name}" has no run.`);
|
|
67
|
+
if (def.autoAccept !== undefined)
|
|
68
|
+
fail(graph, `step "${name}" has autoAccept but is not a person step.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Topological order; a back edge is a cycle.
|
|
72
|
+
const order = [];
|
|
73
|
+
const state = new Map();
|
|
74
|
+
const visit = (name, path) => {
|
|
75
|
+
const seen = state.get(name);
|
|
76
|
+
if (seen === 'done')
|
|
77
|
+
return;
|
|
78
|
+
if (seen === 'visiting')
|
|
79
|
+
fail(graph, `steps form a cycle: ${[...path.slice(path.indexOf(name)), name].join(' -> ')}.`);
|
|
80
|
+
state.set(name, 'visiting');
|
|
81
|
+
for (const need of config.steps[name].needs ?? []) {
|
|
82
|
+
if (config.steps[need] !== undefined)
|
|
83
|
+
visit(need, [...path, name]);
|
|
84
|
+
}
|
|
85
|
+
state.set(name, 'done');
|
|
86
|
+
order.push(name);
|
|
87
|
+
};
|
|
88
|
+
for (const name of names)
|
|
89
|
+
visit(name, []);
|
|
90
|
+
return { order, inputs };
|
|
91
|
+
}
|
|
92
|
+
export function defineGraph(config) {
|
|
93
|
+
const { order, inputs: inputNames } = stepDefinitionChecks(config);
|
|
94
|
+
registerStepTables();
|
|
95
|
+
const graph = config.name;
|
|
96
|
+
const jobType = `pipework.step.${graph}`;
|
|
97
|
+
const steps = config.steps;
|
|
98
|
+
const queue = config.queue;
|
|
99
|
+
const isFanOut = (name) => steps[name].forEach !== undefined;
|
|
100
|
+
const running = new Map();
|
|
101
|
+
/** Every step the targets need, transitively, in dependency order. */
|
|
102
|
+
function closureOf(targets) {
|
|
103
|
+
const wanted = new Set();
|
|
104
|
+
const add = (name) => {
|
|
105
|
+
if (wanted.has(name))
|
|
106
|
+
return;
|
|
107
|
+
wanted.add(name);
|
|
108
|
+
for (const need of steps[name].needs ?? [])
|
|
109
|
+
if (steps[need] !== undefined)
|
|
110
|
+
add(need);
|
|
111
|
+
};
|
|
112
|
+
for (const t of targets)
|
|
113
|
+
add(t);
|
|
114
|
+
return order.filter(n => wanted.has(n));
|
|
115
|
+
}
|
|
116
|
+
/** The steps that depend on `step`, directly or not. */
|
|
117
|
+
function dependentsOf(step) {
|
|
118
|
+
const out = new Set();
|
|
119
|
+
const grow = (name) => {
|
|
120
|
+
for (const other of order) {
|
|
121
|
+
if (!out.has(other) && (steps[other].needs ?? []).includes(name)) {
|
|
122
|
+
out.add(other);
|
|
123
|
+
grow(other);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
grow(step);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
function checkRequest(opts, what) {
|
|
131
|
+
if (typeof opts.item !== 'string' || opts.item === '')
|
|
132
|
+
throw new Error(`[pipework] ${graph}.${what}: item must be a non-empty string.\n`);
|
|
133
|
+
if (!Array.isArray(opts.targets) || opts.targets.length === 0)
|
|
134
|
+
throw new Error(`[pipework] ${graph}.${what}: targets must name at least one step.\n`);
|
|
135
|
+
for (const t of opts.targets) {
|
|
136
|
+
if (steps[t] === undefined) {
|
|
137
|
+
throw new Error(`[pipework] ${graph}.${what}: unknown target step "${t}" (steps: ${order.join(', ')}).\n`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const name of Object.keys(opts.inputs ?? {})) {
|
|
141
|
+
if (!inputNames.has(name)) {
|
|
142
|
+
throw new Error(`[pipework] ${graph}.${what}: unknown input "${name}" (inputs: ${[...inputNames].join(', ') || '(none)'}).\n`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const closure = closureOf(opts.targets);
|
|
146
|
+
for (const name of closure) {
|
|
147
|
+
for (const need of steps[name].needs ?? []) {
|
|
148
|
+
if (inputNames.has(need) && (opts.inputs ?? {})[need] === undefined) {
|
|
149
|
+
throw new Error(`[pipework] ${graph}.${what}: step "${name}" needs input "${need}", which the request does not give.\n`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return closure;
|
|
154
|
+
}
|
|
155
|
+
function tenantOf(explicit, what) {
|
|
156
|
+
const tenant = explicit ?? getContext()?.tenant ?? null;
|
|
157
|
+
if (tenant === null || tenant === '') {
|
|
158
|
+
throw new Error(`[pipework] ${graph}.${what}: no tenant. Pass { tenant } or call inside a tenant Flow; results are recorded per tenant.\n`);
|
|
159
|
+
}
|
|
160
|
+
return tenant;
|
|
161
|
+
}
|
|
162
|
+
let attached;
|
|
163
|
+
/** The session variable the tenant policies read: the graph's own, else the active or attached Manifold's, else the default. */
|
|
164
|
+
function sessionVarOf(instance) {
|
|
165
|
+
if (config.sessionVar !== undefined)
|
|
166
|
+
return config.sessionVar;
|
|
167
|
+
const m = instance ?? getContext()?.instance ?? attached;
|
|
168
|
+
if (m === undefined)
|
|
169
|
+
return 'pipework.tenant_id';
|
|
170
|
+
return m.config.database(m.config.defaultDatabaseName()).rls?.sessionVar ?? 'pipework.tenant_id';
|
|
171
|
+
}
|
|
172
|
+
/** Sets the tenant session variable for the rest of the transaction `tx` is in, and puts back what was there. */
|
|
173
|
+
async function asTenant(tx, tenant, fn, instance) {
|
|
174
|
+
if (tenant === null)
|
|
175
|
+
return fn();
|
|
176
|
+
const sessionVar = sessionVarOf(instance);
|
|
177
|
+
const before = await tx.execute(sql `SELECT current_setting(${sessionVar}, true) AS v`);
|
|
178
|
+
await tx.execute(sql `SELECT set_config(${sessionVar}, ${tenant}, true)`);
|
|
179
|
+
const result = await fn();
|
|
180
|
+
await tx.execute(sql `SELECT set_config(${sessionVar}, ${before[0]?.['v'] ?? ''}, true)`);
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
async function inTenantTx(db, tenant, fn) {
|
|
184
|
+
return db.transaction(async (tx) => asTenant(tx, tenant, () => fn(tx)));
|
|
185
|
+
}
|
|
186
|
+
/** The tenant a call acts for: the one given, else the Flow's, else none (an owner connection needs none). */
|
|
187
|
+
const scopeOf = (explicit) => explicit ?? getContext()?.tenant ?? null;
|
|
188
|
+
/** Cancels the jobs that are still pending in one statement, skipping any row another transaction holds: never waits on a job while the run lock is held. */
|
|
189
|
+
async function cancelPending(tx, jobIds) {
|
|
190
|
+
if (jobIds.length === 0)
|
|
191
|
+
return;
|
|
192
|
+
const table = quoted(queueTable());
|
|
193
|
+
await tx.execute(sql `
|
|
194
|
+
UPDATE ${sql.raw(table)} SET status = 'cancelled', completed_at = now(), claim_token = NULL
|
|
195
|
+
WHERE id IN (SELECT id FROM ${sql.raw(table)}
|
|
196
|
+
WHERE id IN (${sql.join(jobIds.map(id => sql `${id}::uuid`), sql `, `)}) AND status = 'pending'
|
|
197
|
+
FOR UPDATE SKIP LOCKED)`);
|
|
198
|
+
}
|
|
199
|
+
async function lockRun(tx, runId) {
|
|
200
|
+
const rows = await tx.execute(sql `
|
|
201
|
+
SELECT id::text, tenant_id::text AS tenant, item, status, priority, targets, inputs,
|
|
202
|
+
trace_id::text AS trace_id, trace_root_id::text AS trace_root_id, trace_step_id::text AS trace_step_id
|
|
203
|
+
FROM ${sql.raw(RUN)} WHERE id = ${runId}::uuid AND graph = ${graph} FOR UPDATE`);
|
|
204
|
+
const row = rows[0];
|
|
205
|
+
if (row === undefined)
|
|
206
|
+
return null;
|
|
207
|
+
const traceId = row['trace_id'];
|
|
208
|
+
const rootId = row['trace_root_id'];
|
|
209
|
+
return {
|
|
210
|
+
id: row['id'],
|
|
211
|
+
tenant: row['tenant'],
|
|
212
|
+
item: row['item'],
|
|
213
|
+
status: row['status'],
|
|
214
|
+
priority: row['priority'],
|
|
215
|
+
targets: row['targets'],
|
|
216
|
+
inputs: row['inputs'],
|
|
217
|
+
parent: traceId !== null && rootId !== null ? { id: traceId, rootId, stepId: row['trace_step_id'] } : null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
async function loadTasks(tx, runId) {
|
|
221
|
+
const rows = await tx.execute(sql `
|
|
222
|
+
SELECT id::text, step, instance, status, job_id::text AS job_id, input_key, result_id::text AS result_id, element
|
|
223
|
+
FROM ${sql.raw(TASK)} WHERE run_id = ${runId}::uuid ORDER BY step, instance`);
|
|
224
|
+
return rows.map(r => ({
|
|
225
|
+
id: r['id'],
|
|
226
|
+
step: r['step'],
|
|
227
|
+
instance: r['instance'],
|
|
228
|
+
status: r['status'],
|
|
229
|
+
jobId: r['job_id'],
|
|
230
|
+
inputKey: r['input_key'],
|
|
231
|
+
resultId: r['result_id'],
|
|
232
|
+
element: r['element'],
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
async function loadResults(tx, ids) {
|
|
236
|
+
const out = new Map();
|
|
237
|
+
if (ids.length === 0)
|
|
238
|
+
return out;
|
|
239
|
+
const rows = await tx.execute(sql `
|
|
240
|
+
SELECT id::text, value, value_hash FROM ${sql.raw(RESULT)}
|
|
241
|
+
WHERE id IN (${sql.join(ids.map(id => sql `${id}::uuid`), sql `, `)})`);
|
|
242
|
+
for (const r of rows)
|
|
243
|
+
out.set(r['id'], { value: r['value'], hash: r['value_hash'] });
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
async function findRecorded(tx, tenant, item, step, key, instance) {
|
|
247
|
+
const rows = await tx.execute(sql `
|
|
248
|
+
SELECT id::text, value, value_hash FROM ${sql.raw(RESULT)}
|
|
249
|
+
WHERE tenant_id = ${tenant}::uuid AND graph = ${graph} AND item = ${item} AND step = ${step}
|
|
250
|
+
AND version = ${steps[step].version} AND input_key = ${key}
|
|
251
|
+
ORDER BY (instance = ${instance}) DESC, created_at ASC LIMIT 1`);
|
|
252
|
+
const row = rows[0];
|
|
253
|
+
return row === undefined ? null : { id: row['id'], value: row['value'], hash: row['value_hash'] };
|
|
254
|
+
}
|
|
255
|
+
/** Records a result; when one already holds the key (a concurrent run got there) that one is returned. */
|
|
256
|
+
async function recordResult(tx, run, step, key, instance, value, replace) {
|
|
257
|
+
const json = JSON.stringify(value);
|
|
258
|
+
const hash = valueHash(value);
|
|
259
|
+
const conflict = replace
|
|
260
|
+
? sql `ON CONFLICT (tenant_id, graph, item, step, version, input_key, instance)
|
|
261
|
+
DO UPDATE SET value = EXCLUDED.value, value_hash = EXCLUDED.value_hash, created_at = now()`
|
|
262
|
+
: sql `ON CONFLICT (tenant_id, graph, item, step, version, input_key, instance) DO NOTHING`;
|
|
263
|
+
const inserted = await tx.execute(sql `
|
|
264
|
+
INSERT INTO ${sql.raw(RESULT)} (tenant_id, graph, item, step, version, input_key, instance, value, value_hash)
|
|
265
|
+
VALUES (${run.tenant}::uuid, ${graph}, ${run.item}, ${step}, ${steps[step].version}, ${key}, ${instance}, ${json}::jsonb, ${hash})
|
|
266
|
+
${conflict} RETURNING id::text`);
|
|
267
|
+
if (inserted[0] !== undefined)
|
|
268
|
+
return { id: inserted[0]['id'], hash, value };
|
|
269
|
+
const rows = await tx.execute(sql `
|
|
270
|
+
SELECT id::text, value, value_hash FROM ${sql.raw(RESULT)}
|
|
271
|
+
WHERE tenant_id = ${run.tenant}::uuid AND graph = ${graph} AND item = ${run.item} AND step = ${step}
|
|
272
|
+
AND version = ${steps[step].version} AND input_key = ${key} AND instance = ${instance}`);
|
|
273
|
+
const row = rows[0];
|
|
274
|
+
return { id: row['id'], hash: row['value_hash'], value: row['value'] };
|
|
275
|
+
}
|
|
276
|
+
function stageReused(step, instance, resultId) {
|
|
277
|
+
const buffer = getContext()?.trace?.buffer;
|
|
278
|
+
if (buffer === undefined)
|
|
279
|
+
return;
|
|
280
|
+
const now = new Date();
|
|
281
|
+
buffer.stageExternal({
|
|
282
|
+
id: randomUUID(),
|
|
283
|
+
parentStepId: null,
|
|
284
|
+
flowStepRef: 'external:pipework.step.reused',
|
|
285
|
+
status: 'ok',
|
|
286
|
+
startedAt: now,
|
|
287
|
+
completedAt: now,
|
|
288
|
+
durationMicros: 0n,
|
|
289
|
+
capturedInputs: [serializeForTrace({ step, instance: instance === SINGLE ? null : instance, resultId })],
|
|
290
|
+
error: null,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/** The state of one run during an `advance`: its tasks and the results they hold. */
|
|
294
|
+
class RunState {
|
|
295
|
+
run;
|
|
296
|
+
tasks;
|
|
297
|
+
results;
|
|
298
|
+
inputHashes;
|
|
299
|
+
constructor(run, tasks, results, inputHashes) {
|
|
300
|
+
this.run = run;
|
|
301
|
+
this.tasks = tasks;
|
|
302
|
+
this.results = results;
|
|
303
|
+
this.inputHashes = inputHashes;
|
|
304
|
+
}
|
|
305
|
+
of(step) { return this.tasks.filter(t => t.step === step); }
|
|
306
|
+
single(step) { return this.tasks.find(t => t.step === step && t.instance === SINGLE); }
|
|
307
|
+
instances(step) {
|
|
308
|
+
return this.tasks.filter(t => t.step === step && t.instance >= 0).sort((a, b) => a.instance - b.instance);
|
|
309
|
+
}
|
|
310
|
+
good(t) { return t.status === 'done' || t.status === 'reused'; }
|
|
311
|
+
complete(step) {
|
|
312
|
+
const single = this.single(step);
|
|
313
|
+
if (single === undefined)
|
|
314
|
+
return false;
|
|
315
|
+
if (!isFanOut(step))
|
|
316
|
+
return this.good(single);
|
|
317
|
+
return single.status === 'done' && this.instances(step).every(t => this.good(t));
|
|
318
|
+
}
|
|
319
|
+
resultOf(t) {
|
|
320
|
+
const r = t.resultId === null ? undefined : this.results.get(t.resultId);
|
|
321
|
+
if (r === undefined)
|
|
322
|
+
throw new Error(`[pipework] ${graph}: task ${t.id} (${t.step}) has no recorded result.\n`);
|
|
323
|
+
return r;
|
|
324
|
+
}
|
|
325
|
+
/** What a step reads: the value and hash of each need, and the request inputs among them. */
|
|
326
|
+
gather(def) {
|
|
327
|
+
const values = {};
|
|
328
|
+
const hashes = {};
|
|
329
|
+
const inputs = {};
|
|
330
|
+
for (const need of def.needs ?? []) {
|
|
331
|
+
if (inputNames.has(need)) {
|
|
332
|
+
values[need] = this.run.inputs[need];
|
|
333
|
+
inputs[need] = this.run.inputs[need];
|
|
334
|
+
hashes[need] = this.inputHashes[need];
|
|
335
|
+
}
|
|
336
|
+
else if (isFanOut(need)) {
|
|
337
|
+
const rs = this.instances(need).map(t => this.resultOf(t));
|
|
338
|
+
values[need] = rs.map(r => r.value);
|
|
339
|
+
hashes[need] = rs.map(r => r.hash);
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
const r = this.resultOf(this.single(need));
|
|
343
|
+
values[need] = r.value;
|
|
344
|
+
hashes[need] = r.hash;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { values, hashes, inputs };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function inputHashesOf(inputs) {
|
|
351
|
+
return Object.fromEntries(Object.entries(inputs).map(([k, v]) => [k, inputHash(v)]));
|
|
352
|
+
}
|
|
353
|
+
function contentKey(step, element, fanOut, hashes) {
|
|
354
|
+
return sha256Hex(canonicalJson({
|
|
355
|
+
graph, step, version: steps[step].version, element: fanOut ? valueHash(element) : null, needs: hashes,
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
async function loadState(tx, run) {
|
|
359
|
+
const tasks = await loadTasks(tx, run.id);
|
|
360
|
+
const results = await loadResults(tx, tasks.flatMap(t => t.resultId === null ? [] : [t.resultId]));
|
|
361
|
+
return new RunState(run, tasks, results, inputHashesOf(run.inputs));
|
|
362
|
+
}
|
|
363
|
+
function stepContext(state, def, task, fanOut, signal) {
|
|
364
|
+
const g = state.gather(def);
|
|
365
|
+
return {
|
|
366
|
+
needs: g.values,
|
|
367
|
+
element: fanOut ? task.element : undefined,
|
|
368
|
+
index: fanOut ? task.instance : null,
|
|
369
|
+
item: state.run.item,
|
|
370
|
+
inputs: g.inputs,
|
|
371
|
+
runId: state.run.id,
|
|
372
|
+
signal,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
async function setTask(tx, t, patch) {
|
|
376
|
+
await tx.execute(sql `
|
|
377
|
+
UPDATE ${sql.raw(TASK)} SET status = ${patch.status},
|
|
378
|
+
job_id = COALESCE(${patch.jobId ?? null}::uuid, job_id),
|
|
379
|
+
input_key = COALESCE(${patch.inputKey ?? null}, input_key),
|
|
380
|
+
result_id = COALESCE(${patch.resultId ?? null}::uuid, result_id),
|
|
381
|
+
error = COALESCE(${patch.error === undefined ? null : JSON.stringify(patch.error)}::jsonb, error)
|
|
382
|
+
WHERE id = ${t.id}::uuid`);
|
|
383
|
+
t.status = patch.status;
|
|
384
|
+
if (patch.jobId !== undefined)
|
|
385
|
+
t.jobId = patch.jobId;
|
|
386
|
+
if (patch.inputKey !== undefined)
|
|
387
|
+
t.inputKey = patch.inputKey;
|
|
388
|
+
if (patch.resultId !== undefined)
|
|
389
|
+
t.resultId = patch.resultId;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Moves a run as far as its recorded results allow, in one pass over the steps in
|
|
393
|
+
* dependency order: expands fan-out steps whose list is now known, reuses recorded
|
|
394
|
+
* results, queues the rest, and marks the run done when nothing is left. The run
|
|
395
|
+
* row is locked first, so concurrent finishes advance it one at a time.
|
|
396
|
+
*/
|
|
397
|
+
async function advance(tx, runId) {
|
|
398
|
+
const outcome = { reused: 0, queued: 0, completed: false };
|
|
399
|
+
const run = await lockRun(tx, runId);
|
|
400
|
+
if (run === null || run.status !== 'running')
|
|
401
|
+
return { ...outcome, run };
|
|
402
|
+
const state = await loadState(tx, run);
|
|
403
|
+
const closure = closureOf(run.targets);
|
|
404
|
+
const signal = new AbortController().signal;
|
|
405
|
+
const process = async (task, def, fanOut) => {
|
|
406
|
+
const g = state.gather(def);
|
|
407
|
+
const key = contentKey(task.step, task.element, fanOut, g.hashes);
|
|
408
|
+
const recorded = await findRecorded(tx, run.tenant, run.item, task.step, key, task.instance);
|
|
409
|
+
if (recorded !== null) {
|
|
410
|
+
state.results.set(recorded.id, { value: recorded.value, hash: recorded.hash });
|
|
411
|
+
await setTask(tx, task, { status: 'reused', inputKey: key, resultId: recorded.id });
|
|
412
|
+
stageReused(task.step, task.instance, recorded.id);
|
|
413
|
+
outcome.reused++;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (def.person === true) {
|
|
417
|
+
const accepted = def.autoAccept === undefined ? undefined : await def.autoAccept(stepContext(state, def, task, fanOut, signal));
|
|
418
|
+
if (accepted !== undefined) {
|
|
419
|
+
const rec = await recordResult(tx, run, task.step, key, task.instance, toJson(accepted), false);
|
|
420
|
+
state.results.set(rec.id, { value: rec.value, hash: rec.hash });
|
|
421
|
+
await setTask(tx, task, { status: 'done', inputKey: key, resultId: rec.id });
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
await setTask(tx, task, { status: 'waiting', inputKey: key });
|
|
425
|
+
}
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const jobId = await queue.enqueue(tx, {
|
|
429
|
+
type: jobType,
|
|
430
|
+
payload: { kind: 'step', runId: run.id, taskId: task.id },
|
|
431
|
+
priority: run.priority,
|
|
432
|
+
...(def.maxAttempts !== undefined ? { maxAttempts: def.maxAttempts } : {}),
|
|
433
|
+
tenant: run.tenant,
|
|
434
|
+
parent: run.parent,
|
|
435
|
+
});
|
|
436
|
+
await setTask(tx, task, { status: 'queued', inputKey: key, jobId });
|
|
437
|
+
outcome.queued++;
|
|
438
|
+
};
|
|
439
|
+
for (const name of closure) {
|
|
440
|
+
if (state.complete(name))
|
|
441
|
+
continue;
|
|
442
|
+
const def = steps[name];
|
|
443
|
+
const ready = (def.needs ?? []).every(n => steps[n] === undefined || state.complete(n));
|
|
444
|
+
if (!ready)
|
|
445
|
+
continue;
|
|
446
|
+
const expander = state.single(name);
|
|
447
|
+
if (expander === undefined)
|
|
448
|
+
continue;
|
|
449
|
+
if (!isFanOut(name)) {
|
|
450
|
+
if (expander.status === 'blocked')
|
|
451
|
+
await process(expander, def, false);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (expander.status === 'blocked') {
|
|
455
|
+
const g = state.gather(def);
|
|
456
|
+
const forEachCtx = { needs: g.values, item: run.item, inputs: g.inputs, runId: run.id };
|
|
457
|
+
const list = await def.forEach(forEachCtx);
|
|
458
|
+
if (!Array.isArray(list)) {
|
|
459
|
+
throw new Error(`[pipework] ${graph}: forEach of step "${name}" must return an array, got ${typeof list}.\n`);
|
|
460
|
+
}
|
|
461
|
+
const elements = list.map(toJson);
|
|
462
|
+
if (elements.length > 0) {
|
|
463
|
+
const rows = await tx.execute(sql `
|
|
464
|
+
INSERT INTO ${sql.raw(TASK)} (tenant_id, run_id, step, instance, element, status)
|
|
465
|
+
VALUES ${sql.join(elements.map((el, i) => sql `(${run.tenant}::uuid, ${run.id}::uuid, ${name}, ${i}, ${JSON.stringify(el)}::jsonb, 'blocked')`), sql `, `)}
|
|
466
|
+
RETURNING id::text, instance`);
|
|
467
|
+
for (const r of rows) {
|
|
468
|
+
const i = r['instance'];
|
|
469
|
+
state.tasks.push({
|
|
470
|
+
id: r['id'], step: name, instance: i, status: 'blocked', jobId: null, inputKey: null, resultId: null, element: elements[i],
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
await setTask(tx, expander, { status: 'done' });
|
|
475
|
+
}
|
|
476
|
+
for (const t of state.instances(name))
|
|
477
|
+
if (t.status === 'blocked')
|
|
478
|
+
await process(t, def, true);
|
|
479
|
+
}
|
|
480
|
+
if (closure.every(n => state.complete(n))) {
|
|
481
|
+
const done = await tx.execute(sql `
|
|
482
|
+
UPDATE ${sql.raw(RUN)} SET status = 'done', finished_at = now(), notified = ${config.onDone === undefined}
|
|
483
|
+
WHERE id = ${run.id}::uuid AND status = 'running' RETURNING id`);
|
|
484
|
+
if (done.length > 0) {
|
|
485
|
+
outcome.completed = true;
|
|
486
|
+
await tx.execute(sql `SELECT pg_notify('pipework_step_run', ${run.id})`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return { ...outcome, run };
|
|
490
|
+
}
|
|
491
|
+
/** Marks the run's done callback delivered and calls it; false when it already was. */
|
|
492
|
+
async function deliverDone(tx, runId) {
|
|
493
|
+
const rows = await tx.execute(sql `
|
|
494
|
+
UPDATE ${sql.raw(RUN)} SET notified = true
|
|
495
|
+
WHERE id = ${runId}::uuid AND status = 'done' AND notified = false
|
|
496
|
+
RETURNING item, tenant_id::text AS tenant`);
|
|
497
|
+
const row = rows[0];
|
|
498
|
+
if (row === undefined || config.onDone === undefined)
|
|
499
|
+
return;
|
|
500
|
+
await config.onDone({ runId, item: row['item'], tenant: row['tenant'] });
|
|
501
|
+
}
|
|
502
|
+
/** After an advance outside a step job: a finished run's callback is delivered by a job, which has a Flow to run it in. */
|
|
503
|
+
async function scheduleDone(tx, run, outcome) {
|
|
504
|
+
if (run === null || !outcome.completed || config.onDone === undefined)
|
|
505
|
+
return;
|
|
506
|
+
await queue.enqueue(tx, {
|
|
507
|
+
type: jobType, payload: { kind: 'finish', runId: run.id, taskId: run.id }, priority: run.priority, tenant: run.tenant, parent: run.parent,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
async function request(db, opts) {
|
|
511
|
+
const closure = checkRequest(opts, 'request');
|
|
512
|
+
const tenant = tenantOf(opts.tenant, 'request');
|
|
513
|
+
const priority = opts.priority ?? config.priority ?? 0;
|
|
514
|
+
const parent = parentTraceRef();
|
|
515
|
+
const inputs = toJson(opts.inputs ?? {});
|
|
516
|
+
return inTenantTx(db, tenant, async (tx) => {
|
|
517
|
+
const created = await tx.execute(sql `
|
|
518
|
+
INSERT INTO ${sql.raw(RUN)} (tenant_id, graph, item, targets, priority, inputs, trace_id, trace_root_id, trace_step_id)
|
|
519
|
+
VALUES (${tenant}::uuid, ${graph}, ${opts.item}, ${JSON.stringify(opts.targets)}::jsonb, ${priority}, ${JSON.stringify(inputs)}::jsonb,
|
|
520
|
+
${parent?.id ?? null}::uuid, ${parent?.rootId ?? null}::uuid, ${parent?.stepId ?? null}::uuid)
|
|
521
|
+
RETURNING id::text`);
|
|
522
|
+
const runId = created[0]['id'];
|
|
523
|
+
await tx.execute(sql `
|
|
524
|
+
INSERT INTO ${sql.raw(TASK)} (tenant_id, run_id, step, instance, status)
|
|
525
|
+
VALUES ${sql.join(closure.map(step => sql `(${tenant}::uuid, ${runId}::uuid, ${step}, ${SINGLE}, 'blocked')`), sql `, `)}`);
|
|
526
|
+
const outcome = await advance(tx, runId);
|
|
527
|
+
await scheduleDone(tx, outcome.run, outcome);
|
|
528
|
+
return { runId, reused: outcome.reused, queued: outcome.queued };
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
async function plan(db, opts) {
|
|
532
|
+
const closure = checkRequest(opts, 'plan');
|
|
533
|
+
const tenant = tenantOf(opts.tenant, 'plan');
|
|
534
|
+
const inputs = toJson(opts.inputs ?? {});
|
|
535
|
+
const hashes = inputHashesOf(inputs);
|
|
536
|
+
return inTenantTx(db, tenant, async (tx) => {
|
|
537
|
+
// What each step would hold if the run were made now: its values, when every instance is recorded.
|
|
538
|
+
const known = new Map();
|
|
539
|
+
const result = {};
|
|
540
|
+
for (const name of closure) {
|
|
541
|
+
const def = steps[name];
|
|
542
|
+
const fanOut = isFanOut(name);
|
|
543
|
+
const needs = def.needs ?? [];
|
|
544
|
+
if (!needs.every(n => steps[n] === undefined || known.has(n))) {
|
|
545
|
+
result[name] = { reusable: 0, toRun: fanOut ? null : 1 };
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
const needValues = {};
|
|
549
|
+
const needHashes = {};
|
|
550
|
+
const given = {};
|
|
551
|
+
for (const n of needs) {
|
|
552
|
+
if (inputNames.has(n)) {
|
|
553
|
+
needValues[n] = inputs[n];
|
|
554
|
+
given[n] = inputs[n];
|
|
555
|
+
needHashes[n] = hashes[n];
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
const k = known.get(n);
|
|
559
|
+
needValues[n] = isFanOut(n) ? k.values : k.values[0];
|
|
560
|
+
needHashes[n] = isFanOut(n) ? k.hashes : k.hashes[0];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const elements = fanOut
|
|
564
|
+
? (await def.forEach({ needs: needValues, item: opts.item, inputs: given, runId: '' })).map(toJson)
|
|
565
|
+
: [undefined];
|
|
566
|
+
let reusable = 0;
|
|
567
|
+
const values = [];
|
|
568
|
+
const valueHashes = [];
|
|
569
|
+
for (const [i, element] of elements.entries()) {
|
|
570
|
+
const key = contentKey(name, element, fanOut, needHashes);
|
|
571
|
+
const rec = await findRecorded(tx, tenant, opts.item, name, key, fanOut ? i : SINGLE);
|
|
572
|
+
if (rec !== null) {
|
|
573
|
+
reusable++;
|
|
574
|
+
values.push(rec.value);
|
|
575
|
+
valueHashes.push(rec.hash);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
result[name] = { reusable, toRun: elements.length - reusable };
|
|
579
|
+
if (reusable === elements.length)
|
|
580
|
+
known.set(name, { values, hashes: valueHashes });
|
|
581
|
+
}
|
|
582
|
+
return { steps: result };
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
async function status(db, runId, opts) {
|
|
586
|
+
return inTenantTx(db, scopeOf(opts?.tenant), tx => statusIn(tx, runId));
|
|
587
|
+
}
|
|
588
|
+
async function statusIn(db, runId) {
|
|
589
|
+
const runs = await db.execute(sql `SELECT status FROM ${sql.raw(RUN)} WHERE id = ${runId}::uuid AND graph = ${graph}`);
|
|
590
|
+
if (runs[0] === undefined)
|
|
591
|
+
throw new Error(`[pipework] ${graph}.status: no run "${runId}".\n`);
|
|
592
|
+
const tasks = await db.execute(sql `
|
|
593
|
+
SELECT t.step, t.instance, t.status, j.status AS job_status
|
|
594
|
+
FROM ${sql.raw(TASK)} t LEFT JOIN ${sql.raw(quoted(queueTable()))} j ON j.id = t.job_id
|
|
595
|
+
WHERE t.run_id = ${runId}::uuid`);
|
|
596
|
+
const zero = () => ({ total: 0, done: 0, reused: 0, running: 0, waiting: 0, failed: 0, cancelled: 0 });
|
|
597
|
+
const counts = new Map();
|
|
598
|
+
for (const t of tasks) {
|
|
599
|
+
const step = t['step'];
|
|
600
|
+
const fanOut = steps[step] !== undefined && isFanOut(step);
|
|
601
|
+
const c = counts.get(step) ?? zero();
|
|
602
|
+
counts.set(step, c);
|
|
603
|
+
if (fanOut && t['instance'] < 0)
|
|
604
|
+
continue;
|
|
605
|
+
c.total++;
|
|
606
|
+
const s = t['status'];
|
|
607
|
+
const job = t['job_status'];
|
|
608
|
+
if (s === 'done')
|
|
609
|
+
c.done++;
|
|
610
|
+
else if (s === 'reused')
|
|
611
|
+
c.reused++;
|
|
612
|
+
else if (s === 'failed')
|
|
613
|
+
c.failed++;
|
|
614
|
+
else if (s === 'cancelled')
|
|
615
|
+
c.cancelled++;
|
|
616
|
+
else if (s === 'queued' && (job === 'claimed' || job === 'running'))
|
|
617
|
+
c.running++;
|
|
618
|
+
else
|
|
619
|
+
c.waiting++;
|
|
620
|
+
}
|
|
621
|
+
const out = {};
|
|
622
|
+
for (const name of order) {
|
|
623
|
+
const c = counts.get(name);
|
|
624
|
+
if (c !== undefined)
|
|
625
|
+
out[name] = c;
|
|
626
|
+
}
|
|
627
|
+
return { status: runs[0]['status'], steps: out };
|
|
628
|
+
}
|
|
629
|
+
/** The queue's table name, read from its definition. */
|
|
630
|
+
function queueTable() {
|
|
631
|
+
return queue.definition.$name;
|
|
632
|
+
}
|
|
633
|
+
const quoted = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
634
|
+
async function cancelRun(tx, runId) {
|
|
635
|
+
const run = await lockRun(tx, runId);
|
|
636
|
+
if (run === null || run.status !== 'running')
|
|
637
|
+
return;
|
|
638
|
+
await tx.execute(sql `UPDATE ${sql.raw(RUN)} SET status = 'cancelled', finished_at = now() WHERE id = ${runId}::uuid`);
|
|
639
|
+
const live = await tx.execute(sql `
|
|
640
|
+
UPDATE ${sql.raw(TASK)} SET status = 'cancelled'
|
|
641
|
+
WHERE run_id = ${runId}::uuid AND status IN ('blocked', 'queued', 'waiting') RETURNING job_id::text`);
|
|
642
|
+
await cancelPending(tx, live.flatMap(r => r['job_id'] === null ? [] : [r['job_id']]));
|
|
643
|
+
await tx.execute(sql `SELECT pg_notify('pipework_step_run', ${runId})`);
|
|
644
|
+
for (const c of running.get(runId) ?? [])
|
|
645
|
+
c.abort(new Error(`[pipework] ${graph} run ${runId} was cancelled.`));
|
|
646
|
+
}
|
|
647
|
+
async function cancel(db, runId, opts) {
|
|
648
|
+
await inTenantTx(db, scopeOf(opts?.tenant), async (tx) => { await cancelRun(tx, runId); });
|
|
649
|
+
}
|
|
650
|
+
async function decide(db, opts) {
|
|
651
|
+
const def = steps[opts.step];
|
|
652
|
+
if (def === undefined || def.person !== true) {
|
|
653
|
+
throw new Error(`[pipework] ${graph}.decide: "${opts.step}" is not a person step.\n`);
|
|
654
|
+
}
|
|
655
|
+
const instance = opts.index ?? SINGLE;
|
|
656
|
+
const value = toJson(opts.value);
|
|
657
|
+
await inTenantTx(db, scopeOf(opts.tenant), async (tx) => {
|
|
658
|
+
const run = await lockRun(tx, opts.runId);
|
|
659
|
+
if (run === null)
|
|
660
|
+
throw new Error(`[pipework] ${graph}.decide: no run "${opts.runId}".\n`);
|
|
661
|
+
const rows = await tx.execute(sql `
|
|
662
|
+
SELECT id::text, status, input_key, result_id::text AS result_id FROM ${sql.raw(TASK)}
|
|
663
|
+
WHERE run_id = ${run.id}::uuid AND step = ${opts.step} AND instance = ${instance} FOR UPDATE`);
|
|
664
|
+
const task = rows[0];
|
|
665
|
+
if (task === undefined) {
|
|
666
|
+
throw new Error(`[pipework] ${graph}.decide: run ${opts.runId} has no task for step "${opts.step}"${opts.index === undefined ? '' : ` instance ${String(opts.index)}`}.\n`);
|
|
667
|
+
}
|
|
668
|
+
const taskStatus = task['status'];
|
|
669
|
+
if (taskStatus === 'waiting') {
|
|
670
|
+
const rec = await recordResult(tx, run, opts.step, task['input_key'], instance, value, true);
|
|
671
|
+
await tx.execute(sql `UPDATE ${sql.raw(TASK)} SET status = 'done', result_id = ${rec.id}::uuid WHERE id = ${task['id']}::uuid`);
|
|
672
|
+
const outcome = await advance(tx, run.id);
|
|
673
|
+
await scheduleDone(tx, outcome.run, outcome);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (taskStatus === 'done' || taskStatus === 'reused') {
|
|
677
|
+
await tx.execute(sql `
|
|
678
|
+
UPDATE ${sql.raw(RESULT)} SET value = ${JSON.stringify(value)}::jsonb, value_hash = ${valueHash(value)}, created_at = now()
|
|
679
|
+
WHERE id = ${task['result_id']}::uuid`);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
throw new Error(`[pipework] ${graph}.decide: step "${opts.step}" of run ${opts.runId} is ${taskStatus}, not waiting for a decision.\n`);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
async function wait(db, runId, opts) {
|
|
686
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
687
|
+
for (;;) {
|
|
688
|
+
const rows = await inTenantTx(db, scopeOf(opts.tenant), tx => tx.execute(sql `SELECT status FROM ${sql.raw(RUN)} WHERE id = ${runId}::uuid AND graph = ${graph}`));
|
|
689
|
+
if (rows[0] === undefined)
|
|
690
|
+
throw new Error(`[pipework] ${graph}.wait: no run "${runId}".\n`);
|
|
691
|
+
const s = rows[0]['status'];
|
|
692
|
+
if (s !== 'running')
|
|
693
|
+
return s;
|
|
694
|
+
if (Date.now() >= deadline)
|
|
695
|
+
return 'timeout';
|
|
696
|
+
await new Promise(r => setTimeout(r, Math.min(50, Math.max(1, deadline - Date.now()))));
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
async function sweep(db, opts) {
|
|
700
|
+
if (config.resultRetentionDays === undefined)
|
|
701
|
+
return 0;
|
|
702
|
+
const cutoff = new Date(Date.now() - config.resultRetentionDays * MS_PER_DAY).toISOString();
|
|
703
|
+
const tenant = scopeOf(opts?.tenant);
|
|
704
|
+
const only = tenant === null ? sql `` : sql ` AND tenant_id = ${tenant}::uuid`;
|
|
705
|
+
return inTenantTx(db, tenant, async (tx) => {
|
|
706
|
+
const old = await tx.execute(sql `
|
|
707
|
+
SELECT id::text FROM ${sql.raw(RUN)}
|
|
708
|
+
WHERE graph = ${graph} AND status <> 'running' AND finished_at < ${cutoff}::timestamptz${only}`);
|
|
709
|
+
if (old.length > 0) {
|
|
710
|
+
const ids = sql.join(old.map(r => sql `${r['id']}::uuid`), sql `, `);
|
|
711
|
+
await tx.execute(sql `DELETE FROM ${sql.raw(TASK)} WHERE run_id IN (${ids})`);
|
|
712
|
+
await tx.execute(sql `DELETE FROM ${sql.raw(RUN)} WHERE id IN (${ids})`);
|
|
713
|
+
}
|
|
714
|
+
const rows = await tx.execute(sql `
|
|
715
|
+
DELETE FROM ${sql.raw(RESULT)} WHERE graph = ${graph} AND created_at < ${cutoff}::timestamptz${only} RETURNING id`);
|
|
716
|
+
return rows.length;
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
async function removeTenant(db, tenantId) {
|
|
720
|
+
if (typeof tenantId !== 'string' || tenantId.trim() === '') {
|
|
721
|
+
throw new Error(`[pipework] ${graph}.removeTenant: tenantId must be a non-empty string.\n`);
|
|
722
|
+
}
|
|
723
|
+
await inTenantTx(db, tenantId, async (tx) => {
|
|
724
|
+
await tx.execute(sql `DELETE FROM ${sql.raw(TASK)} WHERE tenant_id = ${tenantId}::uuid`);
|
|
725
|
+
await tx.execute(sql `DELETE FROM ${sql.raw(RUN)} WHERE tenant_id = ${tenantId}::uuid`);
|
|
726
|
+
await tx.execute(sql `DELETE FROM ${sql.raw(RESULT)} WHERE tenant_id = ${tenantId}::uuid`);
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
/** The step job: runs one task's step, records its result and advances the run. */
|
|
730
|
+
async function handleJob(tx, payload) {
|
|
731
|
+
if (payload.kind === 'finish') {
|
|
732
|
+
await deliverDone(tx, payload.runId);
|
|
733
|
+
return { finished: payload.runId };
|
|
734
|
+
}
|
|
735
|
+
const live = await tx.execute(sql `
|
|
736
|
+
SELECT 1 FROM ${sql.raw(TASK)} t JOIN ${sql.raw(RUN)} r ON r.id = t.run_id
|
|
737
|
+
WHERE t.id = ${payload.taskId}::uuid AND t.run_id = ${payload.runId}::uuid AND t.status = 'queued' AND r.status = 'running'`);
|
|
738
|
+
if (live.length === 0)
|
|
739
|
+
return { skipped: payload.taskId };
|
|
740
|
+
// Read the run's state without the lock: the step may take a long time, and other steps of the run must proceed meanwhile.
|
|
741
|
+
const runRows = await tx.execute(sql `
|
|
742
|
+
SELECT id::text, tenant_id::text AS tenant, item, status, priority, targets, inputs FROM ${sql.raw(RUN)} WHERE id = ${payload.runId}::uuid`);
|
|
743
|
+
const r = runRows[0];
|
|
744
|
+
const run = {
|
|
745
|
+
id: r['id'], tenant: r['tenant'], item: r['item'], status: r['status'],
|
|
746
|
+
priority: r['priority'], targets: r['targets'], inputs: r['inputs'], parent: null,
|
|
747
|
+
};
|
|
748
|
+
const state = await loadState(tx, run);
|
|
749
|
+
const task = state.tasks.find(t => t.id === payload.taskId);
|
|
750
|
+
const def = steps[task.step];
|
|
751
|
+
const fanOut = isFanOut(task.step);
|
|
752
|
+
const controller = new AbortController();
|
|
753
|
+
let controllers = running.get(run.id);
|
|
754
|
+
if (controllers === undefined) {
|
|
755
|
+
controllers = new Set();
|
|
756
|
+
running.set(run.id, controllers);
|
|
757
|
+
}
|
|
758
|
+
controllers.add(controller);
|
|
759
|
+
let value;
|
|
760
|
+
try {
|
|
761
|
+
value = toJson(await def.run(stepContext(state, def, task, fanOut, controller.signal)));
|
|
762
|
+
}
|
|
763
|
+
finally {
|
|
764
|
+
controllers.delete(controller);
|
|
765
|
+
if (controllers.size === 0)
|
|
766
|
+
running.delete(run.id);
|
|
767
|
+
}
|
|
768
|
+
const locked = await lockRun(tx, run.id);
|
|
769
|
+
if (locked === null)
|
|
770
|
+
return { skipped: payload.taskId };
|
|
771
|
+
const current = await tx.execute(sql `SELECT status FROM ${sql.raw(TASK)} WHERE id = ${task.id}::uuid FOR UPDATE`);
|
|
772
|
+
const rec = await recordResult(tx, locked, task.step, task.inputKey, task.instance, value, false);
|
|
773
|
+
if (current[0]?.['status'] !== 'queued' || locked.status !== 'running') {
|
|
774
|
+
// Cancelled or failed while it ran: the result is kept for later requests, the run does not move.
|
|
775
|
+
return { recorded: rec.id };
|
|
776
|
+
}
|
|
777
|
+
await tx.execute(sql `UPDATE ${sql.raw(TASK)} SET status = 'done', result_id = ${rec.id}::uuid WHERE id = ${task.id}::uuid`);
|
|
778
|
+
const outcome = await advance(tx, run.id);
|
|
779
|
+
if (outcome.completed)
|
|
780
|
+
await deliverDone(tx, run.id);
|
|
781
|
+
return { step: task.step, resultId: rec.id };
|
|
782
|
+
}
|
|
783
|
+
function handler(database) {
|
|
784
|
+
const builder = database === undefined ? fitting.use() : fitting.use(database);
|
|
785
|
+
return builder.job(jobType)
|
|
786
|
+
.input(schema.check.object({ kind: schema.check.string(), runId: schema.check.string(), taskId: schema.check.string() }))
|
|
787
|
+
.fit(async ({ input }) => {
|
|
788
|
+
const tx = pipeRaw(database);
|
|
789
|
+
return asTenant(tx, getContext()?.tenant ?? null, () => handleJob(tx, input));
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
/** A step job of this graph dead-lettered: fail the task, what depends on it and the run, cancel the rest, tell the app once. */
|
|
793
|
+
async function onDeadLetter(tx, job, host) {
|
|
794
|
+
if (job.type !== jobType)
|
|
795
|
+
return;
|
|
796
|
+
const payload = job.payload;
|
|
797
|
+
if (payload.kind !== 'step' || payload.runId === undefined || payload.taskId === undefined)
|
|
798
|
+
return;
|
|
799
|
+
attached = host.instance;
|
|
800
|
+
await asTenant(tx, job.tenant, () => failRun(tx, job, payload.runId, payload.taskId, host), host.instance);
|
|
801
|
+
}
|
|
802
|
+
async function failRun(tx, job, runId, taskId, host) {
|
|
803
|
+
const run = await lockRun(tx, runId);
|
|
804
|
+
if (run === null || run.status !== 'running')
|
|
805
|
+
return;
|
|
806
|
+
const tasks = await loadTasks(tx, run.id);
|
|
807
|
+
const dead = tasks.find(t => t.id === taskId);
|
|
808
|
+
if (dead === undefined || dead.status !== 'queued')
|
|
809
|
+
return;
|
|
810
|
+
const dependents = dependentsOf(dead.step);
|
|
811
|
+
await setTask(tx, dead, { status: 'failed', error: job.error });
|
|
812
|
+
const cancelJobs = [];
|
|
813
|
+
for (const t of tasks) {
|
|
814
|
+
if (t === dead || (t.status !== 'blocked' && t.status !== 'queued' && t.status !== 'waiting'))
|
|
815
|
+
continue;
|
|
816
|
+
if (t.jobId !== null)
|
|
817
|
+
cancelJobs.push(t.jobId);
|
|
818
|
+
await setTask(tx, t, { status: dependents.has(t.step) ? 'failed' : 'cancelled' });
|
|
819
|
+
}
|
|
820
|
+
await cancelPending(tx, cancelJobs);
|
|
821
|
+
await tx.execute(sql `
|
|
822
|
+
UPDATE ${sql.raw(RUN)} SET status = 'failed', finished_at = now(), notified = true,
|
|
823
|
+
error = ${JSON.stringify({ step: dead.step, error: job.error })}::jsonb
|
|
824
|
+
WHERE id = ${run.id}::uuid`);
|
|
825
|
+
await tx.execute(sql `SELECT pg_notify('pipework_step_run', ${run.id})`);
|
|
826
|
+
if (config.onFailed === undefined)
|
|
827
|
+
return;
|
|
828
|
+
const failure = { runId: run.id, item: run.item, tenant: run.tenant, step: dead.step, error: job.error };
|
|
829
|
+
try {
|
|
830
|
+
await tx.transaction(async (savepoint) => {
|
|
831
|
+
const dbName = host.database ?? host.instance.config.defaultDatabaseName();
|
|
832
|
+
const base = createJobContext({ instance: host.instance, jobId: job.id, jobType: job.type, tenant: run.tenant });
|
|
833
|
+
const ctx = { ...base, transactions: new Map([[dbName, bindTransaction(base, dbName, savepoint)]]) };
|
|
834
|
+
await propagateLocals(savepoint, ctx, null);
|
|
835
|
+
await runInContext(ctx, async () => { await config.onFailed(failure); });
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
getBaseLogger().error({ err, runId: run.id, graph }, 'Step graph onFailed callback failed; the run is already marked failed');
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return {
|
|
843
|
+
name: graph,
|
|
844
|
+
jobType,
|
|
845
|
+
queue,
|
|
846
|
+
request, plan, status, cancel, decide, wait, sweep, removeTenant,
|
|
847
|
+
worker: { handler, onDeadLetter },
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
//# sourceMappingURL=graph.js.map
|