@kubun/store-workflow 0.15.0 → 0.16.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.
Files changed (3) hide show
  1. package/lib/api.d.ts +1 -0
  2. package/lib/api.js +83 -24
  3. package/package.json +5 -5
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
- // A transaction gives the pre-checks and the insert one snapshot. The
39
- // partial unique index is the concurrent-race backstop (a second
40
- // in-flight insert for the same active singleton would surface as a
41
- // thrown constraint error); the graceful created:false return is the
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).executeTakeFirstOrThrow();
86
- return {
87
- row: rowToInstance(row),
88
- created: true
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: pick the next `limit` candidates in the inner select, but
126
- // repeat `claimable` in the OUTER update's WHERE so IT is the atomic guard.
127
- // Under a concurrent claimDue the loser blocks on the row lock, then
128
- // re-evaluates the outer WHERE against the now-running row (its own
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, the other
238
- // updates — instead of the loser hitting the PK constraint. RETURNING
239
- // hands back the row as committed by this statement. The update touches
240
- // only next_run + enabled, so an existing row's params/policy and its
241
- // last_outcome/consecutive_count streak survive a re-arm. Under
242
- // reactivate:false the update keeps the stored `enabled` flag (a
243
- // qualified self-reference, portable across SQLite and Postgres) so a
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.15.0",
3
+ "version": "0.16.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.15.0",
19
- "@kubun/db-adapter": "^0.15.0",
18
+ "@kubun/db": "^0.16.0",
19
+ "@kubun/db-adapter": "^0.16.0",
20
20
  "kysely": "^0.29.5"
21
21
  },
22
22
  "devDependencies": {
23
- "@kubun/db-better-sqlite": "^0.15.0",
24
- "@kubun/db-postgres": "^0.15.0",
23
+ "@kubun/db-better-sqlite": "^0.16.0",
24
+ "@kubun/db-postgres": "^0.16.0",
25
25
  "@testcontainers/postgresql": "^12.1.0"
26
26
  },
27
27
  "publishConfig": {