@pikku/mongodb 0.12.21 → 0.12.23
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 +48 -0
- package/README.md +9 -1
- package/dist/src/mongodb-workflow-run-service.js +3 -13
- package/dist/src/mongodb-workflow-service.d.ts +38 -22
- package/dist/src/mongodb-workflow-service.js +130 -88
- package/package.json +10 -6
- package/run-tests.sh +21 -6
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/index.ts +0 -14
- package/src/mongodb-agent-run-service.ts +0 -234
- package/src/mongodb-agent-storage-service.ts +0 -632
- package/src/mongodb-channel-store.ts +0 -109
- package/src/mongodb-deployment-service.ts +0 -136
- package/src/mongodb-eventhub-store.ts +0 -52
- package/src/mongodb-secret-service.ts +0 -232
- package/src/mongodb-services.test.ts +0 -235
- package/src/mongodb-session-store.ts +0 -50
- package/src/mongodb-workflow-run-service.ts +0 -235
- package/src/mongodb-workflow-service.test.ts +0 -238
- package/src/mongodb-workflow-service.ts +0 -687
- package/src/pikku-mongodb.ts +0 -41
- package/tsconfig.json +0 -17
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# @pikku/mongodb
|
|
2
2
|
|
|
3
|
+
## 0.12.23
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- a16b0ba: `RedisWorkflowService` and `MongoDBWorkflowService` now record a step's lease, so a step whose worker died is claimed again once the lease lapses, as it already was on Kysely. Both fence a step's result, error and lease renewal to the attempt that claimed it: a superseded worker's write throws `WorkflowStepSupersededError` and leaves the newer attempt's state alone. Redis does the check and the write in one Lua script. MongoDB now keeps the attempt number on the step document, advances it in the same guarded update that grants the lease, and tags every history document with the attempt it belongs to.
|
|
8
|
+
- a16b0ba: The workflow run lease takes the app's `leaseService` (`holdLease(leaseService, 'workflow-run:<id>', …)`) in place of `pg_advisory_lock` / `GET_LOCK`, for DSL and graph runs alike. A message for a run already being orchestrated elsewhere wakes the run again a second later instead of failing, so a long pass never spends the retry budget the queue keeps for real failures. An inline run whose lease was lost after its body finished keeps the outcome it wrote. `PgWorkflowQueueOptions` and `RunLockHoldTimeoutError` are removed. `WorkflowService.withRunLock` is renamed `withRunLease`, since what it holds runs out unless renewed; a custom workflow service overriding it renames the method.
|
|
9
|
+
|
|
10
|
+
Pass the lease service to the workflow service — `new PgKyselyWorkflowService(db, { leaseService })` — register the same instance as the app's `leaseService`, and migrate `pikku_lease`.
|
|
11
|
+
|
|
12
|
+
A step lease is renewed by the same loop as the run lease. A graph node whose worker died is dispatched again once its lease lapses, instead of leaving the run waiting on it. A replayed step on Kysely now sees its lease, as `getStepState` does. The in-memory store refuses a superseded worker's outcome, as the database stores do. The MySQL workflow service can now record a workflow version; it used `ON CONFLICT`, which MySQL does not have.
|
|
13
|
+
|
|
14
|
+
`pikku db generate` now writes the `pikku_lease` table for any project that runs workflows, not only one that reaches `leaseService`, since every persistent workflow service holds its runs and steps there.
|
|
15
|
+
|
|
16
|
+
- a16b0ba: Persistent workflow services take a required `leaseService` (`WorkflowServiceOptions`) and lock runs and steps on it. The Postgres advisory and MySQL `GET_LOCK` step locks, and Redis's own `SET NX` run and step locks, are removed; `RedisWorkflowService` now takes `(connection, { leaseService, keyPrefix? })`.
|
|
17
|
+
- Updated dependencies [a16b0ba]
|
|
18
|
+
- Updated dependencies [21e9c3a]
|
|
19
|
+
- Updated dependencies [a16b0ba]
|
|
20
|
+
- Updated dependencies [a16b0ba]
|
|
21
|
+
- @pikku/core@0.12.133
|
|
22
|
+
|
|
23
|
+
## 0.12.22
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- 4c7a1b5: Run the monorepo's own scripts through bun instead of yarn. What moves is the
|
|
28
|
+
package manager each package's `prepublishOnly` and build scripts invoke, plus
|
|
29
|
+
the two manifest fixes bun needs to resolve the tree: `uWebSockets.js` is
|
|
30
|
+
declared with an explicit `github:` specifier, and `@pikku/uws-handler` marks
|
|
31
|
+
its `uWebSockets.js` peer optional so a bun install of a consumer that brings
|
|
32
|
+
its own uWS app does not try to fetch it from the registry.
|
|
33
|
+
|
|
34
|
+
Three published behaviours change, all of them cases where an isolated
|
|
35
|
+
`node_modules` or bun as the runtime had been papered over by yarn's hoisting:
|
|
36
|
+
|
|
37
|
+
- `@pikku/migrator-sql` turns foreign keys on when it opens a sqlite database
|
|
38
|
+
through bun. `node:sqlite` enforces them by default and `bun:sqlite` does not,
|
|
39
|
+
which silently turned every `ON DELETE CASCADE` into a no-op under
|
|
40
|
+
`bunx --bun pikku`.
|
|
41
|
+
- `@pikku/cli` resolves a deploy provider against the project being deployed
|
|
42
|
+
rather than against wherever the CLI itself is installed, which is what its
|
|
43
|
+
own "is not installed" error asks the user to arrange.
|
|
44
|
+
- `@pikku/cli` treats a specifier a runtime hands straight back — bun does this
|
|
45
|
+
for the modules it implements itself — as not resolved from the project, so
|
|
46
|
+
it falls back rather than loading the runtime's own copy.
|
|
47
|
+
|
|
48
|
+
- Updated dependencies [4c7a1b5]
|
|
49
|
+
- @pikku/core@0.12.108
|
|
50
|
+
|
|
3
51
|
## 0.12.21
|
|
4
52
|
|
|
5
53
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -13,12 +13,20 @@ npm install @pikku/mongodb
|
|
|
13
13
|
|
|
14
14
|
```typescript
|
|
15
15
|
import { PikkuMongoDB, MongoDBWorkflowService } from '@pikku/mongodb'
|
|
16
|
+
import { RedisLeaseService } from '@pikku/redis'
|
|
16
17
|
|
|
17
18
|
const { db } = new PikkuMongoDB(logger, 'mongodb://localhost:27017')
|
|
18
19
|
|
|
19
|
-
const
|
|
20
|
+
const leaseService = new RedisLeaseService('redis://localhost:6379')
|
|
21
|
+
|
|
22
|
+
const workflowService = new MongoDBWorkflowService(db, { leaseService })
|
|
20
23
|
```
|
|
21
24
|
|
|
25
|
+
The workflow service locks runs and steps on the `leaseService` you give it,
|
|
26
|
+
and this package ships none: pair it with `RedisLeaseService`, a Kysely lease
|
|
27
|
+
service, or `InMemoryLeaseService` from `@pikku/core/services` for a single
|
|
28
|
+
process. Register the same instance as the app's `leaseService`.
|
|
29
|
+
|
|
22
30
|
`PikkuMongoDB` also accepts an existing `MongoClient`, in which case it will not
|
|
23
31
|
close the connection on shutdown.
|
|
24
32
|
|
|
@@ -35,14 +35,6 @@ export class MongoDBWorkflowRunService {
|
|
|
35
35
|
.find({ workflowRunId: runId })
|
|
36
36
|
.sort({ createdAt: 1 })
|
|
37
37
|
.toArray();
|
|
38
|
-
const stepIds = result.map((r) => r._id);
|
|
39
|
-
const historyCounts = await this.stepHistory
|
|
40
|
-
.aggregate([
|
|
41
|
-
{ $match: { workflowStepId: { $in: stepIds } } },
|
|
42
|
-
{ $group: { _id: '$workflowStepId', count: { $sum: 1 } } },
|
|
43
|
-
])
|
|
44
|
-
.toArray();
|
|
45
|
-
const countMap = new Map(historyCounts.map((h) => [h._id, h.count]));
|
|
46
38
|
return result.map((row) => ({
|
|
47
39
|
stepId: row._id,
|
|
48
40
|
stepName: row.stepName,
|
|
@@ -51,7 +43,7 @@ export class MongoDBWorkflowRunService {
|
|
|
51
43
|
status: row.status,
|
|
52
44
|
result: row.result ?? undefined,
|
|
53
45
|
error: row.error ?? undefined,
|
|
54
|
-
attemptCount:
|
|
46
|
+
attemptCount: row.attempt,
|
|
55
47
|
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
56
48
|
retryDelay: row.retryDelay ?? undefined,
|
|
57
49
|
createdAt: new Date(row.createdAt),
|
|
@@ -64,20 +56,18 @@ export class MongoDBWorkflowRunService {
|
|
|
64
56
|
const stepIds = steps.map((s) => s._id);
|
|
65
57
|
const history = await this.stepHistory
|
|
66
58
|
.find({ workflowStepId: { $in: stepIds } })
|
|
67
|
-
.sort({ createdAt: 1 })
|
|
59
|
+
.sort({ createdAt: 1, attempt: 1 })
|
|
68
60
|
.toArray();
|
|
69
|
-
let attemptCounters = {};
|
|
70
61
|
return history.map((row) => {
|
|
71
62
|
const stepId = row.workflowStepId;
|
|
72
63
|
const step = stepMap.get(stepId);
|
|
73
|
-
attemptCounters[stepId] = (attemptCounters[stepId] ?? 0) + 1;
|
|
74
64
|
return {
|
|
75
65
|
stepId,
|
|
76
66
|
stepName: step.stepName,
|
|
77
67
|
status: row.status,
|
|
78
68
|
result: row.result ?? undefined,
|
|
79
69
|
error: row.error ?? undefined,
|
|
80
|
-
attemptCount:
|
|
70
|
+
attemptCount: row.attempt,
|
|
81
71
|
retries: step.retries != null ? Number(step.retries) : undefined,
|
|
82
72
|
retryDelay: step.retryDelay ?? undefined,
|
|
83
73
|
createdAt: new Date(row.createdAt),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SerializedError } from '@pikku/core/errors';
|
|
2
2
|
import { PikkuWorkflowService } from '@pikku/core/workflow';
|
|
3
|
-
import type { WorkflowPlannedStep,
|
|
3
|
+
import type { WorkflowPlannedStep, WorkflowServiceOptions, WorkflowRun, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus } from '@pikku/core/workflow';
|
|
4
4
|
import type { Db } from 'mongodb';
|
|
5
5
|
export declare class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
6
6
|
private initialized;
|
|
@@ -9,7 +9,7 @@ export declare class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
9
9
|
private steps;
|
|
10
10
|
private stepHistory;
|
|
11
11
|
private versions;
|
|
12
|
-
constructor(db: Db, options
|
|
12
|
+
constructor(db: Db, options: WorkflowServiceOptions);
|
|
13
13
|
init(): Promise<void>;
|
|
14
14
|
protected createRunImpl(workflowName: string, input: any, inline: boolean, graphHash: string, wire: WorkflowRunWire, options?: {
|
|
15
15
|
deterministic?: boolean;
|
|
@@ -22,51 +22,66 @@ export declare class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
22
22
|
retryDelay?: string | number;
|
|
23
23
|
}, fromStepName?: string): Promise<StepState>;
|
|
24
24
|
getStepState(runId: string, stepName: string): Promise<StepState>;
|
|
25
|
+
private toStepState;
|
|
25
26
|
getRunHistory(runId: string): Promise<Array<StepState & {
|
|
26
27
|
stepName: string;
|
|
27
28
|
}>>;
|
|
28
29
|
protected setStepRunningImpl(stepId: string): Promise<void>;
|
|
29
30
|
protected setStepScheduledImpl(stepId: string): Promise<void>;
|
|
30
31
|
/**
|
|
31
|
-
* Move
|
|
32
|
+
* Move one attempt of a step's history to a status — `attempt`, or the
|
|
33
|
+
* newest when it is not given.
|
|
32
34
|
*
|
|
33
35
|
* Finding the newest attempt and then updating it by `_id` was two round
|
|
34
36
|
* trips with a window in between; `findOneAndUpdate` applies the same sort
|
|
35
37
|
* server-side and writes the row it selected, so nothing can move underneath
|
|
36
|
-
* it.
|
|
37
|
-
* attempt number — see `getStepState`, which counts history documents.
|
|
38
|
+
* it.
|
|
38
39
|
*/
|
|
39
40
|
private writeLatestHistory;
|
|
40
41
|
private insertHistoryRecord;
|
|
41
42
|
private getTimestampFieldForStatus;
|
|
42
43
|
protected setStepChildRunIdImpl(stepId: string, childRunId: string): Promise<void>;
|
|
43
|
-
|
|
44
|
-
protected
|
|
45
|
-
protected
|
|
46
|
-
withRunLock<T>(id: string, fn: () => Promise<T>): Promise<T>;
|
|
44
|
+
refreshStepLease(stepId: string, leaseMs: number | null, attempt?: number): Promise<boolean>;
|
|
45
|
+
protected setStepResultImpl(stepId: string, result: any, attempt?: number): Promise<void>;
|
|
46
|
+
protected setStepErrorImpl(stepId: string, error: Error, attempt?: number): Promise<void>;
|
|
47
47
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* made by `claimStepForExecution` below as a single conditional write, which
|
|
51
|
-
* needs no lock to be exclusive.
|
|
48
|
+
* Settle a step. With `attempt`, a step claimed again since matches nothing
|
|
49
|
+
* and throws `WorkflowStepSupersededError`, keeping the newer claim's state.
|
|
52
50
|
*/
|
|
53
|
-
|
|
51
|
+
private writeStepOutcome;
|
|
52
|
+
protected createRetryAttemptImpl(stepId: string, status: 'pending' | 'running'): Promise<StepState>;
|
|
54
53
|
/**
|
|
55
|
-
* Claim the step with a
|
|
56
|
-
*
|
|
54
|
+
* Claim the step with a guarded update and read what it matched: a
|
|
55
|
+
* single-document update is atomic in MongoDB, so two dispatches racing for
|
|
57
56
|
* the same step cannot both proceed.
|
|
58
57
|
*
|
|
59
58
|
* This replaces the read-then-write the base engine does under
|
|
60
|
-
* `withStepLock
|
|
61
|
-
*
|
|
62
|
-
*
|
|
59
|
+
* `withStepLock`: one atomic write needs no lease round-trips around it. The
|
|
60
|
+
* winner then goes through the ordinary transition methods, so history rows
|
|
61
|
+
* see exactly what they saw before.
|
|
62
|
+
*
|
|
63
|
+
* A `running` step is taken back only once its lease has lapsed, and the
|
|
64
|
+
* lease is part of the same guarded update, so two dispatches that read one
|
|
65
|
+
* lapsed lease cannot both take the step.
|
|
63
66
|
*/
|
|
64
|
-
protected claimStepForExecution(runId: string, stepName: string, rpcName: string): Promise<StepState | null>;
|
|
67
|
+
protected claimStepForExecution(runId: string, stepName: string, rpcName: string, leaseMs: number): Promise<StepState | null>;
|
|
65
68
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
69
|
+
* Take back a failed or lapsed step as a new attempt. The attempt number is
|
|
70
|
+
* advanced by the same guarded update that grants the lease, so the worker
|
|
71
|
+
* it is taken from is fenced out of its step at once.
|
|
72
|
+
*/
|
|
73
|
+
private reclaimStepAttempt;
|
|
74
|
+
/**
|
|
75
|
+
* Move a step to `running` under a fresh lease, only if it is still the
|
|
76
|
+
* attempt that was read and in one of `from`, reporting whether this caller
|
|
77
|
+
* is the one that moved it.
|
|
68
78
|
*/
|
|
69
79
|
private claimStepStatus;
|
|
80
|
+
/**
|
|
81
|
+
* A `running` step matches only once its lease has lapsed; a null lease —
|
|
82
|
+
* a step parked on a child run — never does.
|
|
83
|
+
*/
|
|
84
|
+
private claimFilter;
|
|
70
85
|
getCompletedGraphState(runId: string): Promise<{
|
|
71
86
|
completedNodeIds: string[];
|
|
72
87
|
failedNodeIds: string[];
|
|
@@ -76,6 +91,7 @@ export declare class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
76
91
|
stepName: string;
|
|
77
92
|
status: StepStatus;
|
|
78
93
|
fromStepName?: string;
|
|
94
|
+
leaseExpiresAt?: Date;
|
|
79
95
|
}>>;
|
|
80
96
|
getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
|
|
81
97
|
protected setBranchTakenImpl(stepId: string, branchKey: string): Promise<void>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PikkuWorkflowService, WorkflowStepFunctionMismatchError, } from '@pikku/core/workflow';
|
|
1
|
+
import { leaseAttemptsExhausted, PikkuWorkflowService, WorkflowStepFunctionMismatchError, WorkflowStepLeaseExpiredError, WorkflowStepSupersededError, } from '@pikku/core/workflow';
|
|
2
2
|
import { MongoDBWorkflowRunService } from './mongodb-workflow-run-service.js';
|
|
3
3
|
export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
4
4
|
initialized = false;
|
|
@@ -7,7 +7,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
7
7
|
steps;
|
|
8
8
|
stepHistory;
|
|
9
9
|
versions;
|
|
10
|
-
constructor(db, options
|
|
10
|
+
constructor(db, options) {
|
|
11
11
|
super(options);
|
|
12
12
|
this.runService = new MongoDBWorkflowRunService(db);
|
|
13
13
|
this.runs = db.collection('workflow_runs');
|
|
@@ -78,10 +78,11 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
78
78
|
retries: stepOptions?.retries ?? null,
|
|
79
79
|
retryDelay: stepOptions?.retryDelay?.toString() ?? null,
|
|
80
80
|
fromStepName: fromStepName ?? null,
|
|
81
|
+
attempt: 1,
|
|
81
82
|
createdAt: now,
|
|
82
83
|
updatedAt: now,
|
|
83
84
|
});
|
|
84
|
-
await this.insertHistoryRecord(stepId, 'pending');
|
|
85
|
+
await this.insertHistoryRecord(stepId, 1, 'pending');
|
|
85
86
|
return {
|
|
86
87
|
stepId,
|
|
87
88
|
status: 'pending',
|
|
@@ -104,19 +105,20 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
104
105
|
if (!row) {
|
|
105
106
|
throw new Error(`Step not found: runId=${runId}, stepName=${stepName}. Use insertStepState to create it.`);
|
|
106
107
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
return this.toStepState(row);
|
|
109
|
+
}
|
|
110
|
+
toStepState(row) {
|
|
110
111
|
return {
|
|
111
112
|
stepId: row._id,
|
|
112
113
|
status: row.status,
|
|
113
114
|
rpcName: row.rpcName ?? null,
|
|
114
115
|
result: row.result ?? undefined,
|
|
115
116
|
error: row.error ?? undefined,
|
|
116
|
-
attemptCount,
|
|
117
|
+
attemptCount: row.attempt,
|
|
117
118
|
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
118
119
|
retryDelay: row.retryDelay ?? undefined,
|
|
119
120
|
fromStepName: row.fromStepName ?? undefined,
|
|
121
|
+
leaseExpiresAt: row.leaseExpiresAt ?? undefined,
|
|
120
122
|
createdAt: new Date(row.createdAt),
|
|
121
123
|
updatedAt: new Date(row.updatedAt),
|
|
122
124
|
};
|
|
@@ -133,28 +135,31 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
133
135
|
await this.writeLatestHistory(stepId, 'scheduled');
|
|
134
136
|
}
|
|
135
137
|
/**
|
|
136
|
-
* Move
|
|
138
|
+
* Move one attempt of a step's history to a status — `attempt`, or the
|
|
139
|
+
* newest when it is not given.
|
|
137
140
|
*
|
|
138
141
|
* Finding the newest attempt and then updating it by `_id` was two round
|
|
139
142
|
* trips with a window in between; `findOneAndUpdate` applies the same sort
|
|
140
143
|
* server-side and writes the row it selected, so nothing can move underneath
|
|
141
|
-
* it.
|
|
142
|
-
* attempt number — see `getStepState`, which counts history documents.
|
|
144
|
+
* it.
|
|
143
145
|
*/
|
|
144
|
-
async writeLatestHistory(stepId, status, values = {}) {
|
|
146
|
+
async writeLatestHistory(stepId, status, values = {}, attempt) {
|
|
145
147
|
const now = new Date();
|
|
146
148
|
const update = { status, ...values };
|
|
147
149
|
const timestampField = this.getTimestampFieldForStatus(status);
|
|
148
150
|
if (timestampField !== 'createdAt') {
|
|
149
151
|
update[timestampField] = now;
|
|
150
152
|
}
|
|
151
|
-
await this.stepHistory.findOneAndUpdate(
|
|
153
|
+
await this.stepHistory.findOneAndUpdate(attempt === undefined
|
|
154
|
+
? { workflowStepId: stepId }
|
|
155
|
+
: { workflowStepId: stepId, attempt }, { $set: update }, { sort: { attempt: -1 } });
|
|
152
156
|
}
|
|
153
|
-
async insertHistoryRecord(stepId, status, result, error) {
|
|
157
|
+
async insertHistoryRecord(stepId, attempt, status, result, error) {
|
|
154
158
|
const now = new Date();
|
|
155
159
|
const doc = {
|
|
156
160
|
_id: crypto.randomUUID(),
|
|
157
161
|
workflowStepId: stepId,
|
|
162
|
+
attempt,
|
|
158
163
|
status,
|
|
159
164
|
result: result ?? null,
|
|
160
165
|
error: error ?? null,
|
|
@@ -192,108 +197,105 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
192
197
|
},
|
|
193
198
|
});
|
|
194
199
|
}
|
|
195
|
-
async
|
|
196
|
-
await this.steps.updateOne({ _id: stepId }, {
|
|
200
|
+
async refreshStepLease(stepId, leaseMs, attempt) {
|
|
201
|
+
const renewed = await this.steps.updateOne(attempt === undefined ? { _id: stepId } : { _id: stepId, attempt }, {
|
|
197
202
|
$set: {
|
|
198
|
-
|
|
199
|
-
result,
|
|
200
|
-
error: null,
|
|
201
|
-
updatedAt: new Date(),
|
|
203
|
+
leaseExpiresAt: leaseMs === null ? null : new Date(Date.now() + leaseMs),
|
|
202
204
|
},
|
|
203
205
|
});
|
|
204
|
-
|
|
206
|
+
return renewed.matchedCount > 0;
|
|
207
|
+
}
|
|
208
|
+
async setStepResultImpl(stepId, result, attempt) {
|
|
209
|
+
await this.writeStepOutcome(stepId, 'succeeded', { result, error: null }, attempt);
|
|
210
|
+
await this.writeLatestHistory(stepId, 'succeeded', { result }, attempt);
|
|
205
211
|
}
|
|
206
|
-
async setStepErrorImpl(stepId, error) {
|
|
212
|
+
async setStepErrorImpl(stepId, error, attempt) {
|
|
207
213
|
const serializedError = {
|
|
208
214
|
message: error.message,
|
|
209
215
|
stack: error.stack,
|
|
210
216
|
code: error.code,
|
|
211
217
|
};
|
|
212
|
-
await this.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
await this.
|
|
221
|
-
|
|
222
|
-
|
|
218
|
+
await this.writeStepOutcome(stepId, 'failed', { error: serializedError, result: null }, attempt);
|
|
219
|
+
await this.writeLatestHistory(stepId, 'failed', { error: serializedError }, attempt);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Settle a step. With `attempt`, a step claimed again since matches nothing
|
|
223
|
+
* and throws `WorkflowStepSupersededError`, keeping the newer claim's state.
|
|
224
|
+
*/
|
|
225
|
+
async writeStepOutcome(stepId, status, values, attempt) {
|
|
226
|
+
const written = await this.steps.updateOne(attempt === undefined ? { _id: stepId } : { _id: stepId, attempt }, { $set: { ...values, status, updatedAt: new Date() } });
|
|
227
|
+
if (attempt !== undefined && written.matchedCount === 0) {
|
|
228
|
+
throw new WorkflowStepSupersededError(stepId, attempt);
|
|
229
|
+
}
|
|
223
230
|
}
|
|
224
231
|
async createRetryAttemptImpl(stepId, status) {
|
|
225
|
-
await this.steps.
|
|
232
|
+
const row = await this.steps.findOneAndUpdate({ _id: stepId }, {
|
|
226
233
|
$set: {
|
|
227
234
|
status,
|
|
228
235
|
result: null,
|
|
229
236
|
error: null,
|
|
230
237
|
updatedAt: new Date(),
|
|
231
238
|
},
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const row = await this.steps.findOne({ _id: stepId });
|
|
239
|
+
$inc: { attempt: 1 },
|
|
240
|
+
}, { returnDocument: 'after' });
|
|
235
241
|
if (!row)
|
|
236
242
|
throw new Error(`Step not found: ${stepId}`);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
});
|
|
240
|
-
return {
|
|
241
|
-
stepId: row._id,
|
|
242
|
-
status: row.status,
|
|
243
|
-
rpcName: row.rpcName ?? null,
|
|
244
|
-
result: row.result ?? undefined,
|
|
245
|
-
error: row.error ?? undefined,
|
|
246
|
-
attemptCount,
|
|
247
|
-
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
248
|
-
retryDelay: row.retryDelay ?? undefined,
|
|
249
|
-
fromStepName: row.fromStepName ?? undefined,
|
|
250
|
-
createdAt: new Date(row.createdAt),
|
|
251
|
-
updatedAt: new Date(row.updatedAt),
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
async withRunLock(id, fn) {
|
|
255
|
-
return fn();
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* A pass-through: MongoDB has no advisory-lock primitive to build one on.
|
|
259
|
-
* The one decision that must exclude — claiming a step to execute it — is
|
|
260
|
-
* made by `claimStepForExecution` below as a single conditional write, which
|
|
261
|
-
* needs no lock to be exclusive.
|
|
262
|
-
*/
|
|
263
|
-
async withStepLock(_runId, _stepName, fn) {
|
|
264
|
-
return fn();
|
|
243
|
+
await this.insertHistoryRecord(stepId, row.attempt, status);
|
|
244
|
+
return this.toStepState(row);
|
|
265
245
|
}
|
|
266
246
|
/**
|
|
267
|
-
* Claim the step with a
|
|
268
|
-
*
|
|
247
|
+
* Claim the step with a guarded update and read what it matched: a
|
|
248
|
+
* single-document update is atomic in MongoDB, so two dispatches racing for
|
|
269
249
|
* the same step cannot both proceed.
|
|
270
250
|
*
|
|
271
251
|
* This replaces the read-then-write the base engine does under
|
|
272
|
-
* `withStepLock
|
|
273
|
-
*
|
|
274
|
-
*
|
|
252
|
+
* `withStepLock`: one atomic write needs no lease round-trips around it. The
|
|
253
|
+
* winner then goes through the ordinary transition methods, so history rows
|
|
254
|
+
* see exactly what they saw before.
|
|
255
|
+
*
|
|
256
|
+
* A `running` step is taken back only once its lease has lapsed, and the
|
|
257
|
+
* lease is part of the same guarded update, so two dispatches that read one
|
|
258
|
+
* lapsed lease cannot both take the step.
|
|
275
259
|
*/
|
|
276
|
-
async claimStepForExecution(runId, stepName, rpcName) {
|
|
260
|
+
async claimStepForExecution(runId, stepName, rpcName, leaseMs) {
|
|
277
261
|
const stepState = await this.getStepState(runId, stepName);
|
|
278
262
|
// knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
|
|
279
263
|
if (stepState.rpcName !== undefined &&
|
|
280
264
|
stepState.rpcName !== (rpcName ?? null)) {
|
|
281
265
|
throw new WorkflowStepFunctionMismatchError(runId, stepName);
|
|
282
266
|
}
|
|
283
|
-
if (stepState.status === 'succeeded'
|
|
267
|
+
if (stepState.status === 'succeeded') {
|
|
284
268
|
return null;
|
|
285
269
|
}
|
|
286
|
-
if (stepState.status === '
|
|
287
|
-
if
|
|
270
|
+
if (stepState.status === 'running' && leaseAttemptsExhausted(stepState)) {
|
|
271
|
+
// Fail it only if its lease is still lapsed at the moment of writing:
|
|
272
|
+
// a worker that renewed since the read above still owns the step.
|
|
273
|
+
if (!(await this.claimStepStatus(stepState, ['running'], leaseMs))) {
|
|
288
274
|
return null;
|
|
289
275
|
}
|
|
290
|
-
|
|
276
|
+
await this.setStepError(stepState.stepId, new WorkflowStepLeaseExpiredError(runId, stepName, stepState.attemptCount), stepState.attemptCount);
|
|
277
|
+
// A null claim reads as "another dispatch owns it", so nothing else
|
|
278
|
+
// would requeue the orchestrator to see this failure.
|
|
279
|
+
await this.resumeWorkflow(runId);
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
// A step that has spent every attempt is settled, however it failed; a
|
|
283
|
+
// redelivered message must not buy it another one.
|
|
284
|
+
if (stepState.status === 'failed' && leaseAttemptsExhausted(stepState)) {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
if (stepState.status === 'failed' || stepState.status === 'running') {
|
|
288
|
+
return this.mirrored(() => this.reclaimStepAttempt(stepState, leaseMs), async (mirror, newStep) => {
|
|
289
|
+
if (newStep) {
|
|
290
|
+
await mirror.createRetryAttempt(stepState.stepId, {
|
|
291
|
+
...newStep,
|
|
292
|
+
stepName,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
});
|
|
291
296
|
}
|
|
292
297
|
if (stepState.status === 'pending' || stepState.status === 'scheduled') {
|
|
293
|
-
if (!(await this.claimStepStatus(stepState
|
|
294
|
-
'pending',
|
|
295
|
-
'scheduled',
|
|
296
|
-
]))) {
|
|
298
|
+
if (!(await this.claimStepStatus(stepState, ['pending', 'scheduled'], leaseMs))) {
|
|
297
299
|
return null;
|
|
298
300
|
}
|
|
299
301
|
await this.setStepRunning(stepState.stepId);
|
|
@@ -302,12 +304,54 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
302
304
|
return stepState;
|
|
303
305
|
}
|
|
304
306
|
/**
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
+
* Take back a failed or lapsed step as a new attempt. The attempt number is
|
|
308
|
+
* advanced by the same guarded update that grants the lease, so the worker
|
|
309
|
+
* it is taken from is fenced out of its step at once.
|
|
307
310
|
*/
|
|
308
|
-
async
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
+
async reclaimStepAttempt(stepState, leaseMs) {
|
|
312
|
+
const now = new Date();
|
|
313
|
+
const row = await this.steps.findOneAndUpdate(this.claimFilter(stepState, [stepState.status], now), {
|
|
314
|
+
$set: {
|
|
315
|
+
status: 'running',
|
|
316
|
+
result: null,
|
|
317
|
+
error: null,
|
|
318
|
+
leaseExpiresAt: new Date(now.getTime() + leaseMs),
|
|
319
|
+
updatedAt: now,
|
|
320
|
+
},
|
|
321
|
+
$inc: { attempt: 1 },
|
|
322
|
+
}, { returnDocument: 'after' });
|
|
323
|
+
if (!row)
|
|
324
|
+
return null;
|
|
325
|
+
await this.insertHistoryRecord(row._id, row.attempt, 'running');
|
|
326
|
+
return this.toStepState(row);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Move a step to `running` under a fresh lease, only if it is still the
|
|
330
|
+
* attempt that was read and in one of `from`, reporting whether this caller
|
|
331
|
+
* is the one that moved it.
|
|
332
|
+
*/
|
|
333
|
+
async claimStepStatus(stepState, from, leaseMs) {
|
|
334
|
+
const now = new Date();
|
|
335
|
+
const claimed = await this.steps.updateOne(this.claimFilter(stepState, from, now), {
|
|
336
|
+
$set: {
|
|
337
|
+
status: 'running',
|
|
338
|
+
leaseExpiresAt: new Date(now.getTime() + leaseMs),
|
|
339
|
+
updatedAt: now,
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
return claimed.matchedCount > 0;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* A `running` step matches only once its lease has lapsed; a null lease —
|
|
346
|
+
* a step parked on a child run — never does.
|
|
347
|
+
*/
|
|
348
|
+
claimFilter(stepState, from, now) {
|
|
349
|
+
return {
|
|
350
|
+
_id: stepState.stepId,
|
|
351
|
+
attempt: stepState.attemptCount,
|
|
352
|
+
status: { $in: from },
|
|
353
|
+
...(from.includes('running') ? { leaseExpiresAt: { $lt: now } } : {}),
|
|
354
|
+
};
|
|
311
355
|
}
|
|
312
356
|
async getCompletedGraphState(runId) {
|
|
313
357
|
const results = await this.steps
|
|
@@ -329,10 +373,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
329
373
|
}
|
|
330
374
|
else if (row.status === 'failed') {
|
|
331
375
|
const maxAttempts = (row.retries ?? 0) + 1;
|
|
332
|
-
|
|
333
|
-
workflowStepId: row._id,
|
|
334
|
-
});
|
|
335
|
-
if (attemptCount >= maxAttempts) {
|
|
376
|
+
if (row.attempt >= maxAttempts) {
|
|
336
377
|
failedNodeIds.push(nodeId);
|
|
337
378
|
}
|
|
338
379
|
}
|
|
@@ -342,12 +383,13 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
342
383
|
async getStepInstances(runId) {
|
|
343
384
|
const rows = await this.steps
|
|
344
385
|
.find({ workflowRunId: runId })
|
|
345
|
-
.project({ stepName: 1, status: 1, fromStepName: 1 })
|
|
386
|
+
.project({ stepName: 1, status: 1, fromStepName: 1, leaseExpiresAt: 1 })
|
|
346
387
|
.toArray();
|
|
347
388
|
return rows.map((r) => ({
|
|
348
389
|
stepName: r.stepName,
|
|
349
390
|
status: r.status,
|
|
350
391
|
fromStepName: r.fromStepName ?? undefined,
|
|
392
|
+
leaseExpiresAt: r.leaseExpiresAt ?? undefined,
|
|
351
393
|
}));
|
|
352
394
|
}
|
|
353
395
|
async getNodeResults(runId, nodeIds) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/mongodb",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.23",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/pikkujs/pikku.git",
|
|
7
|
+
"directory": "packages/services/mongodb"
|
|
8
|
+
},
|
|
4
9
|
"description": "MongoDB services for Pikku — workflow state, agent runs, secrets and channel stores",
|
|
5
10
|
"author": "yasser.fadl@gmail.com",
|
|
6
11
|
"license": "MIT",
|
|
@@ -15,20 +20,19 @@
|
|
|
15
20
|
"test:watch": "bash run-tests.sh --watch",
|
|
16
21
|
"test:coverage": "bash run-tests.sh --coverage",
|
|
17
22
|
"release": "npm run build && npm test",
|
|
18
|
-
"prepublishOnly": "
|
|
23
|
+
"prepublishOnly": "bun run build"
|
|
19
24
|
},
|
|
20
25
|
"peerDependencies": {
|
|
21
|
-
"@pikku/core": "^0.12.
|
|
26
|
+
"@pikku/core": "^0.12.133"
|
|
22
27
|
},
|
|
23
28
|
"dependencies": {
|
|
24
29
|
"mongodb": "^7.5.0"
|
|
25
30
|
},
|
|
26
31
|
"devDependencies": {
|
|
27
32
|
"mongodb": "^7.5.0",
|
|
28
|
-
"mongodb-memory-server": "^11.2.0"
|
|
29
|
-
"typescript": "^6.0.3"
|
|
33
|
+
"mongodb-memory-server": "^11.2.0"
|
|
30
34
|
},
|
|
31
35
|
"exports": {
|
|
32
36
|
".": "./dist/src/index.js"
|
|
33
37
|
}
|
|
34
|
-
}
|
|
38
|
+
}
|