@pikku/mongodb 0.12.22 → 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 CHANGED
@@ -1,5 +1,25 @@
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
+
3
23
  ## 0.12.22
4
24
 
5
25
  ### 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 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
 
@@ -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: countMap.get(row._id) ?? 1,
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: attemptCounters[stepId],
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, 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,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 the current attempt of a step's history to a status.
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. The attempt is still identified by `createdAt` because Mongo keeps no
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
- 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>;
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
- * 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.
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
- withStepLock<T>(_runId: string, _stepName: string, fn: () => Promise<T>): Promise<T>;
51
+ private writeStepOutcome;
52
+ protected createRetryAttemptImpl(stepId: string, status: 'pending' | 'running'): Promise<StepState>;
54
53
  /**
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
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`, 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.
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
- * 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.
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
- const attemptCount = await this.stepHistory.countDocuments({
108
- workflowStepId: row._id,
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 the current attempt of a step's history to a status.
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. The attempt is still identified by `createdAt` because Mongo keeps no
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({ workflowStepId: stepId }, { $set: update }, { sort: { createdAt: -1 } });
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 setStepResultImpl(stepId, result) {
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
- status: 'succeeded',
199
- result,
200
- error: null,
201
- updatedAt: new Date(),
203
+ leaseExpiresAt: leaseMs === null ? null : new Date(Date.now() + leaseMs),
202
204
  },
203
205
  });
204
- await this.writeLatestHistory(stepId, 'succeeded', { result });
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.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
- });
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.updateOne({ _id: stepId }, {
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
- await this.insertHistoryRecord(stepId, status);
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
- 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();
243
+ await this.insertHistoryRecord(stepId, row.attempt, status);
244
+ return this.toStepState(row);
265
245
  }
266
246
  /**
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
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`, 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.
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' || stepState.status === 'running') {
267
+ if (stepState.status === 'succeeded') {
284
268
  return null;
285
269
  }
286
- if (stepState.status === 'failed') {
287
- if (!(await this.claimStepStatus(stepState.stepId, ['failed']))) {
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
- return this.createRetryAttempt(stepState.stepId, 'running');
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.stepId, [
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
- * 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.
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 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;
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
- const attemptCount = await this.stepHistory.countDocuments({
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,6 @@
1
1
  {
2
2
  "name": "@pikku/mongodb",
3
- "version": "0.12.22",
3
+ "version": "0.12.23",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -23,7 +23,7 @@
23
23
  "prepublishOnly": "bun run build"
24
24
  },
25
25
  "peerDependencies": {
26
- "@pikku/core": "^0.12.108"
26
+ "@pikku/core": "^0.12.133"
27
27
  },
28
28
  "dependencies": {
29
29
  "mongodb": "^7.5.0"