@pikku/mongodb 0.12.19 → 0.12.20

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,35 @@
1
1
  # @pikku/mongodb
2
2
 
3
+ ## 0.12.20
4
+
5
+ ### Patch Changes
6
+
7
+ - 3c0012c: Gate console agent-thread reads and deletes on thread ownership, claim MongoDB workflow steps atomically, and reach the deployment fallback from `rpcWithWire`
8
+
9
+ `getAgentThreadMessages` and `deleteAgentThread` in the console addon took a
10
+ caller-supplied `threadId` straight to storage, while their siblings
11
+ `getAgentThreads` and `getAgentThreadRuns` already filtered to what the session
12
+ owns. Both now carry an `isThreadOwner` permission: an admin reaches any thread,
13
+ everyone else only their own, and a missing thread is refused rather than 404'd
14
+ so it is indistinguishable from someone else's.
15
+
16
+ `MongoDBWorkflowService` claimed a step by reading its status and then writing
17
+ it, under a `withStepLock` that is a pass-through — so two dispatches racing for
18
+ the same step could both proceed and run a side-effecting step twice. The claim
19
+ is now a single status-guarded update, atomic on one document.
20
+
21
+ `rpcWithWire` threw `RPCNotFoundError` for any unresolved namespaced call
22
+ instead of falling through to the deployment service, so a namespaced RPC that
23
+ `rpc()` would have dispatched remotely failed when called with an explicit wire.
24
+
25
+ - Updated dependencies [3c0012c]
26
+ - Updated dependencies [05e47cf]
27
+ - Updated dependencies [cfd364a]
28
+ - Updated dependencies [05e47cf]
29
+ - Updated dependencies [05e47cf]
30
+ - Updated dependencies [05e47cf]
31
+ - @pikku/core@0.12.90
32
+
3
33
  ## 0.12.19
4
34
 
5
35
  ### Patch Changes
@@ -44,7 +44,29 @@ export declare class MongoDBWorkflowService extends PikkuWorkflowService {
44
44
  protected setStepErrorImpl(stepId: string, error: Error): Promise<void>;
45
45
  protected createRetryAttemptImpl(stepId: string, status: 'pending' | 'running'): Promise<StepState>;
46
46
  withRunLock<T>(id: string, fn: () => Promise<T>): Promise<T>;
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.
52
+ */
47
53
  withStepLock<T>(_runId: string, _stepName: string, fn: () => Promise<T>): Promise<T>;
54
+ /**
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
57
+ * the same step cannot both proceed.
58
+ *
59
+ * 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.
63
+ */
64
+ protected claimStepForExecution(runId: string, stepName: string, rpcName: string): Promise<StepState | null>;
65
+ /**
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.
68
+ */
69
+ private claimStepStatus;
48
70
  getCompletedGraphState(runId: string): Promise<{
49
71
  completedNodeIds: string[];
50
72
  failedNodeIds: string[];
@@ -1,4 +1,4 @@
1
- import { PikkuWorkflowService } from '@pikku/core/workflow';
1
+ import { PikkuWorkflowService, WorkflowStepFunctionMismatchError, } 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;
@@ -254,9 +254,61 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
254
254
  async withRunLock(id, fn) {
255
255
  return fn();
256
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
+ */
257
263
  async withStepLock(_runId, _stepName, fn) {
258
264
  return fn();
259
265
  }
266
+ /**
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
269
+ * the same step cannot both proceed.
270
+ *
271
+ * 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.
275
+ */
276
+ async claimStepForExecution(runId, stepName, rpcName) {
277
+ const stepState = await this.getStepState(runId, stepName);
278
+ // knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
279
+ if (stepState.rpcName !== undefined &&
280
+ stepState.rpcName !== (rpcName ?? null)) {
281
+ throw new WorkflowStepFunctionMismatchError(runId, stepName);
282
+ }
283
+ if (stepState.status === 'succeeded' || stepState.status === 'running') {
284
+ return null;
285
+ }
286
+ if (stepState.status === 'failed') {
287
+ if (!(await this.claimStepStatus(stepState.stepId, ['failed']))) {
288
+ return null;
289
+ }
290
+ return this.createRetryAttempt(stepState.stepId, 'running');
291
+ }
292
+ if (stepState.status === 'pending' || stepState.status === 'scheduled') {
293
+ if (!(await this.claimStepStatus(stepState.stepId, [
294
+ 'pending',
295
+ 'scheduled',
296
+ ]))) {
297
+ return null;
298
+ }
299
+ await this.setStepRunning(stepState.stepId);
300
+ return { ...stepState, status: 'running' };
301
+ }
302
+ return stepState;
303
+ }
304
+ /**
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
+ */
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
+ }
260
312
  async getCompletedGraphState(runId) {
261
313
  const results = await this.steps
262
314
  .find({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/mongodb",
3
- "version": "0.12.19",
3
+ "version": "0.12.20",
4
4
  "description": "MongoDB services for Pikku — workflow state, agent runs, secrets and channel stores",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  "prepublishOnly": "yarn build"
19
19
  },
20
20
  "peerDependencies": {
21
- "@pikku/core": "^0.12.85"
21
+ "@pikku/core": "^0.12.90"
22
22
  },
23
23
  "dependencies": {
24
24
  "mongodb": "^7.5.0"
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
3
3
  import { MongoClient, type Db } from 'mongodb'
4
4
  import { MongoMemoryServer } from 'mongodb-memory-server'
5
5
 
6
+ import type { StepState } from '@pikku/core/workflow'
6
7
  import { MongoDBWorkflowService } from './mongodb-workflow-service.js'
7
8
 
8
9
  let mongod: MongoMemoryServer
@@ -119,3 +120,119 @@ describe('a step transition reaches the history', () => {
119
120
  assert.equal(state.attemptCount, 2)
120
121
  })
121
122
  })
123
+
124
+ /**
125
+ * Dispatch is at-least-once — the relay re-dispatches a step it believes was
126
+ * dropped, and a queue can redeliver a job it already handed out — so the claim
127
+ * is the only thing standing between a duplicate dispatch and a second
128
+ * execution of a side-effecting step.
129
+ */
130
+ describe('claiming a step for execution', () => {
131
+ const claim = (runId: string, stepName: string, rpcName = 'rpc.fn') =>
132
+ (ws as any).claimStepForExecution(
133
+ runId,
134
+ stepName,
135
+ rpcName
136
+ ) as Promise<StepState | null>
137
+
138
+ const seedRun = () =>
139
+ ws.createRun('flow', {}, false, 'hash', { type: 'test' } as any)
140
+
141
+ test('two dispatches racing for the same pending step: exactly one wins', async () => {
142
+ const runId = await seedRun()
143
+ await ws.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
144
+
145
+ const claims = await Promise.all([claim(runId, 's1'), claim(runId, 's1')])
146
+ const winners = claims.filter((c) => c !== null)
147
+
148
+ assert.equal(
149
+ winners.length,
150
+ 1,
151
+ `both dispatches claimed the step, so a side-effecting step would run twice: ${JSON.stringify(claims)}`
152
+ )
153
+ assert.equal((await ws.getStepState(runId, 's1')).status, 'running')
154
+ })
155
+
156
+ test('two dispatches racing to retry the same failed step: exactly one wins', async () => {
157
+ const runId = await seedRun()
158
+ const step = await ws.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
159
+ await ws.setStepRunning(step.stepId)
160
+ await ws.setStepError(step.stepId, new Error('boom'))
161
+
162
+ const claims = await Promise.all([claim(runId, 's1'), claim(runId, 's1')])
163
+ const winners = claims.filter((c) => c !== null)
164
+
165
+ assert.equal(
166
+ winners.length,
167
+ 1,
168
+ `both dispatches started a retry, so the failed step would be retried twice concurrently: ${JSON.stringify(claims)}`
169
+ )
170
+ assert.equal(
171
+ (await ws.getStepState(runId, 's1')).attemptCount,
172
+ 2,
173
+ 'the retry raced itself and burned more than one attempt'
174
+ )
175
+ })
176
+
177
+ test('two dispatches racing for the same scheduled step: exactly one wins', async () => {
178
+ const runId = await seedRun()
179
+ const step = await ws.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
180
+ await ws.setStepScheduled(step.stepId)
181
+
182
+ const claims = await Promise.all([claim(runId, 's1'), claim(runId, 's1')])
183
+ const winners = claims.filter((c) => c !== null)
184
+
185
+ assert.equal(
186
+ winners.length,
187
+ 1,
188
+ `both dispatches claimed the queued step, so a redelivered job would run it a second time: ${JSON.stringify(claims)}`
189
+ )
190
+ assert.equal((await ws.getStepState(runId, 's1')).status, 'running')
191
+ })
192
+
193
+ test('a step already claimed is not claimable again', async () => {
194
+ const runId = await seedRun()
195
+ await ws.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
196
+
197
+ assert.notEqual(await claim(runId, 's1'), null)
198
+ assert.equal(
199
+ await claim(runId, 's1'),
200
+ null,
201
+ 'a redelivered job re-claimed a step that is already running'
202
+ )
203
+ })
204
+
205
+ test('a succeeded step is not claimable', async () => {
206
+ const runId = await seedRun()
207
+ const step = await ws.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
208
+ await ws.setStepRunning(step.stepId)
209
+ await ws.setStepResult(step.stepId, { ok: true })
210
+
211
+ assert.equal(await claim(runId, 's1'), null)
212
+ })
213
+
214
+ test('a claim that fails releases the step, so it is retryable', async () => {
215
+ const runId = await seedRun()
216
+ const step = await ws.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
217
+
218
+ assert.notEqual(await claim(runId, 's1'), null)
219
+ await ws.setStepError(step.stepId, new Error('boom'))
220
+
221
+ const retry = await claim(runId, 's1')
222
+ assert.notEqual(
223
+ retry,
224
+ null,
225
+ 'the failed step stayed claimed, so it can never be retried'
226
+ )
227
+ assert.equal(retry?.status, 'running')
228
+ assert.equal(retry?.attemptCount, 2)
229
+ })
230
+
231
+ test('a claim for a different function than the step was dispatched with is refused', async () => {
232
+ const runId = await seedRun()
233
+ await ws.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
234
+
235
+ await assert.rejects(() => claim(runId, 's1', 'rpc.other'))
236
+ assert.equal((await ws.getStepState(runId, 's1')).status, 'pending')
237
+ })
238
+ })
@@ -1,5 +1,8 @@
1
1
  import type { SerializedError } from '@pikku/core/errors'
2
- import { PikkuWorkflowService } from '@pikku/core/workflow'
2
+ import {
3
+ PikkuWorkflowService,
4
+ WorkflowStepFunctionMismatchError,
5
+ } from '@pikku/core/workflow'
3
6
  import type {
4
7
  WorkflowPlannedStep,
5
8
  WorkflowQueueOptions,
@@ -444,6 +447,12 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
444
447
  return fn()
445
448
  }
446
449
 
450
+ /**
451
+ * A pass-through: MongoDB has no advisory-lock primitive to build one on.
452
+ * The one decision that must exclude — claiming a step to execute it — is
453
+ * made by `claimStepForExecution` below as a single conditional write, which
454
+ * needs no lock to be exclusive.
455
+ */
447
456
  async withStepLock<T>(
448
457
  _runId: string,
449
458
  _stepName: string,
@@ -452,6 +461,74 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
452
461
  return fn()
453
462
  }
454
463
 
464
+ /**
465
+ * Claim the step with a status-guarded update and read the modified count:
466
+ * a single-document update is atomic in MongoDB, so two dispatches racing for
467
+ * the same step cannot both proceed.
468
+ *
469
+ * This replaces the read-then-write the base engine does under
470
+ * `withStepLock`, which excludes nothing here — the lock above is a
471
+ * pass-through. The winner then goes through the ordinary transition methods,
472
+ * so history rows see exactly what they saw before.
473
+ */
474
+ protected override async claimStepForExecution(
475
+ runId: string,
476
+ stepName: string,
477
+ rpcName: string
478
+ ): Promise<StepState | null> {
479
+ const stepState = await this.getStepState(runId, stepName)
480
+
481
+ // knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
482
+ if (
483
+ stepState.rpcName !== undefined &&
484
+ stepState.rpcName !== (rpcName ?? null)
485
+ ) {
486
+ throw new WorkflowStepFunctionMismatchError(runId, stepName)
487
+ }
488
+
489
+ if (stepState.status === 'succeeded' || stepState.status === 'running') {
490
+ return null
491
+ }
492
+
493
+ if (stepState.status === 'failed') {
494
+ if (!(await this.claimStepStatus(stepState.stepId, ['failed']))) {
495
+ return null
496
+ }
497
+ return this.createRetryAttempt(stepState.stepId, 'running')
498
+ }
499
+
500
+ if (stepState.status === 'pending' || stepState.status === 'scheduled') {
501
+ if (
502
+ !(await this.claimStepStatus(stepState.stepId, [
503
+ 'pending',
504
+ 'scheduled',
505
+ ]))
506
+ ) {
507
+ return null
508
+ }
509
+ await this.setStepRunning(stepState.stepId)
510
+ return { ...stepState, status: 'running' }
511
+ }
512
+
513
+ return stepState
514
+ }
515
+
516
+ /**
517
+ * Move a step to `running` only if it is still in one of `from`, reporting
518
+ * whether this caller is the one that moved it.
519
+ */
520
+ private async claimStepStatus(
521
+ stepId: string,
522
+ from: StepStatus[]
523
+ ): Promise<boolean> {
524
+ const claimed = await this.steps.updateOne(
525
+ { _id: stepId, status: { $in: from } },
526
+ { $set: { status: 'running', updatedAt: new Date() } }
527
+ )
528
+
529
+ return claimed.modifiedCount > 0
530
+ }
531
+
455
532
  async getCompletedGraphState(runId: string): Promise<{
456
533
  completedNodeIds: string[]
457
534
  failedNodeIds: string[]