@pikku/core 0.12.78 → 0.12.79
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 +73 -0
- package/dist/services/in-memory-workflow-service.d.ts +10 -0
- package/dist/services/in-memory-workflow-service.js +23 -0
- package/dist/utils/node-host-resolver.d.ts +12 -0
- package/dist/utils/node-host-resolver.js +16 -0
- package/dist/utils/safe-fetch.d.ts +18 -0
- package/dist/utils/safe-fetch.js +167 -29
- package/dist/wirings/gateway/gateway-runner.js +32 -3
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +52 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +104 -0
- package/knowledge/decisions/security/gateway-handlers-run-through-the-function-runner-gate.md +13 -3
- package/package.json +2 -1
- package/src/services/in-memory-workflow-service.ts +23 -0
- package/src/utils/node-host-resolver.ts +20 -0
- package/src/utils/safe-fetch.test.ts +143 -1
- package/src/utils/safe-fetch.ts +200 -25
- package/src/wirings/gateway/gateway-authorization.test.ts +131 -0
- package/src/wirings/gateway/gateway-runner.ts +35 -3
- package/src/wirings/secret/validate-secret-definitions.test.ts +47 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +131 -0
- package/src/wirings/workflow/workflow-dispatch-relay.test.ts +128 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
addGlobalPermission,
|
|
9
9
|
clearPermissionsCache,
|
|
10
10
|
} from '../../permissions.js'
|
|
11
|
+
import { addTagMiddleware } from '../../middleware-runner.js'
|
|
11
12
|
import type {
|
|
12
13
|
GatewayAdapter,
|
|
13
14
|
GatewayInboundMessage,
|
|
@@ -84,6 +85,31 @@ const seedCompiledMeta = () => {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* What the inspector records for a gateway and the function it was wired with.
|
|
90
|
+
* These tests wire gateways by hand, so nothing populates it otherwise — and it
|
|
91
|
+
* has to be in place before `wireGateway`, exactly as the generated bootstrap
|
|
92
|
+
* loads every meta file before any wiring file.
|
|
93
|
+
*/
|
|
94
|
+
const seedDeclaredHandler = (
|
|
95
|
+
gatewayName: string,
|
|
96
|
+
funcId: string,
|
|
97
|
+
funcMeta: Record<string, any>,
|
|
98
|
+
gatewayMeta: Record<string, any> = {}
|
|
99
|
+
) => {
|
|
100
|
+
;(pikkuState(null, 'function', 'meta') as any)[funcId] = {
|
|
101
|
+
pikkuFuncId: funcId,
|
|
102
|
+
inputSchemaName: null,
|
|
103
|
+
outputSchemaName: null,
|
|
104
|
+
...funcMeta,
|
|
105
|
+
}
|
|
106
|
+
;(pikkuState(null, 'gateway', 'meta') as any)[gatewayName] = {
|
|
107
|
+
pikkuFuncId: funcId,
|
|
108
|
+
name: gatewayName,
|
|
109
|
+
...gatewayMeta,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
87
113
|
const postMessage = async (route: string) => {
|
|
88
114
|
const request = new Request(`http://localhost${route}`, {
|
|
89
115
|
method: 'POST',
|
|
@@ -229,6 +255,68 @@ describe('gateway handler authorization', () => {
|
|
|
229
255
|
assert.deepEqual(calls, ['ran'])
|
|
230
256
|
})
|
|
231
257
|
|
|
258
|
+
test('a handler declared with pikkuFunc keeps its session requirement', async () => {
|
|
259
|
+
const calls: string[] = []
|
|
260
|
+
|
|
261
|
+
seedDeclaredHandler('declared-session', 'myHandler', {
|
|
262
|
+
sessionless: false,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
wireGateway({
|
|
266
|
+
name: 'declared-session',
|
|
267
|
+
type: 'webhook',
|
|
268
|
+
route: '/webhooks/declared-session',
|
|
269
|
+
adapter: createMockAdapter(),
|
|
270
|
+
func: {
|
|
271
|
+
func: async () => {
|
|
272
|
+
calls.push('ran')
|
|
273
|
+
return { text: 'reply' }
|
|
274
|
+
},
|
|
275
|
+
} as any,
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
seedCompiledMeta()
|
|
279
|
+
httpRouter.initialize()
|
|
280
|
+
|
|
281
|
+
const response = await postMessage('/webhooks/declared-session')
|
|
282
|
+
|
|
283
|
+
assert.equal(response.status, 403)
|
|
284
|
+
assert.deepEqual(
|
|
285
|
+
calls,
|
|
286
|
+
[],
|
|
287
|
+
'a session-required pikkuFunc must not be silently made sessionless'
|
|
288
|
+
)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('a handler declared sessionless keeps running without a session', async () => {
|
|
292
|
+
const calls: string[] = []
|
|
293
|
+
|
|
294
|
+
seedDeclaredHandler('declared-sessionless', 'mySessionlessHandler', {
|
|
295
|
+
sessionless: true,
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
wireGateway({
|
|
299
|
+
name: 'declared-sessionless',
|
|
300
|
+
type: 'webhook',
|
|
301
|
+
route: '/webhooks/declared-sessionless',
|
|
302
|
+
adapter: createMockAdapter(),
|
|
303
|
+
func: {
|
|
304
|
+
func: async () => {
|
|
305
|
+
calls.push('ran')
|
|
306
|
+
return { text: 'reply' }
|
|
307
|
+
},
|
|
308
|
+
} as any,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
seedCompiledMeta()
|
|
312
|
+
httpRouter.initialize()
|
|
313
|
+
|
|
314
|
+
const response = await postMessage('/webhooks/declared-sessionless')
|
|
315
|
+
|
|
316
|
+
assert.equal(response.status, 200)
|
|
317
|
+
assert.deepEqual(calls, ['ran'])
|
|
318
|
+
})
|
|
319
|
+
|
|
232
320
|
test('gateway-level auth: true requires a session', async () => {
|
|
233
321
|
const calls: string[] = []
|
|
234
322
|
|
|
@@ -281,6 +369,49 @@ describe('gateway handler authorization', () => {
|
|
|
281
369
|
assert.deepEqual(calls, [], 'auth: true must require a session')
|
|
282
370
|
})
|
|
283
371
|
|
|
372
|
+
test('tag middleware declared for the gateway actually runs', async () => {
|
|
373
|
+
const order: string[] = []
|
|
374
|
+
|
|
375
|
+
addTagMiddleware('audited', [
|
|
376
|
+
async (_s: any, _wire: any, next: any) => {
|
|
377
|
+
order.push('middleware')
|
|
378
|
+
await next()
|
|
379
|
+
},
|
|
380
|
+
] as any)
|
|
381
|
+
|
|
382
|
+
seedDeclaredHandler(
|
|
383
|
+
'tagged',
|
|
384
|
+
'myTaggedHandler',
|
|
385
|
+
{ sessionless: true },
|
|
386
|
+
{ tags: ['audited'], middleware: [{ type: 'tag', tag: 'audited' }] }
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
wireGateway({
|
|
390
|
+
name: 'tagged',
|
|
391
|
+
type: 'webhook',
|
|
392
|
+
route: '/webhooks/tagged',
|
|
393
|
+
adapter: createMockAdapter(),
|
|
394
|
+
func: {
|
|
395
|
+
func: async () => {
|
|
396
|
+
order.push('handler')
|
|
397
|
+
return { text: 'reply' }
|
|
398
|
+
},
|
|
399
|
+
} as any,
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
seedCompiledMeta()
|
|
403
|
+
httpRouter.initialize()
|
|
404
|
+
|
|
405
|
+
const response = await postMessage('/webhooks/tagged')
|
|
406
|
+
|
|
407
|
+
assert.equal(response.status, 200)
|
|
408
|
+
assert.deepEqual(
|
|
409
|
+
order,
|
|
410
|
+
['middleware', 'handler'],
|
|
411
|
+
'addTagMiddleware must gate a gateway carrying the tag'
|
|
412
|
+
)
|
|
413
|
+
})
|
|
414
|
+
|
|
284
415
|
test('the adapter still auto-sends the handler reply', async () => {
|
|
285
416
|
const adapter = createMockAdapter()
|
|
286
417
|
|
|
@@ -23,20 +23,46 @@ const bridgeMiddlewareSession = async (wire: PikkuRawWire): Promise<void> => {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Metadata the inspector recorded for the function the gateway was wired with.
|
|
28
|
+
* The bootstrap loads every meta file before any wiring file, so this is
|
|
29
|
+
* already populated by the time a gateway wires itself. A gateway wired by
|
|
30
|
+
* hand rather than through codegen has no entry, and falls back to the
|
|
31
|
+
* sessionless default below.
|
|
32
|
+
*/
|
|
33
|
+
const declaredHandlerMeta = (config: CoreGateway) => {
|
|
34
|
+
const declaredFuncId = pikkuState(null, 'gateway', 'meta')[config.name]
|
|
35
|
+
?.pikkuFuncId
|
|
36
|
+
return declaredFuncId
|
|
37
|
+
? pikkuState(null, 'function', 'meta')[declaredFuncId]
|
|
38
|
+
: undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
// knowledge: decisions/security/gateway-handlers-run-through-the-function-runner-gate.md
|
|
27
42
|
const registerGatewayHandler = (config: CoreGateway): string => {
|
|
28
43
|
const funcId = gatewayHandlerFuncId(config.name)
|
|
29
44
|
const funcMeta = pikkuState(null, 'function', 'meta')
|
|
45
|
+
const declared = declaredHandlerMeta(config)
|
|
30
46
|
funcMeta[funcId] = {
|
|
47
|
+
...declared,
|
|
31
48
|
pikkuFuncId: funcId,
|
|
32
|
-
inputSchemaName: null,
|
|
33
|
-
outputSchemaName: null,
|
|
34
|
-
sessionless: true,
|
|
49
|
+
inputSchemaName: declared?.inputSchemaName ?? null,
|
|
50
|
+
outputSchemaName: declared?.outputSchemaName ?? null,
|
|
51
|
+
sessionless: declared?.sessionless ?? true,
|
|
35
52
|
}
|
|
36
53
|
addFunction(funcId, config.func as any)
|
|
37
54
|
return funcId
|
|
38
55
|
}
|
|
39
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Tag middleware the inspector resolved for this gateway. It is keyed by
|
|
59
|
+
* gateway name rather than reachable from `config`, because `tags` is a
|
|
60
|
+
* compile-time input everywhere — nothing at runtime maps a tag to its
|
|
61
|
+
* middleware group.
|
|
62
|
+
*/
|
|
63
|
+
const gatewayInheritedMiddleware = (config: CoreGateway) =>
|
|
64
|
+
pikkuState(null, 'gateway', 'meta')[config.name]?.middleware
|
|
65
|
+
|
|
40
66
|
export const resolveGatewayAdapter = (
|
|
41
67
|
config: CoreGateway,
|
|
42
68
|
services: CoreSingletonServices
|
|
@@ -134,6 +160,7 @@ const wireWebhookGateway = (config: CoreGateway): void => {
|
|
|
134
160
|
const createWebhookPostHandler = (config: CoreGateway) => {
|
|
135
161
|
const { name, middleware: userMiddleware } = config
|
|
136
162
|
const handlerFuncId = registerGatewayHandler(config)
|
|
163
|
+
const inheritedMiddleware = gatewayInheritedMiddleware(config)
|
|
137
164
|
|
|
138
165
|
return async (
|
|
139
166
|
services: CoreSingletonServices,
|
|
@@ -169,6 +196,7 @@ const createWebhookPostHandler = (config: CoreGateway) => {
|
|
|
169
196
|
singletonServices: services,
|
|
170
197
|
data: () => parsed,
|
|
171
198
|
auth: config.auth,
|
|
199
|
+
inheritedMiddleware,
|
|
172
200
|
wire: wire as any,
|
|
173
201
|
})
|
|
174
202
|
}
|
|
@@ -253,6 +281,7 @@ const wireWebsocketGateway = (config: CoreGateway): void => {
|
|
|
253
281
|
|
|
254
282
|
const userMiddleware = config.middleware as CorePikkuMiddleware[] | undefined
|
|
255
283
|
const handlerFuncId = registerGatewayHandler(config)
|
|
284
|
+
const inheritedMiddleware = gatewayInheritedMiddleware(config)
|
|
256
285
|
|
|
257
286
|
addFunction(connectFuncId, {
|
|
258
287
|
auth: false,
|
|
@@ -292,6 +321,7 @@ const wireWebsocketGateway = (config: CoreGateway): void => {
|
|
|
292
321
|
singletonServices: services,
|
|
293
322
|
data: () => parsed,
|
|
294
323
|
auth: config.auth,
|
|
324
|
+
inheritedMiddleware,
|
|
295
325
|
wire: wire as any,
|
|
296
326
|
})
|
|
297
327
|
}
|
|
@@ -333,6 +363,7 @@ export const createListenerMessageHandler = (
|
|
|
333
363
|
): ((rawData: unknown) => Promise<void>) => {
|
|
334
364
|
const userMiddleware = config.middleware as CorePikkuMiddleware[] | undefined
|
|
335
365
|
const handlerFuncId = registerGatewayHandler(config)
|
|
366
|
+
const inheritedMiddleware = gatewayInheritedMiddleware(config)
|
|
336
367
|
|
|
337
368
|
return async (rawData: unknown): Promise<void> => {
|
|
338
369
|
const adapter = await resolveGatewayAdapter(config, singletonServices)
|
|
@@ -354,6 +385,7 @@ export const createListenerMessageHandler = (
|
|
|
354
385
|
singletonServices,
|
|
355
386
|
data: () => parsed,
|
|
356
387
|
auth: config.auth,
|
|
388
|
+
inheritedMiddleware,
|
|
357
389
|
wire,
|
|
358
390
|
})
|
|
359
391
|
}
|
|
@@ -108,6 +108,53 @@ describe('validateAndBuildSecretDefinitionsMeta', () => {
|
|
|
108
108
|
)
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
+
test('should carry allowedHosts into the meta', () => {
|
|
112
|
+
const definitions = [
|
|
113
|
+
{
|
|
114
|
+
name: 'example-api',
|
|
115
|
+
displayName: 'Example API',
|
|
116
|
+
secretId: 'EXAMPLE_API_CREDENTIALS',
|
|
117
|
+
allowedHosts: ['api.example.com', '*.example.com'],
|
|
118
|
+
sourceFile: 'a.ts',
|
|
119
|
+
},
|
|
120
|
+
]
|
|
121
|
+
const result = validateAndBuildSecretDefinitionsMeta(
|
|
122
|
+
definitions as any,
|
|
123
|
+
new Map()
|
|
124
|
+
)
|
|
125
|
+
assert.deepStrictEqual(result['example-api']!.allowedHosts, [
|
|
126
|
+
'api.example.com',
|
|
127
|
+
'*.example.com',
|
|
128
|
+
])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('should carry allowedHosts into the meta for a shared secretId', () => {
|
|
132
|
+
const definitions = [
|
|
133
|
+
{
|
|
134
|
+
name: 'cred1',
|
|
135
|
+
displayName: 'Cred 1',
|
|
136
|
+
secretId: 'SHARED',
|
|
137
|
+
allowedHosts: ['first.example.com'],
|
|
138
|
+
sourceFile: 'a.ts',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'cred2',
|
|
142
|
+
displayName: 'Cred 2',
|
|
143
|
+
secretId: 'SHARED',
|
|
144
|
+
allowedHosts: ['second.example.com'],
|
|
145
|
+
sourceFile: 'b.ts',
|
|
146
|
+
},
|
|
147
|
+
]
|
|
148
|
+
const result = validateAndBuildSecretDefinitionsMeta(
|
|
149
|
+
definitions as any,
|
|
150
|
+
new Map()
|
|
151
|
+
)
|
|
152
|
+
assert.deepStrictEqual(result['cred1']!.allowedHosts, ['first.example.com'])
|
|
153
|
+
assert.deepStrictEqual(result['cred2']!.allowedHosts, [
|
|
154
|
+
'second.example.com',
|
|
155
|
+
])
|
|
156
|
+
})
|
|
157
|
+
|
|
111
158
|
test('should handle empty definitions', () => {
|
|
112
159
|
const result = validateAndBuildSecretDefinitionsMeta([], new Map())
|
|
113
160
|
assert.deepStrictEqual(result, {})
|
|
@@ -57,6 +57,7 @@ export function validateAndBuildSecretDefinitionsMeta(
|
|
|
57
57
|
oauth2: def.oauth2,
|
|
58
58
|
rotationPeriod: def.rotationPeriod,
|
|
59
59
|
docsUrl: def.docsUrl,
|
|
60
|
+
allowedHosts: def.allowedHosts,
|
|
60
61
|
sourceFile: def.sourceFile,
|
|
61
62
|
}
|
|
62
63
|
}
|
|
@@ -75,6 +76,7 @@ export function validateAndBuildSecretDefinitionsMeta(
|
|
|
75
76
|
oauth2: def.oauth2,
|
|
76
77
|
rotationPeriod: def.rotationPeriod,
|
|
77
78
|
docsUrl: def.docsUrl,
|
|
79
|
+
allowedHosts: def.allowedHosts,
|
|
78
80
|
sourceFile: def.sourceFile,
|
|
79
81
|
}
|
|
80
82
|
}
|
|
@@ -281,6 +281,25 @@ const DEFAULT_STALLED_RUN_MS = 5 * 60_000
|
|
|
281
281
|
/** Runs re-driven per `recoverStalledRuns` call, so one sweep is bounded. */
|
|
282
282
|
const DEFAULT_STALLED_RUN_LIMIT = 100
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* How long a step may sit `pending` before the relay assumes its dispatch was
|
|
286
|
+
* lost. This is a bet that no healthy queue takes this long to move a job from
|
|
287
|
+
* `pending` to `running`; set it above the observed p99 of that latency.
|
|
288
|
+
*/
|
|
289
|
+
const DEFAULT_UNDISPATCHED_STEP_MS = 30_000
|
|
290
|
+
|
|
291
|
+
/** Steps re-driven per `relayUndispatchedSteps` call, so one tick is bounded. */
|
|
292
|
+
const DEFAULT_UNDISPATCHED_STEP_LIMIT = 100
|
|
293
|
+
|
|
294
|
+
/** First wait before a step already re-dispatched once is re-dispatched again. */
|
|
295
|
+
const REDISPATCH_BACKOFF_MS = 30_000
|
|
296
|
+
|
|
297
|
+
/** Ceiling on the doubling backoff, so a permanently stuck step still gets swept. */
|
|
298
|
+
const REDISPATCH_BACKOFF_MAX_MS = 10 * 60_000
|
|
299
|
+
|
|
300
|
+
/** Bound on the in-process backoff map, so a long-lived process cannot grow it without bound. */
|
|
301
|
+
const REDISPATCH_BACKOFF_MAX_ENTRIES = 10_000
|
|
302
|
+
|
|
284
303
|
const WORKFLOW_POLL_MIN_MS = 10
|
|
285
304
|
|
|
286
305
|
const WORKFLOW_POLL_FACTOR = 1.6
|
|
@@ -975,6 +994,118 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
975
994
|
return { resumed }
|
|
976
995
|
}
|
|
977
996
|
|
|
997
|
+
/**
|
|
998
|
+
* Runs holding a step that has sat `pending` since before `before`, paired
|
|
999
|
+
* with the step that flagged them.
|
|
1000
|
+
*
|
|
1001
|
+
* Returns nothing by default so a store that cannot express the query keeps
|
|
1002
|
+
* working unchanged — and, because it does not opt in, gains no re-dispatches
|
|
1003
|
+
* either. A store must have an atomic `withStepLock` before overriding this,
|
|
1004
|
+
* or no concurrency for one to exclude: the relay makes duplicate dispatch
|
|
1005
|
+
* routine, and the claim in `executeWorkflowStepInner` is what keeps a
|
|
1006
|
+
* duplicate from becoming a second execution. `kysely-postgres` and
|
|
1007
|
+
* `kysely-mysql` qualify on the lock, `in-memory` on being inline and
|
|
1008
|
+
* single-process; `mongodb` and `kysely-sqlite` qualify on neither.
|
|
1009
|
+
*/
|
|
1010
|
+
protected async findUndispatchedSteps(
|
|
1011
|
+
_before: Date,
|
|
1012
|
+
_limit: number
|
|
1013
|
+
): Promise<Array<{ runId: string; stepId: string }>> {
|
|
1014
|
+
return []
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* Re-drive steps whose dispatch was lost, and report which runs were nudged.
|
|
1019
|
+
*
|
|
1020
|
+
* Arming a step is two writes to two systems: the step row lands `pending`,
|
|
1021
|
+
* then a queue or scheduler job is published. Nothing spans both, so a crash
|
|
1022
|
+
* in between leaves a durable row that nothing will ever pick up — the run
|
|
1023
|
+
* neither finishes nor fails. (Seen on a `workflow.sleep()`: a deploy restart
|
|
1024
|
+
* landed between the sleep step's insert and its timer.)
|
|
1025
|
+
*
|
|
1026
|
+
* The row is the outbox record and this is the relay. Age is the only signal
|
|
1027
|
+
* available — a step `pending` because its dispatch was lost is
|
|
1028
|
+
* indistinguishable from one whose job is merely still queued — so a step
|
|
1029
|
+
* past `undispatchedAfterMs` is re-dispatched regardless, and correctness
|
|
1030
|
+
* rests on the claim rather than on the guess being right. A redundant
|
|
1031
|
+
* dispatch costs one queue message: the loser reads `running` and returns
|
|
1032
|
+
* without invoking anything.
|
|
1033
|
+
*
|
|
1034
|
+
* Re-dispatches back off per step (doubling from 30s, capped at 10m) so a
|
|
1035
|
+
* genuine queue backlog is not amplified by a tick that keeps firing at the
|
|
1036
|
+
* steps the backlog is already delaying. The backoff is per process and
|
|
1037
|
+
* advisory — losing it on restart costs extra dispatches, never correctness.
|
|
1038
|
+
*
|
|
1039
|
+
* This is not self-starting. Call it from a scheduled task; ~30s suits a
|
|
1040
|
+
* queue whose `pending`→`running` latency is well under that.
|
|
1041
|
+
*/
|
|
1042
|
+
public async relayUndispatchedSteps(options?: {
|
|
1043
|
+
undispatchedAfterMs?: number
|
|
1044
|
+
limit?: number
|
|
1045
|
+
}): Promise<{ redispatched: string[] }> {
|
|
1046
|
+
const before = new Date(
|
|
1047
|
+
Date.now() -
|
|
1048
|
+
(options?.undispatchedAfterMs ?? DEFAULT_UNDISPATCHED_STEP_MS)
|
|
1049
|
+
)
|
|
1050
|
+
const steps = await this.findUndispatchedSteps(
|
|
1051
|
+
before,
|
|
1052
|
+
options?.limit ?? DEFAULT_UNDISPATCHED_STEP_LIMIT
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
// Backoff is keyed by run, not step, because the run is the unit of
|
|
1056
|
+
// re-drive: `resumeWorkflow` replays the whole run and re-dispatches every
|
|
1057
|
+
// step still owed a job. Holding off a single step while resuming its run
|
|
1058
|
+
// would not suppress anything.
|
|
1059
|
+
const now = Date.now()
|
|
1060
|
+
const runIds = new Set<string>()
|
|
1061
|
+
for (const { runId } of steps) {
|
|
1062
|
+
const eligibleAt = this.redispatchBackoff.get(runId)
|
|
1063
|
+
if (eligibleAt !== undefined && eligibleAt > now) {
|
|
1064
|
+
continue
|
|
1065
|
+
}
|
|
1066
|
+
this.noteRedispatch(runId, now)
|
|
1067
|
+
runIds.add(runId)
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const redispatched: string[] = []
|
|
1071
|
+
for (const runId of runIds) {
|
|
1072
|
+
try {
|
|
1073
|
+
await this.resumeWorkflow(runId)
|
|
1074
|
+
redispatched.push(runId)
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
// One unresumable run must not stop the tick from relaying the rest.
|
|
1077
|
+
getSingletonServices()?.logger?.error(
|
|
1078
|
+
`Failed to re-dispatch workflow run ${runId}: ${err instanceof Error ? err.message : String(err)}`
|
|
1079
|
+
)
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return { redispatched }
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** Advisory, per-process record of when a run may next be re-dispatched. */
|
|
1086
|
+
private readonly redispatchBackoff = new Map<string, number>()
|
|
1087
|
+
|
|
1088
|
+
private readonly redispatchDelays = new Map<string, number>()
|
|
1089
|
+
|
|
1090
|
+
private noteRedispatch(runId: string, now: number): void {
|
|
1091
|
+
const previous = this.redispatchDelays.get(runId)
|
|
1092
|
+
const delay = Math.min(
|
|
1093
|
+
previous === undefined ? REDISPATCH_BACKOFF_MS : previous * 2,
|
|
1094
|
+
REDISPATCH_BACKOFF_MAX_MS
|
|
1095
|
+
)
|
|
1096
|
+
// A run that settles is never returned again, so entries are only evicted by
|
|
1097
|
+
// this bound — oldest first, which is also least recently re-dispatched.
|
|
1098
|
+
if (this.redispatchBackoff.size >= REDISPATCH_BACKOFF_MAX_ENTRIES) {
|
|
1099
|
+
const oldest = this.redispatchBackoff.keys().next()
|
|
1100
|
+
if (!oldest.done) {
|
|
1101
|
+
this.redispatchBackoff.delete(oldest.value)
|
|
1102
|
+
this.redispatchDelays.delete(oldest.value)
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
this.redispatchDelays.set(runId, delay)
|
|
1106
|
+
this.redispatchBackoff.set(runId, now + delay)
|
|
1107
|
+
}
|
|
1108
|
+
|
|
978
1109
|
protected resolveStepJobOptions(
|
|
979
1110
|
stepOptions?: WorkflowStepOptions
|
|
980
1111
|
): JobOptions {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
|
|
5
|
+
import { pikkuState } from '../../pikku-state.js'
|
|
6
|
+
|
|
7
|
+
const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `relayUndispatchedSteps` re-drives a run by putting it back on the
|
|
11
|
+
* orchestrator queue, so the queue is the observation point.
|
|
12
|
+
*/
|
|
13
|
+
function trackResumes(): { runIds: string[] } {
|
|
14
|
+
const seen: string[] = []
|
|
15
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
16
|
+
queueService: {
|
|
17
|
+
add: async (_queue: string, data: { runId: string }) => {
|
|
18
|
+
seen.push(data.runId)
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
logger: silentLogger,
|
|
22
|
+
} as any)
|
|
23
|
+
return { runIds: seen }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Backdates a step's timestamp so it reads as undispatched. */
|
|
27
|
+
async function backdateSteps(
|
|
28
|
+
ws: InMemoryWorkflowService,
|
|
29
|
+
runId: string,
|
|
30
|
+
ms: number
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
const past = new Date(Date.now() - ms)
|
|
33
|
+
for (const step of await ws.getRunHistory(runId)) {
|
|
34
|
+
;(step as any).updatedAt = past
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('undispatched step relay', () => {
|
|
39
|
+
test('re-dispatches a step whose arming was lost', async () => {
|
|
40
|
+
const resumes = trackResumes()
|
|
41
|
+
const ws = new InMemoryWorkflowService()
|
|
42
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
43
|
+
type: 'test',
|
|
44
|
+
})
|
|
45
|
+
// The row committed; the process died before the timer was armed.
|
|
46
|
+
await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
|
|
47
|
+
await backdateSteps(ws, runId, 60_000)
|
|
48
|
+
|
|
49
|
+
const { redispatched } = await ws.relayUndispatchedSteps()
|
|
50
|
+
|
|
51
|
+
assert.deepEqual(redispatched, [runId], 'the run is re-dispatched')
|
|
52
|
+
assert.deepEqual(resumes.runIds, [runId], 'it is put back on the queue')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('leaves a freshly written step alone', async () => {
|
|
56
|
+
const resumes = trackResumes()
|
|
57
|
+
const ws = new InMemoryWorkflowService()
|
|
58
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
59
|
+
type: 'test',
|
|
60
|
+
})
|
|
61
|
+
// Written milliseconds ago — its dispatch is almost certainly in flight.
|
|
62
|
+
await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
|
|
63
|
+
|
|
64
|
+
const { redispatched } = await ws.relayUndispatchedSteps()
|
|
65
|
+
|
|
66
|
+
assert.deepEqual(redispatched, [], 'nothing is re-dispatched')
|
|
67
|
+
assert.deepEqual(resumes.runIds, [], 'nothing is queued')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('backs off rather than re-dispatching the same step every tick', async () => {
|
|
71
|
+
const resumes = trackResumes()
|
|
72
|
+
const ws = new InMemoryWorkflowService()
|
|
73
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
74
|
+
type: 'test',
|
|
75
|
+
})
|
|
76
|
+
await ws.insertStepState(runId, 'Charge card', 'charge', {})
|
|
77
|
+
await backdateSteps(ws, runId, 60_000)
|
|
78
|
+
|
|
79
|
+
const first = await ws.relayUndispatchedSteps()
|
|
80
|
+
// The step is still pending — a real queue backlog looks exactly like this.
|
|
81
|
+
const second = await ws.relayUndispatchedSteps()
|
|
82
|
+
|
|
83
|
+
assert.deepEqual(first.redispatched, [runId], 'the first tick relays it')
|
|
84
|
+
assert.deepEqual(
|
|
85
|
+
second.redispatched,
|
|
86
|
+
[],
|
|
87
|
+
'the next tick is held off by the backoff'
|
|
88
|
+
)
|
|
89
|
+
assert.deepEqual(resumes.runIds, [runId], 'only one queue message')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('ignores steps belonging to a settled run', async () => {
|
|
93
|
+
const resumes = trackResumes()
|
|
94
|
+
const ws = new InMemoryWorkflowService()
|
|
95
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
96
|
+
type: 'test',
|
|
97
|
+
})
|
|
98
|
+
await ws.insertStepState(runId, 'Wait 15s', null, { duration: 15000 })
|
|
99
|
+
await backdateSteps(ws, runId, 60_000)
|
|
100
|
+
await ws.updateRunStatus(runId, 'completed', {})
|
|
101
|
+
|
|
102
|
+
const { redispatched } = await ws.relayUndispatchedSteps()
|
|
103
|
+
|
|
104
|
+
assert.deepEqual(redispatched, [], 'a finished run is not re-dispatched')
|
|
105
|
+
assert.deepEqual(resumes.runIds, [], 'nothing is queued')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('re-dispatches each stuck run once per tick', async () => {
|
|
109
|
+
const resumes = trackResumes()
|
|
110
|
+
const ws = new InMemoryWorkflowService()
|
|
111
|
+
const runIds: string[] = []
|
|
112
|
+
for (const n of [1, 2]) {
|
|
113
|
+
const runId = await ws.createRun('flow', {}, false, 'hash', {
|
|
114
|
+
type: 'test',
|
|
115
|
+
})
|
|
116
|
+
// Two orphaned steps in one run must still produce a single resume.
|
|
117
|
+
await ws.insertStepState(runId, `Wait ${n}a`, null, { duration: 1000 })
|
|
118
|
+
await ws.insertStepState(runId, `Wait ${n}b`, null, { duration: 1000 })
|
|
119
|
+
await backdateSteps(ws, runId, 60_000)
|
|
120
|
+
runIds.push(runId)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { redispatched } = await ws.relayUndispatchedSteps()
|
|
124
|
+
|
|
125
|
+
assert.deepEqual(redispatched.sort(), runIds.sort(), 'both runs relayed')
|
|
126
|
+
assert.equal(resumes.runIds.length, 2, 'one queue message per run')
|
|
127
|
+
})
|
|
128
|
+
})
|