@kubun/store-workflow 0.15.0 → 0.17.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/lib/api.d.ts +1 -0
- package/lib/api.js +83 -24
- package/package.json +7 -7
package/lib/api.d.ts
CHANGED
|
@@ -91,6 +91,7 @@ export type WorkflowStoreAPI = {
|
|
|
91
91
|
checkpoint(instanceID: string, input: CheckpointInput, now: number): Promise<void>;
|
|
92
92
|
fail(instanceID: string, input: FailInput, now: number, bootID?: string): Promise<boolean>;
|
|
93
93
|
finish(instanceID: string, input: FinishInput, now: number, bootID?: string): Promise<boolean>;
|
|
94
|
+
retryFailed(instanceID: string, now: number): Promise<WorkflowInstanceRow | null>;
|
|
94
95
|
listRecoverable(now: number): Promise<Array<WorkflowInstanceRow>>;
|
|
95
96
|
countRunning(queue: string, now: number): Promise<number>;
|
|
96
97
|
listRunnableQueues(now: number): Promise<Array<string>>;
|
package/lib/api.js
CHANGED
|
@@ -35,11 +35,10 @@ export function createWorkflowStore(db, adapter) {
|
|
|
35
35
|
const queue = input.queue ?? input.name;
|
|
36
36
|
const runAfter = input.runAfter ?? input.createdAt;
|
|
37
37
|
const updatedAt = input.updatedAt ?? input.createdAt;
|
|
38
|
-
//
|
|
39
|
-
// partial unique index is the
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// sequential pre-check path, so no driver error strings are parsed.
|
|
38
|
+
// The transaction gives the pre-checks and the insert one snapshot. The
|
|
39
|
+
// partial unique index is the race backstop; the graceful created:false
|
|
40
|
+
// return is the sequential pre-check path, so no driver error strings are
|
|
41
|
+
// parsed.
|
|
43
42
|
return await db.transaction().execute(async (tx)=>{
|
|
44
43
|
if (input.idempotencyKey != null) {
|
|
45
44
|
const existing = await tx.selectFrom('kubun_workflow_instances').selectAll().where('idempotency_key', '=', input.idempotencyKey).executeTakeFirst();
|
|
@@ -64,6 +63,12 @@ export function createWorkflowStore(db, adapter) {
|
|
|
64
63
|
};
|
|
65
64
|
}
|
|
66
65
|
}
|
|
66
|
+
// ON CONFLICT DO NOTHING makes the insert atomic against a racer this
|
|
67
|
+
// snapshot's pre-checks could not yet see: two inserts for the same
|
|
68
|
+
// idempotency_key (the cross-hub idempotent retry) or the same active
|
|
69
|
+
// singleton both clear the pre-checks, and the unique indexes arbitrate.
|
|
70
|
+
// The loser inserts nothing and reads the winner's row below — no
|
|
71
|
+
// adapter-specific constraint-error parsing.
|
|
67
72
|
await tx.insertInto('kubun_workflow_instances').values({
|
|
68
73
|
instance_id: input.instanceID,
|
|
69
74
|
name: input.name,
|
|
@@ -81,12 +86,43 @@ export function createWorkflowStore(db, adapter) {
|
|
|
81
86
|
last_error: null,
|
|
82
87
|
created_at: input.createdAt,
|
|
83
88
|
updated_at: updatedAt
|
|
84
|
-
}).execute();
|
|
85
|
-
const row = await tx.selectFrom('kubun_workflow_instances').selectAll().where('instance_id', '=', input.instanceID).
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
}).onConflict((oc)=>oc.doNothing()).execute();
|
|
90
|
+
const row = await tx.selectFrom('kubun_workflow_instances').selectAll().where('instance_id', '=', input.instanceID).executeTakeFirst();
|
|
91
|
+
if (row != null) {
|
|
92
|
+
return {
|
|
93
|
+
row: rowToInstance(row),
|
|
94
|
+
created: true
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// The insert was a no-op: a concurrent racer holds the conflicting key.
|
|
98
|
+
// Return its row as the idempotent/singleton hit, mirroring the pre-checks.
|
|
99
|
+
if (input.idempotencyKey != null) {
|
|
100
|
+
const existing = await tx.selectFrom('kubun_workflow_instances').selectAll().where('idempotency_key', '=', input.idempotencyKey).executeTakeFirst();
|
|
101
|
+
if (existing != null) {
|
|
102
|
+
return {
|
|
103
|
+
row: rowToInstance(existing),
|
|
104
|
+
created: false,
|
|
105
|
+
reason: 'idempotent'
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (input.singletonKey != null) {
|
|
110
|
+
const active = await tx.selectFrom('kubun_workflow_instances').selectAll().where('name', '=', input.name).where('singleton_key', '=', input.singletonKey).where('status', 'in', [
|
|
111
|
+
'pending',
|
|
112
|
+
'running'
|
|
113
|
+
]).executeTakeFirst();
|
|
114
|
+
if (active != null) {
|
|
115
|
+
return {
|
|
116
|
+
row: rowToInstance(active),
|
|
117
|
+
created: false,
|
|
118
|
+
reason: 'singleton'
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// A no-op insert with no recoverable conflicting row means the constraint
|
|
123
|
+
// hit was neither unique index we recover from — surface it, never a
|
|
124
|
+
// silent empty return.
|
|
125
|
+
throw new Error(`Workflow instance "${input.instanceID}" insert did nothing but no conflicting row was found`);
|
|
90
126
|
});
|
|
91
127
|
},
|
|
92
128
|
async claim (instanceID, bootID, leaseMs, now) {
|
|
@@ -122,11 +158,10 @@ export function createWorkflowStore(db, adapter) {
|
|
|
122
158
|
])
|
|
123
159
|
])
|
|
124
160
|
]);
|
|
125
|
-
// One statement:
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// columns, not the snapshot subquery) and skips it — no double-claim,
|
|
161
|
+
// One statement: the inner select picks the next `limit` candidates, but
|
|
162
|
+
// `claimable` is repeated in the OUTER update's WHERE so IT is the atomic
|
|
163
|
+
// guard. A concurrent loser blocks on the row lock, re-evaluates the outer
|
|
164
|
+
// WHERE against the now-running row, and skips it — no double-claim,
|
|
130
165
|
// without SKIP LOCKED (which SQLite lacks).
|
|
131
166
|
const rows = await db.updateTable('kubun_workflow_instances').set({
|
|
132
167
|
status: 'running',
|
|
@@ -183,6 +218,31 @@ export function createWorkflowStore(db, adapter) {
|
|
|
183
218
|
const row = await query.returning('instance_id').executeTakeFirst();
|
|
184
219
|
return row != null;
|
|
185
220
|
},
|
|
221
|
+
async retryFailed (instanceID, now) {
|
|
222
|
+
// One conditional UPDATE. `status = 'failed'` is the atomic guard: two
|
|
223
|
+
// concurrent retries leave one winner, the loser matching nothing. The
|
|
224
|
+
// singleton guard is a correlated NOT EXISTS re-checked in the same
|
|
225
|
+
// statement; the partial-unique singleton index is the true race backstop
|
|
226
|
+
// (a live singleton slipping past NOT EXISTS violates it on the write to
|
|
227
|
+
// `pending`, surfacing as a thrown singleton refusal). next_action IS NOT
|
|
228
|
+
// NULL keeps an unresumable row from re-arming into a fabricated action.
|
|
229
|
+
const row = await db.updateTable('kubun_workflow_instances').set({
|
|
230
|
+
status: 'pending',
|
|
231
|
+
lease_owner: null,
|
|
232
|
+
lease_expires: null,
|
|
233
|
+
last_error: null,
|
|
234
|
+
attempts: 0,
|
|
235
|
+
run_after: now,
|
|
236
|
+
updated_at: now
|
|
237
|
+
}).where('instance_id', '=', instanceID).where('status', '=', 'failed').where('next_action', 'is not', null).where((eb)=>eb.or([
|
|
238
|
+
eb('singleton_key', 'is', null),
|
|
239
|
+
eb.not(eb.exists(eb.selectFrom('kubun_workflow_instances as o').select('o.instance_id').whereRef('o.name', '=', 'kubun_workflow_instances.name').whereRef('o.singleton_key', '=', 'kubun_workflow_instances.singleton_key').whereRef('o.instance_id', '<>', 'kubun_workflow_instances.instance_id').where('o.status', 'in', [
|
|
240
|
+
'pending',
|
|
241
|
+
'running'
|
|
242
|
+
])))
|
|
243
|
+
])).returningAll().executeTakeFirst();
|
|
244
|
+
return row != null ? rowToInstance(row) : null;
|
|
245
|
+
},
|
|
186
246
|
async listRecoverable (now) {
|
|
187
247
|
const rows = await db.selectFrom('kubun_workflow_instances').selectAll().where('status', '=', 'running').where('lease_expires', '<', now).execute();
|
|
188
248
|
return rows.map(rowToInstance);
|
|
@@ -234,14 +294,13 @@ export function createWorkflowStore(db, adapter) {
|
|
|
234
294
|
async upsertAdaptiveSchedule (input, now) {
|
|
235
295
|
const reactivate = input.reactivate ?? true;
|
|
236
296
|
// One statement, no preceding read: ON CONFLICT DO UPDATE lets two
|
|
237
|
-
// concurrent enables of the same id both commit — one inserts,
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// user-paused row is not silently re-enabled; next_run still advances.
|
|
297
|
+
// concurrent enables of the same id both commit — one inserts, one updates
|
|
298
|
+
// — instead of the loser hitting the PK constraint. RETURNING hands back
|
|
299
|
+
// the committed row. The update touches only next_run + enabled, so an
|
|
300
|
+
// existing row's params/policy and its outcome streak survive a re-arm.
|
|
301
|
+
// Under reactivate:false it keeps the stored `enabled` flag (a qualified
|
|
302
|
+
// self-reference, portable across SQLite and Postgres) so a user-paused row
|
|
303
|
+
// is not silently re-enabled; next_run still advances.
|
|
245
304
|
const row = await db.insertInto('kubun_workflow_schedules').values({
|
|
246
305
|
id: input.id,
|
|
247
306
|
workflow_name: input.workflowName,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/store-workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"keywords": [],
|
|
5
5
|
"license": "see LICENSE.md",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -15,13 +15,13 @@
|
|
|
15
15
|
"LICENSE.md"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@kubun/db": "^0.
|
|
19
|
-
"@kubun/db-adapter": "^0.
|
|
18
|
+
"@kubun/db": "^0.17.0",
|
|
19
|
+
"@kubun/db-adapter": "^0.17.0",
|
|
20
20
|
"kysely": "^0.29.5"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@kubun/db-better-sqlite": "^0.
|
|
24
|
-
"@kubun/db-postgres": "^0.
|
|
23
|
+
"@kubun/db-better-sqlite": "^0.17.0",
|
|
24
|
+
"@kubun/db-postgres": "^0.17.0",
|
|
25
25
|
"@testcontainers/postgresql": "^12.1.0"
|
|
26
26
|
},
|
|
27
27
|
"publishConfig": {
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
32
32
|
"build:clean": "del lib",
|
|
33
|
-
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
34
|
-
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
33
|
+
"build:js": "del 'lib/**/*.js' && swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
34
|
+
"build:types": "del 'lib/**/*.d.ts' 'lib/**/*.d.ts.map' && tsc --emitDeclarationOnly --skipLibCheck",
|
|
35
35
|
"build:types:ci": "tsc --emitDeclarationOnly --declarationMap false",
|
|
36
36
|
"test": "pnpm run test:types && pnpm run test:unit",
|
|
37
37
|
"test:types": "tsc --noEmit -p tsconfig.test.json",
|