@adonis-agora/durable 1.0.0-rc.15 → 1.0.0-rc.17
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 +12 -0
- package/dist/assets/spa/assets/{index-DlaQG-kn.js → index-CnR8n2nD.js} +3 -3
- package/dist/assets/spa/assets/index-CnR8n2nD.js.map +1 -0
- package/dist/assets/spa/index.html +1 -1
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +2 -0
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/dashboard/dev_tools.d.ts.map +1 -1
- package/dist/src/dashboard/dev_tools.js +48 -30
- package/dist/src/dashboard/dev_tools.js.map +1 -1
- package/dist/src/dashboard/handlers.d.ts +27 -35
- package/dist/src/dashboard/handlers.d.ts.map +1 -1
- package/dist/src/dashboard/handlers.js +154 -76
- package/dist/src/dashboard/handlers.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/dashboard/list-filters.d.ts +27 -0
- package/dist/src/dashboard/list-filters.d.ts.map +1 -0
- package/dist/src/dashboard/list-filters.js +69 -0
- package/dist/src/dashboard/list-filters.js.map +1 -0
- package/dist/src/dashboard/openapi.d.ts.map +1 -1
- package/dist/src/dashboard/openapi.js +115 -50
- package/dist/src/dashboard/openapi.js.map +1 -1
- package/dist/src/dashboard/run-filter.d.ts +18 -15
- package/dist/src/dashboard/run-filter.d.ts.map +1 -1
- package/dist/src/dashboard/run-filter.js +29 -22
- package/dist/src/dashboard/run-filter.js.map +1 -1
- package/dist/src/define_config.d.ts +8 -0
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/doctor/checks.d.ts +6 -0
- package/dist/src/doctor/checks.d.ts.map +1 -1
- package/dist/src/doctor/checks.js +91 -0
- package/dist/src/doctor/checks.js.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/sql-schema.d.ts +54 -0
- package/dist/src/sql-schema.d.ts.map +1 -0
- package/dist/src/sql-schema.js +145 -0
- package/dist/src/sql-schema.js.map +1 -0
- package/dist/src/stores/factory.d.ts +6 -0
- package/dist/src/stores/factory.d.ts.map +1 -1
- package/dist/src/stores/factory.js +3 -0
- package/dist/src/stores/factory.js.map +1 -1
- package/dist/src/stores/lucid-schema.d.ts +17 -3
- package/dist/src/stores/lucid-schema.d.ts.map +1 -1
- package/dist/src/stores/lucid-schema.js +84 -62
- package/dist/src/stores/lucid-schema.js.map +1 -1
- package/dist/src/stores/lucid.d.ts +27 -0
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +253 -205
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/transports/db-schema.d.ts +13 -5
- package/dist/src/transports/db-schema.d.ts.map +1 -1
- package/dist/src/transports/db-schema.js +50 -32
- package/dist/src/transports/db-schema.js.map +1 -1
- package/dist/src/transports/db.d.ts +6 -0
- package/dist/src/transports/db.d.ts.map +1 -1
- package/dist/src/transports/db.js +49 -39
- package/dist/src/transports/db.js.map +1 -1
- package/dist/src/transports/factory.d.ts +16 -0
- package/dist/src/transports/factory.d.ts.map +1 -1
- package/dist/src/transports/factory.js +6 -1
- package/dist/src/transports/factory.js.map +1 -1
- package/dist/src/transports/worker-keys-db.d.ts +5 -0
- package/dist/src/transports/worker-keys-db.d.ts.map +1 -1
- package/dist/src/transports/worker-keys-db.js +10 -8
- package/dist/src/transports/worker-keys-db.js.map +1 -1
- package/dist/stubs/database/migrations/create_durable_tables.stub +12 -2
- package/dist/stubs/database/migrations/create_durable_transport_messages.stub +34 -5
- package/dist/stubs/database/migrations/create_durable_transport_tables.stub +30 -8
- package/dist/stubs/database/migrations/create_durable_transport_worker_keys.stub +24 -2
- package/package.json +6 -6
- package/dist/assets/spa/assets/index-DlaQG-kn.js.map +0 -1
package/dist/src/stores/lucid.js
CHANGED
|
@@ -7,9 +7,10 @@ import { runPageWindow } from '../run-pagination.js';
|
|
|
7
7
|
import { axisIsRunColumn, mergeRunValueFacetRows, RUN_VALUE_FACET_LIMIT, RUN_VALUE_FACET_SCAN, runValueFacetsFromRuns, } from '../run-value-facets.js';
|
|
8
8
|
import { attributeColumnFor, attributeOperand, attributeOperands, normalizeAttributeRows, sqlComparator, } from '../search-attributes.js';
|
|
9
9
|
import { SINGLETON_ACTIVE_STATUSES, SINGLETON_ADMITTED_TAG, settlesTerminally, singletonAdmissionDenialPatch, singletonAdmissionTags, withoutAdmission, } from '../singleton-admission.js';
|
|
10
|
+
import { assertSchemaSupported, hasColumn, hasTable, qualify, qualifyAll, schemaExists as schemaExistsIn, validateSchemaName, } from '../sql-schema.js';
|
|
10
11
|
import { CHECKPOINT_COLUMNS_BUT_INPUT, checkpointToRow, parseJsonColumn, rowToCheckpoint, rowToRun, rowToSignalWaiter, runPatchToRow, runToRow, SETTLED_CHECKPOINT_COLUMNS, settlementToRow, } from './lucid-mappers.js';
|
|
11
12
|
import { LucidPgHotPath, quote } from './lucid-pg-hot-path.js';
|
|
12
|
-
import { createDurableTables, DURABLE_TABLES } from './lucid-schema.js';
|
|
13
|
+
import { createDurableTables, DURABLE_SCHEMA_META_TABLE, DURABLE_TABLES } from './lucid-schema.js';
|
|
13
14
|
/**
|
|
14
15
|
* A production-grade, persistent `StateStore` backed by AdonisJS **Lucid** (Knex). Runs, checkpoints,
|
|
15
16
|
* timers, signal waiters, run attributes and recovery leases live in SQL, so durable state survives
|
|
@@ -27,11 +28,22 @@ export class LucidStateStore {
|
|
|
27
28
|
options;
|
|
28
29
|
/** The namespace run reads are fenced to, or undefined for the operator view. */
|
|
29
30
|
scopeNamespace;
|
|
31
|
+
/** The schema the tables live in, or undefined for the connection's current one. */
|
|
32
|
+
schema;
|
|
33
|
+
/** The durable table names, qualified with {@link schema}, for the query builder. */
|
|
34
|
+
t;
|
|
35
|
+
/** The Postgres hot path's statements over {@link t}. */
|
|
36
|
+
sql;
|
|
30
37
|
constructor(db, options = {}) {
|
|
31
38
|
this.db = db;
|
|
32
39
|
this.connectionName = options.connectionName;
|
|
33
40
|
this.options = options;
|
|
34
41
|
this.scopeNamespace = options.scope?.namespace;
|
|
42
|
+
this.schema = validateSchemaName(options.schema, 'stores.lucid');
|
|
43
|
+
if (this.schema !== undefined)
|
|
44
|
+
assertSchemaSupported(this.client(), this.schema, 'stores.lucid');
|
|
45
|
+
this.t = qualifyAll(DURABLE_TABLES, this.schema);
|
|
46
|
+
this.sql = hotStatements(this.schema);
|
|
35
47
|
}
|
|
36
48
|
/**
|
|
37
49
|
* A store over the same `Database` whose run reads are confined to `scope.namespace` (see
|
|
@@ -72,6 +84,7 @@ export class LucidStateStore {
|
|
|
72
84
|
hotReads(hot) {
|
|
73
85
|
this.hotReadStatements ??= hot
|
|
74
86
|
.rows(COLUMNS_PRESENT_SQL, [
|
|
87
|
+
this.schema ?? null,
|
|
75
88
|
[DURABLE_TABLES.runs, DURABLE_TABLES.checkpoints],
|
|
76
89
|
[...RUN_OPTIONAL_COLUMNS, ...CHECKPOINT_OPTIONAL_COLUMNS],
|
|
77
90
|
])
|
|
@@ -86,7 +99,7 @@ export class LucidStateStore {
|
|
|
86
99
|
...CHECKPOINT_BASE_COLUMNS,
|
|
87
100
|
...CHECKPOINT_OPTIONAL_COLUMNS.filter(has(DURABLE_TABLES.checkpoints)),
|
|
88
101
|
];
|
|
89
|
-
return
|
|
102
|
+
return this.sql.reads(runCols.map(quote).join(', '), checkpointCols.map(quote).join(', '));
|
|
90
103
|
})
|
|
91
104
|
.catch((error) => {
|
|
92
105
|
this.hotReadStatements = undefined; // a failed probe is retried, never cached
|
|
@@ -96,10 +109,58 @@ export class LucidStateStore {
|
|
|
96
109
|
}
|
|
97
110
|
/** Idempotently provision the durable tables (called on boot when `autoSchema` is on). */
|
|
98
111
|
async ensureSchema() {
|
|
99
|
-
await createDurableTables(this.db, this.connectionName,
|
|
112
|
+
await createDurableTables(this.db, this.connectionName, {
|
|
113
|
+
...(this.options.collation !== undefined ? { collation: this.options.collation } : {}),
|
|
114
|
+
...(this.schema !== undefined ? { schema: this.schema } : {}),
|
|
115
|
+
});
|
|
100
116
|
// The tables may have gained columns: the next hot read probes them again.
|
|
101
117
|
this.hotReadStatements = undefined;
|
|
102
118
|
}
|
|
119
|
+
/** The database schema the store's tables live in, or `undefined` for the connection's current one. */
|
|
120
|
+
get schemaName() {
|
|
121
|
+
return this.schema;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* What `agora:doctor` reports for a store with a `schema`: whether the schema exists, which durable
|
|
125
|
+
* tables are missing from it, which of `optionalTables` (tables other pieces create when they first
|
|
126
|
+
* run, like the db transport's) are in it, and whether the connection can write there (a probe row
|
|
127
|
+
* inserted into the schema marker table, in a transaction that is rolled back).
|
|
128
|
+
*/
|
|
129
|
+
async inspectSchema(optionalTables = []) {
|
|
130
|
+
const client = this.client();
|
|
131
|
+
const schema = this.schema;
|
|
132
|
+
const schemaExists = schema === undefined || (await schemaExistsIn(client, schema));
|
|
133
|
+
const missingTables = [];
|
|
134
|
+
for (const table of [...Object.values(DURABLE_TABLES), DURABLE_SCHEMA_META_TABLE]) {
|
|
135
|
+
if (!schemaExists || !(await hasTable(client, schema, table)))
|
|
136
|
+
missingTables.push(table);
|
|
137
|
+
}
|
|
138
|
+
const optionalPresent = [];
|
|
139
|
+
if (schemaExists) {
|
|
140
|
+
for (const table of optionalTables) {
|
|
141
|
+
if (await hasTable(client, schema, table))
|
|
142
|
+
optionalPresent.push(table);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
let writeError;
|
|
146
|
+
if (!missingTables.includes(DURABLE_SCHEMA_META_TABLE)) {
|
|
147
|
+
const rollback = new Error('durable doctor probe rollback');
|
|
148
|
+
try {
|
|
149
|
+
await client.transaction(async (trx) => {
|
|
150
|
+
await trx
|
|
151
|
+
.insertQuery()
|
|
152
|
+
.table(qualify(schema, DURABLE_SCHEMA_META_TABLE))
|
|
153
|
+
.insert({ id: '__doctor_probe__', fingerprint: 'probe', applied_at: Date.now() });
|
|
154
|
+
throw rollback;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error !== rollback)
|
|
159
|
+
writeError = error instanceof Error ? error.message : String(error);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { schema, schemaExists, missingTables, optionalPresent, writeError };
|
|
163
|
+
}
|
|
103
164
|
// --- runs ---------------------------------------------------------------
|
|
104
165
|
/**
|
|
105
166
|
* Insert the run, or reject with {@link RunAlreadyExistsError} when its id is taken. The insert
|
|
@@ -114,21 +175,21 @@ export class LucidStateStore {
|
|
|
114
175
|
if (hot) {
|
|
115
176
|
const row = runToRow(run);
|
|
116
177
|
const keys = Object.keys(row);
|
|
117
|
-
const inserted = await hot.rows(
|
|
178
|
+
const inserted = await hot.rows(this.sql.insertRunIfAbsent(keys), keys.map((k) => row[k]));
|
|
118
179
|
if (inserted.length === 0)
|
|
119
180
|
throw new RunAlreadyExistsError(run.id);
|
|
120
|
-
await hot.run(
|
|
181
|
+
await hot.run(this.sql.deleteAttributes, [run.id]);
|
|
121
182
|
return;
|
|
122
183
|
}
|
|
123
184
|
const client = this.client();
|
|
124
|
-
if (!(await insertIfAbsent(client,
|
|
185
|
+
if (!(await insertIfAbsent(client, this.t.runs, runToRow(run), ['id']))) {
|
|
125
186
|
throw new RunAlreadyExistsError(run.id);
|
|
126
187
|
}
|
|
127
|
-
await client.from(
|
|
188
|
+
await client.from(this.t.attributes).where('run_id', run.id).delete();
|
|
128
189
|
return;
|
|
129
190
|
}
|
|
130
191
|
await this.client().transaction(async (trx) => {
|
|
131
|
-
if (!(await insertIfAbsent(trx,
|
|
192
|
+
if (!(await insertIfAbsent(trx, this.t.runs, runToRow(run), ['id']))) {
|
|
132
193
|
throw new RunAlreadyExistsError(run.id);
|
|
133
194
|
}
|
|
134
195
|
await this.reindexAttributes(trx, run.id, run.searchAttributes);
|
|
@@ -150,7 +211,7 @@ export class LucidStateStore {
|
|
|
150
211
|
const row = await this.releaseSlot(this.client(), runId, patch);
|
|
151
212
|
const keys = Object.keys(row);
|
|
152
213
|
if (keys.length) {
|
|
153
|
-
await hot.run(
|
|
214
|
+
await hot.run(this.sql.updateRun(keys, false), [...keys.map((k) => row[k]), runId]);
|
|
154
215
|
}
|
|
155
216
|
return;
|
|
156
217
|
}
|
|
@@ -158,7 +219,7 @@ export class LucidStateStore {
|
|
|
158
219
|
const row = await this.releaseSlot(trx, runId, patch);
|
|
159
220
|
// Knex throws on an empty `.update({})`; skip the UPDATE when no mapped column changed.
|
|
160
221
|
if (Object.keys(row).length) {
|
|
161
|
-
await trx.from(
|
|
222
|
+
await trx.from(this.t.runs).where('id', runId).update(row);
|
|
162
223
|
}
|
|
163
224
|
if ('searchAttributes' in patch) {
|
|
164
225
|
await this.reindexAttributes(trx, runId, patch.searchAttributes);
|
|
@@ -171,10 +232,10 @@ export class LucidStateStore {
|
|
|
171
232
|
const row = await this.releaseSlot(this.client(), runId, patch);
|
|
172
233
|
const keys = Object.keys(row);
|
|
173
234
|
if (keys.length === 0) {
|
|
174
|
-
const found = await hot.rows(
|
|
235
|
+
const found = await hot.rows(this.sql.runInStatuses, [runId, expectedStatuses]);
|
|
175
236
|
return found.length > 0;
|
|
176
237
|
}
|
|
177
|
-
const { count } = await hot.run(
|
|
238
|
+
const { count } = await hot.run(this.sql.updateRun(keys, true), [
|
|
178
239
|
...keys.map((k) => row[k]),
|
|
179
240
|
runId,
|
|
180
241
|
expectedStatuses,
|
|
@@ -189,7 +250,7 @@ export class LucidStateStore {
|
|
|
189
250
|
// structure — the database arbitrates, not a prior JS read. Two racing writers can never both
|
|
190
251
|
// report `true` for the same row.
|
|
191
252
|
const affected = await trx
|
|
192
|
-
.from(
|
|
253
|
+
.from(this.t.runs)
|
|
193
254
|
.where('id', runId)
|
|
194
255
|
.whereIn('status', expectedStatuses)
|
|
195
256
|
.update(row);
|
|
@@ -199,7 +260,7 @@ export class LucidStateStore {
|
|
|
199
260
|
// Knex throws on an empty `.update({})`. Nothing to write, but the predicate's outcome is
|
|
200
261
|
// still observable: report whether it would have matched, without mutating anything.
|
|
201
262
|
const current = await trx
|
|
202
|
-
.from(
|
|
263
|
+
.from(this.t.runs)
|
|
203
264
|
.where('id', runId)
|
|
204
265
|
.whereIn('status', expectedStatuses)
|
|
205
266
|
.first();
|
|
@@ -226,7 +287,7 @@ export class LucidStateStore {
|
|
|
226
287
|
// Sorted, so two batches sharing runs take their row locks in the same order.
|
|
227
288
|
const ids = [...new Set(runIds)].sort();
|
|
228
289
|
await this.client()
|
|
229
|
-
.from(
|
|
290
|
+
.from(this.t.runs)
|
|
230
291
|
.whereIn('id', ids)
|
|
231
292
|
.where('status', 'suspended')
|
|
232
293
|
.andWhere((w) => w.whereNull('wake_at').orWhere('wake_at', '>', wakeAt))
|
|
@@ -236,9 +297,7 @@ export class LucidStateStore {
|
|
|
236
297
|
decisionMarkerColumn;
|
|
237
298
|
hasDecisionMarkerColumn(trx) {
|
|
238
299
|
// Probed on the caller's transaction: a single-connection pool (SQLite) has no other to use.
|
|
239
|
-
this.decisionMarkerColumn ??= trx.schema
|
|
240
|
-
.hasColumn(DURABLE_TABLES.runs, 'awaiting_decision_task_id')
|
|
241
|
-
.catch(() => {
|
|
300
|
+
this.decisionMarkerColumn ??= hasColumn(trx, this.schema, DURABLE_TABLES.runs, 'awaiting_decision_task_id').catch(() => {
|
|
242
301
|
this.decisionMarkerColumn = undefined; // a failed probe is retried, never cached
|
|
243
302
|
return true;
|
|
244
303
|
});
|
|
@@ -273,7 +332,7 @@ export class LucidStateStore {
|
|
|
273
332
|
tags = patch.tags;
|
|
274
333
|
}
|
|
275
334
|
else {
|
|
276
|
-
const q = trx.from(
|
|
335
|
+
const q = trx.from(this.t.runs).where('id', runId).select('tags');
|
|
277
336
|
if (locksRows(trx))
|
|
278
337
|
q.forUpdate();
|
|
279
338
|
const current = (await q.first());
|
|
@@ -287,7 +346,7 @@ export class LucidStateStore {
|
|
|
287
346
|
// Every active run sharing the key. The tag LIKE may over-match (it is only a lock-scope
|
|
288
347
|
// narrowing); the decision re-filters exactly.
|
|
289
348
|
const sharing = () => trx
|
|
290
|
-
.from(
|
|
349
|
+
.from(this.t.runs)
|
|
291
350
|
.where('workflow', workflow)
|
|
292
351
|
.whereIn('status', SINGLETON_ACTIVE_STATUSES)
|
|
293
352
|
.whereRaw(`tags like ? escape '!'`, [`%"${escapeLike(tag)}"%`]);
|
|
@@ -311,13 +370,13 @@ export class LucidStateStore {
|
|
|
311
370
|
if (!tags) {
|
|
312
371
|
const parked = singletonAdmissionDenialPatch(runs, runId, tag, workflow, retryWakeAt);
|
|
313
372
|
if (parked) {
|
|
314
|
-
await trx.from(
|
|
373
|
+
await trx.from(this.t.runs).where('id', runId).update(runPatchToRow(parked));
|
|
315
374
|
}
|
|
316
375
|
return false;
|
|
317
376
|
}
|
|
318
377
|
// Already a holder (a resumed turn): nothing to write.
|
|
319
378
|
if (!runs.find((r) => r.id === runId)?.tags?.includes(SINGLETON_ADMITTED_TAG)) {
|
|
320
|
-
await trx.from(
|
|
379
|
+
await trx.from(this.t.runs).where('id', runId).update(runPatchToRow({ tags }));
|
|
321
380
|
}
|
|
322
381
|
return true;
|
|
323
382
|
});
|
|
@@ -331,14 +390,14 @@ export class LucidStateStore {
|
|
|
331
390
|
: await hot.rows(sql.getScoped, [runId, namespaceFilterValues(this.scopeNamespace)]);
|
|
332
391
|
return rows[0] ? rowToRun(rows[0]) : null;
|
|
333
392
|
}
|
|
334
|
-
const row = await this.fenced(this.client().from(
|
|
393
|
+
const row = await this.fenced(this.client().from(this.t.runs).where('id', runId)).first();
|
|
335
394
|
return row ? rowToRun(row) : null;
|
|
336
395
|
}
|
|
337
396
|
async getRuns(runIds) {
|
|
338
397
|
const ids = [...new Set(runIds)];
|
|
339
398
|
if (ids.length === 0)
|
|
340
399
|
return [];
|
|
341
|
-
const rows = await this.fenced(this.client().from(
|
|
400
|
+
const rows = await this.fenced(this.client().from(this.t.runs).whereIn('id', ids));
|
|
342
401
|
return rows.map(rowToRun);
|
|
343
402
|
}
|
|
344
403
|
async deleteRun(runId) {
|
|
@@ -346,10 +405,10 @@ export class LucidStateStore {
|
|
|
346
405
|
// transaction so a crash can't leave a half-deleted run. (Buffered signals are token-keyed,
|
|
347
406
|
// not run-scoped, so they are not swept here.)
|
|
348
407
|
await this.client().transaction(async (trx) => {
|
|
349
|
-
await trx.from(
|
|
350
|
-
await trx.from(
|
|
351
|
-
await trx.from(
|
|
352
|
-
await trx.from(
|
|
408
|
+
await trx.from(this.t.checkpoints).where('run_id', runId).delete();
|
|
409
|
+
await trx.from(this.t.signalWaiters).where('run_id', runId).delete();
|
|
410
|
+
await trx.from(this.t.attributes).where('run_id', runId).delete();
|
|
411
|
+
await trx.from(this.t.runs).where('id', runId).delete();
|
|
353
412
|
});
|
|
354
413
|
}
|
|
355
414
|
async deleteRuns(runIds) {
|
|
@@ -361,10 +420,10 @@ export class LucidStateStore {
|
|
|
361
420
|
await this.client().transaction(async (trx) => {
|
|
362
421
|
for (let at = 0; at < ids.length; at += DELETE_RUNS_CHUNK) {
|
|
363
422
|
const chunk = ids.slice(at, at + DELETE_RUNS_CHUNK);
|
|
364
|
-
await trx.from(
|
|
365
|
-
await trx.from(
|
|
366
|
-
await trx.from(
|
|
367
|
-
await trx.from(
|
|
423
|
+
await trx.from(this.t.checkpoints).whereIn('run_id', chunk).delete();
|
|
424
|
+
await trx.from(this.t.signalWaiters).whereIn('run_id', chunk).delete();
|
|
425
|
+
await trx.from(this.t.attributes).whereIn('run_id', chunk).delete();
|
|
426
|
+
await trx.from(this.t.runs).whereIn('id', chunk).delete();
|
|
368
427
|
}
|
|
369
428
|
});
|
|
370
429
|
}
|
|
@@ -376,7 +435,7 @@ export class LucidStateStore {
|
|
|
376
435
|
return rows[0] ? rowToCheckpoint(rows[0]) : null;
|
|
377
436
|
}
|
|
378
437
|
const row = await this.client()
|
|
379
|
-
.from(
|
|
438
|
+
.from(this.t.checkpoints)
|
|
380
439
|
.where('run_id', runId)
|
|
381
440
|
.andWhere('seq', seq)
|
|
382
441
|
.first();
|
|
@@ -387,7 +446,7 @@ export class LucidStateStore {
|
|
|
387
446
|
const out = [];
|
|
388
447
|
for (let at = 0; at < unique.length; at += CHECKPOINT_BATCH) {
|
|
389
448
|
const rows = (await this.client()
|
|
390
|
-
.from(
|
|
449
|
+
.from(this.t.checkpoints)
|
|
391
450
|
.where('run_id', runId)
|
|
392
451
|
.whereIn('seq', unique.slice(at, at + CHECKPOINT_BATCH)));
|
|
393
452
|
out.push(...rows.map(rowToCheckpoint));
|
|
@@ -398,7 +457,7 @@ export class LucidStateStore {
|
|
|
398
457
|
const unique = [...new Set(seqs)];
|
|
399
458
|
const hot = this.hot();
|
|
400
459
|
if (hot && unique.length <= CHECKPOINT_BATCH) {
|
|
401
|
-
const rows = await hot.rows(
|
|
460
|
+
const rows = await hot.rows(this.sql.checkpointsForSettle, [runId, unique]);
|
|
402
461
|
return rows
|
|
403
462
|
.map(rowToCheckpoint)
|
|
404
463
|
.sort((a, b) => a.seq - b.seq);
|
|
@@ -406,7 +465,7 @@ export class LucidStateStore {
|
|
|
406
465
|
const out = [];
|
|
407
466
|
for (let at = 0; at < unique.length; at += CHECKPOINT_BATCH) {
|
|
408
467
|
const rows = (await this.client()
|
|
409
|
-
.from(
|
|
468
|
+
.from(this.t.checkpoints)
|
|
410
469
|
.where('run_id', runId)
|
|
411
470
|
.whereIn('seq', unique.slice(at, at + CHECKPOINT_BATCH))
|
|
412
471
|
.select(...CHECKPOINT_COLUMNS_BUT_INPUT));
|
|
@@ -423,18 +482,18 @@ export class LucidStateStore {
|
|
|
423
482
|
// One multi-row upsert per chunk, in one transaction: a rejection writes nothing.
|
|
424
483
|
await this.client().transaction(async (trx) => {
|
|
425
484
|
for (let at = 0; at < checkpoints.length; at += CHECKPOINT_UPSERT_BATCH) {
|
|
426
|
-
await upsertCheckpointRows(trx, checkpoints.slice(at, at + CHECKPOINT_UPSERT_BATCH));
|
|
485
|
+
await upsertCheckpointRows(trx, this.t.checkpoints, checkpoints.slice(at, at + CHECKPOINT_UPSERT_BATCH));
|
|
427
486
|
}
|
|
428
487
|
});
|
|
429
488
|
}
|
|
430
489
|
async saveCheckpointIfPending(checkpoint) {
|
|
431
|
-
return updateCheckpointIfPending(this.client(), checkpoint);
|
|
490
|
+
return updateCheckpointIfPending(this.client(), this.t.checkpoints, checkpoint);
|
|
432
491
|
}
|
|
433
492
|
async renewCheckpointLease(runId, seq, expectedWakeAt, wakeAt) {
|
|
434
493
|
// One guarded UPDATE: the row-level compare-and-set is atomic on every dialect, so a result or a
|
|
435
494
|
// re-dispatch committed since the caller's read makes this match nothing.
|
|
436
495
|
const query = this.client()
|
|
437
|
-
.from(
|
|
496
|
+
.from(this.t.checkpoints)
|
|
438
497
|
.where('run_id', runId)
|
|
439
498
|
.andWhere('seq', seq)
|
|
440
499
|
.andWhere('status', 'pending');
|
|
@@ -452,7 +511,7 @@ export class LucidStateStore {
|
|
|
452
511
|
// for a row is compared against the first one's write), then write every winner with one
|
|
453
512
|
// multi-row upsert per chunk.
|
|
454
513
|
return this.client().transaction(async (trx) => {
|
|
455
|
-
const pending = await lockPendingCheckpoints(trx, checkpoints, ['seq']);
|
|
514
|
+
const pending = await lockPendingCheckpoints(trx, this.t.checkpoints, checkpoints, ['seq']);
|
|
456
515
|
const wrote = [];
|
|
457
516
|
const writes = new Map();
|
|
458
517
|
for (const cp of checkpoints) {
|
|
@@ -468,7 +527,7 @@ export class LucidStateStore {
|
|
|
468
527
|
}
|
|
469
528
|
const rows = [...writes.values()];
|
|
470
529
|
for (let at = 0; at < rows.length; at += CHECKPOINT_UPSERT_BATCH) {
|
|
471
|
-
await upsertCheckpointRows(trx, rows.slice(at, at + CHECKPOINT_UPSERT_BATCH));
|
|
530
|
+
await upsertCheckpointRows(trx, this.t.checkpoints, rows.slice(at, at + CHECKPOINT_UPSERT_BATCH));
|
|
472
531
|
}
|
|
473
532
|
return wrote;
|
|
474
533
|
});
|
|
@@ -479,7 +538,7 @@ export class LucidStateStore {
|
|
|
479
538
|
return [];
|
|
480
539
|
if (settlements.length === 1) {
|
|
481
540
|
const affected = await this.client()
|
|
482
|
-
.from(
|
|
541
|
+
.from(this.t.checkpoints)
|
|
483
542
|
.where('run_id', only.runId)
|
|
484
543
|
.andWhere('seq', only.seq)
|
|
485
544
|
.andWhere('status', 'pending')
|
|
@@ -492,7 +551,7 @@ export class LucidStateStore {
|
|
|
492
551
|
// locked, so the upsert never inserts: its row carries just the columns an insert would require,
|
|
493
552
|
// and `input` is neither sent nor rewritten.
|
|
494
553
|
return this.client().transaction(async (trx) => {
|
|
495
|
-
const pending = await lockPendingCheckpoints(trx, settlements, [
|
|
554
|
+
const pending = await lockPendingCheckpoints(trx, this.t.checkpoints, settlements, [
|
|
496
555
|
'seq',
|
|
497
556
|
'name',
|
|
498
557
|
'kind',
|
|
@@ -521,7 +580,7 @@ export class LucidStateStore {
|
|
|
521
580
|
for (let at = 0; at < rows.length; at += CHECKPOINT_UPSERT_BATCH) {
|
|
522
581
|
await trx
|
|
523
582
|
.insertQuery()
|
|
524
|
-
.table(
|
|
583
|
+
.table(this.t.checkpoints)
|
|
525
584
|
.knexQuery.insert(rows.slice(at, at + CHECKPOINT_UPSERT_BATCH))
|
|
526
585
|
.onConflict(['run_id', 'seq'])
|
|
527
586
|
.merge([...SETTLED_CHECKPOINT_COLUMNS]);
|
|
@@ -544,7 +603,7 @@ export class LucidStateStore {
|
|
|
544
603
|
const live = results.filter((_, i) => first[i]);
|
|
545
604
|
const client = this.client();
|
|
546
605
|
const settled = client.dialect.name === 'postgres'
|
|
547
|
-
? await settleResultsReturningPg(client, this.hot(), live)
|
|
606
|
+
? await settleResultsReturningPg(client, this.hot(), this.schema, live)
|
|
548
607
|
: await this.settleResultsPlain(live);
|
|
549
608
|
const out = [];
|
|
550
609
|
let k = 0;
|
|
@@ -569,7 +628,7 @@ export class LucidStateStore {
|
|
|
569
628
|
const out = new Map();
|
|
570
629
|
const client = this.client();
|
|
571
630
|
const runRows = (await client
|
|
572
|
-
.from(
|
|
631
|
+
.from(this.t.runs)
|
|
573
632
|
.whereIn('id', [...new Set(results.map((r) => r.runId))])
|
|
574
633
|
.select('id', 'status', 'wake_at'));
|
|
575
634
|
const runs = new Map(runRows.map((row) => [row.id, row]));
|
|
@@ -634,16 +693,16 @@ export class LucidStateStore {
|
|
|
634
693
|
if (hot) {
|
|
635
694
|
const keys = Object.keys(row);
|
|
636
695
|
const values = keys.map((k) => row[k] ?? null);
|
|
637
|
-
return (await hot.rows(
|
|
696
|
+
return (await hot.rows(this.sql.insertCheckpointIfAbsent(keys), values)).length > 0;
|
|
638
697
|
}
|
|
639
|
-
return insertIfAbsent(client,
|
|
698
|
+
return insertIfAbsent(client, this.t.checkpoints, row, ['run_id', 'seq']);
|
|
640
699
|
}
|
|
641
700
|
/** Persist the latest worker heartbeat on the step's checkpoint row (see the interface doc).
|
|
642
701
|
* A targeted two-column UPDATE — never an upsert: a beat racing the checkpoint's own insert (or
|
|
643
702
|
* outliving a deleted run) matches zero rows and is a silent no-op, by design. */
|
|
644
703
|
async recordStepHeartbeat(runId, seq, at, progress) {
|
|
645
704
|
await this.client()
|
|
646
|
-
.from(
|
|
705
|
+
.from(this.t.checkpoints)
|
|
647
706
|
.where('run_id', runId)
|
|
648
707
|
.andWhere('seq', seq)
|
|
649
708
|
.update({
|
|
@@ -661,7 +720,7 @@ export class LucidStateStore {
|
|
|
661
720
|
const row = checkpointToRow(checkpoint);
|
|
662
721
|
await client
|
|
663
722
|
.insertQuery()
|
|
664
|
-
.table(
|
|
723
|
+
.table(this.t.checkpoints)
|
|
665
724
|
.knexQuery.insert(row)
|
|
666
725
|
.onConflict(['run_id', 'seq'])
|
|
667
726
|
.merge();
|
|
@@ -669,7 +728,7 @@ export class LucidStateStore {
|
|
|
669
728
|
// --- recovery / dispatch queries ----------------------------------------
|
|
670
729
|
async listIncompleteRuns(namespace) {
|
|
671
730
|
const q = this.fenced(this.client()
|
|
672
|
-
.from(
|
|
731
|
+
.from(this.t.runs)
|
|
673
732
|
.whereIn('status', [...RECOVERABLE_RUN_STATUSES]));
|
|
674
733
|
if (namespace !== undefined)
|
|
675
734
|
q.whereIn('namespace', namespaceFilterValues(namespace));
|
|
@@ -682,7 +741,7 @@ export class LucidStateStore {
|
|
|
682
741
|
// running run — and issues ~zero lock-probe UPDATEs. `owner` adds the runs still leased to that
|
|
683
742
|
// instance id (a rebooted instance's own leftovers; the engine filters what it is executing).
|
|
684
743
|
const q = this.fenced(this.client()
|
|
685
|
-
.from(
|
|
744
|
+
.from(this.t.runs)
|
|
686
745
|
.whereIn('status', [...RECOVERABLE_RUN_STATUSES])
|
|
687
746
|
.andWhere((sub) => {
|
|
688
747
|
sub.whereNull('locked_until').orWhere('locked_until', '<=', nowMs);
|
|
@@ -695,7 +754,7 @@ export class LucidStateStore {
|
|
|
695
754
|
return rows.map(rowToRun);
|
|
696
755
|
}
|
|
697
756
|
async listPendingRuns(limit, namespace) {
|
|
698
|
-
const q = this.fenced(this.client().from(
|
|
757
|
+
const q = this.fenced(this.client().from(this.t.runs).where('status', 'pending'));
|
|
699
758
|
if (namespace !== undefined)
|
|
700
759
|
q.whereIn('namespace', namespaceFilterValues(namespace));
|
|
701
760
|
const rows = await q
|
|
@@ -722,9 +781,9 @@ export class LucidStateStore {
|
|
|
722
781
|
if (dialect === 'postgres') {
|
|
723
782
|
// One statement: pick the oldest free pending rows, skipping any another claimer holds locked
|
|
724
783
|
// right now, and lease them. Several pollers at once each get a different slice.
|
|
725
|
-
const pick = oldest(client.from(
|
|
784
|
+
const pick = oldest(client.from(this.t.runs)).forUpdate().skipLocked();
|
|
726
785
|
rows = (await client
|
|
727
|
-
.from(
|
|
786
|
+
.from(this.t.runs)
|
|
728
787
|
.whereIn('id', pick)
|
|
729
788
|
.update({ locked_by: owner, locked_until: leaseUntilMs })
|
|
730
789
|
.returning('*'));
|
|
@@ -733,20 +792,20 @@ export class LucidStateStore {
|
|
|
733
792
|
// No UPDATE … RETURNING: lock the batch (skipping rows another claimer holds), lease it and read
|
|
734
793
|
// it back, in one transaction.
|
|
735
794
|
rows = await client.transaction(async (trx) => {
|
|
736
|
-
const ids = (await oldest(trx.from(
|
|
795
|
+
const ids = (await oldest(trx.from(this.t.runs)).forUpdate().skipLocked()).map((r) => r.id);
|
|
737
796
|
if (ids.length === 0)
|
|
738
797
|
return [];
|
|
739
798
|
await trx
|
|
740
|
-
.from(
|
|
799
|
+
.from(this.t.runs)
|
|
741
800
|
.whereIn('id', ids)
|
|
742
801
|
.update({ locked_by: owner, locked_until: leaseUntilMs });
|
|
743
|
-
return (await trx.from(
|
|
802
|
+
return (await trx.from(this.t.runs).whereIn('id', ids));
|
|
744
803
|
});
|
|
745
804
|
}
|
|
746
805
|
else {
|
|
747
806
|
// SQLite has no row locks to skip: each run is taken with the lease's own compare-and-set.
|
|
748
807
|
const claimed = [];
|
|
749
|
-
const candidates = (await free(client.from(
|
|
808
|
+
const candidates = (await free(client.from(this.t.runs))
|
|
750
809
|
.orderBy('created_at', 'asc')
|
|
751
810
|
.orderBy('id', 'asc')
|
|
752
811
|
.limit(limit));
|
|
@@ -764,7 +823,7 @@ export class LucidStateStore {
|
|
|
764
823
|
async listDueTimers(nowMs, namespace, limit) {
|
|
765
824
|
const marker = await this.hasWakeMarkerColumn();
|
|
766
825
|
const q = this.fenced(this.client()
|
|
767
|
-
.from(
|
|
826
|
+
.from(this.t.runs)
|
|
768
827
|
.where('status', 'suspended')
|
|
769
828
|
.where((w) => {
|
|
770
829
|
w.where((d) => d.whereNotNull('wake_at').andWhere('wake_at', '<=', nowMs));
|
|
@@ -787,7 +846,7 @@ export class LucidStateStore {
|
|
|
787
846
|
// Atomic compare-and-set: acquire ONLY if currently unlocked or the lease has expired. The single
|
|
788
847
|
// conditional UPDATE is the concurrency guard — two instances racing can never both report `true`.
|
|
789
848
|
const affected = await this.client()
|
|
790
|
-
.from(
|
|
849
|
+
.from(this.t.runs)
|
|
791
850
|
.where('id', runId)
|
|
792
851
|
.andWhere((q) => {
|
|
793
852
|
q.whereNull('locked_until').orWhere('locked_until', '<=', nowMs);
|
|
@@ -815,7 +874,7 @@ export class LucidStateStore {
|
|
|
815
874
|
// One statement: the lease's compare-and-set and the read of the row it locked. The scope
|
|
816
875
|
// fence sits in the WHERE clause, so a run outside it is neither locked nor read.
|
|
817
876
|
const rows = (await this.fenced(client
|
|
818
|
-
.from(
|
|
877
|
+
.from(this.t.runs)
|
|
819
878
|
.where('id', runId)
|
|
820
879
|
.andWhere((q) => {
|
|
821
880
|
q.whereNull('locked_until').orWhere('locked_until', '<=', nowMs);
|
|
@@ -836,15 +895,15 @@ export class LucidStateStore {
|
|
|
836
895
|
const hot = this.hot();
|
|
837
896
|
if (hot) {
|
|
838
897
|
if (owner === undefined)
|
|
839
|
-
await hot.run(
|
|
898
|
+
await hot.run(this.sql.releaseLock, [runId]);
|
|
840
899
|
else
|
|
841
|
-
await hot.run(
|
|
900
|
+
await hot.run(this.sql.releaseOwnLock, [runId, owner]);
|
|
842
901
|
return;
|
|
843
902
|
}
|
|
844
903
|
// Owner-scoped release (when `owner` is given): the `locked_by` predicate lives in the UPDATE's
|
|
845
904
|
// WHERE clause, so a stale executor whose lease was taken over matches zero rows and cannot
|
|
846
905
|
// wipe the new owner's lease — same conditional-write shape as renewRunLock.
|
|
847
|
-
const q = this.client().from(
|
|
906
|
+
const q = this.client().from(this.t.runs).where('id', runId);
|
|
848
907
|
if (owner !== undefined)
|
|
849
908
|
q.andWhere('locked_by', owner);
|
|
850
909
|
await q.update({ locked_by: null, locked_until: null });
|
|
@@ -855,19 +914,13 @@ export class LucidStateStore {
|
|
|
855
914
|
const marker = await this.hasWakeMarkerColumn();
|
|
856
915
|
const hot = this.hot();
|
|
857
916
|
if (hot) {
|
|
858
|
-
const released = await hot.run(marker ?
|
|
859
|
-
runId,
|
|
860
|
-
owner,
|
|
861
|
-
]);
|
|
917
|
+
const released = await hot.run(marker ? this.sql.releaseUnlessWake : this.sql.releaseOwnLock, [runId, owner]);
|
|
862
918
|
if (released.count === 1)
|
|
863
919
|
return true;
|
|
864
|
-
const rows = await hot.rows(
|
|
920
|
+
const rows = await hot.rows(this.sql.lockedBy, [runId]);
|
|
865
921
|
return rows[0]?.locked_by !== owner;
|
|
866
922
|
}
|
|
867
|
-
const q = this.client()
|
|
868
|
-
.from(DURABLE_TABLES.runs)
|
|
869
|
-
.where('id', runId)
|
|
870
|
-
.andWhere('locked_by', owner);
|
|
923
|
+
const q = this.client().from(this.t.runs).where('id', runId).andWhere('locked_by', owner);
|
|
871
924
|
if (marker)
|
|
872
925
|
q.whereNull('wake_requested_at');
|
|
873
926
|
const affected = await q.update({ locked_by: null, locked_until: null });
|
|
@@ -875,7 +928,7 @@ export class LucidStateStore {
|
|
|
875
928
|
return true;
|
|
876
929
|
// Nothing matched: the lease is not ours (any more), or a turn was asked for.
|
|
877
930
|
const row = (await this.client()
|
|
878
|
-
.from(
|
|
931
|
+
.from(this.t.runs)
|
|
879
932
|
.where('id', runId)
|
|
880
933
|
.select('locked_by')
|
|
881
934
|
.first());
|
|
@@ -883,9 +936,7 @@ export class LucidStateStore {
|
|
|
883
936
|
}
|
|
884
937
|
/** {@link hasWakeMarkerColumn} on the caller's transaction (a single-connection pool has no other). */
|
|
885
938
|
hasWakeMarkerColumnIn(trx) {
|
|
886
|
-
this.wakeMarkerColumn ??= trx.schema
|
|
887
|
-
.hasColumn(DURABLE_TABLES.runs, 'wake_requested_at')
|
|
888
|
-
.catch(() => {
|
|
939
|
+
this.wakeMarkerColumn ??= hasColumn(trx, this.schema, DURABLE_TABLES.runs, 'wake_requested_at').catch(() => {
|
|
889
940
|
this.wakeMarkerColumn = undefined;
|
|
890
941
|
return false;
|
|
891
942
|
});
|
|
@@ -894,9 +945,7 @@ export class LucidStateStore {
|
|
|
894
945
|
/** Whether the runs table has `wake_requested_at`, probed once per store and cached. */
|
|
895
946
|
wakeMarkerColumn;
|
|
896
947
|
hasWakeMarkerColumn() {
|
|
897
|
-
this.wakeMarkerColumn ??= this.client()
|
|
898
|
-
.schema.hasColumn(DURABLE_TABLES.runs, 'wake_requested_at')
|
|
899
|
-
.catch(() => {
|
|
948
|
+
this.wakeMarkerColumn ??= hasColumn(this.client(), this.schema, DURABLE_TABLES.runs, 'wake_requested_at').catch(() => {
|
|
900
949
|
this.wakeMarkerColumn = undefined;
|
|
901
950
|
return false;
|
|
902
951
|
});
|
|
@@ -905,10 +954,10 @@ export class LucidStateStore {
|
|
|
905
954
|
async renewRunLock(runId, owner, leaseUntilMs) {
|
|
906
955
|
const hot = this.hot();
|
|
907
956
|
if (hot)
|
|
908
|
-
return (await hot.run(
|
|
957
|
+
return (await hot.run(this.sql.renewLock, [leaseUntilMs, runId, owner])).count === 1;
|
|
909
958
|
// Extend ONLY if `owner` still holds the lease — a dead worker's lease still expires & is reclaimed.
|
|
910
959
|
const affected = await this.client()
|
|
911
|
-
.from(
|
|
960
|
+
.from(this.t.runs)
|
|
912
961
|
.where('id', runId)
|
|
913
962
|
.andWhere('locked_by', owner)
|
|
914
963
|
.update({ locked_until: leaseUntilMs });
|
|
@@ -920,7 +969,7 @@ export class LucidStateStore {
|
|
|
920
969
|
// runs once per wait registration, i.e. on every signal/child/event suspension).
|
|
921
970
|
await this.client()
|
|
922
971
|
.insertQuery()
|
|
923
|
-
.table(
|
|
972
|
+
.table(this.t.signalWaiters)
|
|
924
973
|
.knexQuery.insert({
|
|
925
974
|
token: waiter.token,
|
|
926
975
|
run_id: waiter.runId,
|
|
@@ -935,10 +984,10 @@ export class LucidStateStore {
|
|
|
935
984
|
// the same waiter to two callers. The DELETE arbitrates, so no transaction is needed around it,
|
|
936
985
|
// and a token with no waiter (the common case) costs one read.
|
|
937
986
|
const client = this.client();
|
|
938
|
-
const row = await client.from(
|
|
987
|
+
const row = await client.from(this.t.signalWaiters).where('token', token).first();
|
|
939
988
|
if (!row)
|
|
940
989
|
return null;
|
|
941
|
-
const deleted = await client.from(
|
|
990
|
+
const deleted = await client.from(this.t.signalWaiters).where('token', token).delete();
|
|
942
991
|
if (rowsAffected(deleted) !== 1)
|
|
943
992
|
return null;
|
|
944
993
|
return rowToSignalWaiter(row);
|
|
@@ -947,14 +996,14 @@ export class LucidStateStore {
|
|
|
947
996
|
// The prefix embeds caller-supplied names (event names via eventPrefix) — escape their LIKE
|
|
948
997
|
// metacharacters so a name containing `%`/`_` can't scan (or match) other tokens.
|
|
949
998
|
const rows = await this.client()
|
|
950
|
-
.from(
|
|
999
|
+
.from(this.t.signalWaiters)
|
|
951
1000
|
.whereRaw(`token like ? escape '!'`, [`${escapeLike(prefix)}%`]);
|
|
952
1001
|
return rows.map(rowToSignalWaiter);
|
|
953
1002
|
}
|
|
954
1003
|
async listSignalWaitersByRunIds(runIds) {
|
|
955
1004
|
if (runIds.length === 0)
|
|
956
1005
|
return [];
|
|
957
|
-
const rows = await this.client().from(
|
|
1006
|
+
const rows = await this.client().from(this.t.signalWaiters).whereIn('run_id', runIds);
|
|
958
1007
|
return rows.map(rowToSignalWaiter);
|
|
959
1008
|
}
|
|
960
1009
|
async hasSignalWaiter(runId, tokenPrefix) {
|
|
@@ -962,7 +1011,7 @@ export class LucidStateStore {
|
|
|
962
1011
|
// MySQL's default collations ignore case), so a hit is confirmed in JS. Only a match that is not
|
|
963
1012
|
// a real prefix match pays for reading the run's whole matching set.
|
|
964
1013
|
const matching = () => this.client()
|
|
965
|
-
.from(
|
|
1014
|
+
.from(this.t.signalWaiters)
|
|
966
1015
|
.select('token')
|
|
967
1016
|
.where('run_id', runId)
|
|
968
1017
|
.andWhereRaw(`token like ? escape '!'`, [`${escapeLike(tokenPrefix)}%`]);
|
|
@@ -985,7 +1034,7 @@ export class LucidStateStore {
|
|
|
985
1034
|
const chunk = entries.slice(at, at + CHILD_WAIT_ROWS);
|
|
986
1035
|
await trx
|
|
987
1036
|
.insertQuery()
|
|
988
|
-
.table(
|
|
1037
|
+
.table(this.t.signalWaiters)
|
|
989
1038
|
.knexQuery.insert(chunk.map(({ waiter }) => ({
|
|
990
1039
|
token: waiter.token,
|
|
991
1040
|
run_id: waiter.runId,
|
|
@@ -999,7 +1048,7 @@ export class LucidStateStore {
|
|
|
999
1048
|
const chunk = entries.slice(at, at + CHILD_WAIT_ROWS);
|
|
1000
1049
|
await trx
|
|
1001
1050
|
.insertQuery()
|
|
1002
|
-
.table(
|
|
1051
|
+
.table(this.t.checkpoints)
|
|
1003
1052
|
.knexQuery.insert(chunk.map(({ checkpoint }) => checkpointToRow(checkpoint)))
|
|
1004
1053
|
.onConflict(['run_id', 'seq'])
|
|
1005
1054
|
.merge();
|
|
@@ -1009,7 +1058,7 @@ export class LucidStateStore {
|
|
|
1009
1058
|
async compactCheckpoints(runId, fromSeq, toSeq, into) {
|
|
1010
1059
|
await this.client().transaction(async (trx) => {
|
|
1011
1060
|
await trx
|
|
1012
|
-
.from(
|
|
1061
|
+
.from(this.t.checkpoints)
|
|
1013
1062
|
.where('run_id', runId)
|
|
1014
1063
|
.andWhere('seq', '>=', fromSeq)
|
|
1015
1064
|
.andWhere('seq', '<=', toSeq)
|
|
@@ -1022,7 +1071,7 @@ export class LucidStateStore {
|
|
|
1022
1071
|
// primary key, so a later putSignalWaiter for the same token may have replaced the row with a
|
|
1023
1072
|
// different run's waiter — the run_id/seq predicate leaves that one untouched. No-op if absent.
|
|
1024
1073
|
await this.client()
|
|
1025
|
-
.from(
|
|
1074
|
+
.from(this.t.signalWaiters)
|
|
1026
1075
|
.where('token', waiter.token)
|
|
1027
1076
|
.andWhere('run_id', waiter.runId)
|
|
1028
1077
|
.andWhere('seq', waiter.seq)
|
|
@@ -1031,12 +1080,12 @@ export class LucidStateStore {
|
|
|
1031
1080
|
// --- buffered signals (FIFO per token) ----------------------------------
|
|
1032
1081
|
async bufferSignal(token, payload) {
|
|
1033
1082
|
await this.client()
|
|
1034
|
-
.table(
|
|
1083
|
+
.table(this.t.bufferedSignals)
|
|
1035
1084
|
.insert({ token, payload: payload === undefined ? null : JSON.stringify(payload) });
|
|
1036
1085
|
}
|
|
1037
1086
|
async listBufferedSignalTokens(prefix, limit) {
|
|
1038
1087
|
const rows = (await this.client()
|
|
1039
|
-
.from(
|
|
1088
|
+
.from(this.t.bufferedSignals)
|
|
1040
1089
|
.whereRaw(`token like ? escape '!'`, [`${escapeLike(prefix)}%`])
|
|
1041
1090
|
.distinct('token')
|
|
1042
1091
|
.orderBy('token', 'asc')
|
|
@@ -1045,7 +1094,7 @@ export class LucidStateStore {
|
|
|
1045
1094
|
}
|
|
1046
1095
|
async listBufferedSignals(tokenPrefix, afterId, limit) {
|
|
1047
1096
|
const q = this.client()
|
|
1048
|
-
.from(
|
|
1097
|
+
.from(this.t.bufferedSignals)
|
|
1049
1098
|
.whereRaw(`token like ? escape '!'`, [`${escapeLike(tokenPrefix)}%`]);
|
|
1050
1099
|
if (afterId !== undefined)
|
|
1051
1100
|
q.where('id', '>', Number(afterId));
|
|
@@ -1061,7 +1110,7 @@ export class LucidStateStore {
|
|
|
1061
1110
|
async deleteBufferedSignals(ids) {
|
|
1062
1111
|
for (let at = 0; at < ids.length; at += 500) {
|
|
1063
1112
|
await this.client()
|
|
1064
|
-
.from(
|
|
1113
|
+
.from(this.t.bufferedSignals)
|
|
1065
1114
|
.whereIn('id', ids.slice(at, at + 500).map((id) => Number(id)))
|
|
1066
1115
|
.delete();
|
|
1067
1116
|
}
|
|
@@ -1072,9 +1121,9 @@ export class LucidStateStore {
|
|
|
1072
1121
|
const client = this.client();
|
|
1073
1122
|
const hot = this.hot();
|
|
1074
1123
|
const row = hot
|
|
1075
|
-
? (await hot.rows(
|
|
1124
|
+
? (await hot.rows(this.sql.oldestBufferedSignal, [token]))[0]
|
|
1076
1125
|
: await client
|
|
1077
|
-
.from(
|
|
1126
|
+
.from(this.t.bufferedSignals)
|
|
1078
1127
|
.where('token', token)
|
|
1079
1128
|
.orderBy('id', 'asc')
|
|
1080
1129
|
.first();
|
|
@@ -1082,8 +1131,8 @@ export class LucidStateStore {
|
|
|
1082
1131
|
return null;
|
|
1083
1132
|
const r = row;
|
|
1084
1133
|
const deleted = hot
|
|
1085
|
-
? (await hot.run(
|
|
1086
|
-
: rowsAffected(await client.from(
|
|
1134
|
+
? (await hot.run(this.sql.deleteBufferedSignal, [r.id])).count
|
|
1135
|
+
: rowsAffected(await client.from(this.t.bufferedSignals).where('id', r.id).delete());
|
|
1087
1136
|
if (deleted !== 1)
|
|
1088
1137
|
return null;
|
|
1089
1138
|
return {
|
|
@@ -1093,7 +1142,7 @@ export class LucidStateStore {
|
|
|
1093
1142
|
// --- buffered events (name-keyed, match-based consumption) ---------------
|
|
1094
1143
|
async bufferEvent(input) {
|
|
1095
1144
|
await this.client()
|
|
1096
|
-
.table(
|
|
1145
|
+
.table(this.t.bufferedEvents)
|
|
1097
1146
|
.insert({
|
|
1098
1147
|
id: input.id,
|
|
1099
1148
|
name: input.name,
|
|
@@ -1104,9 +1153,9 @@ export class LucidStateStore {
|
|
|
1104
1153
|
async listBufferedEvents(name, limit) {
|
|
1105
1154
|
const hot = this.hot();
|
|
1106
1155
|
const rows = hot
|
|
1107
|
-
? await hot.rows(
|
|
1156
|
+
? await hot.rows(this.sql.bufferedEvents, [name, limit])
|
|
1108
1157
|
: await this.client()
|
|
1109
|
-
.from(
|
|
1158
|
+
.from(this.t.bufferedEvents)
|
|
1110
1159
|
.where('name', name)
|
|
1111
1160
|
.orderBy('published_at', 'asc') // oldest first
|
|
1112
1161
|
.limit(limit);
|
|
@@ -1123,7 +1172,7 @@ export class LucidStateStore {
|
|
|
1123
1172
|
// IN subquery. The age predicate is repeated on the DELETE so a row re-published under the same
|
|
1124
1173
|
// id meanwhile is left alone.
|
|
1125
1174
|
const rows = (await this.client()
|
|
1126
|
-
.from(
|
|
1175
|
+
.from(this.t.bufferedEvents)
|
|
1127
1176
|
.select('id')
|
|
1128
1177
|
.where('published_at', '<', publishedBefore)
|
|
1129
1178
|
.orderBy('published_at', 'asc')
|
|
@@ -1131,7 +1180,7 @@ export class LucidStateStore {
|
|
|
1131
1180
|
if (rows.length === 0)
|
|
1132
1181
|
return 0;
|
|
1133
1182
|
const deleted = await this.client()
|
|
1134
|
-
.from(
|
|
1183
|
+
.from(this.t.bufferedEvents)
|
|
1135
1184
|
.whereIn('id', rows.map((r) => r.id))
|
|
1136
1185
|
.andWhere('published_at', '<', publishedBefore)
|
|
1137
1186
|
.delete();
|
|
@@ -1140,10 +1189,7 @@ export class LucidStateStore {
|
|
|
1140
1189
|
async removeBufferedEvent(id) {
|
|
1141
1190
|
// Atomic claim: whoever's delete affects the row (returns 1) is the one that gets to deliver;
|
|
1142
1191
|
// a concurrent claimant sees 0 and backs off. `id` is the primary key.
|
|
1143
|
-
const deleted = await this.client()
|
|
1144
|
-
.from(DURABLE_TABLES.bufferedEvents)
|
|
1145
|
-
.where('id', id)
|
|
1146
|
-
.delete();
|
|
1192
|
+
const deleted = await this.client().from(this.t.bufferedEvents).where('id', id).delete();
|
|
1147
1193
|
return rowsAffected(deleted) === 1;
|
|
1148
1194
|
}
|
|
1149
1195
|
// --- transaction (exactly-once business write + checkpoint) -------------
|
|
@@ -1175,13 +1221,13 @@ export class LucidStateStore {
|
|
|
1175
1221
|
const row = scheduleToRow(record);
|
|
1176
1222
|
await this.client()
|
|
1177
1223
|
.insertQuery()
|
|
1178
|
-
.table(
|
|
1224
|
+
.table(this.t.schedules)
|
|
1179
1225
|
.knexQuery.insert(row)
|
|
1180
1226
|
.onConflict('id')
|
|
1181
1227
|
.merge();
|
|
1182
1228
|
}
|
|
1183
1229
|
async getSchedule(id) {
|
|
1184
|
-
const row = await this.client().from(
|
|
1230
|
+
const row = await this.client().from(this.t.schedules).where('id', id).first();
|
|
1185
1231
|
return row ? rowToSchedule(row) : null;
|
|
1186
1232
|
}
|
|
1187
1233
|
/** One conditional UPDATE: the compare-and-set on `next_fire_at` lives in its WHERE clause. */
|
|
@@ -1205,7 +1251,7 @@ export class LucidStateStore {
|
|
|
1205
1251
|
set.updated_at = patch.updatedAt.getTime();
|
|
1206
1252
|
if (!Object.keys(set).length)
|
|
1207
1253
|
return (await this.getSchedule(id)) !== null;
|
|
1208
|
-
const q = this.client().from(
|
|
1254
|
+
const q = this.client().from(this.t.schedules).where('id', id);
|
|
1209
1255
|
if (expectedNextFireAt === null)
|
|
1210
1256
|
q.whereNull('next_fire_at');
|
|
1211
1257
|
else if (expectedNextFireAt !== undefined)
|
|
@@ -1213,11 +1259,11 @@ export class LucidStateStore {
|
|
|
1213
1259
|
return rowsAffected(await q.update(set)) > 0;
|
|
1214
1260
|
}
|
|
1215
1261
|
async deleteSchedule(id) {
|
|
1216
|
-
const affected = await this.client().from(
|
|
1262
|
+
const affected = await this.client().from(this.t.schedules).where('id', id).delete();
|
|
1217
1263
|
return rowsAffected(affected) > 0;
|
|
1218
1264
|
}
|
|
1219
1265
|
async listSchedules(query) {
|
|
1220
|
-
const q = this.client().from(
|
|
1266
|
+
const q = this.client().from(this.t.schedules);
|
|
1221
1267
|
if (query.namespace !== undefined) {
|
|
1222
1268
|
q.whereIn('namespace', namespaceFilterValues(query.namespace));
|
|
1223
1269
|
}
|
|
@@ -1300,7 +1346,7 @@ export class LucidStateStore {
|
|
|
1300
1346
|
* exactly the set the list would show.
|
|
1301
1347
|
*/
|
|
1302
1348
|
scopedRuns(query) {
|
|
1303
|
-
const q = this.fenced(this.client().from(
|
|
1349
|
+
const q = this.fenced(this.client().from(this.t.runs));
|
|
1304
1350
|
if (query.workflow)
|
|
1305
1351
|
q.where('workflow', query.workflow);
|
|
1306
1352
|
if (query.workflows) {
|
|
@@ -1404,10 +1450,10 @@ export class LucidStateStore {
|
|
|
1404
1450
|
}
|
|
1405
1451
|
q.whereExists((sub) => {
|
|
1406
1452
|
sub
|
|
1407
|
-
.from(
|
|
1408
|
-
.whereRaw(`${
|
|
1409
|
-
.andWhere(`${
|
|
1410
|
-
.whereIn(`${
|
|
1453
|
+
.from(this.t.attributes)
|
|
1454
|
+
.whereRaw('?? = ??', [`${this.t.attributes}.run_id`, `${this.t.runs}.id`])
|
|
1455
|
+
.andWhere(`${this.t.attributes}.key`, f.key)
|
|
1456
|
+
.whereIn(`${this.t.attributes}.${col}`, operands);
|
|
1411
1457
|
});
|
|
1412
1458
|
return;
|
|
1413
1459
|
}
|
|
@@ -1415,15 +1461,15 @@ export class LucidStateStore {
|
|
|
1415
1461
|
const operand = attributeOperand(f);
|
|
1416
1462
|
q.whereExists((sub) => {
|
|
1417
1463
|
sub
|
|
1418
|
-
.from(
|
|
1419
|
-
.whereRaw(`${
|
|
1420
|
-
.andWhere(`${
|
|
1421
|
-
.andWhere(`${
|
|
1464
|
+
.from(this.t.attributes)
|
|
1465
|
+
.whereRaw('?? = ??', [`${this.t.attributes}.run_id`, `${this.t.runs}.id`])
|
|
1466
|
+
.andWhere(`${this.t.attributes}.key`, f.key)
|
|
1467
|
+
.andWhere(`${this.t.attributes}.${col}`, cmp, operand);
|
|
1422
1468
|
});
|
|
1423
1469
|
}
|
|
1424
1470
|
async listCheckpoints(runId) {
|
|
1425
1471
|
const rows = await this.client()
|
|
1426
|
-
.from(
|
|
1472
|
+
.from(this.t.checkpoints)
|
|
1427
1473
|
.where('run_id', runId)
|
|
1428
1474
|
.orderBy('seq', 'asc');
|
|
1429
1475
|
return rows.map(rowToCheckpoint);
|
|
@@ -1431,9 +1477,9 @@ export class LucidStateStore {
|
|
|
1431
1477
|
async listCheckpointsForReplay(runId) {
|
|
1432
1478
|
const hot = this.hot();
|
|
1433
1479
|
const rows = (hot
|
|
1434
|
-
? await hot.rows(
|
|
1480
|
+
? await hot.rows(this.sql.replayCheckpoints, [runId])
|
|
1435
1481
|
: await this.client()
|
|
1436
|
-
.from(
|
|
1482
|
+
.from(this.t.checkpoints)
|
|
1437
1483
|
.where('run_id', runId)
|
|
1438
1484
|
.orderBy('seq', 'asc')
|
|
1439
1485
|
.select(...CHECKPOINT_COLUMNS_BUT_INPUT));
|
|
@@ -1446,7 +1492,7 @@ export class LucidStateStore {
|
|
|
1446
1492
|
const inputs = new Map();
|
|
1447
1493
|
for (let at = 0; at < unsettled.length; at += CHECKPOINT_BATCH) {
|
|
1448
1494
|
const found = (await this.client()
|
|
1449
|
-
.from(
|
|
1495
|
+
.from(this.t.checkpoints)
|
|
1450
1496
|
.where('run_id', runId)
|
|
1451
1497
|
.whereIn('seq', unsettled.slice(at, at + CHECKPOINT_BATCH))
|
|
1452
1498
|
.select('seq', 'input'));
|
|
@@ -1462,7 +1508,7 @@ export class LucidStateStore {
|
|
|
1462
1508
|
}
|
|
1463
1509
|
async getLatestCheckpointByName(runId, name) {
|
|
1464
1510
|
const row = await this.client()
|
|
1465
|
-
.from(
|
|
1511
|
+
.from(this.t.checkpoints)
|
|
1466
1512
|
.where('run_id', runId)
|
|
1467
1513
|
.andWhere('name', name)
|
|
1468
1514
|
.orderBy('seq', 'desc')
|
|
@@ -1473,7 +1519,7 @@ export class LucidStateStore {
|
|
|
1473
1519
|
if (prefixes.length === 0)
|
|
1474
1520
|
return [];
|
|
1475
1521
|
const rows = await this.client()
|
|
1476
|
-
.from(
|
|
1522
|
+
.from(this.t.checkpoints)
|
|
1477
1523
|
.where('run_id', runId)
|
|
1478
1524
|
.andWhere((q) => {
|
|
1479
1525
|
for (const p of prefixes)
|
|
@@ -1487,11 +1533,11 @@ export class LucidStateStore {
|
|
|
1487
1533
|
return 0;
|
|
1488
1534
|
const hot = this.hot();
|
|
1489
1535
|
if (hot) {
|
|
1490
|
-
const rows = await hot.rows(
|
|
1536
|
+
const rows = await hot.rows(this.sql.countCheckpoints, [runId, fromSeq, toSeq, statuses]);
|
|
1491
1537
|
return Number(rows[0]?.count ?? 0);
|
|
1492
1538
|
}
|
|
1493
1539
|
const rows = await this.client()
|
|
1494
|
-
.from(
|
|
1540
|
+
.from(this.t.checkpoints)
|
|
1495
1541
|
.where('run_id', runId)
|
|
1496
1542
|
.andWhere('seq', '>=', fromSeq)
|
|
1497
1543
|
.andWhere('seq', '<=', toSeq)
|
|
@@ -1504,7 +1550,7 @@ export class LucidStateStore {
|
|
|
1504
1550
|
if (statuses.length === 0)
|
|
1505
1551
|
return false;
|
|
1506
1552
|
const row = await this.client()
|
|
1507
|
-
.from(
|
|
1553
|
+
.from(this.t.checkpoints)
|
|
1508
1554
|
.select('seq')
|
|
1509
1555
|
.where('run_id', runId)
|
|
1510
1556
|
.andWhere('seq', '>=', fromSeq)
|
|
@@ -1524,7 +1570,7 @@ export class LucidStateStore {
|
|
|
1524
1570
|
const client = this.client();
|
|
1525
1571
|
const kind = nameKindSql(client.dialect.name);
|
|
1526
1572
|
const q = client
|
|
1527
|
-
.from(
|
|
1573
|
+
.from(this.t.checkpoints)
|
|
1528
1574
|
.select('parallel_group as grp', 'status')
|
|
1529
1575
|
.select(client.raw(`${kind} as name_kind`))
|
|
1530
1576
|
.count('* as n')
|
|
@@ -1570,7 +1616,7 @@ export class LucidStateStore {
|
|
|
1570
1616
|
if (query.statuses?.length === 0 || query.namePrefixes?.length === 0)
|
|
1571
1617
|
return [];
|
|
1572
1618
|
const client = this.client();
|
|
1573
|
-
const q = client.from(
|
|
1619
|
+
const q = client.from(this.t.checkpoints).where('run_id', runId);
|
|
1574
1620
|
if (query.group !== undefined)
|
|
1575
1621
|
q.andWhere('parallel_group', query.group);
|
|
1576
1622
|
if (query.excludeGroups?.length) {
|
|
@@ -1605,10 +1651,10 @@ export class LucidStateStore {
|
|
|
1605
1651
|
/** Rewrite a run's normalized attribute rows: delete the old set, insert the current one. Mirrors
|
|
1606
1652
|
* the in-memory store's reindex so the side-table always reflects the run's live searchAttributes. */
|
|
1607
1653
|
async reindexAttributes(client, runId, attributes) {
|
|
1608
|
-
await client.from(
|
|
1654
|
+
await client.from(this.t.attributes).where('run_id', runId).delete();
|
|
1609
1655
|
const rows = normalizeAttributeRows(runId, attributes);
|
|
1610
1656
|
if (rows.length) {
|
|
1611
|
-
await client.table(
|
|
1657
|
+
await client.table(this.t.attributes).multiInsert(rows.map((r) => ({
|
|
1612
1658
|
run_id: r.runId,
|
|
1613
1659
|
key: r.key,
|
|
1614
1660
|
str_value: r.strValue,
|
|
@@ -1704,20 +1750,20 @@ const CHECKPOINT_BATCH = 500;
|
|
|
1704
1750
|
/** Most checkpoint rows one multi-row upsert writes (kept under every dialect's bind limit). */
|
|
1705
1751
|
const CHECKPOINT_UPSERT_BATCH = 200;
|
|
1706
1752
|
/** One multi-row `INSERT … ON CONFLICT (run_id, seq) DO UPDATE` (MySQL: `ON DUPLICATE KEY`). */
|
|
1707
|
-
async function upsertCheckpointRows(client, checkpoints) {
|
|
1753
|
+
async function upsertCheckpointRows(client, checkpointsTable, checkpoints) {
|
|
1708
1754
|
if (checkpoints.length === 0)
|
|
1709
1755
|
return;
|
|
1710
1756
|
await client
|
|
1711
1757
|
.insertQuery()
|
|
1712
|
-
.table(
|
|
1758
|
+
.table(checkpointsTable)
|
|
1713
1759
|
.knexQuery.insert(checkpoints.map(checkpointToRow))
|
|
1714
1760
|
.onConflict(['run_id', 'seq'])
|
|
1715
1761
|
.merge();
|
|
1716
1762
|
}
|
|
1717
|
-
async function updateCheckpointIfPending(client, checkpoint) {
|
|
1763
|
+
async function updateCheckpointIfPending(client, checkpointsTable, checkpoint) {
|
|
1718
1764
|
const { run_id: _runId, seq: _seq, ...row } = checkpointToRow(checkpoint);
|
|
1719
1765
|
const affected = await client
|
|
1720
|
-
.from(
|
|
1766
|
+
.from(checkpointsTable)
|
|
1721
1767
|
.where('run_id', checkpoint.runId)
|
|
1722
1768
|
.andWhere('seq', checkpoint.seq)
|
|
1723
1769
|
.andWhere('status', 'pending')
|
|
@@ -1747,8 +1793,6 @@ function escapeLike(value) {
|
|
|
1747
1793
|
return value.replace(/[!%_]/g, (c) => `!${c}`);
|
|
1748
1794
|
}
|
|
1749
1795
|
// --- the Postgres hot path's statements (see `lucid-pg-hot-path.ts`) --------------------------------
|
|
1750
|
-
const RUNS_T = quote(DURABLE_TABLES.runs);
|
|
1751
|
-
const CHECKPOINTS_T = quote(DURABLE_TABLES.checkpoints);
|
|
1752
1796
|
const CHECKPOINT_LIST = CHECKPOINT_COLUMNS_BUT_INPUT.map(quote).join(', ');
|
|
1753
1797
|
/**
|
|
1754
1798
|
* The runs columns every schema has. The hot path names its columns instead of `*`: a named prepared
|
|
@@ -1794,65 +1838,69 @@ const CHECKPOINT_BASE_COLUMNS = [
|
|
|
1794
1838
|
...CHECKPOINT_COLUMNS_BUT_INPUT.filter((c) => !CHECKPOINT_OPTIONAL_COLUMNS.includes(c)),
|
|
1795
1839
|
'input',
|
|
1796
1840
|
];
|
|
1797
|
-
|
|
1841
|
+
/** The later-wave columns present, in the configured schema (`?` first) or the current one. */
|
|
1842
|
+
const COLUMNS_PRESENT_SQL = `select "table_name", "column_name" from information_schema.columns where "table_schema" = coalesce(?, current_schema()) and "table_name" = any(?) and "column_name" = any(?)`;
|
|
1798
1843
|
const BUFFERED_SIGNAL_LIST = ['id', 'token', 'payload'].map(quote).join(', ');
|
|
1799
1844
|
const BUFFERED_EVENT_LIST = ['id', 'name', 'payload', 'published_at'].map(quote).join(', ');
|
|
1800
|
-
/**
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
return {
|
|
1804
|
-
getCheckpoint: shapedSql(`checkpoint-get:${checkpointCols}`, () => `select ${checkpointCols} from ${CHECKPOINTS_T} where "run_id" = ? and "seq" = ? limit 1`),
|
|
1805
|
-
get: shapedSql(`run-get:${cols}`, () => `select ${cols} from ${RUNS_T} where "id" = ? limit 1`),
|
|
1806
|
-
getScoped: shapedSql(`run-get-scoped:${cols}`, () => `select ${cols} from ${RUNS_T} where "id" = ? and "namespace" = any(?) limit 1`),
|
|
1807
|
-
lockAndGet: shapedSql(`run-lock:${cols}`, () => `update ${RUNS_T} set "locked_by" = ?, "locked_until" = ? where "id" = ? and ("locked_until" is null or "locked_until" <= ?) returning ${cols}`),
|
|
1808
|
-
lockAndGetScoped: shapedSql(`run-lock-scoped:${cols}`, () => `update ${RUNS_T} set "locked_by" = ?, "locked_until" = ? where "id" = ? and ("locked_until" is null or "locked_until" <= ?) and "namespace" = any(?) returning ${cols}`),
|
|
1809
|
-
};
|
|
1845
|
+
/** A quoted, schema-qualified table name for raw SQL: `"schema"."table"`, or `"table"`. */
|
|
1846
|
+
function quotedTable(schema, table) {
|
|
1847
|
+
return schema ? `${quote(schema)}.${quote(table)}` : quote(table);
|
|
1810
1848
|
}
|
|
1811
|
-
const
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
const
|
|
1815
|
-
const
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
const
|
|
1819
|
-
const
|
|
1820
|
-
const
|
|
1821
|
-
const
|
|
1822
|
-
const
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
const
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
}
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1849
|
+
const hotStatementsBySchema = new Map();
|
|
1850
|
+
/** {@link HotStatements} for `schema` (the connection's current schema when unset). */
|
|
1851
|
+
function hotStatements(schema) {
|
|
1852
|
+
const cacheKey = schema ?? '';
|
|
1853
|
+
const cached = hotStatementsBySchema.get(cacheKey);
|
|
1854
|
+
if (cached)
|
|
1855
|
+
return cached;
|
|
1856
|
+
const RUNS_T = quotedTable(schema, DURABLE_TABLES.runs);
|
|
1857
|
+
const CHECKPOINTS_T = quotedTable(schema, DURABLE_TABLES.checkpoints);
|
|
1858
|
+
const ATTRIBUTES_T = quotedTable(schema, DURABLE_TABLES.attributes);
|
|
1859
|
+
const SIGNALS_T = quotedTable(schema, DURABLE_TABLES.bufferedSignals);
|
|
1860
|
+
const EVENTS_T = quotedTable(schema, DURABLE_TABLES.bufferedEvents);
|
|
1861
|
+
/** Statements whose column list is the row's own keys, built once per key set. */
|
|
1862
|
+
const shaped = new Map();
|
|
1863
|
+
const shapedSql = (key, build) => {
|
|
1864
|
+
let text = shaped.get(key);
|
|
1865
|
+
if (text === undefined) {
|
|
1866
|
+
text = build();
|
|
1867
|
+
shaped.set(key, text);
|
|
1868
|
+
}
|
|
1869
|
+
return text;
|
|
1870
|
+
};
|
|
1871
|
+
/** `INSERT` of one row of `table` (quoted) with the columns `keys`. */
|
|
1872
|
+
const insertSql = (table, keys) => `insert into ${table} (${keys.map(quote).join(', ')}) values (${keys.map(() => '?').join(', ')})`;
|
|
1873
|
+
const statements = {
|
|
1874
|
+
reads: (cols, checkpointCols) => ({
|
|
1875
|
+
getCheckpoint: shapedSql(`checkpoint-get:${checkpointCols}`, () => `select ${checkpointCols} from ${CHECKPOINTS_T} where "run_id" = ? and "seq" = ? limit 1`),
|
|
1876
|
+
get: shapedSql(`run-get:${cols}`, () => `select ${cols} from ${RUNS_T} where "id" = ? limit 1`),
|
|
1877
|
+
getScoped: shapedSql(`run-get-scoped:${cols}`, () => `select ${cols} from ${RUNS_T} where "id" = ? and "namespace" = any(?) limit 1`),
|
|
1878
|
+
lockAndGet: shapedSql(`run-lock:${cols}`, () => `update ${RUNS_T} set "locked_by" = ?, "locked_until" = ? where "id" = ? and ("locked_until" is null or "locked_until" <= ?) returning ${cols}`),
|
|
1879
|
+
lockAndGetScoped: shapedSql(`run-lock-scoped:${cols}`, () => `update ${RUNS_T} set "locked_by" = ?, "locked_until" = ? where "id" = ? and ("locked_until" is null or "locked_until" <= ?) and "namespace" = any(?) returning ${cols}`),
|
|
1880
|
+
}),
|
|
1881
|
+
runInStatuses: `select 1 from ${RUNS_T} where "id" = ? and "status" = any(?) limit 1`,
|
|
1882
|
+
releaseLock: `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ?`,
|
|
1883
|
+
releaseOwnLock: `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ? and "locked_by" = ?`,
|
|
1884
|
+
releaseUnlessWake: `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ? and "locked_by" = ? and "wake_requested_at" is null`,
|
|
1885
|
+
lockedBy: `select "locked_by" from ${RUNS_T} where "id" = ? limit 1`,
|
|
1886
|
+
renewLock: `update ${RUNS_T} set "locked_until" = ? where "id" = ? and "locked_by" = ?`,
|
|
1887
|
+
deleteAttributes: `delete from ${ATTRIBUTES_T} where "run_id" = ?`,
|
|
1888
|
+
checkpointsForSettle: `select ${CHECKPOINT_LIST} from ${CHECKPOINTS_T} where "run_id" = ? and "seq" = any(?)`,
|
|
1889
|
+
replayCheckpoints: `select ${CHECKPOINT_LIST} from ${CHECKPOINTS_T} where "run_id" = ? order by "seq" asc`,
|
|
1890
|
+
countCheckpoints: `select count(*) as "count" from ${CHECKPOINTS_T} where "run_id" = ? and "seq" >= ? and "seq" <= ? and "status" = any(?)`,
|
|
1891
|
+
oldestBufferedSignal: `select ${BUFFERED_SIGNAL_LIST} from ${SIGNALS_T} where "token" = ? order by "id" asc limit 1`,
|
|
1892
|
+
deleteBufferedSignal: `delete from ${SIGNALS_T} where "id" = ?`,
|
|
1893
|
+
bufferedEvents: `select ${BUFFERED_EVENT_LIST} from ${EVENTS_T} where "name" = ? order by "published_at" asc limit ?`,
|
|
1894
|
+
insertRunIfAbsent: (keys) => shapedSql(`insert-run-if-absent:${keys.join(',')}`, () => `${insertSql(RUNS_T, keys)} on conflict ("id") do nothing returning "id"`),
|
|
1895
|
+
insertCheckpointIfAbsent: (keys) => shapedSql(`insert-if-absent:${keys.join(',')}`, () => `${insertSql(CHECKPOINTS_T, keys)} on conflict ("run_id", "seq") do nothing returning "seq"`),
|
|
1896
|
+
updateRun: (keys, conditional) => shapedSql(`update-run:${conditional}:${keys.join(',')}`, () => {
|
|
1897
|
+
const set = keys.map((k) => `${quote(k)} = ?`).join(', ');
|
|
1898
|
+
const where = conditional ? `"id" = ? and "status" = any(?)` : `"id" = ?`;
|
|
1899
|
+
return `update ${RUNS_T} set ${set} where ${where}`;
|
|
1900
|
+
}),
|
|
1901
|
+
};
|
|
1902
|
+
hotStatementsBySchema.set(cacheKey, statements);
|
|
1903
|
+
return statements;
|
|
1856
1904
|
}
|
|
1857
1905
|
/**
|
|
1858
1906
|
* {@link LucidStateStore.settleResultsIfPending} on Postgres: one `UPDATE … FROM (VALUES …) …
|
|
@@ -1860,7 +1908,7 @@ function updateRunSql(keys, conditional) {
|
|
|
1860
1908
|
* in the same statement, and read back with its `wake_at`. What a result does not carry keeps its
|
|
1861
1909
|
* stored value: `output` unless it completed, `events` and `started_at` unless present.
|
|
1862
1910
|
*/
|
|
1863
|
-
async function settleResultsReturningPg(client, hot, results) {
|
|
1911
|
+
async function settleResultsReturningPg(client, hot, schema, results) {
|
|
1864
1912
|
const values = [];
|
|
1865
1913
|
const bindings = [];
|
|
1866
1914
|
for (const r of results) {
|
|
@@ -1874,7 +1922,7 @@ async function settleResultsReturningPg(client, hot, results) {
|
|
|
1874
1922
|
const cols = CHECKPOINT_COLUMNS_BUT_INPUT.map((c) => `c.${c}`).join(', ');
|
|
1875
1923
|
const statuses = SETTLEABLE_RUN_STATUSES.map(() => '?').join(', ');
|
|
1876
1924
|
bindings.push(...SETTLEABLE_RUN_STATUSES);
|
|
1877
|
-
const sql = `update ${DURABLE_TABLES.checkpoints} as c set
|
|
1925
|
+
const sql = `update ${quotedTable(schema, DURABLE_TABLES.checkpoints)} as c set
|
|
1878
1926
|
status = v.status,
|
|
1879
1927
|
output = case when v.status = 'completed' then v.output else c.output end,
|
|
1880
1928
|
error = v.error,
|
|
@@ -1883,7 +1931,7 @@ async function settleResultsReturningPg(client, hot, results) {
|
|
|
1883
1931
|
finished_at = v.finished_at
|
|
1884
1932
|
from (values ${values.join(', ')})
|
|
1885
1933
|
as v(run_id, seq, status, output, error, has_events, events, started_at, finished_at),
|
|
1886
|
-
${DURABLE_TABLES.runs} as r
|
|
1934
|
+
${quotedTable(schema, DURABLE_TABLES.runs)} as r
|
|
1887
1935
|
where c.run_id = v.run_id and c.seq = v.seq and c.status = 'pending'
|
|
1888
1936
|
and r.id = c.run_id and r.status in (${statuses})
|
|
1889
1937
|
returning ${cols}, r.status as run_status, r.wake_at as run_wake_at`;
|
|
@@ -1941,11 +1989,11 @@ function checkpointKey(runId, seq) {
|
|
|
1941
1989
|
* so the database write lock is taken FIRST with a no-op write, and no other connection or process
|
|
1942
1990
|
* settles a row between the read and the caller's writes.
|
|
1943
1991
|
*/
|
|
1944
|
-
async function lockPendingCheckpoints(trx, entries, columns) {
|
|
1992
|
+
async function lockPendingCheckpoints(trx, checkpointsTable, entries, columns) {
|
|
1945
1993
|
const first = entries[0];
|
|
1946
1994
|
if (first && !locksRows(trx)) {
|
|
1947
1995
|
await trx
|
|
1948
|
-
.from(
|
|
1996
|
+
.from(checkpointsTable)
|
|
1949
1997
|
.where('run_id', first.runId)
|
|
1950
1998
|
.andWhere('seq', first.seq)
|
|
1951
1999
|
.andWhere('status', 'pending')
|
|
@@ -1962,7 +2010,7 @@ async function lockPendingCheckpoints(trx, entries, columns) {
|
|
|
1962
2010
|
const seqs = [...seqSet];
|
|
1963
2011
|
for (let at = 0; at < seqs.length; at += CHECKPOINT_BATCH) {
|
|
1964
2012
|
const q = trx
|
|
1965
|
-
.from(
|
|
2013
|
+
.from(checkpointsTable)
|
|
1966
2014
|
.where('run_id', runId)
|
|
1967
2015
|
.whereIn('seq', seqs.slice(at, at + CHECKPOINT_BATCH))
|
|
1968
2016
|
.andWhere('status', 'pending')
|