@pikku/kysely 0.13.10 → 0.13.12
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,52 @@
|
|
|
1
|
+
## 0.13.12
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- e848eb2: Add `relayUndispatchedSteps()`, which re-drives steps whose queue or scheduler
|
|
6
|
+
dispatch was lost.
|
|
7
|
+
|
|
8
|
+
Arming a step is two writes to two systems — the step row lands `pending`, then
|
|
9
|
+
a job is published — and nothing spans both, so a crash in between leaves a
|
|
10
|
+
durable row nothing will ever pick up. The run then neither finishes nor fails.
|
|
11
|
+
|
|
12
|
+
The step row is the outbox record and this is the relay. It is safe to
|
|
13
|
+
re-dispatch a step that already has a live job because `executeWorkflowStepInner`
|
|
14
|
+
claims the step under `withStepLock` before invoking anything: the loser reads
|
|
15
|
+
`running` and returns. Stores opt in by overriding `findUndispatchedSteps`; the
|
|
16
|
+
default returns nothing, so a store without an atomic step lock gains no
|
|
17
|
+
re-dispatches. Opted in: `kysely-postgres` and `kysely-mysql` (real locks) and
|
|
18
|
+
in-memory (inline, single-process, no queues). Not opted in: `mongodb` and
|
|
19
|
+
`kysely-sqlite`, whose `withStepLock` is a pass-through.
|
|
20
|
+
|
|
21
|
+
Not self-starting — call it from a scheduled task.
|
|
22
|
+
|
|
23
|
+
- Updated dependencies [e848eb2]
|
|
24
|
+
- Updated dependencies [b170489]
|
|
25
|
+
- Updated dependencies [ae4e898]
|
|
26
|
+
- @pikku/core@0.12.79
|
|
27
|
+
|
|
28
|
+
## 0.13.11
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- f5ce870: Recover workflow runs stalled by a crash mid-dispatch.
|
|
33
|
+
|
|
34
|
+
Arming a step is two writes to two systems — the step row, then the queue or
|
|
35
|
+
scheduler job — so a process that died between them left a run `running` with
|
|
36
|
+
nothing in flight. It parked on a step that would never complete and never
|
|
37
|
+
error, so the run neither finished nor failed, and nothing swept it up.
|
|
38
|
+
|
|
39
|
+
`workflowService.recoverStalledRuns()` re-drives those runs through
|
|
40
|
+
`resumeWorkflow`. Replay is memoized per step, so resuming a run that was not
|
|
41
|
+
actually stuck changes nothing; runs mid-sleep or with a step in flight are
|
|
42
|
+
excluded outright. It is not self-starting — call it from a scheduled task.
|
|
43
|
+
|
|
44
|
+
Stores opt in by overriding `findStalledRunIds`; implemented here for the
|
|
45
|
+
Kysely and in-memory services, and a no-op elsewhere.
|
|
46
|
+
|
|
47
|
+
- Updated dependencies [f5ce870]
|
|
48
|
+
- @pikku/core@0.12.78
|
|
49
|
+
|
|
1
50
|
## 0.13.10
|
|
2
51
|
|
|
3
52
|
### Patch Changes
|
|
@@ -79,6 +79,21 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
79
79
|
*/
|
|
80
80
|
private jsonPathFor;
|
|
81
81
|
protected updateRunStateImpl(runId: string, name: string, value: unknown): Promise<void>;
|
|
82
|
+
/**
|
|
83
|
+
* The undispatched-step query, shared by the dialects that can safely relay.
|
|
84
|
+
*
|
|
85
|
+
* Deliberately NOT an override of `findUndispatchedSteps`: this class's
|
|
86
|
+
* `withStepLock` is a pass-through, so a subclass inheriting it (kysely-sqlite)
|
|
87
|
+
* has no atomic claim, and the relay's redundant dispatches would become
|
|
88
|
+
* double executions. A dialect opts in by overriding `findUndispatchedSteps`
|
|
89
|
+
* to call this — which `kysely-postgres` and `kysely-mysql` do, having real
|
|
90
|
+
* locks.
|
|
91
|
+
*/
|
|
92
|
+
protected queryUndispatchedSteps(before: Date, limit: number): Promise<Array<{
|
|
93
|
+
runId: string;
|
|
94
|
+
stepId: string;
|
|
95
|
+
}>>;
|
|
96
|
+
protected findStalledRunIds(before: Date, limit: number): Promise<string[]>;
|
|
82
97
|
getRunState(runId: string): Promise<Record<string, unknown>>;
|
|
83
98
|
protected upsertWorkflowVersionImpl(name: string, graphHash: string, graph: any, source: string, status?: WorkflowVersionStatus): Promise<void>;
|
|
84
99
|
protected updateWorkflowVersionStatusImpl(name: string, graphHash: string, status: WorkflowVersionStatus): Promise<void>;
|
|
@@ -464,6 +464,56 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
464
464
|
.where('workflowRunId', '=', runId)
|
|
465
465
|
.execute();
|
|
466
466
|
}
|
|
467
|
+
/**
|
|
468
|
+
* The undispatched-step query, shared by the dialects that can safely relay.
|
|
469
|
+
*
|
|
470
|
+
* Deliberately NOT an override of `findUndispatchedSteps`: this class's
|
|
471
|
+
* `withStepLock` is a pass-through, so a subclass inheriting it (kysely-sqlite)
|
|
472
|
+
* has no atomic claim, and the relay's redundant dispatches would become
|
|
473
|
+
* double executions. A dialect opts in by overriding `findUndispatchedSteps`
|
|
474
|
+
* to call this — which `kysely-postgres` and `kysely-mysql` do, having real
|
|
475
|
+
* locks.
|
|
476
|
+
*/
|
|
477
|
+
async queryUndispatchedSteps(before, limit) {
|
|
478
|
+
const rows = await this.db
|
|
479
|
+
.selectFrom('workflowStep as s')
|
|
480
|
+
.innerJoin('workflowRuns as r', 'r.workflowRunId', 's.workflowRunId')
|
|
481
|
+
.select(['s.workflowStepId', 's.workflowRunId'])
|
|
482
|
+
.where('s.status', '=', 'pending')
|
|
483
|
+
.where('s.updatedAt', '<', before)
|
|
484
|
+
// Leftover `pending` steps on a settled run are not work; only a live run
|
|
485
|
+
// still has anything to dispatch.
|
|
486
|
+
.where('r.status', '=', 'running')
|
|
487
|
+
.orderBy('s.updatedAt', 'asc')
|
|
488
|
+
.limit(limit)
|
|
489
|
+
.execute();
|
|
490
|
+
return rows.map((row) => ({
|
|
491
|
+
runId: row.workflowRunId,
|
|
492
|
+
stepId: row.workflowStepId,
|
|
493
|
+
}));
|
|
494
|
+
}
|
|
495
|
+
async findStalledRunIds(before, limit) {
|
|
496
|
+
const rows = await this.db
|
|
497
|
+
.selectFrom('workflowRuns as r')
|
|
498
|
+
.select('r.workflowRunId')
|
|
499
|
+
.where('r.status', '=', 'running')
|
|
500
|
+
.where(({ not, exists, selectFrom }) => not(exists(selectFrom('workflowStep as s')
|
|
501
|
+
.select('s.workflowStepId')
|
|
502
|
+
.whereRef('s.workflowRunId', '=', 'r.workflowRunId')
|
|
503
|
+
.where('s.status', 'in', ['running', 'scheduled', 'suspended']))))
|
|
504
|
+
// The run row only moves on a status change, so idleness has to hold for
|
|
505
|
+
// the steps too — expressed as "no step touched since `before`" rather
|
|
506
|
+
// than max(updated_at) so it stays one index-friendly NOT EXISTS.
|
|
507
|
+
.where('r.updatedAt', '<', before)
|
|
508
|
+
.where(({ not, exists, selectFrom }) => not(exists(selectFrom('workflowStep as s')
|
|
509
|
+
.select('s.workflowStepId')
|
|
510
|
+
.whereRef('s.workflowRunId', '=', 'r.workflowRunId')
|
|
511
|
+
.where('s.updatedAt', '>=', before))))
|
|
512
|
+
.orderBy('r.updatedAt', 'asc')
|
|
513
|
+
.limit(limit)
|
|
514
|
+
.execute();
|
|
515
|
+
return rows.map((row) => row.workflowRunId);
|
|
516
|
+
}
|
|
467
517
|
async getRunState(runId) {
|
|
468
518
|
const row = await this.db
|
|
469
519
|
.selectFrom('workflowRuns')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/kysely",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.12",
|
|
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.79"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"kysely": "^0.29.0"
|
|
@@ -600,6 +600,78 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
600
600
|
.execute()
|
|
601
601
|
}
|
|
602
602
|
|
|
603
|
+
/**
|
|
604
|
+
* The undispatched-step query, shared by the dialects that can safely relay.
|
|
605
|
+
*
|
|
606
|
+
* Deliberately NOT an override of `findUndispatchedSteps`: this class's
|
|
607
|
+
* `withStepLock` is a pass-through, so a subclass inheriting it (kysely-sqlite)
|
|
608
|
+
* has no atomic claim, and the relay's redundant dispatches would become
|
|
609
|
+
* double executions. A dialect opts in by overriding `findUndispatchedSteps`
|
|
610
|
+
* to call this — which `kysely-postgres` and `kysely-mysql` do, having real
|
|
611
|
+
* locks.
|
|
612
|
+
*/
|
|
613
|
+
protected async queryUndispatchedSteps(
|
|
614
|
+
before: Date,
|
|
615
|
+
limit: number
|
|
616
|
+
): Promise<Array<{ runId: string; stepId: string }>> {
|
|
617
|
+
const rows = await this.db
|
|
618
|
+
.selectFrom('workflowStep as s')
|
|
619
|
+
.innerJoin('workflowRuns as r', 'r.workflowRunId', 's.workflowRunId')
|
|
620
|
+
.select(['s.workflowStepId', 's.workflowRunId'])
|
|
621
|
+
.where('s.status', '=', 'pending')
|
|
622
|
+
.where('s.updatedAt', '<', before)
|
|
623
|
+
// Leftover `pending` steps on a settled run are not work; only a live run
|
|
624
|
+
// still has anything to dispatch.
|
|
625
|
+
.where('r.status', '=', 'running')
|
|
626
|
+
.orderBy('s.updatedAt', 'asc')
|
|
627
|
+
.limit(limit)
|
|
628
|
+
.execute()
|
|
629
|
+
|
|
630
|
+
return rows.map((row) => ({
|
|
631
|
+
runId: row.workflowRunId,
|
|
632
|
+
stepId: row.workflowStepId,
|
|
633
|
+
}))
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
protected async findStalledRunIds(
|
|
637
|
+
before: Date,
|
|
638
|
+
limit: number
|
|
639
|
+
): Promise<string[]> {
|
|
640
|
+
const rows = await this.db
|
|
641
|
+
.selectFrom('workflowRuns as r')
|
|
642
|
+
.select('r.workflowRunId')
|
|
643
|
+
.where('r.status', '=', 'running')
|
|
644
|
+
.where(({ not, exists, selectFrom }) =>
|
|
645
|
+
not(
|
|
646
|
+
exists(
|
|
647
|
+
selectFrom('workflowStep as s')
|
|
648
|
+
.select('s.workflowStepId')
|
|
649
|
+
.whereRef('s.workflowRunId', '=', 'r.workflowRunId')
|
|
650
|
+
.where('s.status', 'in', ['running', 'scheduled', 'suspended'])
|
|
651
|
+
)
|
|
652
|
+
)
|
|
653
|
+
)
|
|
654
|
+
// The run row only moves on a status change, so idleness has to hold for
|
|
655
|
+
// the steps too — expressed as "no step touched since `before`" rather
|
|
656
|
+
// than max(updated_at) so it stays one index-friendly NOT EXISTS.
|
|
657
|
+
.where('r.updatedAt', '<', before)
|
|
658
|
+
.where(({ not, exists, selectFrom }) =>
|
|
659
|
+
not(
|
|
660
|
+
exists(
|
|
661
|
+
selectFrom('workflowStep as s')
|
|
662
|
+
.select('s.workflowStepId')
|
|
663
|
+
.whereRef('s.workflowRunId', '=', 'r.workflowRunId')
|
|
664
|
+
.where('s.updatedAt', '>=', before)
|
|
665
|
+
)
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
.orderBy('r.updatedAt', 'asc')
|
|
669
|
+
.limit(limit)
|
|
670
|
+
.execute()
|
|
671
|
+
|
|
672
|
+
return rows.map((row) => row.workflowRunId)
|
|
673
|
+
}
|
|
674
|
+
|
|
603
675
|
async getRunState(runId: string): Promise<Record<string, unknown>> {
|
|
604
676
|
const row = await this.db
|
|
605
677
|
.selectFrom('workflowRuns')
|