@pikku/core 0.12.129 → 0.12.133
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 +111 -0
- package/dist/function/function-meta.types.d.ts +14 -0
- package/dist/middleware-runner.d.ts +4 -0
- package/dist/middleware-runner.js +52 -16
- package/dist/services/in-memory-lease-service.d.ts +13 -0
- package/dist/services/in-memory-lease-service.js +49 -0
- package/dist/services/in-memory-workflow-service.d.ts +11 -3
- package/dist/services/in-memory-workflow-service.js +53 -23
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +2 -0
- package/dist/services/lease-service.d.ts +61 -0
- package/dist/services/lease-service.js +110 -0
- package/dist/services/trigger-source-store.d.ts +19 -2
- package/dist/services/trigger-source-store.js +14 -1
- package/dist/services/workflow-service.d.ts +1 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/service-tests/lease-service-tests.d.ts +3 -0
- package/dist/testing/service-tests/lease-service-tests.js +138 -0
- package/dist/testing/service-tests/workflow-fencing-tests.d.ts +18 -0
- package/dist/testing/service-tests/workflow-fencing-tests.js +216 -0
- package/dist/testing/service-tests.d.ts +5 -0
- package/dist/testing/service-tests.js +8 -0
- package/dist/types/core.types.d.ts +12 -8
- package/dist/utils/hmac.d.ts +14 -10
- package/dist/utils/hmac.js +29 -25
- package/dist/wirings/http/pikku-fetch-http-response.js +13 -0
- package/dist/wirings/secret/derive-oauth2-app-secrets.js +1 -0
- package/dist/wirings/secret/secret.types.d.ts +6 -0
- package/dist/wirings/trigger/index.d.ts +4 -4
- package/dist/wirings/trigger/index.js +2 -2
- package/dist/wirings/trigger/webhook-source-runner.d.ts +51 -4
- package/dist/wirings/trigger/webhook-source-runner.js +201 -15
- package/dist/wirings/trigger/webhook-source.types.d.ts +50 -0
- package/dist/wirings/trigger/webhook-source.types.js +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +33 -14
- package/dist/wirings/workflow/index.d.ts +4 -3
- package/dist/wirings/workflow/index.js +3 -2
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +57 -18
- package/dist/wirings/workflow/pikku-workflow-service.js +148 -145
- package/dist/wirings/workflow/workflow-constants.d.ts +28 -1
- package/dist/wirings/workflow/workflow-constants.js +28 -1
- package/dist/wirings/workflow/workflow-errors.d.ts +21 -0
- package/dist/wirings/workflow/workflow-errors.js +38 -0
- package/dist/wirings/workflow/workflow-queue-routing.d.ts +4 -2
- package/dist/wirings/workflow/workflow-queue-routing.js +9 -1
- package/dist/wirings/workflow/workflow-run-lease.d.ts +10 -0
- package/dist/wirings/workflow/workflow-run-lease.js +25 -0
- package/dist/wirings/workflow/workflow-run-status.d.ts +4 -0
- package/dist/wirings/workflow/workflow-run-status.js +37 -0
- package/dist/wirings/workflow/workflow-status-stream.d.ts +2 -1
- package/dist/wirings/workflow/workflow-status-stream.js +18 -1
- package/dist/wirings/workflow/workflow-step-claim.d.ts +13 -1
- package/dist/wirings/workflow/workflow-step-claim.js +29 -5
- package/dist/wirings/workflow/workflow-step-lease.d.ts +25 -0
- package/dist/wirings/workflow/workflow-step-lease.js +53 -0
- package/dist/wirings/workflow/workflow-step-retry.d.ts +14 -0
- package/dist/wirings/workflow/workflow-step-retry.js +33 -0
- package/dist/wirings/workflow/workflow-version-fallback.d.ts +11 -0
- package/dist/wirings/workflow/workflow-version-fallback.js +30 -0
- package/dist/wirings/workflow/workflow.types.d.ts +17 -0
- package/knowledge/decisions/internals/a-held-run-is-woken-later-not-retried.md +28 -0
- package/knowledge/decisions/internals/index.md +2 -1
- package/knowledge/decisions/internals/the-in-memory-workflow-service-is-inline-only-and-single-process.md +2 -2
- package/knowledge/decisions/internals/workflow-step-lock-is-held-only-to-claim-the-step.md +12 -5
- package/knowledge/decisions/security/an-actor-credential-is-derived-per-persona.md +4 -3
- package/package.json +1 -1
- package/src/public-surface.json +21 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,114 @@
|
|
|
1
|
+
## 0.12.133
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
|
|
7
|
+
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`.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
`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.
|
|
12
|
+
|
|
13
|
+
- 21e9c3a: An SSE response stream ignores a send or close after the stream has already closed or the client has gone, instead of throwing `Controller is already closed` and taking the process down.
|
|
14
|
+
- a16b0ba: Give a running workflow step a lease, so a step whose worker dies can be claimed again
|
|
15
|
+
|
|
16
|
+
A step moving from `pending` to `running` only proves it was dispatched; it says
|
|
17
|
+
nothing about whether the worker holding it is still alive. A worker that died
|
|
18
|
+
mid-step therefore left the step wedged in `running` forever: unclaimable by the
|
|
19
|
+
next dispatch, and invisible to the stalled-run sweep.
|
|
20
|
+
|
|
21
|
+
The dispatch that claims a step now stamps a lease on it, refreshes that lease on
|
|
22
|
+
an interval for as long as it is working, and releases it when the step parks on
|
|
23
|
+
a child run. A step whose lease has lapsed is claimable again, and counts as
|
|
24
|
+
in-flight no longer, so the stalled sweep can pick its run back up. A step that
|
|
25
|
+
keeps losing its worker fails with `WorkflowStepLeaseExpiredError` once its
|
|
26
|
+
attempts run out, rather than looping.
|
|
27
|
+
|
|
28
|
+
The lease duration is taken from the queue the step is dispatched through, so the
|
|
29
|
+
lease and the queue lock never disagree about who owns the job.
|
|
30
|
+
|
|
31
|
+
The refresh runs at half the lease and never later, even when that is under the
|
|
32
|
+
one-second floor the interval otherwise respects: applying that floor as a
|
|
33
|
+
maximum would renew a shorter lease for the first time after it had already
|
|
34
|
+
lapsed, which is the double-claim the lease exists to prevent. A lease that
|
|
35
|
+
short is logged once, pointing at the queue's `lockDuration`.
|
|
36
|
+
|
|
37
|
+
The lease is written and judged on the database's clock, never the worker's,
|
|
38
|
+
so a worker whose clock runs fast cannot claim a step another worker still
|
|
39
|
+
holds. A dispatch whose step has since been claimed by another stops renewing,
|
|
40
|
+
and says so: `refreshStepLease(stepId, leaseMs, attempt)` resolves `false` once
|
|
41
|
+
the step is no longer that attempt's.
|
|
42
|
+
|
|
43
|
+
The lease lives in a new `workflow_step.lease_expires_at` column, in epoch
|
|
44
|
+
milliseconds (`bigint`). Run `pikku db generate` and `pikku db migrate` after
|
|
45
|
+
upgrading: the runtime does not add columns, and every step read selects this
|
|
46
|
+
one.
|
|
47
|
+
|
|
48
|
+
- 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? })`.
|
|
49
|
+
|
|
50
|
+
## 0.12.132
|
|
51
|
+
|
|
52
|
+
### Patch Changes
|
|
53
|
+
|
|
54
|
+
- d327fa5: Two gaps from the August security sweep that never reached main:
|
|
55
|
+
|
|
56
|
+
- The memoized middleware chains are capped per wire type. The key is the requested wire id, so a caller varying RPC names could otherwise grow the cache without limit.
|
|
57
|
+
- The console's generated variable brokers require `pikku:console`. They are emitted into the application rather than the console addon, so the addon's scope never reached them and any signed-in user could read and overwrite variables through `/rpc`.
|
|
58
|
+
|
|
59
|
+
- 942ebdd: Add `LeaseService`: named leases shared by every process on the same store. A lease is not a mutex: a holder that stalls past its expiry loses the key without knowing, so whatever the body writes to should check the lease's `token` and refuse an older one. `acquire` never blocks and answers `null` while someone else holds the key; a lease lapses on its own if its holder dies; and each lease carries a `token` that rises every time the key changes hands, so a stale holder cannot refresh or release its successor's lease. `holdLease` refreshes the lease while its body runs, hands the body an `AbortSignal` that fires the moment the lease is lost, and throws `LeaseLostError` once the body finishes if the lease lapsed or changed hands meanwhile, so a caller never takes that result as produced under the lease.
|
|
60
|
+
|
|
61
|
+
Ships as `InMemoryLeaseService`, `PgKyselyLeaseService` (`@pikku/kysely-postgres`), `MySQLKyselyLeaseService` (`@pikku/kysely-mysql`) and `KyselyLeaseService` (SQLite), all on `pikku_lease`. The Postgres and MySQL services judge every lease by the database's clock, so a worker whose clock runs fast cannot take a live lease or stretch its own; `KyselyLeaseService` reads the process clock and is for a single-host SQLite app. `pikku db generate` writes the table for any project that reaches `leaseService`.
|
|
62
|
+
|
|
63
|
+
`RedisLeaseService` (`@pikku/redis`) keeps each lease in a hash Redis expires itself, with every check-and-set in one Lua script that reads Redis's `TIME`, so it too judges leases by the server's clock; its token counter outlives release, so the next holder always gets a higher token.
|
|
64
|
+
|
|
65
|
+
- 8ac25a8: An OAuth2 credential can set `scopeSeparator` for a provider that wants scopes joined by something other than a space (Twist wants a comma). A credential with no scopes already sent no `scope` parameter; that is now tested.
|
|
66
|
+
- 6606777: A webhook source can declare how its requests are signed, and the runner checks every request before `receive` runs:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
wireTriggerWebhookSource({
|
|
70
|
+
name: 'github',
|
|
71
|
+
verify: {
|
|
72
|
+
hmac: {
|
|
73
|
+
header: 'x-hub-signature-256',
|
|
74
|
+
prefix: 'sha256=',
|
|
75
|
+
algorithm: 'sha256',
|
|
76
|
+
encoding: 'hex',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
receive: githubWebhookReceive,
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`verify` is an HMAC over the raw body, a shared token or a public-key signature in one header, or a function `(request, secret, services) => boolean` for anything else. A request is refused while the secret is unset or when the signature does not match. A request without a body reaches `receive` unchecked so handshakes still work, but it may only be answered: events from it are refused.
|
|
84
|
+
|
|
85
|
+
Declaring `verify` declares the secret's credential too, so it needs no `defineCredential`. It is a singleton string named `<source>WebhookSecret` in camelCase (`microsoft-outlook` → `microsoftOutlookWebhookSecret`, see `webhookSecretCredentialName`), or whatever `credential` names, described by `credentialDescription`.
|
|
86
|
+
|
|
87
|
+
`@pikku/core/hmac` gains `hmacDigest`, `verifyHmacSignature` and `verifyPublicKeySignature`. `WebhookSigningSecret` is deprecated.
|
|
88
|
+
|
|
89
|
+
## 0.12.131
|
|
90
|
+
|
|
91
|
+
### Patch Changes
|
|
92
|
+
|
|
93
|
+
- 58cccc1: `wire.getCredential('name')` is typed by the project's own credentials, and is always on the wire. Function types were written from the setup-only inspection, which never sees `defineCredential`, so every project fell back to an untyped map; `pikku all` now rewrites them once the credentials leaf exists. `getCredential` and `getCredentials` are no longer optional on `PikkuWire`, since the function runner always sets them. Without a credentials map, `getCredential<string>('name')` returns `string` rather than `unknown`.
|
|
94
|
+
- a26c60e: The workflow status stream sends a `suspended` frame with the run's reason when a run suspends, and stays open for the resume. It used to send nothing to say that no more progress was coming without action.
|
|
95
|
+
|
|
96
|
+
## 0.12.130
|
|
97
|
+
|
|
98
|
+
### Patch Changes
|
|
99
|
+
|
|
100
|
+
- dfcd351: The deploy planner now binds a unit to the units of the functions its functions call with `rpc.invoke('name')` / `rpc.remote('name')`. Before, a caller and callee split into different units (e.g. a no-service function in `svc-base` calling a DB function in `svc-kysely`) got no service binding and failed on the deployed stage with "No service binding for function". The inspector records literal RPC names per function as `invokes` (single, double or substitution-free template quotes; `rpc!`, `wire.rpc`) and warns on a computed name, which the planner cannot see (#1883).
|
|
101
|
+
- cf40182: Deployed units that call `rpc.startWorkflow('x')` now get x's meta, so the call no longer fails with `WorkflowNotFoundError`. The inspector records `startsWorkflows` for literal `rpc.startWorkflow(...)` calls. It warns when a handler computes the workflow name or passes `rpc` to a helper, because the planner cannot see those calls.
|
|
102
|
+
|
|
103
|
+
With workflow queues, the deploy planner gives a starter unit the workflow meta and orchestrator queue meta only (new `--workflowMeta` filter), plus `workflow-state` and `queue` services. Without queues the start runs inline, so the whole workflow is bundled. Core's `startWorkflow` now requires the workflow registration only for inline runs; queued runs need only the meta.
|
|
104
|
+
|
|
105
|
+
- 5bce779: `pikku dev` registers webhook sources with their providers when `PIKKU_DEV_WEBHOOK_URL` is set. What it registered, signing secrets included, is kept in a git-ignored `.webhook-registrations.gen.json` next to `pikku.config.json`, so a database reset or `.pikku` wipe does not register again, and nothing reaches a provider while the url and events are unchanged. Registrations no source declares any more are warned about on every run, with the endpoint and label to delete by hand. The prefix defaults to `dev-<username>`; override it with `PIKKU_DEV_WEBHOOK_LABEL_PREFIX`. New in core: `reconcileWebhookRegistrations`.
|
|
106
|
+
- 5bce779: Webhook trigger sources are off until someone turns them on, and addons declare their own.
|
|
107
|
+
|
|
108
|
+
An addon calls `wireTriggerWebhookSource` in its own package, and an app that wires the addon gets the source (its route included) without declaring it: named and routed after the addon's namespace, so two instances get one each. A source the app declares under the same name wins.
|
|
109
|
+
|
|
110
|
+
Every source now has an `enabled` switch in the `triggerSourceStore`, off by default. `reconcileTriggerSources` registers only enabled sources and records each one's `baseUrl` and `labelPrefix`; a disabled source's route answers 404 without running `receive`. New in core: `enableTriggerSource` registers a source with its provider and `disableTriggerSource` stops it receiving, then tears it down, both at the recorded address unless one is given. The admin addon exposes them as `triggerSourceEnable` and `triggerSourceDisable`. The `pikkuTriggerSource` table gains `enabled`, `baseUrl` and `labelPrefix`: run `pikku db generate` for the migration.
|
|
111
|
+
|
|
1
112
|
## 0.12.129
|
|
2
113
|
|
|
3
114
|
### Patch Changes
|
|
@@ -138,12 +138,26 @@ export type FunctionMeta = FunctionRuntimeMeta & Partial<{
|
|
|
138
138
|
isDirectFunction: boolean;
|
|
139
139
|
sourceFile: string;
|
|
140
140
|
exportedName: string;
|
|
141
|
+
/**
|
|
142
|
+
* RPC names this function's own body calls by literal name through
|
|
143
|
+
* `rpc.invoke(...)` / `rpc.remote(...)`. The deploy planner binds the
|
|
144
|
+
* function's unit to each callee's unit from this; a call with a computed
|
|
145
|
+
* name is not listed, and is warned about instead.
|
|
146
|
+
*/
|
|
147
|
+
invokes: string[];
|
|
141
148
|
/** File containing the handler body when it differs from sourceFile (imported handlers) */
|
|
142
149
|
bodySourceFile?: string;
|
|
143
150
|
/** 1-indexed first line of the handler body (verbose meta; coverage mapping) */
|
|
144
151
|
bodyStart: number;
|
|
145
152
|
/** 1-indexed last line of the handler body (verbose meta; coverage mapping) */
|
|
146
153
|
bodyEnd: number;
|
|
154
|
+
/**
|
|
155
|
+
* Workflow names this function's own body starts by literal name through
|
|
156
|
+
* `rpc.startWorkflow(...)`. The deploy planner bundles each one's meta
|
|
157
|
+
* into the function's unit, which is all a queued start needs. A call
|
|
158
|
+
* made from a helper the handler passes `rpc` to is not listed.
|
|
159
|
+
*/
|
|
160
|
+
startsWorkflows: string[];
|
|
147
161
|
} & CommonWireMeta>;
|
|
148
162
|
export type FunctionsRuntimeMeta = Record<string, FunctionRuntimeMeta>;
|
|
149
163
|
export type FunctionsMeta = Record<string, FunctionMeta>;
|
|
@@ -12,6 +12,10 @@ export declare const addTagMiddleware: <PikkuMiddleware extends CorePikkuMiddlew
|
|
|
12
12
|
*/
|
|
13
13
|
export declare const addGlobalMiddleware: <PikkuMiddleware extends CorePikkuMiddleware>(middleware: CorePikkuMiddlewareGroup, packageName?: string | null) => CorePikkuMiddlewareGroup;
|
|
14
14
|
export declare const clearMiddlewareCache: () => void;
|
|
15
|
+
/** The per-wire-type entry cap. Exposed so the boundedness invariant is testable. */
|
|
16
|
+
export declare const MIDDLEWARE_CACHE_MAX_ENTRIES = 4096;
|
|
17
|
+
/** Current number of cached chains for a wire type. Test/observability only. */
|
|
18
|
+
export declare const middlewareCacheSize: (wireType: PikkuWiringTypes) => number;
|
|
15
19
|
export declare const combineMiddleware: (wireType: PikkuWiringTypes, uid: string, { wireInheritedMiddleware, wireMiddleware, funcInheritedMiddleware, funcMiddleware, packageName, }?: {
|
|
16
20
|
wireInheritedMiddleware?: MiddlewareMetadata[];
|
|
17
21
|
wireMiddleware?: CorePikkuMiddleware[];
|
|
@@ -65,27 +65,64 @@ const getMiddlewareByName = (name) => {
|
|
|
65
65
|
const middleware = middlewareStore[name];
|
|
66
66
|
return middleware?.[0];
|
|
67
67
|
};
|
|
68
|
+
/**
|
|
69
|
+
* Per-wire-type cap on the memoized middleware chains. The cache key is the
|
|
70
|
+
* wire id, which for an RPC is the requested function name — and a name can
|
|
71
|
+
* carry attacker-influenced version/alias suffixes that still resolve. Left
|
|
72
|
+
* unbounded, a caller could grow the map without limit and exhaust memory. It
|
|
73
|
+
* is a pure memoization, so evicting the oldest entry and recomputing on the
|
|
74
|
+
* next miss is free of correctness cost. The cap sits well above any realistic
|
|
75
|
+
* function count.
|
|
76
|
+
*/
|
|
77
|
+
const MIDDLEWARE_CACHE_MAX = 4096;
|
|
78
|
+
const newCache = () => new Map();
|
|
68
79
|
const middlewareCache = {
|
|
69
|
-
http:
|
|
70
|
-
rpc:
|
|
71
|
-
channel:
|
|
72
|
-
queue:
|
|
73
|
-
scheduler:
|
|
74
|
-
trigger:
|
|
75
|
-
mcp:
|
|
76
|
-
agent:
|
|
77
|
-
cli:
|
|
78
|
-
workflow:
|
|
79
|
-
gateway:
|
|
80
|
+
http: newCache(),
|
|
81
|
+
rpc: newCache(),
|
|
82
|
+
channel: newCache(),
|
|
83
|
+
queue: newCache(),
|
|
84
|
+
scheduler: newCache(),
|
|
85
|
+
trigger: newCache(),
|
|
86
|
+
mcp: newCache(),
|
|
87
|
+
agent: newCache(),
|
|
88
|
+
cli: newCache(),
|
|
89
|
+
workflow: newCache(),
|
|
90
|
+
gateway: newCache(),
|
|
91
|
+
};
|
|
92
|
+
const cacheGet = (wireType, uid) => {
|
|
93
|
+
const cache = middlewareCache[wireType];
|
|
94
|
+
const hit = cache.get(uid);
|
|
95
|
+
if (hit !== undefined) {
|
|
96
|
+
// Refresh recency: delete + re-set moves the key to the end (LRU).
|
|
97
|
+
cache.delete(uid);
|
|
98
|
+
cache.set(uid, hit);
|
|
99
|
+
}
|
|
100
|
+
return hit;
|
|
101
|
+
};
|
|
102
|
+
const cacheSet = (wireType, uid, value) => {
|
|
103
|
+
const cache = middlewareCache[wireType];
|
|
104
|
+
cache.set(uid, value);
|
|
105
|
+
if (cache.size > MIDDLEWARE_CACHE_MAX) {
|
|
106
|
+
// Evict the least-recently-used entry (Map iteration is insertion order).
|
|
107
|
+
const oldest = cache.keys().next().value;
|
|
108
|
+
if (oldest !== undefined)
|
|
109
|
+
cache.delete(oldest);
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
80
112
|
};
|
|
81
113
|
export const clearMiddlewareCache = () => {
|
|
82
114
|
for (const key of Object.keys(middlewareCache)) {
|
|
83
|
-
middlewareCache[key] =
|
|
115
|
+
middlewareCache[key] = newCache();
|
|
84
116
|
}
|
|
85
117
|
};
|
|
118
|
+
/** The per-wire-type entry cap. Exposed so the boundedness invariant is testable. */
|
|
119
|
+
export const MIDDLEWARE_CACHE_MAX_ENTRIES = MIDDLEWARE_CACHE_MAX;
|
|
120
|
+
/** Current number of cached chains for a wire type. Test/observability only. */
|
|
121
|
+
export const middlewareCacheSize = (wireType) => middlewareCache[wireType].size;
|
|
86
122
|
export const combineMiddleware = (wireType, uid, { wireInheritedMiddleware, wireMiddleware, funcInheritedMiddleware, funcMiddleware, packageName = null, } = {}) => {
|
|
87
|
-
|
|
88
|
-
|
|
123
|
+
const cached = cacheGet(wireType, uid);
|
|
124
|
+
if (cached) {
|
|
125
|
+
return cached;
|
|
89
126
|
}
|
|
90
127
|
const resolved = [];
|
|
91
128
|
// Global middleware is application-wide, so the root namespace always
|
|
@@ -138,6 +175,5 @@ export const combineMiddleware = (wireType, uid, { wireInheritedMiddleware, wire
|
|
|
138
175
|
resolved.push(...funcMiddleware);
|
|
139
176
|
}
|
|
140
177
|
sortByPriority(resolved);
|
|
141
|
-
|
|
142
|
-
return middlewareCache[wireType][uid];
|
|
178
|
+
return cacheSet(wireType, uid, freezeDedupe(resolved));
|
|
143
179
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Lease, LeaseService } from './lease-service.js';
|
|
2
|
+
export declare class InMemoryLeaseService implements LeaseService {
|
|
3
|
+
private now;
|
|
4
|
+
private leases;
|
|
5
|
+
constructor(now?: () => number);
|
|
6
|
+
acquire(key: string, holder: string, ttlMs: number): Promise<Lease | null>;
|
|
7
|
+
refresh(lease: Lease, ttlMs: number): Promise<Lease | null>;
|
|
8
|
+
release(lease: Lease): Promise<void>;
|
|
9
|
+
get(key: string): Promise<Lease | null>;
|
|
10
|
+
private lapsed;
|
|
11
|
+
private holds;
|
|
12
|
+
private set;
|
|
13
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export class InMemoryLeaseService {
|
|
2
|
+
now;
|
|
3
|
+
leases = new Map();
|
|
4
|
+
constructor(now = Date.now) {
|
|
5
|
+
this.now = now;
|
|
6
|
+
}
|
|
7
|
+
async acquire(key, holder, ttlMs) {
|
|
8
|
+
const current = this.leases.get(key);
|
|
9
|
+
const expiresAt = new Date(this.now() + ttlMs);
|
|
10
|
+
if (!current)
|
|
11
|
+
return this.set({ key, holder, token: 1, expiresAt });
|
|
12
|
+
if (this.lapsed(current)) {
|
|
13
|
+
return this.set({ key, holder, token: current.token + 1, expiresAt });
|
|
14
|
+
}
|
|
15
|
+
if (current.holder === holder)
|
|
16
|
+
return this.set({ ...current, expiresAt });
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
async refresh(lease, ttlMs) {
|
|
20
|
+
const current = this.leases.get(lease.key);
|
|
21
|
+
if (!this.holds(current, lease) || this.lapsed(current))
|
|
22
|
+
return null;
|
|
23
|
+
return this.set({ ...current, expiresAt: new Date(this.now() + ttlMs) });
|
|
24
|
+
}
|
|
25
|
+
async release(lease) {
|
|
26
|
+
const current = this.leases.get(lease.key);
|
|
27
|
+
if (this.holds(current, lease)) {
|
|
28
|
+
this.set({ ...current, expiresAt: new Date(0) });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async get(key) {
|
|
32
|
+
const current = this.leases.get(key);
|
|
33
|
+
if (!current || this.lapsed(current))
|
|
34
|
+
return null;
|
|
35
|
+
return { ...current };
|
|
36
|
+
}
|
|
37
|
+
lapsed(lease) {
|
|
38
|
+
return lease.expiresAt.getTime() <= this.now();
|
|
39
|
+
}
|
|
40
|
+
holds(current, lease) {
|
|
41
|
+
return (!!current &&
|
|
42
|
+
current.holder === lease.holder &&
|
|
43
|
+
current.token === lease.token);
|
|
44
|
+
}
|
|
45
|
+
set(lease) {
|
|
46
|
+
this.leases.set(lease.key, lease);
|
|
47
|
+
return { ...lease };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -24,9 +24,16 @@ export declare class InMemoryWorkflowService extends PikkuWorkflowService implem
|
|
|
24
24
|
protected insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: WorkflowStepOptions, fromStepName?: string): Promise<StepState>;
|
|
25
25
|
getStepState(runId: string, stepName: string): Promise<StepState>;
|
|
26
26
|
protected setStepRunningImpl(stepId: string): Promise<void>;
|
|
27
|
+
refreshStepLease(stepId: string, leaseMs: number | null, attempt?: number): Promise<boolean>;
|
|
27
28
|
protected setStepScheduledImpl(stepId: string): Promise<void>;
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The live step `stepId` names, when `attempt` still owns it. A retry is a
|
|
31
|
+
* new step with its own id, so a superseded attempt's id no longer names a
|
|
32
|
+
* live step at all.
|
|
33
|
+
*/
|
|
34
|
+
private ownedStep;
|
|
35
|
+
protected setStepResultImpl(stepId: string, result: any, attempt?: number): Promise<void>;
|
|
36
|
+
protected setStepErrorImpl(stepId: string, error: Error, attempt?: number): Promise<void>;
|
|
30
37
|
protected setStepChildRunIdImpl(stepId: string, childRunId: string): Promise<void>;
|
|
31
38
|
protected createRetryAttemptImpl(failedStepId: string, status: 'pending' | 'running'): Promise<StepState>;
|
|
32
39
|
/**
|
|
@@ -53,7 +60,7 @@ export declare class InMemoryWorkflowService extends PikkuWorkflowService implem
|
|
|
53
60
|
}>>;
|
|
54
61
|
getDistinctWorkflowNames(): Promise<string[]>;
|
|
55
62
|
deleteRun(id: string): Promise<boolean>;
|
|
56
|
-
|
|
63
|
+
withRunLease<T>(_id: string, fn: () => Promise<T>): Promise<T>;
|
|
57
64
|
withStepLock<T>(_runId: string, _stepName: string, fn: () => Promise<T>): Promise<T>;
|
|
58
65
|
close(): Promise<void>;
|
|
59
66
|
getCompletedGraphState(runId: string): Promise<{
|
|
@@ -68,6 +75,7 @@ export declare class InMemoryWorkflowService extends PikkuWorkflowService implem
|
|
|
68
75
|
stepName: string;
|
|
69
76
|
status: StepStatus;
|
|
70
77
|
fromStepName?: string;
|
|
78
|
+
leaseExpiresAt?: Date;
|
|
71
79
|
}>>;
|
|
72
80
|
getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
|
|
73
81
|
setBranchKey(runId: string, nodeId: string, branchKey: string): Promise<void>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
2
|
import { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js';
|
|
3
3
|
import { isExpectedError } from '../errors/error-handler.js';
|
|
4
|
+
import { isStepLeaseLive } from '../wirings/workflow/workflow-constants.js';
|
|
5
|
+
import { WorkflowStepSupersededError } from '../wirings/workflow/workflow-errors.js';
|
|
4
6
|
// knowledge: decisions/internals/the-in-memory-workflow-service-is-inline-only-and-single-process.md
|
|
5
7
|
export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
6
8
|
constructor(options = {}) {
|
|
@@ -110,42 +112,69 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
|
-
async
|
|
115
|
+
async refreshStepLease(stepId, leaseMs, attempt) {
|
|
114
116
|
for (const step of this.steps.values()) {
|
|
115
117
|
if (step.stepId === stepId) {
|
|
116
|
-
step.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
if (attempt !== undefined && step.attemptCount !== attempt) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
step.leaseExpiresAt =
|
|
122
|
+
leaseMs === null ? undefined : new Date(Date.now() + leaseMs);
|
|
123
|
+
return true;
|
|
120
124
|
}
|
|
121
125
|
}
|
|
126
|
+
return false;
|
|
122
127
|
}
|
|
123
|
-
async
|
|
128
|
+
async setStepScheduledImpl(stepId) {
|
|
124
129
|
for (const step of this.steps.values()) {
|
|
125
130
|
if (step.stepId === stepId) {
|
|
126
|
-
step.status = '
|
|
127
|
-
step.
|
|
128
|
-
step.succeededAt = new Date();
|
|
131
|
+
step.status = 'scheduled';
|
|
132
|
+
step.scheduledAt = new Date();
|
|
129
133
|
step.updatedAt = new Date();
|
|
130
134
|
break;
|
|
131
135
|
}
|
|
132
136
|
}
|
|
133
137
|
}
|
|
134
|
-
|
|
138
|
+
/**
|
|
139
|
+
* The live step `stepId` names, when `attempt` still owns it. A retry is a
|
|
140
|
+
* new step with its own id, so a superseded attempt's id no longer names a
|
|
141
|
+
* live step at all.
|
|
142
|
+
*/
|
|
143
|
+
ownedStep(stepId, attempt) {
|
|
144
|
+
let live;
|
|
135
145
|
for (const step of this.steps.values()) {
|
|
136
146
|
if (step.stepId === stepId) {
|
|
137
|
-
|
|
138
|
-
step.error = {
|
|
139
|
-
name: error.name,
|
|
140
|
-
message: error.message,
|
|
141
|
-
stack: error.stack,
|
|
142
|
-
expected: isExpectedError(error),
|
|
143
|
-
};
|
|
144
|
-
step.failedAt = new Date();
|
|
145
|
-
step.updatedAt = new Date();
|
|
147
|
+
live = step;
|
|
146
148
|
break;
|
|
147
149
|
}
|
|
148
150
|
}
|
|
151
|
+
if (attempt !== undefined && live?.attemptCount !== attempt) {
|
|
152
|
+
throw new WorkflowStepSupersededError(stepId, attempt);
|
|
153
|
+
}
|
|
154
|
+
return live;
|
|
155
|
+
}
|
|
156
|
+
async setStepResultImpl(stepId, result, attempt) {
|
|
157
|
+
const step = this.ownedStep(stepId, attempt);
|
|
158
|
+
if (!step)
|
|
159
|
+
return;
|
|
160
|
+
step.status = 'succeeded';
|
|
161
|
+
step.result = result;
|
|
162
|
+
step.succeededAt = new Date();
|
|
163
|
+
step.updatedAt = new Date();
|
|
164
|
+
}
|
|
165
|
+
async setStepErrorImpl(stepId, error, attempt) {
|
|
166
|
+
const step = this.ownedStep(stepId, attempt);
|
|
167
|
+
if (!step)
|
|
168
|
+
return;
|
|
169
|
+
step.status = 'failed';
|
|
170
|
+
step.error = {
|
|
171
|
+
name: error.name,
|
|
172
|
+
message: error.message,
|
|
173
|
+
stack: error.stack,
|
|
174
|
+
expected: isExpectedError(error),
|
|
175
|
+
};
|
|
176
|
+
step.failedAt = new Date();
|
|
177
|
+
step.updatedAt = new Date();
|
|
149
178
|
}
|
|
150
179
|
async setStepChildRunIdImpl(stepId, childRunId) {
|
|
151
180
|
for (const step of this.steps.values()) {
|
|
@@ -230,9 +259,9 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
|
230
259
|
if (run.status !== 'running')
|
|
231
260
|
continue;
|
|
232
261
|
const steps = this.stepHistory.get(runId) ?? [];
|
|
233
|
-
if (steps.some((step) => step.status === '
|
|
234
|
-
step.status === '
|
|
235
|
-
step.status === '
|
|
262
|
+
if (steps.some((step) => step.status === 'scheduled' ||
|
|
263
|
+
step.status === 'suspended' ||
|
|
264
|
+
(step.status === 'running' && isStepLeaseLive(step.leaseExpiresAt)))) {
|
|
236
265
|
continue;
|
|
237
266
|
}
|
|
238
267
|
const lastActivity = steps.reduce((latest, step) => (step.updatedAt > latest ? step.updatedAt : latest), run.updatedAt);
|
|
@@ -293,7 +322,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
|
293
322
|
}
|
|
294
323
|
return existed;
|
|
295
324
|
}
|
|
296
|
-
async
|
|
325
|
+
async withRunLease(_id, fn) {
|
|
297
326
|
// knowledge: decisions/internals/the-in-memory-workflow-service-is-inline-only-and-single-process.md
|
|
298
327
|
return fn();
|
|
299
328
|
}
|
|
@@ -360,6 +389,7 @@ export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
|
360
389
|
stepName: key.substring(prefix.length),
|
|
361
390
|
status: step.status,
|
|
362
391
|
fromStepName: step.fromStepName,
|
|
392
|
+
leaseExpiresAt: step.leaseExpiresAt,
|
|
363
393
|
});
|
|
364
394
|
}
|
|
365
395
|
return instances;
|
package/dist/services/index.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export { REMOTE_JOBS_SECRET_HEADER, REMOTE_JOBS_SECRET_VARIABLE, REMOTE_QUEUE_JO
|
|
|
17
17
|
export { InMemoryQueueService } from './in-memory-queue-service.js';
|
|
18
18
|
export { InMemoryTriggerService } from './in-memory-trigger-service.js';
|
|
19
19
|
export { InMemoryTriggerSourceStore, type DeclaredTriggerSource, type TriggerSourceKind, type TriggerSourceResult, type TriggerSourceRow, type TriggerSourceStore, } from './trigger-source-store.js';
|
|
20
|
+
export { LeaseLostError, LeaseTakenError, holdLease, type Lease, type LeaseService, } from './lease-service.js';
|
|
21
|
+
export { InMemoryLeaseService } from './in-memory-lease-service.js';
|
|
20
22
|
export { InMemoryAgentRunStateService } from './in-memory-agent-run-state-service.js';
|
|
21
23
|
export { LocalGatewayService } from './local-gateway-service.js';
|
|
22
24
|
export { FileScenarioRunStore, scenarioArtifactContentType, scenarioRunSummary, type FileScenarioRunStoreOptions, } from './file-scenario-run-store.js';
|
package/dist/services/index.js
CHANGED
|
@@ -17,6 +17,8 @@ export { REMOTE_JOBS_SECRET_HEADER, REMOTE_JOBS_SECRET_VARIABLE, REMOTE_QUEUE_JO
|
|
|
17
17
|
export { InMemoryQueueService } from './in-memory-queue-service.js';
|
|
18
18
|
export { InMemoryTriggerService } from './in-memory-trigger-service.js';
|
|
19
19
|
export { InMemoryTriggerSourceStore, } from './trigger-source-store.js';
|
|
20
|
+
export { LeaseLostError, LeaseTakenError, holdLease, } from './lease-service.js';
|
|
21
|
+
export { InMemoryLeaseService } from './in-memory-lease-service.js';
|
|
20
22
|
export { InMemoryAgentRunStateService } from './in-memory-agent-run-state-service.js';
|
|
21
23
|
export { LocalGatewayService } from './local-gateway-service.js';
|
|
22
24
|
export { FileScenarioRunStore, scenarioArtifactContentType, scenarioRunSummary, } from './file-scenario-run-store.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type Lease = {
|
|
2
|
+
key: string;
|
|
3
|
+
holder: string;
|
|
4
|
+
token: number;
|
|
5
|
+
expiresAt: Date;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Named leases shared by every process on the same store. A lease is held until
|
|
9
|
+
* it is released or runs out, so it is not a mutex: a holder that stalls past
|
|
10
|
+
* its expiry loses the key without knowing. `token` rises every time the key
|
|
11
|
+
* changes hands, and a write that must not come from a stale holder checks it.
|
|
12
|
+
*/
|
|
13
|
+
export interface LeaseService {
|
|
14
|
+
acquire(key: string, holder: string, ttlMs: number): Promise<Lease | null>;
|
|
15
|
+
refresh(lease: Lease, ttlMs: number): Promise<Lease | null>;
|
|
16
|
+
release(lease: Lease): Promise<void>;
|
|
17
|
+
get(key: string): Promise<Lease | null>;
|
|
18
|
+
}
|
|
19
|
+
export declare class LeaseTakenError extends Error {
|
|
20
|
+
readonly key: string;
|
|
21
|
+
constructor(key: string);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Thrown by `holdLease` when the lease lapsed or passed to another holder while
|
|
25
|
+
* the body ran, so its result was not produced under the lease. The body's
|
|
26
|
+
* `signal` is aborted with it as soon as the loss is seen.
|
|
27
|
+
*/
|
|
28
|
+
export declare class LeaseLostError extends Error {
|
|
29
|
+
readonly key: string;
|
|
30
|
+
constructor(key: string);
|
|
31
|
+
}
|
|
32
|
+
/** How many renewals fit in one lease, so a failed one still leaves others before it runs out. */
|
|
33
|
+
export declare const RENEWALS_PER_LEASE = 3;
|
|
34
|
+
/** How long a holder waits between renewals of a `ttlMs` lease. */
|
|
35
|
+
export declare const leaseRenewalIntervalMs: (ttlMs: number) => number;
|
|
36
|
+
export type LeaseRenewal = {
|
|
37
|
+
/** Aborts with `LeaseLostError` once the lease is no longer this holder's. */
|
|
38
|
+
signal: AbortSignal;
|
|
39
|
+
/** Stops renewing, after waiting for a renewal already in flight. */
|
|
40
|
+
stop: () => Promise<void>;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Renews a held lease every third of `ttlMs` until stopped, so a renewal that
|
|
44
|
+
* fails still leaves two more before the lease runs out.
|
|
45
|
+
*
|
|
46
|
+
* `renew` resolves `true` while the lease is still this holder's and `false`
|
|
47
|
+
* once another holder has it. A throw proves neither, so the lease is only
|
|
48
|
+
* counted lost once renewals have kept failing past its expiry on this
|
|
49
|
+
* process's clock.
|
|
50
|
+
*
|
|
51
|
+
* `stop` waits for a renewal in flight, so a release made after it cannot be
|
|
52
|
+
* undone by a late one.
|
|
53
|
+
*/
|
|
54
|
+
export declare const keepLeaseAlive: (key: string, ttlMs: number, renew: () => Promise<boolean>) => LeaseRenewal;
|
|
55
|
+
/**
|
|
56
|
+
* Runs `fn` holding `key` on `leases`, renewing the lease every third of
|
|
57
|
+
* `ttlMs` until it settles. `ttlMs` is how long a crashed holder keeps the key
|
|
58
|
+
* from everyone else. Never waits for a taken key: that throws
|
|
59
|
+
* `LeaseTakenError`.
|
|
60
|
+
*/
|
|
61
|
+
export declare const holdLease: <T>(leases: LeaseService, key: string, fn: (lease: Lease, signal: AbortSignal) => Promise<T>, ttlMs?: number) => Promise<T>;
|