@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
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export class LeaseTakenError extends Error {
|
|
2
|
+
key;
|
|
3
|
+
constructor(key) {
|
|
4
|
+
super(`Lease ${key} is held by another holder`);
|
|
5
|
+
this.key = key;
|
|
6
|
+
this.name = 'LeaseTakenError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Thrown by `holdLease` when the lease lapsed or passed to another holder while
|
|
11
|
+
* the body ran, so its result was not produced under the lease. The body's
|
|
12
|
+
* `signal` is aborted with it as soon as the loss is seen.
|
|
13
|
+
*/
|
|
14
|
+
export class LeaseLostError extends Error {
|
|
15
|
+
key;
|
|
16
|
+
constructor(key) {
|
|
17
|
+
super(`Lease ${key} was lost before the body finished`);
|
|
18
|
+
this.key = key;
|
|
19
|
+
this.name = 'LeaseLostError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Resolves after `ms`, or as soon as `signal` aborts. */
|
|
23
|
+
const sleep = (ms, signal) => new Promise((resolve) => {
|
|
24
|
+
if (signal.aborted)
|
|
25
|
+
return resolve();
|
|
26
|
+
const done = () => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
signal.removeEventListener('abort', done);
|
|
29
|
+
resolve();
|
|
30
|
+
};
|
|
31
|
+
const timer = setTimeout(done, ms);
|
|
32
|
+
timer.unref?.();
|
|
33
|
+
signal.addEventListener('abort', done);
|
|
34
|
+
});
|
|
35
|
+
/** How many renewals fit in one lease, so a failed one still leaves others before it runs out. */
|
|
36
|
+
export const RENEWALS_PER_LEASE = 3;
|
|
37
|
+
/** How long a holder waits between renewals of a `ttlMs` lease. */
|
|
38
|
+
export const leaseRenewalIntervalMs = (ttlMs) => Math.max(1, ttlMs / RENEWALS_PER_LEASE);
|
|
39
|
+
/**
|
|
40
|
+
* Renews a held lease every third of `ttlMs` until stopped, so a renewal that
|
|
41
|
+
* fails still leaves two more before the lease runs out.
|
|
42
|
+
*
|
|
43
|
+
* `renew` resolves `true` while the lease is still this holder's and `false`
|
|
44
|
+
* once another holder has it. A throw proves neither, so the lease is only
|
|
45
|
+
* counted lost once renewals have kept failing past its expiry on this
|
|
46
|
+
* process's clock.
|
|
47
|
+
*
|
|
48
|
+
* `stop` waits for a renewal in flight, so a release made after it cannot be
|
|
49
|
+
* undone by a late one.
|
|
50
|
+
*/
|
|
51
|
+
export const keepLeaseAlive = (key, ttlMs, renew) => {
|
|
52
|
+
const lost = new AbortController();
|
|
53
|
+
const stopped = new AbortController();
|
|
54
|
+
const renewing = (async () => {
|
|
55
|
+
let heldUntil = Date.now() + ttlMs;
|
|
56
|
+
while (!lost.signal.aborted) {
|
|
57
|
+
await sleep(leaseRenewalIntervalMs(ttlMs), stopped.signal);
|
|
58
|
+
if (stopped.signal.aborted)
|
|
59
|
+
return;
|
|
60
|
+
const renewedAt = Date.now();
|
|
61
|
+
const held = await renew().catch(() => undefined);
|
|
62
|
+
if (held) {
|
|
63
|
+
heldUntil = renewedAt + ttlMs;
|
|
64
|
+
}
|
|
65
|
+
else if (held === false || Date.now() >= heldUntil) {
|
|
66
|
+
lost.abort(new LeaseLostError(key));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
return {
|
|
71
|
+
signal: lost.signal,
|
|
72
|
+
stop: async () => {
|
|
73
|
+
stopped.abort();
|
|
74
|
+
await renewing;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Runs `fn` holding `key` on `leases`, renewing the lease every third of
|
|
80
|
+
* `ttlMs` until it settles. `ttlMs` is how long a crashed holder keeps the key
|
|
81
|
+
* from everyone else. Never waits for a taken key: that throws
|
|
82
|
+
* `LeaseTakenError`.
|
|
83
|
+
*/
|
|
84
|
+
export const holdLease = async (leases, key, fn, ttlMs = 30_000) => {
|
|
85
|
+
const acquired = await leases.acquire(key, crypto.randomUUID(), ttlMs);
|
|
86
|
+
if (!acquired)
|
|
87
|
+
throw new LeaseTakenError(key);
|
|
88
|
+
let lease = acquired;
|
|
89
|
+
const renew = async () => {
|
|
90
|
+
const next = await leases.refresh(lease, ttlMs);
|
|
91
|
+
if (next)
|
|
92
|
+
lease = next;
|
|
93
|
+
return next !== null;
|
|
94
|
+
};
|
|
95
|
+
const renewal = keepLeaseAlive(key, ttlMs, renew);
|
|
96
|
+
try {
|
|
97
|
+
const result = await fn(lease, renewal.signal);
|
|
98
|
+
await renewal.stop();
|
|
99
|
+
// The last renewal can be a third of a lease old, so check once more
|
|
100
|
+
// before vouching that the body ran alone.
|
|
101
|
+
const stillHeld = !renewal.signal.aborted && (await renew().catch(() => false));
|
|
102
|
+
if (!stillHeld)
|
|
103
|
+
throw new LeaseLostError(key);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await renewal.stop();
|
|
108
|
+
await leases.release(lease).catch(() => { });
|
|
109
|
+
}
|
|
110
|
+
};
|
|
@@ -3,15 +3,22 @@ export type TriggerSourceKind = 'webhook';
|
|
|
3
3
|
export type DeclaredTriggerSource = {
|
|
4
4
|
name: string;
|
|
5
5
|
kind: TriggerSourceKind;
|
|
6
|
+
/** Where the running app is served and how it labels provider endpoints: what enabling a source registers it with. */
|
|
7
|
+
baseUrl?: string;
|
|
8
|
+
labelPrefix?: string;
|
|
6
9
|
};
|
|
7
10
|
/**
|
|
8
11
|
* What was registered with the provider for one declared trigger source.
|
|
9
12
|
* `state` is what the last `setup` returned — provider identifiers only; any
|
|
10
13
|
* secret it issued lives in the credential service.
|
|
11
14
|
*/
|
|
12
|
-
export type TriggerSourceRow = DeclaredTriggerSource & {
|
|
15
|
+
export type TriggerSourceRow = Required<Pick<DeclaredTriggerSource, 'name' | 'kind'>> & {
|
|
16
|
+
baseUrl: string | null;
|
|
17
|
+
labelPrefix: string | null;
|
|
13
18
|
/** False once the declaration has gone from code without a teardown: an orphan. */
|
|
14
19
|
declared: boolean;
|
|
20
|
+
/** Only an enabled source is registered with its provider or receives events. Off until someone turns it on. */
|
|
21
|
+
enabled: boolean;
|
|
15
22
|
status: string | null;
|
|
16
23
|
state: WebhookSourceState | null;
|
|
17
24
|
/** The last step's instructions or error. */
|
|
@@ -25,11 +32,12 @@ export type TriggerSourceResult = {
|
|
|
25
32
|
};
|
|
26
33
|
/** What each declared trigger source registered with its provider. */
|
|
27
34
|
export interface TriggerSourceStore {
|
|
28
|
-
/** Upserts the declared sources and marks the rest undeclared. */
|
|
35
|
+
/** Upserts the declared sources, keeping a known address when none is given, and marks the rest undeclared. */
|
|
29
36
|
syncTriggerSources(sources: DeclaredTriggerSource[]): Promise<void>;
|
|
30
37
|
listTriggerSources(): Promise<TriggerSourceRow[]>;
|
|
31
38
|
getTriggerSource(name: string): Promise<TriggerSourceRow | null>;
|
|
32
39
|
recordTriggerSource(name: string, result: TriggerSourceResult): Promise<void>;
|
|
40
|
+
setTriggerSourceEnabled(name: string, enabled: boolean): Promise<void>;
|
|
33
41
|
deleteTriggerSource(name: string): Promise<void>;
|
|
34
42
|
}
|
|
35
43
|
export declare class InMemoryTriggerSourceStore implements TriggerSourceStore {
|
|
@@ -38,8 +46,12 @@ export declare class InMemoryTriggerSourceStore implements TriggerSourceStore {
|
|
|
38
46
|
listTriggerSources(): Promise<{
|
|
39
47
|
name: string;
|
|
40
48
|
kind: TriggerSourceKind;
|
|
49
|
+
baseUrl: string | null;
|
|
50
|
+
labelPrefix: string | null;
|
|
41
51
|
/** False once the declaration has gone from code without a teardown: an orphan. */
|
|
42
52
|
declared: boolean;
|
|
53
|
+
/** Only an enabled source is registered with its provider or receives events. Off until someone turns it on. */
|
|
54
|
+
enabled: boolean;
|
|
43
55
|
status: string | null;
|
|
44
56
|
state: WebhookSourceState | null;
|
|
45
57
|
/** The last step's instructions or error. */
|
|
@@ -49,8 +61,12 @@ export declare class InMemoryTriggerSourceStore implements TriggerSourceStore {
|
|
|
49
61
|
getTriggerSource(name: string): Promise<{
|
|
50
62
|
name: string;
|
|
51
63
|
kind: TriggerSourceKind;
|
|
64
|
+
baseUrl: string | null;
|
|
65
|
+
labelPrefix: string | null;
|
|
52
66
|
/** False once the declaration has gone from code without a teardown: an orphan. */
|
|
53
67
|
declared: boolean;
|
|
68
|
+
/** Only an enabled source is registered with its provider or receives events. Off until someone turns it on. */
|
|
69
|
+
enabled: boolean;
|
|
54
70
|
status: string | null;
|
|
55
71
|
state: WebhookSourceState | null;
|
|
56
72
|
/** The last step's instructions or error. */
|
|
@@ -58,5 +74,6 @@ export declare class InMemoryTriggerSourceStore implements TriggerSourceStore {
|
|
|
58
74
|
updatedAt: string | null;
|
|
59
75
|
} | null>;
|
|
60
76
|
recordTriggerSource(name: string, result: TriggerSourceResult): Promise<void>;
|
|
77
|
+
setTriggerSourceEnabled(name: string, enabled: boolean): Promise<void>;
|
|
61
78
|
deleteTriggerSource(name: string): Promise<void>;
|
|
62
79
|
}
|
|
@@ -5,16 +5,23 @@ export class InMemoryTriggerSourceStore {
|
|
|
5
5
|
for (const row of this.rows.values()) {
|
|
6
6
|
row.declared = names.has(row.name);
|
|
7
7
|
}
|
|
8
|
-
for (const { name, kind } of sources) {
|
|
8
|
+
for (const { name, kind, baseUrl, labelPrefix } of sources) {
|
|
9
9
|
const row = this.rows.get(name);
|
|
10
10
|
if (row) {
|
|
11
11
|
row.kind = kind;
|
|
12
|
+
if (baseUrl !== undefined)
|
|
13
|
+
row.baseUrl = baseUrl;
|
|
14
|
+
if (labelPrefix !== undefined)
|
|
15
|
+
row.labelPrefix = labelPrefix;
|
|
12
16
|
continue;
|
|
13
17
|
}
|
|
14
18
|
this.rows.set(name, {
|
|
15
19
|
name,
|
|
16
20
|
kind,
|
|
21
|
+
baseUrl: baseUrl ?? null,
|
|
22
|
+
labelPrefix: labelPrefix ?? null,
|
|
17
23
|
declared: true,
|
|
24
|
+
enabled: false,
|
|
18
25
|
status: null,
|
|
19
26
|
state: null,
|
|
20
27
|
detail: null,
|
|
@@ -41,6 +48,12 @@ export class InMemoryTriggerSourceStore {
|
|
|
41
48
|
row.detail = result.detail ?? null;
|
|
42
49
|
row.updatedAt = new Date().toISOString();
|
|
43
50
|
}
|
|
51
|
+
async setTriggerSourceEnabled(name, enabled) {
|
|
52
|
+
const row = this.rows.get(name);
|
|
53
|
+
if (!row)
|
|
54
|
+
throw new Error(`Unknown trigger source: ${name}`);
|
|
55
|
+
row.enabled = enabled;
|
|
56
|
+
}
|
|
44
57
|
async deleteTriggerSource(name) {
|
|
45
58
|
this.rows.delete(name);
|
|
46
59
|
}
|
|
@@ -16,7 +16,7 @@ export interface WorkflowService {
|
|
|
16
16
|
getRunTimeline(id: string): Promise<RunTimeline | null>;
|
|
17
17
|
reconstructRunStateAt(id: string, at?: number | Date): Promise<ReconstructedRunState | null>;
|
|
18
18
|
updateRunStatus(id: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
|
|
19
|
-
|
|
19
|
+
withRunLease<T>(id: string, fn: () => Promise<T>): Promise<T>;
|
|
20
20
|
close(): Promise<void>;
|
|
21
21
|
resumeWorkflow(runId: string): Promise<void>;
|
|
22
22
|
/**
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { holdLease, } from '../../services/lease-service.js';
|
|
4
|
+
const HOUR = 60 * 60 * 1000;
|
|
5
|
+
/**
|
|
6
|
+
* A lease a test lets lapse, and the lease `holdLease` renews. Both leave room
|
|
7
|
+
* for a real database's round-trips: a lease has to outlive the statement that
|
|
8
|
+
* writes it and the one that reads it back.
|
|
9
|
+
*/
|
|
10
|
+
const SHORT = 500;
|
|
11
|
+
const RENEWED = 1_000;
|
|
12
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
13
|
+
/** Conformance suite for `leaseService`. Each test gets a fresh service. */
|
|
14
|
+
export const defineLeaseServiceTests = (name, create) => {
|
|
15
|
+
describe(`LeaseService [${name}]`, () => {
|
|
16
|
+
test('one holder at a time', async () => {
|
|
17
|
+
const leases = await create();
|
|
18
|
+
const a = await leases.acquire('stage', 'a', HOUR);
|
|
19
|
+
assert.equal(a?.holder, 'a');
|
|
20
|
+
assert.equal(a?.token, 1);
|
|
21
|
+
assert.equal(await leases.acquire('stage', 'b', HOUR), null);
|
|
22
|
+
assert.equal((await leases.get('stage'))?.holder, 'a');
|
|
23
|
+
});
|
|
24
|
+
test('keys are independent', async () => {
|
|
25
|
+
const leases = await create();
|
|
26
|
+
await leases.acquire('one', 'a', HOUR);
|
|
27
|
+
assert.equal((await leases.acquire('two', 'b', HOUR))?.holder, 'b');
|
|
28
|
+
});
|
|
29
|
+
test('the holder re-acquires under the same token', async () => {
|
|
30
|
+
const leases = await create();
|
|
31
|
+
const first = await leases.acquire('stage', 'a', SHORT);
|
|
32
|
+
const again = await leases.acquire('stage', 'a', HOUR);
|
|
33
|
+
assert.equal(again?.token, first?.token);
|
|
34
|
+
assert.ok(again.expiresAt > first.expiresAt);
|
|
35
|
+
});
|
|
36
|
+
test('a lapsed lease passes to the next holder with a higher token', async () => {
|
|
37
|
+
const leases = await create();
|
|
38
|
+
const a = await leases.acquire('stage', 'a', SHORT);
|
|
39
|
+
await wait(2 * SHORT);
|
|
40
|
+
const b = await leases.acquire('stage', 'b', HOUR);
|
|
41
|
+
assert.equal(b?.holder, 'b');
|
|
42
|
+
assert.equal(b?.token, a.token + 1);
|
|
43
|
+
assert.equal(await leases.refresh(a, HOUR), null);
|
|
44
|
+
});
|
|
45
|
+
test('release frees the key and the token keeps rising', async () => {
|
|
46
|
+
const leases = await create();
|
|
47
|
+
const a = await leases.acquire('stage', 'a', HOUR);
|
|
48
|
+
await leases.release(a);
|
|
49
|
+
assert.equal(await leases.get('stage'), null);
|
|
50
|
+
const b = await leases.acquire('stage', 'b', HOUR);
|
|
51
|
+
assert.equal(b?.token, a.token + 1);
|
|
52
|
+
});
|
|
53
|
+
test('a stale lease cannot refresh or release the new holder', async () => {
|
|
54
|
+
const leases = await create();
|
|
55
|
+
const a = await leases.acquire('stage', 'a', SHORT);
|
|
56
|
+
await wait(2 * SHORT);
|
|
57
|
+
await leases.acquire('stage', 'b', HOUR);
|
|
58
|
+
await leases.release(a);
|
|
59
|
+
assert.equal((await leases.get('stage'))?.holder, 'b');
|
|
60
|
+
});
|
|
61
|
+
test('refresh extends a live lease', async () => {
|
|
62
|
+
const leases = await create();
|
|
63
|
+
const a = await leases.acquire('stage', 'a', SHORT);
|
|
64
|
+
const refreshed = await leases.refresh(a, HOUR);
|
|
65
|
+
assert.equal(refreshed?.token, a.token);
|
|
66
|
+
await wait(2 * SHORT);
|
|
67
|
+
assert.equal(await leases.acquire('stage', 'b', HOUR), null);
|
|
68
|
+
});
|
|
69
|
+
test('concurrent acquires admit exactly one holder', async () => {
|
|
70
|
+
const leases = await create();
|
|
71
|
+
const results = await Promise.all(['a', 'b', 'c', 'd', 'e'].map((holder) => leases.acquire('stage', holder, HOUR)));
|
|
72
|
+
assert.equal(results.filter(Boolean).length, 1);
|
|
73
|
+
});
|
|
74
|
+
test('holdLease hands the body its lease and frees the key', async () => {
|
|
75
|
+
const leases = await create();
|
|
76
|
+
let seen;
|
|
77
|
+
const result = await holdLease(leases, 'stage', async (lease) => {
|
|
78
|
+
seen = lease;
|
|
79
|
+
return 42;
|
|
80
|
+
});
|
|
81
|
+
assert.equal(result, 42);
|
|
82
|
+
assert.equal(seen?.key, 'stage');
|
|
83
|
+
assert.equal(await leases.get('stage'), null);
|
|
84
|
+
});
|
|
85
|
+
test('holdLease refuses a taken key', async () => {
|
|
86
|
+
const leases = await create();
|
|
87
|
+
await leases.acquire('stage', 'a', HOUR);
|
|
88
|
+
await assert.rejects(holdLease(leases, 'stage', async () => { }), (err) => err.name === 'LeaseTakenError');
|
|
89
|
+
});
|
|
90
|
+
test('holdLease frees the key when the body throws', async () => {
|
|
91
|
+
const leases = await create();
|
|
92
|
+
await assert.rejects(holdLease(leases, 'stage', async () => {
|
|
93
|
+
throw new Error('boom');
|
|
94
|
+
}), /boom/);
|
|
95
|
+
assert.equal(await leases.get('stage'), null);
|
|
96
|
+
});
|
|
97
|
+
test('holdLease refuses to vouch for a body that outlived its lease', async () => {
|
|
98
|
+
const leases = await create();
|
|
99
|
+
await assert.rejects(holdLease(leases, 'stage', async (lease) => {
|
|
100
|
+
await leases.release(lease);
|
|
101
|
+
assert.ok(await leases.acquire('stage', 'other', HOUR));
|
|
102
|
+
await wait(RENEWED);
|
|
103
|
+
return 'unprotected';
|
|
104
|
+
}, RENEWED), (err) => err.name === 'LeaseLostError');
|
|
105
|
+
assert.equal((await leases.get('stage'))?.holder, 'other');
|
|
106
|
+
});
|
|
107
|
+
test('holdLease notices a lease lost after its last renewal', async () => {
|
|
108
|
+
const leases = await create();
|
|
109
|
+
await assert.rejects(holdLease(leases, 'stage', async (lease) => {
|
|
110
|
+
await leases.release(lease);
|
|
111
|
+
assert.ok(await leases.acquire('stage', 'other', HOUR));
|
|
112
|
+
return 'finished before any renewal ran';
|
|
113
|
+
}), (err) => err.name === 'LeaseLostError');
|
|
114
|
+
assert.equal((await leases.get('stage'))?.holder, 'other');
|
|
115
|
+
});
|
|
116
|
+
test('holdLease aborts the body once its lease is lost', async () => {
|
|
117
|
+
const leases = await create();
|
|
118
|
+
let aborted = false;
|
|
119
|
+
await assert.rejects(holdLease(leases, 'stage', async (lease, signal) => {
|
|
120
|
+
await leases.release(lease);
|
|
121
|
+
assert.ok(await leases.acquire('stage', 'other', HOUR));
|
|
122
|
+
await new Promise((resolve) => {
|
|
123
|
+
signal.addEventListener('abort', () => resolve());
|
|
124
|
+
setTimeout(resolve, 5 * RENEWED);
|
|
125
|
+
});
|
|
126
|
+
aborted = signal.aborted;
|
|
127
|
+
}, RENEWED), (err) => err.name === 'LeaseLostError');
|
|
128
|
+
assert.equal(aborted, true, 'the body was never told its lease was gone');
|
|
129
|
+
});
|
|
130
|
+
test('holdLease keeps the lease alive past its ttl', async () => {
|
|
131
|
+
const leases = await create();
|
|
132
|
+
await holdLease(leases, 'stage', async () => {
|
|
133
|
+
await wait(2 * RENEWED);
|
|
134
|
+
assert.ok(await leases.get('stage'), 'the lease lapsed mid-body');
|
|
135
|
+
}, RENEWED);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PikkuWorkflowService } from '../../wirings/workflow/pikku-workflow-service.js';
|
|
2
|
+
/** A workflow service under test, and the means to kill the worker holding a step. */
|
|
3
|
+
export type WorkflowFencingHarness = {
|
|
4
|
+
service: PikkuWorkflowService;
|
|
5
|
+
/**
|
|
6
|
+
* Make the step's lease read as lapsed, as it would once its worker died, on
|
|
7
|
+
* whichever clock the store judges leases by.
|
|
8
|
+
*/
|
|
9
|
+
lapseLease: (runId: string, stepName: string) => Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Conformance suite for a workflow service that fences a step's writes to the
|
|
13
|
+
* claim that made them. Each test gets a fresh harness.
|
|
14
|
+
*
|
|
15
|
+
* Every scenario has a stale attempt — claimed, then its lease lapsed and the
|
|
16
|
+
* step claimed again — and the attempt that replaced it.
|
|
17
|
+
*/
|
|
18
|
+
export declare const defineWorkflowFencingTests: (name: string, create: () => Promise<WorkflowFencingHarness>) => void;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { RPCNotFoundError } from '../../wirings/rpc/rpc-runner.js';
|
|
4
|
+
import { pikkuState } from '../../pikku-state.js';
|
|
5
|
+
import { ConsoleLogger } from '../../services/logger-console.js';
|
|
6
|
+
import { LogLevel } from '../../services/logger.js';
|
|
7
|
+
const STEP = 'charge';
|
|
8
|
+
const RPC = 'charge:card';
|
|
9
|
+
const LEASE_MS = 60_000;
|
|
10
|
+
const RACES = 25;
|
|
11
|
+
const createSilentLogger = () => {
|
|
12
|
+
const logger = new ConsoleLogger();
|
|
13
|
+
logger.setLevel(LogLevel.critical);
|
|
14
|
+
return logger;
|
|
15
|
+
};
|
|
16
|
+
const superseded = (e) => e.name === 'WorkflowStepSupersededError';
|
|
17
|
+
/**
|
|
18
|
+
* Conformance suite for a workflow service that fences a step's writes to the
|
|
19
|
+
* claim that made them. Each test gets a fresh harness.
|
|
20
|
+
*
|
|
21
|
+
* Every scenario has a stale attempt — claimed, then its lease lapsed and the
|
|
22
|
+
* step claimed again — and the attempt that replaced it.
|
|
23
|
+
*/
|
|
24
|
+
export const defineWorkflowFencingTests = (name, create) => {
|
|
25
|
+
describe(`WorkflowService fencing [${name}]`, () => {
|
|
26
|
+
const claim = ({ service }, runId) => service['claimStepForExecution'](runId, STEP, RPC, LEASE_MS);
|
|
27
|
+
const startRun = async ({ service }) => {
|
|
28
|
+
const runId = await service.createRun('fenced', {}, false, 'hash', {
|
|
29
|
+
type: 'test',
|
|
30
|
+
});
|
|
31
|
+
await service.insertStepState(runId, STEP, RPC, {}, { retries: 5 });
|
|
32
|
+
return runId;
|
|
33
|
+
};
|
|
34
|
+
/** Claim the step, lose its worker, and claim it again. */
|
|
35
|
+
const supersede = async (h, runId) => {
|
|
36
|
+
const stale = await claim(h, runId);
|
|
37
|
+
assert.ok(stale, 'the first dispatch did not get the step');
|
|
38
|
+
await h.lapseLease(runId, STEP);
|
|
39
|
+
const current = await claim(h, runId);
|
|
40
|
+
assert.ok(current, 'the lapsed step was not claimed again');
|
|
41
|
+
assert.equal(current.attemptCount, stale.attemptCount + 1);
|
|
42
|
+
return { stale, current };
|
|
43
|
+
};
|
|
44
|
+
const stepOf = (h, runId) => h.service.getStepState(runId, STEP);
|
|
45
|
+
const leaseOf = async (h, runId) => (await stepOf(h, runId)).leaseExpiresAt?.getTime();
|
|
46
|
+
/** Runs `fn` with the engine's singletons quiet, then puts back what was there. */
|
|
47
|
+
const quietly = async (fn) => {
|
|
48
|
+
const previous = pikkuState(null, 'package', 'singletonServices');
|
|
49
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
50
|
+
...previous,
|
|
51
|
+
logger: createSilentLogger(),
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
return await fn();
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
pikkuState(null, 'package', 'singletonServices', previous);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Dispatch the step through the engine, with an RPC that loses the step to
|
|
62
|
+
* a newer claim while it runs and then does `outcome`.
|
|
63
|
+
*/
|
|
64
|
+
const dispatchStale = (h, runId, outcome) => quietly(async () => {
|
|
65
|
+
let current = null;
|
|
66
|
+
const staleWorker = {
|
|
67
|
+
rpcWithWire: async () => {
|
|
68
|
+
await h.lapseLease(runId, STEP);
|
|
69
|
+
current = await claim(h, runId);
|
|
70
|
+
return outcome();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const surfaced = await h.service
|
|
74
|
+
.executeWorkflowStep(runId, STEP, RPC, {}, staleWorker)
|
|
75
|
+
.then(() => undefined, (e) => e);
|
|
76
|
+
assert.ok(current, 'the step was never claimed from under the worker');
|
|
77
|
+
return { surfaced, current: current };
|
|
78
|
+
});
|
|
79
|
+
test("a stale attempt's result is refused and the current attempt's lands", async () => {
|
|
80
|
+
const h = await create();
|
|
81
|
+
const runId = await startRun(h);
|
|
82
|
+
const { stale, current } = await supersede(h, runId);
|
|
83
|
+
await assert.rejects(h.service.setStepResult(stale.stepId, 'stale', stale.attemptCount), superseded);
|
|
84
|
+
await h.service.setStepResult(current.stepId, 'fresh', current.attemptCount);
|
|
85
|
+
const step = await stepOf(h, runId);
|
|
86
|
+
assert.equal(step.status, 'succeeded');
|
|
87
|
+
assert.equal(step.result, 'fresh');
|
|
88
|
+
assert.equal(step.attemptCount, current.attemptCount);
|
|
89
|
+
const history = (await h.service.getRunHistory(runId)).filter((attempt) => attempt.stepName === STEP);
|
|
90
|
+
assert.equal(history.length, 2, 'each claim keeps its own attempt');
|
|
91
|
+
assert.equal(history[0].status, 'running', 'the stale attempt was recorded with an outcome it was refused');
|
|
92
|
+
assert.notEqual(history[0].result, 'stale');
|
|
93
|
+
assert.equal(history[1].status, 'succeeded');
|
|
94
|
+
assert.equal(history[1].result, 'fresh');
|
|
95
|
+
});
|
|
96
|
+
test("a stale attempt's error is refused and does not fail the step", async () => {
|
|
97
|
+
const h = await create();
|
|
98
|
+
const runId = await startRun(h);
|
|
99
|
+
const { stale, current } = await supersede(h, runId);
|
|
100
|
+
await assert.rejects(h.service.setStepError(stale.stepId, new Error('stale'), stale.attemptCount), superseded);
|
|
101
|
+
const step = await stepOf(h, runId);
|
|
102
|
+
assert.equal(step.status, 'running', 'the stale attempt failed the step');
|
|
103
|
+
assert.equal(step.error, undefined);
|
|
104
|
+
assert.equal(step.attemptCount, current.attemptCount);
|
|
105
|
+
});
|
|
106
|
+
test('a stale attempt cannot renew the lease, nor move the one that replaced it', async () => {
|
|
107
|
+
const h = await create();
|
|
108
|
+
const runId = await startRun(h);
|
|
109
|
+
const { stale, current } = await supersede(h, runId);
|
|
110
|
+
const held = await leaseOf(h, runId);
|
|
111
|
+
assert.equal(await h.service.refreshStepLease(stale.stepId, 10 * LEASE_MS, stale.attemptCount), false, 'the stale attempt was told it still holds the step');
|
|
112
|
+
assert.equal(await leaseOf(h, runId), held, 'the stale renewal landed');
|
|
113
|
+
assert.equal(await h.service.refreshStepLease(stale.stepId, null, stale.attemptCount), false);
|
|
114
|
+
assert.equal(await leaseOf(h, runId), held, 'the stale release landed');
|
|
115
|
+
assert.equal(await h.service.refreshStepLease(current.stepId, 10 * LEASE_MS, current.attemptCount), true);
|
|
116
|
+
});
|
|
117
|
+
test('a stale attempt cannot overwrite a step the current attempt already succeeded', async () => {
|
|
118
|
+
const h = await create();
|
|
119
|
+
const runId = await startRun(h);
|
|
120
|
+
const { stale, current } = await supersede(h, runId);
|
|
121
|
+
await h.service.setStepResult(current.stepId, 'fresh', current.attemptCount);
|
|
122
|
+
await assert.rejects(h.service.setStepResult(stale.stepId, 'stale', stale.attemptCount), superseded);
|
|
123
|
+
await assert.rejects(h.service.setStepError(stale.stepId, new Error('stale'), stale.attemptCount), superseded);
|
|
124
|
+
const step = await stepOf(h, runId);
|
|
125
|
+
assert.equal(step.status, 'succeeded');
|
|
126
|
+
assert.equal(step.result, 'fresh');
|
|
127
|
+
assert.equal(step.error, undefined);
|
|
128
|
+
});
|
|
129
|
+
test('a stale attempt cannot overwrite a step the current attempt already failed', async () => {
|
|
130
|
+
const h = await create();
|
|
131
|
+
const runId = await startRun(h);
|
|
132
|
+
const { stale, current } = await supersede(h, runId);
|
|
133
|
+
await h.service.setStepError(current.stepId, new Error('fresh'), current.attemptCount);
|
|
134
|
+
await assert.rejects(h.service.setStepResult(stale.stepId, 'stale', stale.attemptCount), superseded);
|
|
135
|
+
const step = await stepOf(h, runId);
|
|
136
|
+
assert.equal(step.status, 'failed');
|
|
137
|
+
assert.equal(step.error?.message, 'fresh');
|
|
138
|
+
assert.equal(step.result, undefined);
|
|
139
|
+
});
|
|
140
|
+
test('when both attempts write at once, only the current one ever lands', async () => {
|
|
141
|
+
const h = await create();
|
|
142
|
+
for (let i = 0; i < RACES; i++) {
|
|
143
|
+
const runId = await startRun(h);
|
|
144
|
+
const { stale, current } = await supersede(h, runId);
|
|
145
|
+
const staleWrite = () => i % 2 === 0
|
|
146
|
+
? h.service.setStepResult(stale.stepId, 'stale', stale.attemptCount)
|
|
147
|
+
: h.service.setStepError(stale.stepId, new Error('stale'), stale.attemptCount);
|
|
148
|
+
const currentWrite = () => h.service.setStepResult(current.stepId, 'fresh', current.attemptCount);
|
|
149
|
+
const [staleOutcome, currentOutcome] = i % 3 === 0
|
|
150
|
+
? (await Promise.allSettled([currentWrite(), staleWrite()])).reverse()
|
|
151
|
+
: await Promise.allSettled([staleWrite(), currentWrite()]);
|
|
152
|
+
assert.equal(staleOutcome.status, 'rejected', `race ${i}`);
|
|
153
|
+
assert.ok(superseded(staleOutcome.reason), `race ${i}: ${staleOutcome.reason}`);
|
|
154
|
+
assert.equal(currentOutcome.status, 'fulfilled', `race ${i}`);
|
|
155
|
+
const step = await stepOf(h, runId);
|
|
156
|
+
assert.equal(step.status, 'succeeded', `race ${i}`);
|
|
157
|
+
assert.equal(step.result, 'fresh', `race ${i}`);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
test('a third claim fences out both attempts before it', async () => {
|
|
161
|
+
const h = await create();
|
|
162
|
+
const runId = await startRun(h);
|
|
163
|
+
const { stale: first, current: second } = await supersede(h, runId);
|
|
164
|
+
await h.lapseLease(runId, STEP);
|
|
165
|
+
const third = await claim(h, runId);
|
|
166
|
+
assert.ok(third);
|
|
167
|
+
assert.equal(third.attemptCount, second.attemptCount + 1);
|
|
168
|
+
const held = await leaseOf(h, runId);
|
|
169
|
+
for (const stale of [first, second]) {
|
|
170
|
+
await assert.rejects(h.service.setStepResult(stale.stepId, 'stale', stale.attemptCount), superseded, `attempt ${stale.attemptCount} recorded a result`);
|
|
171
|
+
await assert.rejects(h.service.setStepError(stale.stepId, new Error('stale'), stale.attemptCount), superseded, `attempt ${stale.attemptCount} recorded an error`);
|
|
172
|
+
assert.equal(await h.service.refreshStepLease(stale.stepId, 10 * LEASE_MS, stale.attemptCount), false, `attempt ${stale.attemptCount} renewed the lease`);
|
|
173
|
+
}
|
|
174
|
+
assert.equal(await leaseOf(h, runId), held);
|
|
175
|
+
await h.service.setStepResult(third.stepId, 'third', third.attemptCount);
|
|
176
|
+
const step = await stepOf(h, runId);
|
|
177
|
+
assert.equal(step.status, 'succeeded');
|
|
178
|
+
assert.equal(step.result, 'third');
|
|
179
|
+
});
|
|
180
|
+
test('a superseded dispatch whose RPC is missing neither fails the step nor suspends the run', async () => {
|
|
181
|
+
const h = await create();
|
|
182
|
+
const runId = await startRun(h);
|
|
183
|
+
const { surfaced, current } = await dispatchStale(h, runId, () => {
|
|
184
|
+
throw new RPCNotFoundError(RPC);
|
|
185
|
+
});
|
|
186
|
+
const step = await stepOf(h, runId);
|
|
187
|
+
assert.equal(step.status, 'running', 'the stale attempt failed the step');
|
|
188
|
+
assert.equal(step.attemptCount, current.attemptCount);
|
|
189
|
+
assert.equal((await h.service.getRun(runId))?.status, 'running', 'the stale attempt suspended the run');
|
|
190
|
+
assert.equal(surfaced, undefined, 'a superseded dispatch ends quietly');
|
|
191
|
+
});
|
|
192
|
+
test('a superseded dispatch whose RPC throws ends quietly and leaves the step to its new owner', async () => {
|
|
193
|
+
const h = await create();
|
|
194
|
+
const runId = await startRun(h);
|
|
195
|
+
const { surfaced, current } = await dispatchStale(h, runId, () => {
|
|
196
|
+
throw new Error('the card was declined');
|
|
197
|
+
});
|
|
198
|
+
assert.equal(surfaced, undefined, 'the stale failure surfaced');
|
|
199
|
+
const step = await stepOf(h, runId);
|
|
200
|
+
assert.equal(step.status, 'running', 'the stale attempt failed the step');
|
|
201
|
+
assert.equal(step.error, undefined);
|
|
202
|
+
assert.equal(step.attemptCount, current.attemptCount);
|
|
203
|
+
assert.equal((await h.service.getRun(runId))?.status, 'running');
|
|
204
|
+
});
|
|
205
|
+
test('a superseded dispatch whose RPC succeeds does not record its result', async () => {
|
|
206
|
+
const h = await create();
|
|
207
|
+
const runId = await startRun(h);
|
|
208
|
+
const { surfaced, current } = await dispatchStale(h, runId, () => 'stale');
|
|
209
|
+
assert.equal(surfaced, undefined);
|
|
210
|
+
const step = await stepOf(h, runId);
|
|
211
|
+
assert.equal(step.status, 'running', 'the stale result landed');
|
|
212
|
+
assert.equal(step.result, undefined);
|
|
213
|
+
assert.equal(step.attemptCount, current.attemptCount);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
};
|
|
@@ -9,6 +9,8 @@ import type { SecretService } from '../services/secret-service.js';
|
|
|
9
9
|
import type { CredentialService } from '../services/credential-service.js';
|
|
10
10
|
import type { AgentRunService } from '../wirings/agent/agent.types.js';
|
|
11
11
|
import type { SessionStore } from '../services/session-store.js';
|
|
12
|
+
import type { LeaseService } from '../services/lease-service.js';
|
|
13
|
+
import type { WorkflowFencingHarness } from './service-tests/workflow-fencing-tests.js';
|
|
12
14
|
export interface ServiceTestConfig {
|
|
13
15
|
name: string;
|
|
14
16
|
services: {
|
|
@@ -36,6 +38,9 @@ export interface ServiceTestConfig {
|
|
|
36
38
|
rotateKEK?(): Promise<number>;
|
|
37
39
|
}>;
|
|
38
40
|
sessionStore?: () => Promise<SessionStore>;
|
|
41
|
+
leaseService?: () => Promise<LeaseService>;
|
|
42
|
+
/** A workflow service that fences each step write to the claim that made it. */
|
|
43
|
+
workflowFencing?: () => Promise<WorkflowFencingHarness>;
|
|
39
44
|
};
|
|
40
45
|
}
|
|
41
46
|
/**
|
|
@@ -8,6 +8,8 @@ import { defineSecretServiceTests } from './service-tests/secret-service-tests.j
|
|
|
8
8
|
import { defineCredentialServiceTests } from './service-tests/credential-service-tests.js';
|
|
9
9
|
import { defineAgentRunServiceTests } from './service-tests/agent-run-service-tests.js';
|
|
10
10
|
import { defineSessionStoreTests } from './service-tests/session-store-tests.js';
|
|
11
|
+
import { defineLeaseServiceTests } from './service-tests/lease-service-tests.js';
|
|
12
|
+
import { defineWorkflowFencingTests } from './service-tests/workflow-fencing-tests.js';
|
|
11
13
|
/**
|
|
12
14
|
* The shared conformance suite every storage backend runs.
|
|
13
15
|
*
|
|
@@ -46,4 +48,10 @@ export function defineServiceTests(config) {
|
|
|
46
48
|
if (services.sessionStore) {
|
|
47
49
|
defineSessionStoreTests(name, services.sessionStore);
|
|
48
50
|
}
|
|
51
|
+
if (services.leaseService) {
|
|
52
|
+
defineLeaseServiceTests(name, services.leaseService);
|
|
53
|
+
}
|
|
54
|
+
if (services.workflowFencing) {
|
|
55
|
+
defineWorkflowFencingTests(name, services.workflowFencing);
|
|
56
|
+
}
|
|
49
57
|
}
|