@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.
|
|
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.
|
|
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 {
|
|
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[]
|