@pikku/kysely 0.13.18 → 0.13.19
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
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
## 0.13.19
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 2d21628: fix(kysely): claim a workflow step atomically in every SQL dialect
|
|
6
|
+
|
|
7
|
+
The workflow engine's "atomic claim" was a read-then-write guarded by
|
|
8
|
+
`withStepLock`, and `@pikku/kysely` inherited a silent pass-through for that
|
|
9
|
+
lock — so on every dialect but Postgres and MySQL a redelivered queue job could
|
|
10
|
+
claim a step another dispatch was already running, executing a side-effecting
|
|
11
|
+
step twice.
|
|
12
|
+
|
|
13
|
+
`@pikku/kysely` now claims the step with a status-guarded `UPDATE` and reads the
|
|
14
|
+
affected-row count, which is atomic in every SQL dialect without an
|
|
15
|
+
advisory-lock primitive. Relay redispatch is enabled for all Kysely dialects as
|
|
16
|
+
a result, not just Postgres and MySQL.
|
|
17
|
+
|
|
18
|
+
- Updated dependencies [9687ad1]
|
|
19
|
+
- Updated dependencies [2d21628]
|
|
20
|
+
- Updated dependencies [985b87b]
|
|
21
|
+
- Updated dependencies [3a83f85]
|
|
22
|
+
- @pikku/core@0.12.87
|
|
23
|
+
|
|
1
24
|
## 0.13.18
|
|
2
25
|
|
|
3
26
|
### Patch Changes
|
|
@@ -47,7 +47,36 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
47
47
|
protected setStepErrorImpl(stepId: string, error: Error): Promise<void>;
|
|
48
48
|
protected createRetryAttemptImpl(stepId: string, status: 'pending' | 'running'): Promise<StepState>;
|
|
49
49
|
withRunLock<T>(_id: string, fn: () => Promise<T>): Promise<T>;
|
|
50
|
+
/**
|
|
51
|
+
* A pass-through, and deliberately so: the one decision that must exclude —
|
|
52
|
+
* claiming a step to execute it — is made by `claimStepForExecution` below as
|
|
53
|
+
* a single conditional write, which needs no lock to be exclusive.
|
|
54
|
+
*
|
|
55
|
+
* What is left are the suspend and approval sections in the engine, where the
|
|
56
|
+
* lock is genuine mutual exclusion rather than a claim. A dialect with a real
|
|
57
|
+
* primitive overrides this to cover them — `kysely-postgres` with
|
|
58
|
+
* `pg_advisory_xact_lock`, `kysely-mysql` with `GET_LOCK`.
|
|
59
|
+
*/
|
|
50
60
|
withStepLock<T>(_runId: string, _stepName: string, fn: () => Promise<T>): Promise<T>;
|
|
61
|
+
/**
|
|
62
|
+
* Claim the step with a status-guarded `UPDATE` and read the affected-row
|
|
63
|
+
* count: the database decides the winner in one statement, so two dispatches
|
|
64
|
+
* racing for the same step cannot both proceed.
|
|
65
|
+
*
|
|
66
|
+
* This replaces the read-then-write the base engine does under `withStepLock`,
|
|
67
|
+
* which is only as exclusive as that lock — and every dialect but Postgres and
|
|
68
|
+
* MySQL inherits a pass-through. A conditional update needs no advisory-lock
|
|
69
|
+
* primitive, so every SQL dialect gets the same guarantee.
|
|
70
|
+
*
|
|
71
|
+
* The winner then goes through the ordinary transition methods, so history
|
|
72
|
+
* rows and the mirror see exactly what they saw before.
|
|
73
|
+
*/
|
|
74
|
+
protected claimStepForExecution(runId: string, stepName: string, rpcName: string): Promise<StepState | null>;
|
|
75
|
+
/**
|
|
76
|
+
* Move a step to `running` only if it is still in one of `from`, reporting
|
|
77
|
+
* whether this caller is the one that moved it.
|
|
78
|
+
*/
|
|
79
|
+
private claimStepStatus;
|
|
51
80
|
getCompletedGraphState(runId: string): Promise<{
|
|
52
81
|
completedNodeIds: string[];
|
|
53
82
|
failedNodeIds: string[];
|
|
@@ -81,16 +110,18 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
81
110
|
private jsonPathFor;
|
|
82
111
|
protected updateRunStateImpl(runId: string, name: string, value: unknown): Promise<void>;
|
|
83
112
|
/**
|
|
84
|
-
* The undispatched-step query, shared by
|
|
113
|
+
* The undispatched-step query, shared by every dialect.
|
|
85
114
|
*
|
|
86
|
-
*
|
|
87
|
-
* `withStepLock` is a pass-through
|
|
88
|
-
*
|
|
89
|
-
* double executions
|
|
90
|
-
*
|
|
91
|
-
*
|
|
115
|
+
* It used to be deliberately NOT an override, because this class's
|
|
116
|
+
* `withStepLock` is a pass-through: a subclass inheriting it (kysely-sqlite)
|
|
117
|
+
* had no atomic claim, so the relay's redundant dispatches would have become
|
|
118
|
+
* double executions, and only `kysely-postgres` and `kysely-mysql` opted in
|
|
119
|
+
* on the strength of their real locks. `claimStepForExecution` no longer
|
|
120
|
+
* needs that lock — the claim is a status-guarded `UPDATE`, atomic in every
|
|
121
|
+
* dialect — so the relay is safe here for all of them and the override lives
|
|
122
|
+
* in the base.
|
|
92
123
|
*/
|
|
93
|
-
protected
|
|
124
|
+
protected findUndispatchedSteps(before: Date, limit: number): Promise<Array<{
|
|
94
125
|
runId: string;
|
|
95
126
|
stepId: string;
|
|
96
127
|
}>>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PikkuWorkflowService } from '@pikku/core/workflow';
|
|
1
|
+
import { PikkuWorkflowService, WorkflowStepFunctionMismatchError, } from '@pikku/core/workflow';
|
|
2
2
|
import { sql } from 'kysely';
|
|
3
3
|
import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js';
|
|
4
4
|
import { parseJson } from './kysely-json.js';
|
|
@@ -363,9 +363,73 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
363
363
|
async withRunLock(_id, fn) {
|
|
364
364
|
return fn();
|
|
365
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* A pass-through, and deliberately so: the one decision that must exclude —
|
|
368
|
+
* claiming a step to execute it — is made by `claimStepForExecution` below as
|
|
369
|
+
* a single conditional write, which needs no lock to be exclusive.
|
|
370
|
+
*
|
|
371
|
+
* What is left are the suspend and approval sections in the engine, where the
|
|
372
|
+
* lock is genuine mutual exclusion rather than a claim. A dialect with a real
|
|
373
|
+
* primitive overrides this to cover them — `kysely-postgres` with
|
|
374
|
+
* `pg_advisory_xact_lock`, `kysely-mysql` with `GET_LOCK`.
|
|
375
|
+
*/
|
|
366
376
|
async withStepLock(_runId, _stepName, fn) {
|
|
367
377
|
return fn();
|
|
368
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* Claim the step with a status-guarded `UPDATE` and read the affected-row
|
|
381
|
+
* count: the database decides the winner in one statement, so two dispatches
|
|
382
|
+
* racing for the same step cannot both proceed.
|
|
383
|
+
*
|
|
384
|
+
* This replaces the read-then-write the base engine does under `withStepLock`,
|
|
385
|
+
* which is only as exclusive as that lock — and every dialect but Postgres and
|
|
386
|
+
* MySQL inherits a pass-through. A conditional update needs no advisory-lock
|
|
387
|
+
* primitive, so every SQL dialect gets the same guarantee.
|
|
388
|
+
*
|
|
389
|
+
* The winner then goes through the ordinary transition methods, so history
|
|
390
|
+
* rows and the mirror see exactly what they saw before.
|
|
391
|
+
*/
|
|
392
|
+
async claimStepForExecution(runId, stepName, rpcName) {
|
|
393
|
+
const stepState = await this.getStepState(runId, stepName);
|
|
394
|
+
// knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
|
|
395
|
+
if (stepState.rpcName !== undefined &&
|
|
396
|
+
stepState.rpcName !== (rpcName ?? null)) {
|
|
397
|
+
throw new WorkflowStepFunctionMismatchError(runId, stepName);
|
|
398
|
+
}
|
|
399
|
+
if (stepState.status === 'succeeded' || stepState.status === 'running') {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
if (stepState.status === 'failed') {
|
|
403
|
+
if (!(await this.claimStepStatus(stepState.stepId, ['failed']))) {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
return this.createRetryAttempt(stepState.stepId, 'running');
|
|
407
|
+
}
|
|
408
|
+
if (stepState.status === 'pending' || stepState.status === 'scheduled') {
|
|
409
|
+
if (!(await this.claimStepStatus(stepState.stepId, [
|
|
410
|
+
'pending',
|
|
411
|
+
'scheduled',
|
|
412
|
+
]))) {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
await this.setStepRunning(stepState.stepId);
|
|
416
|
+
return { ...stepState, status: 'running' };
|
|
417
|
+
}
|
|
418
|
+
return stepState;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Move a step to `running` only if it is still in one of `from`, reporting
|
|
422
|
+
* whether this caller is the one that moved it.
|
|
423
|
+
*/
|
|
424
|
+
async claimStepStatus(stepId, from) {
|
|
425
|
+
const claimed = await this.db
|
|
426
|
+
.updateTable('workflowStep')
|
|
427
|
+
.set({ status: 'running', updatedAt: new Date() })
|
|
428
|
+
.where('workflowStepId', '=', stepId)
|
|
429
|
+
.where('status', 'in', from)
|
|
430
|
+
.executeTakeFirst();
|
|
431
|
+
return Number(claimed?.numUpdatedRows ?? 0n) > 0;
|
|
432
|
+
}
|
|
369
433
|
async getCompletedGraphState(runId) {
|
|
370
434
|
const results = await this.db
|
|
371
435
|
.selectFrom('workflowStep as ws')
|
|
@@ -470,16 +534,18 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
470
534
|
.execute();
|
|
471
535
|
}
|
|
472
536
|
/**
|
|
473
|
-
* The undispatched-step query, shared by
|
|
537
|
+
* The undispatched-step query, shared by every dialect.
|
|
474
538
|
*
|
|
475
|
-
*
|
|
476
|
-
* `withStepLock` is a pass-through
|
|
477
|
-
*
|
|
478
|
-
* double executions
|
|
479
|
-
*
|
|
480
|
-
*
|
|
539
|
+
* It used to be deliberately NOT an override, because this class's
|
|
540
|
+
* `withStepLock` is a pass-through: a subclass inheriting it (kysely-sqlite)
|
|
541
|
+
* had no atomic claim, so the relay's redundant dispatches would have become
|
|
542
|
+
* double executions, and only `kysely-postgres` and `kysely-mysql` opted in
|
|
543
|
+
* on the strength of their real locks. `claimStepForExecution` no longer
|
|
544
|
+
* needs that lock — the claim is a status-guarded `UPDATE`, atomic in every
|
|
545
|
+
* dialect — so the relay is safe here for all of them and the override lives
|
|
546
|
+
* in the base.
|
|
481
547
|
*/
|
|
482
|
-
async
|
|
548
|
+
async findUndispatchedSteps(before, limit) {
|
|
483
549
|
const rows = await this.db
|
|
484
550
|
.selectFrom('workflowStep as s')
|
|
485
551
|
.innerJoin('workflowRuns as r', 'r.workflowRunId', 's.workflowRunId')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/kysely",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.19",
|
|
4
4
|
"description": "Kysely-backed Pikku services — workflow state, secrets, sessions, scopes and audit",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"prepublishOnly": "yarn build"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
|
-
"@pikku/core": "^0.12.
|
|
24
|
+
"@pikku/core": "^0.12.87"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"kysely": "^0.29.5"
|
|
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
|
|
4
4
|
import Database from 'better-sqlite3'
|
|
5
5
|
|
|
6
|
+
import type { StepState } from '@pikku/core/ecosystem/workflow'
|
|
6
7
|
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
7
8
|
import { SerializePlugin } from './serialize-plugin.js'
|
|
8
9
|
import { KyselyWorkflowService } from './kysely-workflow-service.js'
|
|
@@ -428,3 +429,114 @@ describe('KyselyWorkflowService — a transition that addresses no history row',
|
|
|
428
429
|
assert.equal(history[0]!.status, 'succeeded')
|
|
429
430
|
})
|
|
430
431
|
})
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Dispatch is at-least-once — the relay re-dispatches a step it believes was
|
|
435
|
+
* dropped, and a queue can redeliver a job it already handed out — so the claim
|
|
436
|
+
* is the only thing standing between a duplicate dispatch and a second
|
|
437
|
+
* execution of a side-effecting step.
|
|
438
|
+
*/
|
|
439
|
+
describe('KyselyWorkflowService — claiming a step for execution', () => {
|
|
440
|
+
const claim = (runId: string, stepName: string, rpcName = 'rpc.fn') =>
|
|
441
|
+
(service as any).claimStepForExecution(
|
|
442
|
+
runId,
|
|
443
|
+
stepName,
|
|
444
|
+
rpcName
|
|
445
|
+
) as Promise<StepState | null>
|
|
446
|
+
|
|
447
|
+
test('two dispatches racing for the same pending step: exactly one wins', async () => {
|
|
448
|
+
const runId = await seedRun()
|
|
449
|
+
await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
|
|
450
|
+
|
|
451
|
+
const claims = await Promise.all([claim(runId, 's1'), claim(runId, 's1')])
|
|
452
|
+
const winners = claims.filter((c) => c !== null)
|
|
453
|
+
|
|
454
|
+
assert.equal(
|
|
455
|
+
winners.length,
|
|
456
|
+
1,
|
|
457
|
+
`both dispatches claimed the step, so a side-effecting step would run twice: ${JSON.stringify(claims)}`
|
|
458
|
+
)
|
|
459
|
+
assert.equal((await service.getStepState(runId, 's1')).status, 'running')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
test('two dispatches racing to retry the same failed step: exactly one wins', async () => {
|
|
463
|
+
const runId = await seedRun()
|
|
464
|
+
const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
|
|
465
|
+
await service.setStepRunning(step.stepId)
|
|
466
|
+
await service.setStepError(step.stepId, new Error('boom'))
|
|
467
|
+
|
|
468
|
+
const claims = await Promise.all([claim(runId, 's1'), claim(runId, 's1')])
|
|
469
|
+
const winners = claims.filter((c) => c !== null)
|
|
470
|
+
|
|
471
|
+
assert.equal(
|
|
472
|
+
winners.length,
|
|
473
|
+
1,
|
|
474
|
+
`both dispatches started a retry, so the failed step would be retried twice concurrently: ${JSON.stringify(claims)}`
|
|
475
|
+
)
|
|
476
|
+
assert.equal(
|
|
477
|
+
(await service.getStepState(runId, 's1')).attemptCount,
|
|
478
|
+
2,
|
|
479
|
+
'the retry raced itself and burned more than one attempt'
|
|
480
|
+
)
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
test('a step already claimed is not claimable again', async () => {
|
|
484
|
+
const runId = await seedRun()
|
|
485
|
+
await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
|
|
486
|
+
|
|
487
|
+
assert.notEqual(await claim(runId, 's1'), null)
|
|
488
|
+
assert.equal(
|
|
489
|
+
await claim(runId, 's1'),
|
|
490
|
+
null,
|
|
491
|
+
'a redelivered job re-claimed a step that is already running'
|
|
492
|
+
)
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
test('a succeeded step is not claimable', async () => {
|
|
496
|
+
const runId = await seedRun()
|
|
497
|
+
const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
|
|
498
|
+
await service.setStepRunning(step.stepId)
|
|
499
|
+
await service.setStepResult(step.stepId, { ok: true })
|
|
500
|
+
|
|
501
|
+
assert.equal(await claim(runId, 's1'), null)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
test('a claim that fails releases the step, so it is retryable', async () => {
|
|
505
|
+
const runId = await seedRun()
|
|
506
|
+
const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
|
|
507
|
+
|
|
508
|
+
const first = await claim(runId, 's1')
|
|
509
|
+
assert.notEqual(first, null)
|
|
510
|
+
await service.setStepError(step.stepId, new Error('boom'))
|
|
511
|
+
|
|
512
|
+
const retry = await claim(runId, 's1')
|
|
513
|
+
assert.notEqual(
|
|
514
|
+
retry,
|
|
515
|
+
null,
|
|
516
|
+
'the failed step stayed claimed, so it can never be retried'
|
|
517
|
+
)
|
|
518
|
+
assert.equal(retry?.status, 'running')
|
|
519
|
+
assert.equal(retry?.attemptCount, 2)
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
test('a claim for a different function than the step was dispatched with is refused', async () => {
|
|
523
|
+
const runId = await seedRun()
|
|
524
|
+
await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
|
|
525
|
+
|
|
526
|
+
await assert.rejects(() => claim(runId, 's1', 'rpc.other'))
|
|
527
|
+
assert.equal(
|
|
528
|
+
(await service.getStepState(runId, 's1')).status,
|
|
529
|
+
'pending',
|
|
530
|
+
'the mismatched claim moved the step anyway'
|
|
531
|
+
)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
test('a scheduled step is claimable, and only once', async () => {
|
|
535
|
+
const runId = await seedRun()
|
|
536
|
+
const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
|
|
537
|
+
await service.setStepScheduled(step.stepId)
|
|
538
|
+
|
|
539
|
+
const claims = await Promise.all([claim(runId, 's1'), claim(runId, 's1')])
|
|
540
|
+
assert.equal(claims.filter((c) => c !== null).length, 1)
|
|
541
|
+
})
|
|
542
|
+
})
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { SerializedError } from '@pikku/core/errors'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
PikkuWorkflowService,
|
|
4
|
+
WorkflowStepFunctionMismatchError,
|
|
5
|
+
} from '@pikku/core/workflow'
|
|
3
6
|
import type {
|
|
4
7
|
WorkflowPlannedStep,
|
|
5
8
|
WorkflowQueueOptions,
|
|
@@ -460,6 +463,16 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
460
463
|
return fn()
|
|
461
464
|
}
|
|
462
465
|
|
|
466
|
+
/**
|
|
467
|
+
* A pass-through, and deliberately so: the one decision that must exclude —
|
|
468
|
+
* claiming a step to execute it — is made by `claimStepForExecution` below as
|
|
469
|
+
* a single conditional write, which needs no lock to be exclusive.
|
|
470
|
+
*
|
|
471
|
+
* What is left are the suspend and approval sections in the engine, where the
|
|
472
|
+
* lock is genuine mutual exclusion rather than a claim. A dialect with a real
|
|
473
|
+
* primitive overrides this to cover them — `kysely-postgres` with
|
|
474
|
+
* `pg_advisory_xact_lock`, `kysely-mysql` with `GET_LOCK`.
|
|
475
|
+
*/
|
|
463
476
|
async withStepLock<T>(
|
|
464
477
|
_runId: string,
|
|
465
478
|
_stepName: string,
|
|
@@ -468,6 +481,79 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
468
481
|
return fn()
|
|
469
482
|
}
|
|
470
483
|
|
|
484
|
+
/**
|
|
485
|
+
* Claim the step with a status-guarded `UPDATE` and read the affected-row
|
|
486
|
+
* count: the database decides the winner in one statement, so two dispatches
|
|
487
|
+
* racing for the same step cannot both proceed.
|
|
488
|
+
*
|
|
489
|
+
* This replaces the read-then-write the base engine does under `withStepLock`,
|
|
490
|
+
* which is only as exclusive as that lock — and every dialect but Postgres and
|
|
491
|
+
* MySQL inherits a pass-through. A conditional update needs no advisory-lock
|
|
492
|
+
* primitive, so every SQL dialect gets the same guarantee.
|
|
493
|
+
*
|
|
494
|
+
* The winner then goes through the ordinary transition methods, so history
|
|
495
|
+
* rows and the mirror see exactly what they saw before.
|
|
496
|
+
*/
|
|
497
|
+
protected override async claimStepForExecution(
|
|
498
|
+
runId: string,
|
|
499
|
+
stepName: string,
|
|
500
|
+
rpcName: string
|
|
501
|
+
): Promise<StepState | null> {
|
|
502
|
+
const stepState = await this.getStepState(runId, stepName)
|
|
503
|
+
|
|
504
|
+
// knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
|
|
505
|
+
if (
|
|
506
|
+
stepState.rpcName !== undefined &&
|
|
507
|
+
stepState.rpcName !== (rpcName ?? null)
|
|
508
|
+
) {
|
|
509
|
+
throw new WorkflowStepFunctionMismatchError(runId, stepName)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (stepState.status === 'succeeded' || stepState.status === 'running') {
|
|
513
|
+
return null
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (stepState.status === 'failed') {
|
|
517
|
+
if (!(await this.claimStepStatus(stepState.stepId, ['failed']))) {
|
|
518
|
+
return null
|
|
519
|
+
}
|
|
520
|
+
return this.createRetryAttempt(stepState.stepId, 'running')
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (stepState.status === 'pending' || stepState.status === 'scheduled') {
|
|
524
|
+
if (
|
|
525
|
+
!(await this.claimStepStatus(stepState.stepId, [
|
|
526
|
+
'pending',
|
|
527
|
+
'scheduled',
|
|
528
|
+
]))
|
|
529
|
+
) {
|
|
530
|
+
return null
|
|
531
|
+
}
|
|
532
|
+
await this.setStepRunning(stepState.stepId)
|
|
533
|
+
return { ...stepState, status: 'running' }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return stepState
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Move a step to `running` only if it is still in one of `from`, reporting
|
|
541
|
+
* whether this caller is the one that moved it.
|
|
542
|
+
*/
|
|
543
|
+
private async claimStepStatus(
|
|
544
|
+
stepId: string,
|
|
545
|
+
from: StepStatus[]
|
|
546
|
+
): Promise<boolean> {
|
|
547
|
+
const claimed = await this.db
|
|
548
|
+
.updateTable('workflowStep')
|
|
549
|
+
.set({ status: 'running', updatedAt: new Date() })
|
|
550
|
+
.where('workflowStepId', '=', stepId)
|
|
551
|
+
.where('status', 'in', from)
|
|
552
|
+
.executeTakeFirst()
|
|
553
|
+
|
|
554
|
+
return Number(claimed?.numUpdatedRows ?? 0n) > 0
|
|
555
|
+
}
|
|
556
|
+
|
|
471
557
|
async getCompletedGraphState(runId: string): Promise<{
|
|
472
558
|
completedNodeIds: string[]
|
|
473
559
|
failedNodeIds: string[]
|
|
@@ -606,16 +692,18 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
606
692
|
}
|
|
607
693
|
|
|
608
694
|
/**
|
|
609
|
-
* The undispatched-step query, shared by
|
|
695
|
+
* The undispatched-step query, shared by every dialect.
|
|
610
696
|
*
|
|
611
|
-
*
|
|
612
|
-
* `withStepLock` is a pass-through
|
|
613
|
-
*
|
|
614
|
-
* double executions
|
|
615
|
-
*
|
|
616
|
-
*
|
|
697
|
+
* It used to be deliberately NOT an override, because this class's
|
|
698
|
+
* `withStepLock` is a pass-through: a subclass inheriting it (kysely-sqlite)
|
|
699
|
+
* had no atomic claim, so the relay's redundant dispatches would have become
|
|
700
|
+
* double executions, and only `kysely-postgres` and `kysely-mysql` opted in
|
|
701
|
+
* on the strength of their real locks. `claimStepForExecution` no longer
|
|
702
|
+
* needs that lock — the claim is a status-guarded `UPDATE`, atomic in every
|
|
703
|
+
* dialect — so the relay is safe here for all of them and the override lives
|
|
704
|
+
* in the base.
|
|
617
705
|
*/
|
|
618
|
-
protected async
|
|
706
|
+
protected override async findUndispatchedSteps(
|
|
619
707
|
before: Date,
|
|
620
708
|
limit: number
|
|
621
709
|
): Promise<Array<{ runId: string; stepId: string }>> {
|