@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 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 workflowService = new MongoDBWorkflowService(db)
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(() => this.sendHeartbeat(), this.heartbeatInterval);
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 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
- ])
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 countMap = new Map(historyCounts.map((h) => [h._id, h.count]));
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: countMap.get(row._id) ?? 1,
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: attemptCounters[stepId],
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, WorkflowQueueOptions, WorkflowRun, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus } from '@pikku/core/workflow';
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?: WorkflowQueueOptions);
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 the current attempt of a step's history to a status.
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. The attempt is still identified by `createdAt` because Mongo keeps no
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
- protected setStepResultImpl(stepId: string, result: any): Promise<void>;
44
- protected setStepErrorImpl(stepId: string, error: Error): Promise<void>;
45
- protected createRetryAttemptImpl(stepId: string, status: 'pending' | 'running'): Promise<StepState>;
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
- * A pass-through: MongoDB has no advisory-lock primitive to build one on.
49
- * The one decision that must exclude — claiming a step to execute it — is
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
- withStepLock<T>(_runId: string, _stepName: string, fn: () => Promise<T>): Promise<T>;
56
+ private writeStepOutcome;
57
+ protected createRetryAttemptImpl(stepId: string, status: 'pending' | 'running'): Promise<StepState>;
54
58
  /**
55
- * Claim the step with a status-guarded update and read the modified count:
56
- * a single-document update is atomic in MongoDB, so two dispatches racing for
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`, which excludes nothing here — the lock above is a
61
- * pass-through. The winner then goes through the ordinary transition methods,
62
- * so history rows see exactly what they saw before.
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
- * Move a step to `running` only if it is still in one of `from`, reporting
67
- * whether this caller is the one that moved it.
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
- const attemptCount = await this.stepHistory.countDocuments({
108
- workflowStepId: row._id,
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 the current attempt of a step's history to a status.
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. The attempt is still identified by `createdAt` because Mongo keeps no
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({ workflowStepId: stepId }, { $set: update }, { sort: { createdAt: -1 } });
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 setStepResultImpl(stepId, result) {
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
- status: 'succeeded',
199
- result,
200
- error: null,
201
- updatedAt: new Date(),
214
+ leaseExpiresAt: leaseMs === null ? null : new Date(Date.now() + leaseMs),
202
215
  },
203
216
  });
204
- await this.writeLatestHistory(stepId, 'succeeded', { result });
217
+ return renewed.matchedCount > 0;
205
218
  }
206
- async setStepErrorImpl(stepId, error) {
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.steps.updateOne({ _id: stepId }, {
213
- $set: {
214
- status: 'failed',
215
- error: serializedError,
216
- result: null,
217
- updatedAt: new Date(),
218
- },
219
- });
220
- await this.writeLatestHistory(stepId, 'failed', {
221
- error: serializedError,
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.updateOne({ _id: stepId }, {
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
- await this.insertHistoryRecord(stepId, status);
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
- const attemptCount = await this.stepHistory.countDocuments({
238
- workflowStepId: stepId,
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 status-guarded update and read the modified count:
268
- * a single-document update is atomic in MongoDB, so two dispatches racing for
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`, which excludes nothing here — the lock above is a
273
- * pass-through. The winner then goes through the ordinary transition methods,
274
- * so history rows see exactly what they saw before.
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' || stepState.status === 'running') {
278
+ if (stepState.status === 'succeeded') {
284
279
  return null;
285
280
  }
286
- if (stepState.status === 'failed') {
287
- if (!(await this.claimStepStatus(stepState.stepId, ['failed']))) {
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
- return this.createRetryAttempt(stepState.stepId, 'running');
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.stepId, [
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
- * Move a step to `running` only if it is still in one of `from`, reporting
306
- * whether this caller is the one that moved it.
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
- async claimStepStatus(stepId, from) {
309
- const claimed = await this.steps.updateOne({ _id: stepId, status: { $in: from } }, { $set: { status: 'running', updatedAt: new Date() } });
310
- return claimed.modifiedCount > 0;
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
- const attemptCount = await this.stepHistory.countDocuments({
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.22",
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.108"
27
+ "@pikku/core": "^0.12.144"
27
28
  },
28
29
  "dependencies": {
29
- "mongodb": "^7.5.0"
30
+ "mongodb": "^7.7.0"
30
31
  },
31
32
  "devDependencies": {
32
- "mongodb": "^7.5.0",
33
- "mongodb-memory-server": "^11.2.0"
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
  }