@adonis-agora/durable 0.59.0 → 0.60.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 +24 -0
- package/dist/assets/spa/assets/index-70hGzOnT.css +1 -0
- package/dist/assets/spa/assets/index-BI1EANJO.js +16 -0
- package/dist/assets/spa/assets/index-BI1EANJO.js.map +1 -0
- package/dist/assets/spa/index.html +2 -2
- package/dist/providers/durable_provider.d.ts.map +1 -1
- package/dist/providers/durable_provider.js +3 -0
- package/dist/providers/durable_provider.js.map +1 -1
- package/dist/src/codec-state-store.d.ts +10 -1
- package/dist/src/codec-state-store.d.ts.map +1 -1
- package/dist/src/codec-state-store.js +25 -0
- package/dist/src/codec-state-store.js.map +1 -1
- package/dist/src/dashboard/index.d.ts +1 -1
- package/dist/src/dashboard/index.js +1 -1
- package/dist/src/define_config.d.ts +13 -0
- package/dist/src/define_config.d.ts.map +1 -1
- package/dist/src/define_config.js.map +1 -1
- package/dist/src/engine.d.ts +75 -1
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +510 -23
- package/dist/src/engine.js.map +1 -1
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +4 -0
- package/dist/src/errors.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/interfaces.d.ts +94 -1
- package/dist/src/interfaces.d.ts.map +1 -1
- package/dist/src/interfaces.js +11 -0
- package/dist/src/interfaces.js.map +1 -1
- package/dist/src/sticky-continuation.d.ts +141 -0
- package/dist/src/sticky-continuation.d.ts.map +1 -0
- package/dist/src/sticky-continuation.js +231 -0
- package/dist/src/sticky-continuation.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-pg-hot-path.d.ts +30 -0
- package/dist/src/stores/lucid-pg-hot-path.d.ts.map +1 -0
- package/dist/src/stores/lucid-pg-hot-path.js +67 -0
- package/dist/src/stores/lucid-pg-hot-path.js.map +1 -0
- package/dist/src/stores/lucid.d.ts +30 -1
- package/dist/src/stores/lucid.d.ts.map +1 -1
- package/dist/src/stores/lucid.js +456 -17
- package/dist/src/stores/lucid.js.map +1 -1
- package/dist/src/testing/in-memory-state-store.d.ts +12 -1
- package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
- package/dist/src/testing/in-memory-state-store.js +101 -1
- package/dist/src/testing/in-memory-state-store.js.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
- package/dist/src/testing-kit/state-store-conformance.js +199 -0
- package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
- package/dist/src/transport-pool.d.ts +10 -0
- package/dist/src/transport-pool.d.ts.map +1 -1
- package/dist/src/transport-pool.js +23 -2
- package/dist/src/transport-pool.js.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts +41 -1
- package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
- package/dist/src/transports/bullmq/bullmq-transport.js +283 -5
- package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
- package/dist/src/transports/bullmq/deps.d.ts +12 -1
- package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
- package/dist/src/transports/bullmq/deps.js.map +1 -1
- package/dist/src/transports/bullmq/naming.d.ts +10 -0
- package/dist/src/transports/bullmq/naming.d.ts.map +1 -1
- package/dist/src/transports/bullmq/naming.js +16 -0
- package/dist/src/transports/bullmq/naming.js.map +1 -1
- package/dist/src/transports/db.d.ts +3 -1
- package/dist/src/transports/db.d.ts.map +1 -1
- package/dist/src/transports/db.js +4 -4
- package/dist/src/transports/db.js.map +1 -1
- package/dist/src/transports/queue.d.ts +3 -1
- package/dist/src/transports/queue.d.ts.map +1 -1
- package/dist/src/transports/queue.js +2 -2
- package/dist/src/transports/queue.js.map +1 -1
- package/dist/src/transports/worker-keys.d.ts +2 -1
- package/dist/src/transports/worker-keys.d.ts.map +1 -1
- package/dist/src/transports/worker-keys.js +3 -2
- package/dist/src/transports/worker-keys.js.map +1 -1
- package/dist/src/workflow-ctx.d.ts +7 -0
- package/dist/src/workflow-ctx.d.ts.map +1 -1
- package/dist/src/workflow-ctx.js +28 -1
- package/dist/src/workflow-ctx.js.map +1 -1
- package/package.json +3 -3
- package/dist/assets/spa/assets/index-Co0ef3-q.css +0 -1
- package/dist/assets/spa/assets/index-DmgfShPp.js +0 -16
- package/dist/assets/spa/assets/index-DmgfShPp.js.map +0 -1
package/dist/src/stores/lucid.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SETTLEABLE_RUN_STATUSES } from '../interfaces.js';
|
|
1
2
|
import { namespaceFilterValues, normalizeNamespace } from '../namespaces.js';
|
|
2
3
|
import { mergeRunFacetRows } from '../run-facets.js';
|
|
3
4
|
import { runPageWindow } from '../run-pagination.js';
|
|
@@ -5,6 +6,7 @@ import { axisIsRunColumn, mergeRunValueFacetRows, RUN_VALUE_FACET_LIMIT, RUN_VAL
|
|
|
5
6
|
import { attributeColumnFor, attributeOperand, attributeOperands, normalizeAttributeRows, sqlComparator, } from '../search-attributes.js';
|
|
6
7
|
import { SINGLETON_ACTIVE_STATUSES, SINGLETON_ADMITTED_TAG, settlesTerminally, singletonAdmissionDenialPatch, singletonAdmissionTags, withoutAdmission, } from '../singleton-admission.js';
|
|
7
8
|
import { CHECKPOINT_COLUMNS_BUT_INPUT, checkpointToRow, parseJsonColumn, rowToCheckpoint, rowToRun, rowToSignalWaiter, runPatchToRow, runToRow, SETTLED_CHECKPOINT_COLUMNS, settlementToRow, } from './lucid-mappers.js';
|
|
9
|
+
import { LucidPgHotPath, quote } from './lucid-pg-hot-path.js';
|
|
8
10
|
import { createDurableTables, DURABLE_TABLES } from './lucid-schema.js';
|
|
9
11
|
/**
|
|
10
12
|
* A production-grade, persistent `StateStore` backed by AdonisJS **Lucid** (Knex). Runs, checkpoints,
|
|
@@ -47,6 +49,16 @@ export class LucidStateStore {
|
|
|
47
49
|
client() {
|
|
48
50
|
return this.connectionName ? this.db.connection(this.connectionName) : this.db.connection();
|
|
49
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The Postgres hot path (see `lucid-pg-hot-path.ts`): the statements a turn and a step result run
|
|
54
|
+
* on every step, as constant SQL. `undefined` on any other dialect, or with Lucid's `debug` on.
|
|
55
|
+
* Resolved per call, like {@link client}: under a global transaction it is the transaction's.
|
|
56
|
+
*/
|
|
57
|
+
hot() {
|
|
58
|
+
return LucidPgHotPath.for(this.client(), {
|
|
59
|
+
preparedStatements: this.options.preparedStatements,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
50
62
|
/** Idempotently provision the durable tables (called on boot when `autoSchema` is on). */
|
|
51
63
|
async ensureSchema() {
|
|
52
64
|
await createDurableTables(this.db, this.connectionName, this.options.collation !== undefined ? { collation: this.options.collation } : {});
|
|
@@ -56,6 +68,14 @@ export class LucidStateStore {
|
|
|
56
68
|
if (normalizeAttributeRows(run.id, run.searchAttributes).length === 0) {
|
|
57
69
|
// Nothing to index: the insert, then clear any attribute rows a deleted run of this id left,
|
|
58
70
|
// as two statements. The insert goes first, so a run that already exists keeps its rows.
|
|
71
|
+
const hot = this.hot();
|
|
72
|
+
if (hot) {
|
|
73
|
+
const row = runToRow(run);
|
|
74
|
+
const keys = Object.keys(row);
|
|
75
|
+
await hot.run(insertSql(DURABLE_TABLES.runs, keys), keys.map((k) => row[k]));
|
|
76
|
+
await hot.run(DELETE_ATTRIBUTES_SQL, [run.id]);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
59
79
|
const client = this.client();
|
|
60
80
|
await client.table(DURABLE_TABLES.runs).insert(runToRow(run));
|
|
61
81
|
await client.from(DURABLE_TABLES.attributes).where('run_id', run.id).delete();
|
|
@@ -77,6 +97,15 @@ export class LucidStateStore {
|
|
|
77
97
|
return this.client().transaction((trx) => write(trx));
|
|
78
98
|
}
|
|
79
99
|
async updateRun(runId, patch) {
|
|
100
|
+
const hot = this.hotFor(patch);
|
|
101
|
+
if (hot) {
|
|
102
|
+
const row = await this.releaseSlot(this.client(), runId, patch);
|
|
103
|
+
const keys = Object.keys(row);
|
|
104
|
+
if (keys.length) {
|
|
105
|
+
await hot.run(updateRunSql(keys, false), [...keys.map((k) => row[k]), runId]);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
80
109
|
await this.writeRun(patch, async (trx) => {
|
|
81
110
|
const row = await this.releaseSlot(trx, runId, patch);
|
|
82
111
|
// Knex throws on an empty `.update({})`; skip the UPDATE when no mapped column changed.
|
|
@@ -89,6 +118,21 @@ export class LucidStateStore {
|
|
|
89
118
|
});
|
|
90
119
|
}
|
|
91
120
|
async updateRunIf(runId, expectedStatuses, patch) {
|
|
121
|
+
const hot = this.hotFor(patch);
|
|
122
|
+
if (hot) {
|
|
123
|
+
const row = await this.releaseSlot(this.client(), runId, patch);
|
|
124
|
+
const keys = Object.keys(row);
|
|
125
|
+
if (keys.length === 0) {
|
|
126
|
+
const found = await hot.rows(RUN_IN_STATUSES_SQL, [runId, expectedStatuses]);
|
|
127
|
+
return found.length > 0;
|
|
128
|
+
}
|
|
129
|
+
const { count } = await hot.run(updateRunSql(keys, true), [
|
|
130
|
+
...keys.map((k) => row[k]),
|
|
131
|
+
runId,
|
|
132
|
+
expectedStatuses,
|
|
133
|
+
]);
|
|
134
|
+
return count === 1;
|
|
135
|
+
}
|
|
92
136
|
return this.writeRun(patch, async (trx) => {
|
|
93
137
|
const row = await this.releaseSlot(trx, runId, patch);
|
|
94
138
|
let applied;
|
|
@@ -119,6 +163,15 @@ export class LucidStateStore {
|
|
|
119
163
|
return applied;
|
|
120
164
|
});
|
|
121
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* The hot path for a run write of `patch`: one that needs no transaction (see {@link writeRun}),
|
|
168
|
+
* on a connection that takes it.
|
|
169
|
+
*/
|
|
170
|
+
hotFor(patch) {
|
|
171
|
+
if (settlesTerminally(patch) || 'searchAttributes' in patch)
|
|
172
|
+
return undefined;
|
|
173
|
+
return this.hot();
|
|
174
|
+
}
|
|
122
175
|
async pullRunWakesIn(runIds, wakeAt) {
|
|
123
176
|
if (runIds.length === 0)
|
|
124
177
|
return;
|
|
@@ -222,6 +275,13 @@ export class LucidStateStore {
|
|
|
222
275
|
});
|
|
223
276
|
}
|
|
224
277
|
async getRun(runId) {
|
|
278
|
+
const hot = this.hot();
|
|
279
|
+
if (hot) {
|
|
280
|
+
const rows = this.scopeNamespace === undefined
|
|
281
|
+
? await hot.rows(GET_RUN_SQL, [runId])
|
|
282
|
+
: await hot.rows(GET_RUN_SCOPED_SQL, [runId, namespaceFilterValues(this.scopeNamespace)]);
|
|
283
|
+
return rows[0] ? rowToRun(rows[0]) : null;
|
|
284
|
+
}
|
|
225
285
|
const row = await this.fenced(this.client().from(DURABLE_TABLES.runs).where('id', runId)).first();
|
|
226
286
|
return row ? rowToRun(row) : null;
|
|
227
287
|
}
|
|
@@ -261,6 +321,11 @@ export class LucidStateStore {
|
|
|
261
321
|
}
|
|
262
322
|
// --- checkpoints --------------------------------------------------------
|
|
263
323
|
async getCheckpoint(runId, seq) {
|
|
324
|
+
const hot = this.hot();
|
|
325
|
+
if (hot) {
|
|
326
|
+
const rows = await hot.rows(GET_CHECKPOINT_SQL, [runId, seq]);
|
|
327
|
+
return rows[0] ? rowToCheckpoint(rows[0]) : null;
|
|
328
|
+
}
|
|
264
329
|
const row = await this.client()
|
|
265
330
|
.from(DURABLE_TABLES.checkpoints)
|
|
266
331
|
.where('run_id', runId)
|
|
@@ -282,6 +347,13 @@ export class LucidStateStore {
|
|
|
282
347
|
}
|
|
283
348
|
async getCheckpointsForSettle(runId, seqs) {
|
|
284
349
|
const unique = [...new Set(seqs)];
|
|
350
|
+
const hot = this.hot();
|
|
351
|
+
if (hot && unique.length <= CHECKPOINT_BATCH) {
|
|
352
|
+
const rows = await hot.rows(CHECKPOINTS_FOR_SETTLE_SQL, [runId, unique]);
|
|
353
|
+
return rows
|
|
354
|
+
.map(rowToCheckpoint)
|
|
355
|
+
.sort((a, b) => a.seq - b.seq);
|
|
356
|
+
}
|
|
285
357
|
const out = [];
|
|
286
358
|
for (let at = 0; at < unique.length; at += CHECKPOINT_BATCH) {
|
|
287
359
|
const rows = (await this.client()
|
|
@@ -408,6 +480,135 @@ export class LucidStateStore {
|
|
|
408
480
|
return wrote;
|
|
409
481
|
});
|
|
410
482
|
}
|
|
483
|
+
async settleResultsIfPending(results) {
|
|
484
|
+
if (results.length === 0)
|
|
485
|
+
return [];
|
|
486
|
+
// A later entry for a row an earlier one claims loses, before anything is written.
|
|
487
|
+
const seen = new Set();
|
|
488
|
+
const first = results.map((r) => {
|
|
489
|
+
const k = checkpointKey(r.runId, r.seq);
|
|
490
|
+
if (seen.has(k))
|
|
491
|
+
return false;
|
|
492
|
+
seen.add(k);
|
|
493
|
+
return true;
|
|
494
|
+
});
|
|
495
|
+
const live = results.filter((_, i) => first[i]);
|
|
496
|
+
const client = this.client();
|
|
497
|
+
const settled = client.dialect.name === 'postgres'
|
|
498
|
+
? await settleResultsReturningPg(client, this.hot(), live)
|
|
499
|
+
: await this.settleResultsPlain(live);
|
|
500
|
+
const out = [];
|
|
501
|
+
let k = 0;
|
|
502
|
+
for (let i = 0; i < results.length; i += 1) {
|
|
503
|
+
if (!first[i]) {
|
|
504
|
+
out.push(null);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const r = live[k];
|
|
508
|
+
k += 1;
|
|
509
|
+
out.push(settled.get(checkpointKey(r.runId, r.seq)) ?? null);
|
|
510
|
+
}
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* {@link settleResultsIfPending} on a dialect without `UPDATE … RETURNING` in one statement
|
|
515
|
+
* (MySQL, SQLite): the runs' statuses, the rows, then the settle (one conditional UPDATE for a
|
|
516
|
+
* single result, the batched transaction for several). The same three statements the engine's
|
|
517
|
+
* read-first path costs.
|
|
518
|
+
*/
|
|
519
|
+
async settleResultsPlain(results) {
|
|
520
|
+
const out = new Map();
|
|
521
|
+
const client = this.client();
|
|
522
|
+
const runRows = (await client
|
|
523
|
+
.from(DURABLE_TABLES.runs)
|
|
524
|
+
.whereIn('id', [...new Set(results.map((r) => r.runId))])
|
|
525
|
+
.select('id', 'status', 'wake_at'));
|
|
526
|
+
const runs = new Map(runRows.map((row) => [row.id, row]));
|
|
527
|
+
const settleable = results.filter((r) => SETTLEABLE_RUN_STATUSES.includes(runs.get(r.runId)?.status));
|
|
528
|
+
if (settleable.length === 0)
|
|
529
|
+
return out;
|
|
530
|
+
// What the result does not carry keeps its stored value, so the rows are read first.
|
|
531
|
+
const stored = new Map();
|
|
532
|
+
const byRun = new Map();
|
|
533
|
+
for (const r of settleable)
|
|
534
|
+
byRun.set(r.runId, [...(byRun.get(r.runId) ?? []), r.seq]);
|
|
535
|
+
for (const [runId, seqs] of byRun) {
|
|
536
|
+
for (const cp of await this.getCheckpointsForSettle(runId, seqs)) {
|
|
537
|
+
stored.set(checkpointKey(cp.runId, cp.seq), cp);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const settlements = settleable.flatMap((r) => {
|
|
541
|
+
const cp = stored.get(checkpointKey(r.runId, r.seq));
|
|
542
|
+
if (!cp)
|
|
543
|
+
return [];
|
|
544
|
+
return [
|
|
545
|
+
{
|
|
546
|
+
runId: r.runId,
|
|
547
|
+
seq: r.seq,
|
|
548
|
+
status: r.status,
|
|
549
|
+
output: r.status === 'completed' ? r.output : cp.output,
|
|
550
|
+
error: r.error,
|
|
551
|
+
events: r.events ?? cp.events,
|
|
552
|
+
startedAt: r.startedAt ?? cp.startedAt,
|
|
553
|
+
finishedAt: r.finishedAt,
|
|
554
|
+
},
|
|
555
|
+
];
|
|
556
|
+
});
|
|
557
|
+
const wrote = await this.settleCheckpointsIfPending(settlements);
|
|
558
|
+
// The rows as they now read: the stored row with the settlement written over it (the settle is a
|
|
559
|
+
// compare-and-set on `pending`, so nothing else wrote it in between).
|
|
560
|
+
settlements.forEach((settlement, i) => {
|
|
561
|
+
if (!wrote[i])
|
|
562
|
+
return;
|
|
563
|
+
const k = checkpointKey(settlement.runId, settlement.seq);
|
|
564
|
+
const { runId: _runId, seq: _seq, ...written } = settlement;
|
|
565
|
+
const checkpoint = { ...stored.get(k), ...written };
|
|
566
|
+
for (const key of ['output', 'error', 'events']) {
|
|
567
|
+
if (checkpoint[key] === undefined)
|
|
568
|
+
delete checkpoint[key];
|
|
569
|
+
}
|
|
570
|
+
const run = runs.get(settlement.runId);
|
|
571
|
+
out.set(k, {
|
|
572
|
+
checkpoint,
|
|
573
|
+
run: {
|
|
574
|
+
status: run.status,
|
|
575
|
+
wakeAt: run.wake_at == null ? undefined : Number(run.wake_at),
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
async insertCheckpointIfAbsent(checkpoint) {
|
|
582
|
+
const client = this.client();
|
|
583
|
+
const row = checkpointToRow(checkpoint);
|
|
584
|
+
const hot = this.hot();
|
|
585
|
+
if (hot) {
|
|
586
|
+
const keys = Object.keys(row);
|
|
587
|
+
const values = keys.map((k) => row[k] ?? null);
|
|
588
|
+
return (await hot.rows(insertCheckpointIfAbsentSql(keys), values)).length > 0;
|
|
589
|
+
}
|
|
590
|
+
if (client.dialect.name === 'postgres') {
|
|
591
|
+
const inserted = (await client
|
|
592
|
+
.insertQuery()
|
|
593
|
+
.table(DURABLE_TABLES.checkpoints)
|
|
594
|
+
.knexQuery.insert(row)
|
|
595
|
+
.onConflict(['run_id', 'seq'])
|
|
596
|
+
.ignore()
|
|
597
|
+
.returning('seq'));
|
|
598
|
+
return inserted.length > 0;
|
|
599
|
+
}
|
|
600
|
+
// MySQL and SQLite: a plain INSERT, whose duplicate-key error is the "already there" answer (an
|
|
601
|
+
// `INSERT IGNORE` would also swallow every other error, and reports no row count through Knex).
|
|
602
|
+
try {
|
|
603
|
+
await client.table(DURABLE_TABLES.checkpoints).insert(row);
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
catch (err) {
|
|
607
|
+
if (isDuplicateKey(err))
|
|
608
|
+
return false;
|
|
609
|
+
throw err;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
411
612
|
/** Persist the latest worker heartbeat on the step's checkpoint row (see the interface doc).
|
|
412
613
|
* A targeted two-column UPDATE — never an upsert: a beat racing the checkpoint's own insert (or
|
|
413
614
|
* outliving a deleted run) matches zero rows and is a silent no-op, by design. */
|
|
@@ -472,6 +673,63 @@ export class LucidStateStore {
|
|
|
472
673
|
.limit(limit);
|
|
473
674
|
return rows.map(rowToRun);
|
|
474
675
|
}
|
|
676
|
+
async claimPendingRuns(limit, owner, leaseUntilMs, nowMs, namespace) {
|
|
677
|
+
if (limit <= 0)
|
|
678
|
+
return [];
|
|
679
|
+
const client = this.client();
|
|
680
|
+
const dialect = client.dialect.name;
|
|
681
|
+
const free = (q) => {
|
|
682
|
+
q.where('status', 'pending').andWhere((w) => {
|
|
683
|
+
w.whereNull('locked_until').orWhere('locked_until', '<=', nowMs);
|
|
684
|
+
});
|
|
685
|
+
if (namespace !== undefined)
|
|
686
|
+
q.whereIn('namespace', namespaceFilterValues(namespace));
|
|
687
|
+
return this.fenced(q);
|
|
688
|
+
};
|
|
689
|
+
const oldest = (q) => free(q).select('id').orderBy('created_at', 'asc').orderBy('id', 'asc').limit(limit);
|
|
690
|
+
let rows;
|
|
691
|
+
if (dialect === 'postgres') {
|
|
692
|
+
// One statement: pick the oldest free pending rows, skipping any another claimer holds locked
|
|
693
|
+
// right now, and lease them. Several pollers at once each get a different slice.
|
|
694
|
+
const pick = oldest(client.from(DURABLE_TABLES.runs)).forUpdate().skipLocked();
|
|
695
|
+
rows = (await client
|
|
696
|
+
.from(DURABLE_TABLES.runs)
|
|
697
|
+
.whereIn('id', pick)
|
|
698
|
+
.update({ locked_by: owner, locked_until: leaseUntilMs })
|
|
699
|
+
.returning('*'));
|
|
700
|
+
}
|
|
701
|
+
else if (dialect === 'mysql') {
|
|
702
|
+
// No UPDATE … RETURNING: lock the batch (skipping rows another claimer holds), lease it and read
|
|
703
|
+
// it back, in one transaction.
|
|
704
|
+
rows = await client.transaction(async (trx) => {
|
|
705
|
+
const ids = (await oldest(trx.from(DURABLE_TABLES.runs)).forUpdate().skipLocked()).map((r) => r.id);
|
|
706
|
+
if (ids.length === 0)
|
|
707
|
+
return [];
|
|
708
|
+
await trx
|
|
709
|
+
.from(DURABLE_TABLES.runs)
|
|
710
|
+
.whereIn('id', ids)
|
|
711
|
+
.update({ locked_by: owner, locked_until: leaseUntilMs });
|
|
712
|
+
return (await trx.from(DURABLE_TABLES.runs).whereIn('id', ids));
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
// SQLite has no row locks to skip: each run is taken with the lease's own compare-and-set.
|
|
717
|
+
const claimed = [];
|
|
718
|
+
const candidates = (await free(client.from(DURABLE_TABLES.runs))
|
|
719
|
+
.orderBy('created_at', 'asc')
|
|
720
|
+
.orderBy('id', 'asc')
|
|
721
|
+
.limit(limit));
|
|
722
|
+
for (const run of candidates.map(rowToRun)) {
|
|
723
|
+
if (await this.tryLockRun(run.id, owner, leaseUntilMs, nowMs)) {
|
|
724
|
+
claimed.push({ ...run, lockedBy: owner, lockedUntil: leaseUntilMs });
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return claimed;
|
|
728
|
+
}
|
|
729
|
+
return rows
|
|
730
|
+
.map(rowToRun)
|
|
731
|
+
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id));
|
|
732
|
+
}
|
|
475
733
|
async listDueTimers(nowMs, namespace, limit) {
|
|
476
734
|
const marker = await this.hasWakeMarkerColumn();
|
|
477
735
|
const q = this.fenced(this.client()
|
|
@@ -506,7 +764,51 @@ export class LucidStateStore {
|
|
|
506
764
|
.update({ locked_by: owner, locked_until: leaseUntilMs });
|
|
507
765
|
return rowsAffected(affected) === 1;
|
|
508
766
|
}
|
|
767
|
+
async tryLockRunAndGet(runId, owner, leaseUntilMs, nowMs) {
|
|
768
|
+
const hot = this.hot();
|
|
769
|
+
if (hot) {
|
|
770
|
+
const rows = this.scopeNamespace === undefined
|
|
771
|
+
? await hot.rows(LOCK_AND_GET_SQL, [owner, leaseUntilMs, runId, nowMs])
|
|
772
|
+
: await hot.rows(LOCK_AND_GET_SCOPED_SQL, [
|
|
773
|
+
owner,
|
|
774
|
+
leaseUntilMs,
|
|
775
|
+
runId,
|
|
776
|
+
nowMs,
|
|
777
|
+
namespaceFilterValues(this.scopeNamespace),
|
|
778
|
+
]);
|
|
779
|
+
return rows[0] ? rowToRun(rows[0]) : null;
|
|
780
|
+
}
|
|
781
|
+
const client = this.client();
|
|
782
|
+
if (client.dialect.name === 'postgres') {
|
|
783
|
+
// One statement: the lease's compare-and-set and the read of the row it locked. The scope
|
|
784
|
+
// fence sits in the WHERE clause, so a run outside it is neither locked nor read.
|
|
785
|
+
const rows = (await this.fenced(client
|
|
786
|
+
.from(DURABLE_TABLES.runs)
|
|
787
|
+
.where('id', runId)
|
|
788
|
+
.andWhere((q) => {
|
|
789
|
+
q.whereNull('locked_until').orWhere('locked_until', '<=', nowMs);
|
|
790
|
+
}))
|
|
791
|
+
.update({ locked_by: owner, locked_until: leaseUntilMs })
|
|
792
|
+
.returning('*'));
|
|
793
|
+
return rows[0] ? rowToRun(rows[0]) : null;
|
|
794
|
+
}
|
|
795
|
+
if (!(await this.tryLockRun(runId, owner, leaseUntilMs, nowMs)))
|
|
796
|
+
return null;
|
|
797
|
+
const run = await this.getRun(runId);
|
|
798
|
+
// Outside the store's read scope: give the lease back.
|
|
799
|
+
if (!run)
|
|
800
|
+
await this.releaseRunLock(runId, owner);
|
|
801
|
+
return run;
|
|
802
|
+
}
|
|
509
803
|
async releaseRunLock(runId, owner) {
|
|
804
|
+
const hot = this.hot();
|
|
805
|
+
if (hot) {
|
|
806
|
+
if (owner === undefined)
|
|
807
|
+
await hot.run(RELEASE_LOCK_SQL, [runId]);
|
|
808
|
+
else
|
|
809
|
+
await hot.run(RELEASE_OWN_LOCK_SQL, [runId, owner]);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
510
812
|
// Owner-scoped release (when `owner` is given): the `locked_by` predicate lives in the UPDATE's
|
|
511
813
|
// WHERE clause, so a stale executor whose lease was taken over matches zero rows and cannot
|
|
512
814
|
// wipe the new owner's lease — same conditional-write shape as renewRunLock.
|
|
@@ -519,6 +821,17 @@ export class LucidStateStore {
|
|
|
519
821
|
// One conditional UPDATE: a marker a refused wake set after the holder's last look keeps the
|
|
520
822
|
// lease where it is, and the holder runs another turn.
|
|
521
823
|
const marker = await this.hasWakeMarkerColumn();
|
|
824
|
+
const hot = this.hot();
|
|
825
|
+
if (hot) {
|
|
826
|
+
const released = await hot.run(marker ? RELEASE_UNLESS_WAKE_SQL : RELEASE_OWN_LOCK_SQL, [
|
|
827
|
+
runId,
|
|
828
|
+
owner,
|
|
829
|
+
]);
|
|
830
|
+
if (released.count === 1)
|
|
831
|
+
return true;
|
|
832
|
+
const rows = await hot.rows(LOCKED_BY_SQL, [runId]);
|
|
833
|
+
return rows[0]?.locked_by !== owner;
|
|
834
|
+
}
|
|
522
835
|
const q = this.client()
|
|
523
836
|
.from(DURABLE_TABLES.runs)
|
|
524
837
|
.where('id', runId)
|
|
@@ -558,6 +871,9 @@ export class LucidStateStore {
|
|
|
558
871
|
return this.wakeMarkerColumn;
|
|
559
872
|
}
|
|
560
873
|
async renewRunLock(runId, owner, leaseUntilMs) {
|
|
874
|
+
const hot = this.hot();
|
|
875
|
+
if (hot)
|
|
876
|
+
return (await hot.run(RENEW_LOCK_SQL, [leaseUntilMs, runId, owner])).count === 1;
|
|
561
877
|
// Extend ONLY if `owner` still holds the lease — a dead worker's lease still expires & is reclaimed.
|
|
562
878
|
const affected = await this.client()
|
|
563
879
|
.from(DURABLE_TABLES.runs)
|
|
@@ -722,16 +1038,21 @@ export class LucidStateStore {
|
|
|
722
1038
|
// The oldest signal is ours only if our DELETE removed it, so two takers never get the same one;
|
|
723
1039
|
// that needs no transaction, and a token with nothing buffered (the common case) costs one read.
|
|
724
1040
|
const client = this.client();
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
.
|
|
728
|
-
|
|
729
|
-
|
|
1041
|
+
const hot = this.hot();
|
|
1042
|
+
const row = hot
|
|
1043
|
+
? (await hot.rows(OLDEST_BUFFERED_SIGNAL_SQL, [token]))[0]
|
|
1044
|
+
: await client
|
|
1045
|
+
.from(DURABLE_TABLES.bufferedSignals)
|
|
1046
|
+
.where('token', token)
|
|
1047
|
+
.orderBy('id', 'asc')
|
|
1048
|
+
.first();
|
|
730
1049
|
if (!row)
|
|
731
1050
|
return null;
|
|
732
1051
|
const r = row;
|
|
733
|
-
const deleted =
|
|
734
|
-
|
|
1052
|
+
const deleted = hot
|
|
1053
|
+
? (await hot.run(DELETE_BUFFERED_SIGNAL_SQL, [r.id])).count
|
|
1054
|
+
: rowsAffected(await client.from(DURABLE_TABLES.bufferedSignals).where('id', r.id).delete());
|
|
1055
|
+
if (deleted !== 1)
|
|
735
1056
|
return null;
|
|
736
1057
|
return {
|
|
737
1058
|
payload: parseJsonColumn(r.payload, `${DURABLE_TABLES.bufferedSignals}.payload`, String(r.id)),
|
|
@@ -749,11 +1070,14 @@ export class LucidStateStore {
|
|
|
749
1070
|
});
|
|
750
1071
|
}
|
|
751
1072
|
async listBufferedEvents(name, limit) {
|
|
752
|
-
const
|
|
753
|
-
|
|
754
|
-
.
|
|
755
|
-
.
|
|
756
|
-
|
|
1073
|
+
const hot = this.hot();
|
|
1074
|
+
const rows = hot
|
|
1075
|
+
? await hot.rows(BUFFERED_EVENTS_SQL, [name, limit])
|
|
1076
|
+
: await this.client()
|
|
1077
|
+
.from(DURABLE_TABLES.bufferedEvents)
|
|
1078
|
+
.where('name', name)
|
|
1079
|
+
.orderBy('published_at', 'asc') // oldest first
|
|
1080
|
+
.limit(limit);
|
|
757
1081
|
return rows.map((r) => ({
|
|
758
1082
|
id: r.id,
|
|
759
1083
|
payload: parseJsonColumn(r.payload, `${DURABLE_TABLES.bufferedEvents}.payload`, r.id),
|
|
@@ -1056,11 +1380,14 @@ export class LucidStateStore {
|
|
|
1056
1380
|
return rows.map(rowToCheckpoint);
|
|
1057
1381
|
}
|
|
1058
1382
|
async listCheckpointsForReplay(runId) {
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
.
|
|
1062
|
-
.
|
|
1063
|
-
|
|
1383
|
+
const hot = this.hot();
|
|
1384
|
+
const rows = (hot
|
|
1385
|
+
? await hot.rows(REPLAY_CHECKPOINTS_SQL, [runId])
|
|
1386
|
+
: await this.client()
|
|
1387
|
+
.from(DURABLE_TABLES.checkpoints)
|
|
1388
|
+
.where('run_id', runId)
|
|
1389
|
+
.orderBy('seq', 'asc')
|
|
1390
|
+
.select(...CHECKPOINT_COLUMNS_BUT_INPUT));
|
|
1064
1391
|
// The input of every row but a completed one, read by seq: a row that completed since the read
|
|
1065
1392
|
// above still gets its input, as that read reported it unsettled.
|
|
1066
1393
|
const unsettled = rows
|
|
@@ -1109,6 +1436,11 @@ export class LucidStateStore {
|
|
|
1109
1436
|
async countCheckpoints(runId, fromSeq, toSeq, statuses) {
|
|
1110
1437
|
if (statuses.length === 0)
|
|
1111
1438
|
return 0;
|
|
1439
|
+
const hot = this.hot();
|
|
1440
|
+
if (hot) {
|
|
1441
|
+
const rows = await hot.rows(COUNT_CHECKPOINTS_SQL, [runId, fromSeq, toSeq, statuses]);
|
|
1442
|
+
return Number(rows[0]?.count ?? 0);
|
|
1443
|
+
}
|
|
1112
1444
|
const rows = await this.client()
|
|
1113
1445
|
.from(DURABLE_TABLES.checkpoints)
|
|
1114
1446
|
.where('run_id', runId)
|
|
@@ -1328,6 +1660,113 @@ function nameKindSql(dialect) {
|
|
|
1328
1660
|
function escapeLike(value) {
|
|
1329
1661
|
return value.replace(/[!%_]/g, (c) => `!${c}`);
|
|
1330
1662
|
}
|
|
1663
|
+
// --- the Postgres hot path's statements (see `lucid-pg-hot-path.ts`) --------------------------------
|
|
1664
|
+
const RUNS_T = quote(DURABLE_TABLES.runs);
|
|
1665
|
+
const CHECKPOINTS_T = quote(DURABLE_TABLES.checkpoints);
|
|
1666
|
+
const CHECKPOINT_LIST = CHECKPOINT_COLUMNS_BUT_INPUT.map(quote).join(', ');
|
|
1667
|
+
const GET_RUN_SQL = `select * from ${RUNS_T} where "id" = ? limit 1`;
|
|
1668
|
+
const GET_RUN_SCOPED_SQL = `select * from ${RUNS_T} where "id" = ? and "namespace" = any(?) limit 1`;
|
|
1669
|
+
const RUN_IN_STATUSES_SQL = `select 1 from ${RUNS_T} where "id" = ? and "status" = any(?) limit 1`;
|
|
1670
|
+
const LOCK_AND_GET_SQL = `update ${RUNS_T} set "locked_by" = ?, "locked_until" = ? where "id" = ? and ("locked_until" is null or "locked_until" <= ?) returning *`;
|
|
1671
|
+
const LOCK_AND_GET_SCOPED_SQL = `update ${RUNS_T} set "locked_by" = ?, "locked_until" = ? where "id" = ? and ("locked_until" is null or "locked_until" <= ?) and "namespace" = any(?) returning *`;
|
|
1672
|
+
const RELEASE_LOCK_SQL = `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ?`;
|
|
1673
|
+
const RELEASE_OWN_LOCK_SQL = `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ? and "locked_by" = ?`;
|
|
1674
|
+
const RELEASE_UNLESS_WAKE_SQL = `update ${RUNS_T} set "locked_by" = null, "locked_until" = null where "id" = ? and "locked_by" = ? and "wake_requested_at" is null`;
|
|
1675
|
+
const LOCKED_BY_SQL = `select "locked_by" from ${RUNS_T} where "id" = ? limit 1`;
|
|
1676
|
+
const RENEW_LOCK_SQL = `update ${RUNS_T} set "locked_until" = ? where "id" = ? and "locked_by" = ?`;
|
|
1677
|
+
const DELETE_ATTRIBUTES_SQL = `delete from ${quote(DURABLE_TABLES.attributes)} where "run_id" = ?`;
|
|
1678
|
+
const GET_CHECKPOINT_SQL = `select * from ${CHECKPOINTS_T} where "run_id" = ? and "seq" = ? limit 1`;
|
|
1679
|
+
const CHECKPOINTS_FOR_SETTLE_SQL = `select ${CHECKPOINT_LIST} from ${CHECKPOINTS_T} where "run_id" = ? and "seq" = any(?)`;
|
|
1680
|
+
const REPLAY_CHECKPOINTS_SQL = `select ${CHECKPOINT_LIST} from ${CHECKPOINTS_T} where "run_id" = ? order by "seq" asc`;
|
|
1681
|
+
const COUNT_CHECKPOINTS_SQL = `select count(*) as "count" from ${CHECKPOINTS_T} where "run_id" = ? and "seq" >= ? and "seq" <= ? and "status" = any(?)`;
|
|
1682
|
+
const OLDEST_BUFFERED_SIGNAL_SQL = `select * from ${quote(DURABLE_TABLES.bufferedSignals)} where "token" = ? order by "id" asc limit 1`;
|
|
1683
|
+
const DELETE_BUFFERED_SIGNAL_SQL = `delete from ${quote(DURABLE_TABLES.bufferedSignals)} where "id" = ?`;
|
|
1684
|
+
const BUFFERED_EVENTS_SQL = `select * from ${quote(DURABLE_TABLES.bufferedEvents)} where "name" = ? order by "published_at" asc limit ?`;
|
|
1685
|
+
/** Statements whose column list is the row's own keys, built once per key set. */
|
|
1686
|
+
const shaped = new Map();
|
|
1687
|
+
function shapedSql(key, build) {
|
|
1688
|
+
let text = shaped.get(key);
|
|
1689
|
+
if (text === undefined) {
|
|
1690
|
+
text = build();
|
|
1691
|
+
shaped.set(key, text);
|
|
1692
|
+
}
|
|
1693
|
+
return text;
|
|
1694
|
+
}
|
|
1695
|
+
/** `INSERT` of one row of `table` with the columns `keys`. */
|
|
1696
|
+
function insertSql(table, keys) {
|
|
1697
|
+
return shapedSql(`insert:${table}:${keys.join(',')}`, () => {
|
|
1698
|
+
const cols = keys.map(quote).join(', ');
|
|
1699
|
+
return `insert into ${quote(table)} (${cols}) values (${keys.map(() => '?').join(', ')})`;
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
/** {@link insertSql} of a checkpoint that writes nothing when its position is taken. */
|
|
1703
|
+
function insertCheckpointIfAbsentSql(keys) {
|
|
1704
|
+
return shapedSql(`insert-if-absent:${keys.join(',')}`, () => `${insertSql(DURABLE_TABLES.checkpoints, keys)} on conflict ("run_id", "seq") do nothing returning "seq"`);
|
|
1705
|
+
}
|
|
1706
|
+
/** `UPDATE` of one run's columns `keys`, with a status predicate when `conditional`. */
|
|
1707
|
+
function updateRunSql(keys, conditional) {
|
|
1708
|
+
return shapedSql(`update-run:${conditional}:${keys.join(',')}`, () => {
|
|
1709
|
+
const set = keys.map((k) => `${quote(k)} = ?`).join(', ');
|
|
1710
|
+
const where = conditional ? `"id" = ? and "status" = any(?)` : `"id" = ?`;
|
|
1711
|
+
return `update ${RUNS_T} set ${set} where ${where}`;
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* {@link LucidStateStore.settleResultsIfPending} on Postgres: one `UPDATE … FROM (VALUES …) …
|
|
1716
|
+
* RETURNING` for the whole batch (entries already deduplicated by row). The run's status is checked
|
|
1717
|
+
* in the same statement, and read back with its `wake_at`. What a result does not carry keeps its
|
|
1718
|
+
* stored value: `output` unless it completed, `events` and `started_at` unless present.
|
|
1719
|
+
*/
|
|
1720
|
+
async function settleResultsReturningPg(client, hot, results) {
|
|
1721
|
+
const values = [];
|
|
1722
|
+
const bindings = [];
|
|
1723
|
+
for (const r of results) {
|
|
1724
|
+
const row = settlementToRow({
|
|
1725
|
+
...r,
|
|
1726
|
+
startedAt: r.startedAt ?? new Date(0),
|
|
1727
|
+
});
|
|
1728
|
+
values.push('(?::text, ?::int, ?::text, ?::text, ?::text, ?::boolean, ?::text, ?::bigint, ?::bigint)');
|
|
1729
|
+
bindings.push(r.runId, r.seq, r.status, r.status === 'completed' ? row.output : null, row.error, r.events !== undefined, row.events, r.startedAt ? row.started_at : null, row.finished_at);
|
|
1730
|
+
}
|
|
1731
|
+
const cols = CHECKPOINT_COLUMNS_BUT_INPUT.map((c) => `c.${c}`).join(', ');
|
|
1732
|
+
const statuses = SETTLEABLE_RUN_STATUSES.map(() => '?').join(', ');
|
|
1733
|
+
bindings.push(...SETTLEABLE_RUN_STATUSES);
|
|
1734
|
+
const sql = `update ${DURABLE_TABLES.checkpoints} as c set
|
|
1735
|
+
status = v.status,
|
|
1736
|
+
output = case when v.status = 'completed' then v.output else c.output end,
|
|
1737
|
+
error = v.error,
|
|
1738
|
+
events = case when v.has_events then v.events else c.events end,
|
|
1739
|
+
started_at = coalesce(v.started_at, c.started_at),
|
|
1740
|
+
finished_at = v.finished_at
|
|
1741
|
+
from (values ${values.join(', ')})
|
|
1742
|
+
as v(run_id, seq, status, output, error, has_events, events, started_at, finished_at),
|
|
1743
|
+
${DURABLE_TABLES.runs} as r
|
|
1744
|
+
where c.run_id = v.run_id and c.seq = v.seq and c.status = 'pending'
|
|
1745
|
+
and r.id = c.run_id and r.status in (${statuses})
|
|
1746
|
+
returning ${cols}, r.status as run_status, r.wake_at as run_wake_at`;
|
|
1747
|
+
const { rows } = (hot ? { rows: await hot.rows(sql, bindings) } : await client.rawQuery(sql, bindings));
|
|
1748
|
+
const out = new Map();
|
|
1749
|
+
for (const row of rows) {
|
|
1750
|
+
const checkpoint = rowToCheckpoint(row);
|
|
1751
|
+
out.set(checkpointKey(checkpoint.runId, checkpoint.seq), {
|
|
1752
|
+
checkpoint,
|
|
1753
|
+
run: {
|
|
1754
|
+
status: row.run_status,
|
|
1755
|
+
wakeAt: row.run_wake_at == null ? undefined : Number(row.run_wake_at),
|
|
1756
|
+
},
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
return out;
|
|
1760
|
+
}
|
|
1761
|
+
/** A duplicate-key violation, as MySQL and SQLite report it through Knex. */
|
|
1762
|
+
function isDuplicateKey(err) {
|
|
1763
|
+
const e = err;
|
|
1764
|
+
return (e?.code === 'ER_DUP_ENTRY' ||
|
|
1765
|
+
e?.errno === 1062 ||
|
|
1766
|
+
e?.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' ||
|
|
1767
|
+
e?.code === 'SQLITE_CONSTRAINT_UNIQUE' ||
|
|
1768
|
+
e?.code === '23505');
|
|
1769
|
+
}
|
|
1331
1770
|
function checkpointKey(runId, seq) {
|
|
1332
1771
|
return `${runId}\u0000${seq}`;
|
|
1333
1772
|
}
|