@pikku/mongodb 0.12.22 → 0.12.24
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/README.md +9 -1
- package/dist/src/mongodb-deployment-service.js +4 -1
- package/dist/src/mongodb-workflow-run-service.js +16 -12
- package/dist/src/mongodb-workflow-service.d.ts +43 -22
- package/dist/src/mongodb-workflow-service.js +146 -93
- package/package.json +15 -9
- package/CHANGELOG.md +0 -1049
- package/run-tests.sh +0 -68
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
|
|
|
@@ -40,7 +40,10 @@ export class MongoDBDeploymentService {
|
|
|
40
40
|
createdAt: now,
|
|
41
41
|
},
|
|
42
42
|
}, { upsert: true });
|
|
43
|
-
this.heartbeatTimer = setInterval(() =>
|
|
43
|
+
this.heartbeatTimer = setInterval(() => {
|
|
44
|
+
// sendHeartbeat catches its own errors; it retries on the next tick
|
|
45
|
+
void this.sendHeartbeat();
|
|
46
|
+
}, this.heartbeatInterval);
|
|
44
47
|
}
|
|
45
48
|
async stop() {
|
|
46
49
|
if (this.heartbeatTimer) {
|
|
@@ -35,23 +35,29 @@ export class MongoDBWorkflowRunService {
|
|
|
35
35
|
.find({ workflowRunId: runId })
|
|
36
36
|
.sort({ createdAt: 1 })
|
|
37
37
|
.toArray();
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
.
|
|
41
|
-
{ $match: { workflowStepId: { $in: stepIds } } },
|
|
42
|
-
{ $group: { _id: '$workflowStepId', count: { $sum: 1 } } },
|
|
43
|
-
])
|
|
38
|
+
const history = await this.stepHistory
|
|
39
|
+
.find({ workflowStepId: { $in: result.map((row) => row._id) } })
|
|
40
|
+
.sort({ createdAt: 1, attempt: 1 })
|
|
44
41
|
.toArray();
|
|
45
|
-
const
|
|
42
|
+
const latest = new Map();
|
|
43
|
+
for (const row of history)
|
|
44
|
+
latest.set(row.workflowStepId, row);
|
|
46
45
|
return result.map((row) => ({
|
|
47
46
|
stepId: row._id,
|
|
48
47
|
stepName: row.stepName,
|
|
49
48
|
rpcName: row.rpcName ?? undefined,
|
|
50
49
|
data: row.data ?? undefined,
|
|
50
|
+
childRunId: row.childRunId ?? undefined,
|
|
51
|
+
succeededAt: latest.get(row._id)?.succeededAt
|
|
52
|
+
? new Date(latest.get(row._id).succeededAt)
|
|
53
|
+
: undefined,
|
|
54
|
+
failedAt: latest.get(row._id)?.failedAt
|
|
55
|
+
? new Date(latest.get(row._id).failedAt)
|
|
56
|
+
: undefined,
|
|
51
57
|
status: row.status,
|
|
52
58
|
result: row.result ?? undefined,
|
|
53
59
|
error: row.error ?? undefined,
|
|
54
|
-
attemptCount:
|
|
60
|
+
attemptCount: row.attempt,
|
|
55
61
|
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
56
62
|
retryDelay: row.retryDelay ?? undefined,
|
|
57
63
|
createdAt: new Date(row.createdAt),
|
|
@@ -64,20 +70,18 @@ export class MongoDBWorkflowRunService {
|
|
|
64
70
|
const stepIds = steps.map((s) => s._id);
|
|
65
71
|
const history = await this.stepHistory
|
|
66
72
|
.find({ workflowStepId: { $in: stepIds } })
|
|
67
|
-
.sort({ createdAt: 1 })
|
|
73
|
+
.sort({ createdAt: 1, attempt: 1 })
|
|
68
74
|
.toArray();
|
|
69
|
-
let attemptCounters = {};
|
|
70
75
|
return history.map((row) => {
|
|
71
76
|
const stepId = row.workflowStepId;
|
|
72
77
|
const step = stepMap.get(stepId);
|
|
73
|
-
attemptCounters[stepId] = (attemptCounters[stepId] ?? 0) + 1;
|
|
74
78
|
return {
|
|
75
79
|
stepId,
|
|
76
80
|
stepName: step.stepName,
|
|
77
81
|
status: row.status,
|
|
78
82
|
result: row.result ?? undefined,
|
|
79
83
|
error: row.error ?? undefined,
|
|
80
|
-
attemptCount:
|
|
84
|
+
attemptCount: row.attempt,
|
|
81
85
|
retries: step.retries != null ? Number(step.retries) : undefined,
|
|
82
86
|
retryDelay: step.retryDelay ?? undefined,
|
|
83
87
|
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,71 @@ 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;
|
|
26
|
+
getRunSteps(runId: string): Promise<Array<StepState & {
|
|
27
|
+
stepName: string;
|
|
28
|
+
rpcName?: string;
|
|
29
|
+
data?: any;
|
|
30
|
+
}>>;
|
|
25
31
|
getRunHistory(runId: string): Promise<Array<StepState & {
|
|
26
32
|
stepName: string;
|
|
27
33
|
}>>;
|
|
28
34
|
protected setStepRunningImpl(stepId: string): Promise<void>;
|
|
29
35
|
protected setStepScheduledImpl(stepId: string): Promise<void>;
|
|
30
36
|
/**
|
|
31
|
-
* Move
|
|
37
|
+
* Move one attempt of a step's history to a status — `attempt`, or the
|
|
38
|
+
* newest when it is not given.
|
|
32
39
|
*
|
|
33
40
|
* Finding the newest attempt and then updating it by `_id` was two round
|
|
34
41
|
* trips with a window in between; `findOneAndUpdate` applies the same sort
|
|
35
42
|
* server-side and writes the row it selected, so nothing can move underneath
|
|
36
|
-
* it.
|
|
37
|
-
* attempt number — see `getStepState`, which counts history documents.
|
|
43
|
+
* it.
|
|
38
44
|
*/
|
|
39
45
|
private writeLatestHistory;
|
|
40
46
|
private insertHistoryRecord;
|
|
41
47
|
private getTimestampFieldForStatus;
|
|
42
48
|
protected setStepChildRunIdImpl(stepId: string, childRunId: string): Promise<void>;
|
|
43
|
-
|
|
44
|
-
protected
|
|
45
|
-
protected
|
|
46
|
-
withRunLock<T>(id: string, fn: () => Promise<T>): Promise<T>;
|
|
49
|
+
refreshStepLease(stepId: string, leaseMs: number | null, attempt?: number): Promise<boolean>;
|
|
50
|
+
protected setStepResultImpl(stepId: string, result: any, attempt?: number): Promise<void>;
|
|
51
|
+
protected setStepErrorImpl(stepId: string, error: Error, attempt?: number): Promise<void>;
|
|
47
52
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* made by `claimStepForExecution` below as a single conditional write, which
|
|
51
|
-
* needs no lock to be exclusive.
|
|
53
|
+
* Settle a step. With `attempt`, a step claimed again since matches nothing
|
|
54
|
+
* and throws `WorkflowStepSupersededError`, keeping the newer claim's state.
|
|
52
55
|
*/
|
|
53
|
-
|
|
56
|
+
private writeStepOutcome;
|
|
57
|
+
protected createRetryAttemptImpl(stepId: string, status: 'pending' | 'running'): Promise<StepState>;
|
|
54
58
|
/**
|
|
55
|
-
* Claim the step with a
|
|
56
|
-
*
|
|
59
|
+
* Claim the step with a guarded update and read what it matched: a
|
|
60
|
+
* single-document update is atomic in MongoDB, so two dispatches racing for
|
|
57
61
|
* the same step cannot both proceed.
|
|
58
62
|
*
|
|
59
63
|
* This replaces the read-then-write the base engine does under
|
|
60
|
-
* `withStepLock
|
|
61
|
-
*
|
|
62
|
-
*
|
|
64
|
+
* `withStepLock`: one atomic write needs no lease round-trips around it. The
|
|
65
|
+
* winner then goes through the ordinary transition methods, so history rows
|
|
66
|
+
* see exactly what they saw before.
|
|
67
|
+
*
|
|
68
|
+
* A `running` step is taken back only once its lease has lapsed, and the
|
|
69
|
+
* lease is part of the same guarded update, so two dispatches that read one
|
|
70
|
+
* lapsed lease cannot both take the step.
|
|
63
71
|
*/
|
|
64
|
-
protected claimStepForExecution(runId: string, stepName: string, rpcName: string): Promise<StepState | null>;
|
|
72
|
+
protected claimStepForExecution(runId: string, stepName: string, rpcName: string, leaseMs: number): Promise<StepState | null>;
|
|
65
73
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
74
|
+
* Take back a failed or lapsed step as a new attempt. The attempt number is
|
|
75
|
+
* advanced by the same guarded update that grants the lease, so the worker
|
|
76
|
+
* it is taken from is fenced out of its step at once.
|
|
77
|
+
*/
|
|
78
|
+
private reclaimStepAttempt;
|
|
79
|
+
/**
|
|
80
|
+
* Move a step to `running` under a fresh lease, only if it is still the
|
|
81
|
+
* attempt that was read and in one of `from`, reporting whether this caller
|
|
82
|
+
* is the one that moved it.
|
|
68
83
|
*/
|
|
69
84
|
private claimStepStatus;
|
|
85
|
+
/**
|
|
86
|
+
* A `running` step matches only once its lease has lapsed; a null lease —
|
|
87
|
+
* a step parked on a child run — never does.
|
|
88
|
+
*/
|
|
89
|
+
private claimFilter;
|
|
70
90
|
getCompletedGraphState(runId: string): Promise<{
|
|
71
91
|
completedNodeIds: string[];
|
|
72
92
|
failedNodeIds: string[];
|
|
@@ -76,6 +96,7 @@ export declare class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
76
96
|
stepName: string;
|
|
77
97
|
status: StepStatus;
|
|
78
98
|
fromStepName?: string;
|
|
99
|
+
leaseExpiresAt?: Date;
|
|
79
100
|
}>>;
|
|
80
101
|
getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
|
|
81
102
|
protected setBranchTakenImpl(stepId: string, branchKey: string): Promise<void>;
|
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
const ENCODED_STATE_KEY = '__enc_';
|
|
4
|
+
/** Field names cannot hold `.` or `$`, and step names are free text. */
|
|
5
|
+
const encodeStateKey = (name) => /^[a-zA-Z0-9_]+$/.test(name) && !name.startsWith(ENCODED_STATE_KEY)
|
|
6
|
+
? name
|
|
7
|
+
: ENCODED_STATE_KEY + Buffer.from(name, 'utf8').toString('hex');
|
|
8
|
+
const decodeStateKey = (key) => key.startsWith(ENCODED_STATE_KEY)
|
|
9
|
+
? Buffer.from(key.slice(ENCODED_STATE_KEY.length), 'hex').toString('utf8')
|
|
10
|
+
: key;
|
|
3
11
|
export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
4
12
|
initialized = false;
|
|
5
13
|
runService;
|
|
@@ -7,7 +15,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
7
15
|
steps;
|
|
8
16
|
stepHistory;
|
|
9
17
|
versions;
|
|
10
|
-
constructor(db, options
|
|
18
|
+
constructor(db, options) {
|
|
11
19
|
super(options);
|
|
12
20
|
this.runService = new MongoDBWorkflowRunService(db);
|
|
13
21
|
this.runs = db.collection('workflow_runs');
|
|
@@ -78,10 +86,11 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
78
86
|
retries: stepOptions?.retries ?? null,
|
|
79
87
|
retryDelay: stepOptions?.retryDelay?.toString() ?? null,
|
|
80
88
|
fromStepName: fromStepName ?? null,
|
|
89
|
+
attempt: 1,
|
|
81
90
|
createdAt: now,
|
|
82
91
|
updatedAt: now,
|
|
83
92
|
});
|
|
84
|
-
await this.insertHistoryRecord(stepId, 'pending');
|
|
93
|
+
await this.insertHistoryRecord(stepId, 1, 'pending');
|
|
85
94
|
return {
|
|
86
95
|
stepId,
|
|
87
96
|
status: 'pending',
|
|
@@ -104,23 +113,27 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
104
113
|
if (!row) {
|
|
105
114
|
throw new Error(`Step not found: runId=${runId}, stepName=${stepName}. Use insertStepState to create it.`);
|
|
106
115
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
return this.toStepState(row);
|
|
117
|
+
}
|
|
118
|
+
toStepState(row) {
|
|
110
119
|
return {
|
|
111
120
|
stepId: row._id,
|
|
112
121
|
status: row.status,
|
|
113
122
|
rpcName: row.rpcName ?? null,
|
|
114
123
|
result: row.result ?? undefined,
|
|
115
124
|
error: row.error ?? undefined,
|
|
116
|
-
attemptCount,
|
|
125
|
+
attemptCount: row.attempt,
|
|
117
126
|
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
118
127
|
retryDelay: row.retryDelay ?? undefined,
|
|
119
128
|
fromStepName: row.fromStepName ?? undefined,
|
|
129
|
+
leaseExpiresAt: row.leaseExpiresAt ?? undefined,
|
|
120
130
|
createdAt: new Date(row.createdAt),
|
|
121
131
|
updatedAt: new Date(row.updatedAt),
|
|
122
132
|
};
|
|
123
133
|
}
|
|
134
|
+
async getRunSteps(runId) {
|
|
135
|
+
return this.runService.getRunSteps(runId);
|
|
136
|
+
}
|
|
124
137
|
async getRunHistory(runId) {
|
|
125
138
|
return this.runService.getRunHistory(runId);
|
|
126
139
|
}
|
|
@@ -133,28 +146,31 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
133
146
|
await this.writeLatestHistory(stepId, 'scheduled');
|
|
134
147
|
}
|
|
135
148
|
/**
|
|
136
|
-
* Move
|
|
149
|
+
* Move one attempt of a step's history to a status — `attempt`, or the
|
|
150
|
+
* newest when it is not given.
|
|
137
151
|
*
|
|
138
152
|
* Finding the newest attempt and then updating it by `_id` was two round
|
|
139
153
|
* trips with a window in between; `findOneAndUpdate` applies the same sort
|
|
140
154
|
* server-side and writes the row it selected, so nothing can move underneath
|
|
141
|
-
* it.
|
|
142
|
-
* attempt number — see `getStepState`, which counts history documents.
|
|
155
|
+
* it.
|
|
143
156
|
*/
|
|
144
|
-
async writeLatestHistory(stepId, status, values = {}) {
|
|
157
|
+
async writeLatestHistory(stepId, status, values = {}, attempt) {
|
|
145
158
|
const now = new Date();
|
|
146
159
|
const update = { status, ...values };
|
|
147
160
|
const timestampField = this.getTimestampFieldForStatus(status);
|
|
148
161
|
if (timestampField !== 'createdAt') {
|
|
149
162
|
update[timestampField] = now;
|
|
150
163
|
}
|
|
151
|
-
await this.stepHistory.findOneAndUpdate(
|
|
164
|
+
await this.stepHistory.findOneAndUpdate(attempt === undefined
|
|
165
|
+
? { workflowStepId: stepId }
|
|
166
|
+
: { workflowStepId: stepId, attempt }, { $set: update }, { sort: { attempt: -1 } });
|
|
152
167
|
}
|
|
153
|
-
async insertHistoryRecord(stepId, status, result, error) {
|
|
168
|
+
async insertHistoryRecord(stepId, attempt, status, result, error) {
|
|
154
169
|
const now = new Date();
|
|
155
170
|
const doc = {
|
|
156
171
|
_id: crypto.randomUUID(),
|
|
157
172
|
workflowStepId: stepId,
|
|
173
|
+
attempt,
|
|
158
174
|
status,
|
|
159
175
|
result: result ?? null,
|
|
160
176
|
error: error ?? null,
|
|
@@ -192,108 +208,105 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
192
208
|
},
|
|
193
209
|
});
|
|
194
210
|
}
|
|
195
|
-
async
|
|
196
|
-
await this.steps.updateOne({ _id: stepId }, {
|
|
211
|
+
async refreshStepLease(stepId, leaseMs, attempt) {
|
|
212
|
+
const renewed = await this.steps.updateOne(attempt === undefined ? { _id: stepId } : { _id: stepId, attempt }, {
|
|
197
213
|
$set: {
|
|
198
|
-
|
|
199
|
-
result,
|
|
200
|
-
error: null,
|
|
201
|
-
updatedAt: new Date(),
|
|
214
|
+
leaseExpiresAt: leaseMs === null ? null : new Date(Date.now() + leaseMs),
|
|
202
215
|
},
|
|
203
216
|
});
|
|
204
|
-
|
|
217
|
+
return renewed.matchedCount > 0;
|
|
205
218
|
}
|
|
206
|
-
async
|
|
219
|
+
async setStepResultImpl(stepId, result, attempt) {
|
|
220
|
+
await this.writeStepOutcome(stepId, 'succeeded', { result, error: null }, attempt);
|
|
221
|
+
await this.writeLatestHistory(stepId, 'succeeded', { result }, attempt);
|
|
222
|
+
}
|
|
223
|
+
async setStepErrorImpl(stepId, error, attempt) {
|
|
207
224
|
const serializedError = {
|
|
208
225
|
message: error.message,
|
|
209
226
|
stack: error.stack,
|
|
210
227
|
code: error.code,
|
|
211
228
|
};
|
|
212
|
-
await this.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
await this.
|
|
221
|
-
|
|
222
|
-
|
|
229
|
+
await this.writeStepOutcome(stepId, 'failed', { error: serializedError, result: null }, attempt);
|
|
230
|
+
await this.writeLatestHistory(stepId, 'failed', { error: serializedError }, attempt);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Settle a step. With `attempt`, a step claimed again since matches nothing
|
|
234
|
+
* and throws `WorkflowStepSupersededError`, keeping the newer claim's state.
|
|
235
|
+
*/
|
|
236
|
+
async writeStepOutcome(stepId, status, values, attempt) {
|
|
237
|
+
const written = await this.steps.updateOne(attempt === undefined ? { _id: stepId } : { _id: stepId, attempt }, { $set: { ...values, status, updatedAt: new Date() } });
|
|
238
|
+
if (attempt !== undefined && written.matchedCount === 0) {
|
|
239
|
+
throw new WorkflowStepSupersededError(stepId, attempt);
|
|
240
|
+
}
|
|
223
241
|
}
|
|
224
242
|
async createRetryAttemptImpl(stepId, status) {
|
|
225
|
-
await this.steps.
|
|
243
|
+
const row = await this.steps.findOneAndUpdate({ _id: stepId }, {
|
|
226
244
|
$set: {
|
|
227
245
|
status,
|
|
228
246
|
result: null,
|
|
229
247
|
error: null,
|
|
230
248
|
updatedAt: new Date(),
|
|
231
249
|
},
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const row = await this.steps.findOne({ _id: stepId });
|
|
250
|
+
$inc: { attempt: 1 },
|
|
251
|
+
}, { returnDocument: 'after' });
|
|
235
252
|
if (!row)
|
|
236
253
|
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();
|
|
254
|
+
await this.insertHistoryRecord(stepId, row.attempt, status);
|
|
255
|
+
return this.toStepState(row);
|
|
265
256
|
}
|
|
266
257
|
/**
|
|
267
|
-
* Claim the step with a
|
|
268
|
-
*
|
|
258
|
+
* Claim the step with a guarded update and read what it matched: a
|
|
259
|
+
* single-document update is atomic in MongoDB, so two dispatches racing for
|
|
269
260
|
* the same step cannot both proceed.
|
|
270
261
|
*
|
|
271
262
|
* This replaces the read-then-write the base engine does under
|
|
272
|
-
* `withStepLock
|
|
273
|
-
*
|
|
274
|
-
*
|
|
263
|
+
* `withStepLock`: one atomic write needs no lease round-trips around it. The
|
|
264
|
+
* winner then goes through the ordinary transition methods, so history rows
|
|
265
|
+
* see exactly what they saw before.
|
|
266
|
+
*
|
|
267
|
+
* A `running` step is taken back only once its lease has lapsed, and the
|
|
268
|
+
* lease is part of the same guarded update, so two dispatches that read one
|
|
269
|
+
* lapsed lease cannot both take the step.
|
|
275
270
|
*/
|
|
276
|
-
async claimStepForExecution(runId, stepName, rpcName) {
|
|
271
|
+
async claimStepForExecution(runId, stepName, rpcName, leaseMs) {
|
|
277
272
|
const stepState = await this.getStepState(runId, stepName);
|
|
278
273
|
// knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
|
|
279
274
|
if (stepState.rpcName !== undefined &&
|
|
280
275
|
stepState.rpcName !== (rpcName ?? null)) {
|
|
281
276
|
throw new WorkflowStepFunctionMismatchError(runId, stepName);
|
|
282
277
|
}
|
|
283
|
-
if (stepState.status === 'succeeded'
|
|
278
|
+
if (stepState.status === 'succeeded') {
|
|
284
279
|
return null;
|
|
285
280
|
}
|
|
286
|
-
if (stepState.status === '
|
|
287
|
-
if
|
|
281
|
+
if (stepState.status === 'running' && leaseAttemptsExhausted(stepState)) {
|
|
282
|
+
// Fail it only if its lease is still lapsed at the moment of writing:
|
|
283
|
+
// a worker that renewed since the read above still owns the step.
|
|
284
|
+
if (!(await this.claimStepStatus(stepState, ['running'], leaseMs))) {
|
|
288
285
|
return null;
|
|
289
286
|
}
|
|
290
|
-
|
|
287
|
+
await this.setStepError(stepState.stepId, new WorkflowStepLeaseExpiredError(runId, stepName, stepState.attemptCount), stepState.attemptCount);
|
|
288
|
+
// A null claim reads as "another dispatch owns it", so nothing else
|
|
289
|
+
// would requeue the orchestrator to see this failure.
|
|
290
|
+
await this.resumeWorkflow(runId);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
// A step that has spent every attempt is settled, however it failed; a
|
|
294
|
+
// redelivered message must not buy it another one.
|
|
295
|
+
if (stepState.status === 'failed' && leaseAttemptsExhausted(stepState)) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
if (stepState.status === 'failed' || stepState.status === 'running') {
|
|
299
|
+
return this.mirrored(() => this.reclaimStepAttempt(stepState, leaseMs), async (mirror, newStep) => {
|
|
300
|
+
if (newStep) {
|
|
301
|
+
await mirror.createRetryAttempt(stepState.stepId, {
|
|
302
|
+
...newStep,
|
|
303
|
+
stepName,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
});
|
|
291
307
|
}
|
|
292
308
|
if (stepState.status === 'pending' || stepState.status === 'scheduled') {
|
|
293
|
-
if (!(await this.claimStepStatus(stepState
|
|
294
|
-
'pending',
|
|
295
|
-
'scheduled',
|
|
296
|
-
]))) {
|
|
309
|
+
if (!(await this.claimStepStatus(stepState, ['pending', 'scheduled'], leaseMs))) {
|
|
297
310
|
return null;
|
|
298
311
|
}
|
|
299
312
|
await this.setStepRunning(stepState.stepId);
|
|
@@ -302,12 +315,54 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
302
315
|
return stepState;
|
|
303
316
|
}
|
|
304
317
|
/**
|
|
305
|
-
*
|
|
306
|
-
*
|
|
318
|
+
* Take back a failed or lapsed step as a new attempt. The attempt number is
|
|
319
|
+
* advanced by the same guarded update that grants the lease, so the worker
|
|
320
|
+
* it is taken from is fenced out of its step at once.
|
|
321
|
+
*/
|
|
322
|
+
async reclaimStepAttempt(stepState, leaseMs) {
|
|
323
|
+
const now = new Date();
|
|
324
|
+
const row = await this.steps.findOneAndUpdate(this.claimFilter(stepState, [stepState.status], now), {
|
|
325
|
+
$set: {
|
|
326
|
+
status: 'running',
|
|
327
|
+
result: null,
|
|
328
|
+
error: null,
|
|
329
|
+
leaseExpiresAt: new Date(now.getTime() + leaseMs),
|
|
330
|
+
updatedAt: now,
|
|
331
|
+
},
|
|
332
|
+
$inc: { attempt: 1 },
|
|
333
|
+
}, { returnDocument: 'after' });
|
|
334
|
+
if (!row)
|
|
335
|
+
return null;
|
|
336
|
+
await this.insertHistoryRecord(row._id, row.attempt, 'running');
|
|
337
|
+
return this.toStepState(row);
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Move a step to `running` under a fresh lease, only if it is still the
|
|
341
|
+
* attempt that was read and in one of `from`, reporting whether this caller
|
|
342
|
+
* is the one that moved it.
|
|
343
|
+
*/
|
|
344
|
+
async claimStepStatus(stepState, from, leaseMs) {
|
|
345
|
+
const now = new Date();
|
|
346
|
+
const claimed = await this.steps.updateOne(this.claimFilter(stepState, from, now), {
|
|
347
|
+
$set: {
|
|
348
|
+
status: 'running',
|
|
349
|
+
leaseExpiresAt: new Date(now.getTime() + leaseMs),
|
|
350
|
+
updatedAt: now,
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
return claimed.matchedCount > 0;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* A `running` step matches only once its lease has lapsed; a null lease —
|
|
357
|
+
* a step parked on a child run — never does.
|
|
307
358
|
*/
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
359
|
+
claimFilter(stepState, from, now) {
|
|
360
|
+
return {
|
|
361
|
+
_id: stepState.stepId,
|
|
362
|
+
attempt: stepState.attemptCount,
|
|
363
|
+
status: { $in: from },
|
|
364
|
+
...(from.includes('running') ? { leaseExpiresAt: { $lt: now } } : {}),
|
|
365
|
+
};
|
|
311
366
|
}
|
|
312
367
|
async getCompletedGraphState(runId) {
|
|
313
368
|
const results = await this.steps
|
|
@@ -329,10 +384,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
329
384
|
}
|
|
330
385
|
else if (row.status === 'failed') {
|
|
331
386
|
const maxAttempts = (row.retries ?? 0) + 1;
|
|
332
|
-
|
|
333
|
-
workflowStepId: row._id,
|
|
334
|
-
});
|
|
335
|
-
if (attemptCount >= maxAttempts) {
|
|
387
|
+
if (row.attempt >= maxAttempts) {
|
|
336
388
|
failedNodeIds.push(nodeId);
|
|
337
389
|
}
|
|
338
390
|
}
|
|
@@ -342,12 +394,13 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
342
394
|
async getStepInstances(runId) {
|
|
343
395
|
const rows = await this.steps
|
|
344
396
|
.find({ workflowRunId: runId })
|
|
345
|
-
.project({ stepName: 1, status: 1, fromStepName: 1 })
|
|
397
|
+
.project({ stepName: 1, status: 1, fromStepName: 1, leaseExpiresAt: 1 })
|
|
346
398
|
.toArray();
|
|
347
399
|
return rows.map((r) => ({
|
|
348
400
|
stepName: r.stepName,
|
|
349
401
|
status: r.status,
|
|
350
402
|
fromStepName: r.fromStepName ?? undefined,
|
|
403
|
+
leaseExpiresAt: r.leaseExpiresAt ?? undefined,
|
|
351
404
|
}));
|
|
352
405
|
}
|
|
353
406
|
async getNodeResults(runId, nodeIds) {
|
|
@@ -370,12 +423,9 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
370
423
|
await this.steps.updateOne({ _id: stepId }, { $set: { branchTaken: branchKey, updatedAt: new Date() } });
|
|
371
424
|
}
|
|
372
425
|
async updateRunStateImpl(runId, name, value) {
|
|
373
|
-
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
|
|
374
|
-
throw new Error('Invalid state key name');
|
|
375
|
-
}
|
|
376
426
|
await this.runs.updateOne({ _id: runId }, {
|
|
377
427
|
$set: {
|
|
378
|
-
[`state.${name}`]: value,
|
|
428
|
+
[`state.${encodeStateKey(name)}`]: value,
|
|
379
429
|
updatedAt: new Date(),
|
|
380
430
|
},
|
|
381
431
|
});
|
|
@@ -384,7 +434,10 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
|
|
|
384
434
|
const row = await this.runs.findOne({ _id: runId }, { projection: { state: 1 } });
|
|
385
435
|
if (!row)
|
|
386
436
|
return {};
|
|
387
|
-
return row.state ?? {}
|
|
437
|
+
return Object.fromEntries(Object.entries(row.state ?? {}).map(([key, value]) => [
|
|
438
|
+
decodeStateKey(key),
|
|
439
|
+
value,
|
|
440
|
+
]));
|
|
388
441
|
}
|
|
389
442
|
async upsertWorkflowVersionImpl(name, graphHash, graph, source, status) {
|
|
390
443
|
await this.versions.updateOne({ workflowName: name, graphHash }, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/mongodb",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.24",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/pikkujs/pikku.git",
|
|
@@ -15,24 +15,30 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"tsc": "tsc",
|
|
17
17
|
"ncu": "npx npm-check-updates",
|
|
18
|
-
"build": "tsc -b",
|
|
18
|
+
"build": "bun run clean && tsc -b",
|
|
19
19
|
"test": "bash run-tests.sh",
|
|
20
20
|
"test:watch": "bash run-tests.sh --watch",
|
|
21
21
|
"test:coverage": "bash run-tests.sh --coverage",
|
|
22
22
|
"release": "npm run build && npm test",
|
|
23
|
-
"prepublishOnly": "bun run build"
|
|
23
|
+
"prepublishOnly": "bun run build",
|
|
24
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo"
|
|
24
25
|
},
|
|
25
26
|
"peerDependencies": {
|
|
26
|
-
"@pikku/core": "^0.12.
|
|
27
|
+
"@pikku/core": "^0.12.144"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
|
-
"mongodb": "^7.
|
|
30
|
+
"mongodb": "^7.7.0"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
|
-
"mongodb": "^7.
|
|
33
|
-
"mongodb-memory-server": "^11.
|
|
33
|
+
"mongodb": "^7.7.0",
|
|
34
|
+
"mongodb-memory-server": "^11.3.0"
|
|
34
35
|
},
|
|
35
36
|
"exports": {
|
|
36
|
-
".": "./dist/src/index.js"
|
|
37
|
-
|
|
37
|
+
".": "./dist/src/index.js",
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"!dist/**/*.tsbuildinfo"
|
|
43
|
+
]
|
|
38
44
|
}
|