@pikku/kysely 0.13.31 → 0.13.33
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 +69 -0
- package/README.md +12 -2
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +2 -1
- package/dist/src/kysely-lease-clock.d.ts +14 -0
- package/dist/src/kysely-lease-clock.js +14 -0
- package/dist/src/kysely-lease-service.d.ts +37 -0
- package/dist/src/kysely-lease-service.js +116 -0
- package/dist/src/kysely-tables.d.ts +16 -0
- package/dist/src/kysely-workflow-service.d.ts +31 -20
- package/dist/src/kysely-workflow-service.js +192 -102
- package/dist/src/schema/index.d.ts +1 -0
- package/dist/src/schema/index.js +3 -0
- package/dist/src/schema/lease.schema.d.ts +2 -0
- package/dist/src/schema/lease.schema.js +12 -0
- package/dist/src/schema/workflow.schema.js +1 -0
- package/knowledge/decisions/a-lease-is-taken-by-one-guarded-update.md +30 -0
- package/knowledge/decisions/index.md +3 -0
- package/knowledge/decisions/leases-are-judged-by-the-database-clock.md +42 -0
- package/knowledge/decisions/workflows-own-the-lease-table-too.md +22 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,72 @@
|
|
|
1
|
+
## 0.13.33
|
|
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
|
+
- a16b0ba: Give a running workflow step a lease, so a step whose worker dies can be claimed again
|
|
14
|
+
|
|
15
|
+
A step moving from `pending` to `running` only proves it was dispatched; it says
|
|
16
|
+
nothing about whether the worker holding it is still alive. A worker that died
|
|
17
|
+
mid-step therefore left the step wedged in `running` forever: unclaimable by the
|
|
18
|
+
next dispatch, and invisible to the stalled-run sweep.
|
|
19
|
+
|
|
20
|
+
The dispatch that claims a step now stamps a lease on it, refreshes that lease on
|
|
21
|
+
an interval for as long as it is working, and releases it when the step parks on
|
|
22
|
+
a child run. A step whose lease has lapsed is claimable again, and counts as
|
|
23
|
+
in-flight no longer, so the stalled sweep can pick its run back up. A step that
|
|
24
|
+
keeps losing its worker fails with `WorkflowStepLeaseExpiredError` once its
|
|
25
|
+
attempts run out, rather than looping.
|
|
26
|
+
|
|
27
|
+
The lease duration is taken from the queue the step is dispatched through, so the
|
|
28
|
+
lease and the queue lock never disagree about who owns the job.
|
|
29
|
+
|
|
30
|
+
The refresh runs at half the lease and never later, even when that is under the
|
|
31
|
+
one-second floor the interval otherwise respects: applying that floor as a
|
|
32
|
+
maximum would renew a shorter lease for the first time after it had already
|
|
33
|
+
lapsed, which is the double-claim the lease exists to prevent. A lease that
|
|
34
|
+
short is logged once, pointing at the queue's `lockDuration`.
|
|
35
|
+
|
|
36
|
+
The lease is written and judged on the database's clock, never the worker's,
|
|
37
|
+
so a worker whose clock runs fast cannot claim a step another worker still
|
|
38
|
+
holds. A dispatch whose step has since been claimed by another stops renewing,
|
|
39
|
+
and says so: `refreshStepLease(stepId, leaseMs, attempt)` resolves `false` once
|
|
40
|
+
the step is no longer that attempt's.
|
|
41
|
+
|
|
42
|
+
The lease lives in a new `workflow_step.lease_expires_at` column, in epoch
|
|
43
|
+
milliseconds (`bigint`). Run `pikku db generate` and `pikku db migrate` after
|
|
44
|
+
upgrading: the runtime does not add columns, and every step read selects this
|
|
45
|
+
one.
|
|
46
|
+
|
|
47
|
+
- 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? })`.
|
|
48
|
+
- Updated dependencies [a16b0ba]
|
|
49
|
+
- Updated dependencies [21e9c3a]
|
|
50
|
+
- Updated dependencies [a16b0ba]
|
|
51
|
+
- Updated dependencies [a16b0ba]
|
|
52
|
+
- @pikku/core@0.12.133
|
|
53
|
+
|
|
54
|
+
## 0.13.32
|
|
55
|
+
|
|
56
|
+
### Patch Changes
|
|
57
|
+
|
|
58
|
+
- 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.
|
|
59
|
+
|
|
60
|
+
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`.
|
|
61
|
+
|
|
62
|
+
`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.
|
|
63
|
+
|
|
64
|
+
- Updated dependencies [d327fa5]
|
|
65
|
+
- Updated dependencies [942ebdd]
|
|
66
|
+
- Updated dependencies [8ac25a8]
|
|
67
|
+
- Updated dependencies [6606777]
|
|
68
|
+
- @pikku/core@0.12.132
|
|
69
|
+
|
|
1
70
|
## 0.13.31
|
|
2
71
|
|
|
3
72
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -15,16 +15,26 @@ npm install @pikku/kysely kysely
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
17
|
```typescript
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
KyselyLeaseService,
|
|
20
|
+
KyselySecretService,
|
|
21
|
+
KyselyWorkflowService,
|
|
22
|
+
} from '@pikku/kysely'
|
|
19
23
|
import type { KyselyPikkuDB } from '@pikku/kysely'
|
|
20
24
|
import type { Kysely } from 'kysely'
|
|
21
25
|
|
|
22
26
|
declare const db: Kysely<KyselyPikkuDB>
|
|
23
27
|
|
|
24
|
-
const
|
|
28
|
+
const leaseService = new KyselyLeaseService(db)
|
|
29
|
+
await leaseService.init()
|
|
30
|
+
|
|
31
|
+
const workflowService = new KyselyWorkflowService(db, { leaseService })
|
|
25
32
|
const secretService = new KyselySecretService(db, { key: encryptionKey })
|
|
26
33
|
```
|
|
27
34
|
|
|
35
|
+
The workflow service locks runs and steps on the `leaseService` you give it;
|
|
36
|
+
register the same instance as the app's `leaseService`.
|
|
37
|
+
|
|
28
38
|
## Docs
|
|
29
39
|
|
|
30
40
|
https://pikku.dev/docs
|
package/dist/src/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { KyselyFeatureFlagStore } from './kysely-feature-flag-store.js';
|
|
|
15
15
|
export type { KyselyFeatureFlagStoreOptions } from './kysely-feature-flag-store.js';
|
|
16
16
|
export { KyselyWebhookService } from './kysely-webhook-service.js';
|
|
17
17
|
export { KyselyTriggerSourceStore } from './kysely-trigger-source-store.js';
|
|
18
|
+
export { KyselyLeaseService } from './kysely-lease-service.js';
|
|
18
19
|
export { KyselyIncomingWebhookService } from './kysely-incoming-webhook-service.js';
|
|
19
20
|
export { createAuditedKysely, type CreateAuditedKyselyOptions, } from './create-audited-kysely.js';
|
|
20
21
|
export { KyselyAuditService } from './kysely-audit-service.js';
|
|
@@ -26,7 +27,7 @@ export { SerializePlugin, BaseSerializePlugin, type Serializer, type Deserialize
|
|
|
26
27
|
export { createCoercionPlugin, type ColumnKind, type CoercionMap, type CreateCoercionPluginOptions, } from './coercion-plugin.js';
|
|
27
28
|
export { createClassificationPlugin, type CreateClassificationPluginOptions, } from './classification-plugin.js';
|
|
28
29
|
export { ClassificationCrypto, DEFAULT_KEY_ID, isColumnEnvelope, parseColumnEnvelope, type ClassificationCryptoOptions, type ColumnEnvelope, type KEKResolver, type ResolvedKEK, } from './classification-crypto.js';
|
|
29
|
-
export { agentSchema, analyticsSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, flagSchema, incomingWebhookSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, type PikkuSchema, type RequiredTypes, type UnmetRequirement, type SchemaStatementFactory, } from './schema/index.js';
|
|
30
|
+
export { agentSchema, analyticsSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, flagSchema, incomingWebhookSchema, leaseSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, type PikkuSchema, type RequiredTypes, type UnmetRequirement, type SchemaStatementFactory, } from './schema/index.js';
|
|
30
31
|
export type { KyselyPikkuDB } from './kysely-tables.js';
|
|
31
32
|
export type { WorkflowRunService } from '@pikku/core/workflow';
|
|
32
33
|
export type { AgentRunService, AgentRunRow } from '@pikku/core/agent';
|
package/dist/src/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export { KyselyScopeService } from './kysely-scope-service.js';
|
|
|
14
14
|
export { KyselyFeatureFlagStore } from './kysely-feature-flag-store.js';
|
|
15
15
|
export { KyselyWebhookService } from './kysely-webhook-service.js';
|
|
16
16
|
export { KyselyTriggerSourceStore } from './kysely-trigger-source-store.js';
|
|
17
|
+
export { KyselyLeaseService } from './kysely-lease-service.js';
|
|
17
18
|
export { KyselyIncomingWebhookService } from './kysely-incoming-webhook-service.js';
|
|
18
19
|
export { createAuditedKysely, } from './create-audited-kysely.js';
|
|
19
20
|
export { KyselyAuditService } from './kysely-audit-service.js';
|
|
@@ -28,4 +29,4 @@ export { SerializePlugin, BaseSerializePlugin, } from './serialize-plugin.js';
|
|
|
28
29
|
export { createCoercionPlugin, } from './coercion-plugin.js';
|
|
29
30
|
export { createClassificationPlugin, } from './classification-plugin.js';
|
|
30
31
|
export { ClassificationCrypto, DEFAULT_KEY_ID, isColumnEnvelope, parseColumnEnvelope, } from './classification-crypto.js';
|
|
31
|
-
export { agentSchema, analyticsSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, flagSchema, incomingWebhookSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, } from './schema/index.js';
|
|
32
|
+
export { agentSchema, analyticsSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, flagSchema, incomingWebhookSchema, leaseSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, } from './schema/index.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type RawBuilder } from 'kysely';
|
|
2
|
+
/**
|
|
3
|
+
* Now, in epoch milliseconds, as a SQL expression: the clock every lease in
|
|
4
|
+
* this package is granted and judged on.
|
|
5
|
+
*
|
|
6
|
+
* A lease is compared by whichever worker reads it next, so judging it on each
|
|
7
|
+
* worker's own clock lets a worker running ahead take a lease that is still
|
|
8
|
+
* held. The dialect services read the database's clock instead, which every
|
|
9
|
+
* worker shares. This default binds the process clock, for SQLite, where the
|
|
10
|
+
* database lives in the process anyway.
|
|
11
|
+
*/
|
|
12
|
+
export declare const appNowMs: () => RawBuilder<number>;
|
|
13
|
+
/** `now + ms`, for a lease that runs `ms` from the clock's now. */
|
|
14
|
+
export declare const leaseUntil: (now: RawBuilder<number>, ms: number) => RawBuilder<number>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
/**
|
|
3
|
+
* Now, in epoch milliseconds, as a SQL expression: the clock every lease in
|
|
4
|
+
* this package is granted and judged on.
|
|
5
|
+
*
|
|
6
|
+
* A lease is compared by whichever worker reads it next, so judging it on each
|
|
7
|
+
* worker's own clock lets a worker running ahead take a lease that is still
|
|
8
|
+
* held. The dialect services read the database's clock instead, which every
|
|
9
|
+
* worker shares. This default binds the process clock, for SQLite, where the
|
|
10
|
+
* database lives in the process anyway.
|
|
11
|
+
*/
|
|
12
|
+
export const appNowMs = () => sql `cast(${Date.now()} as bigint)`;
|
|
13
|
+
/** `now + ms`, for a lease that runs `ms` from the clock's now. */
|
|
14
|
+
export const leaseUntil = (now, ms) => sql `${now} + ${ms}`;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Lease, LeaseService } from '@pikku/core/services';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
import type { KyselyPikkuDB } from './kysely-tables.js';
|
|
4
|
+
/**
|
|
5
|
+
* Leases on the `pikku_lease` table.
|
|
6
|
+
*
|
|
7
|
+
* Use it directly on SQLite. On PostgreSQL and MySQL use
|
|
8
|
+
* `PgKyselyLeaseService` and `MySQLKyselyLeaseService`, which judge every lease
|
|
9
|
+
* on the database's clock rather than on each worker's own.
|
|
10
|
+
*
|
|
11
|
+
* Every statement is one MySQL can run too — no `RETURNING`, no conditional
|
|
12
|
+
* upsert — so the dialects differ only in `nowMs` and `insertIfAbsent`.
|
|
13
|
+
*/
|
|
14
|
+
export declare class KyselyLeaseService implements LeaseService {
|
|
15
|
+
protected db: Kysely<KyselyPikkuDB>;
|
|
16
|
+
private initialized;
|
|
17
|
+
constructor(db: Kysely<KyselyPikkuDB>);
|
|
18
|
+
init(): Promise<void>;
|
|
19
|
+
/** Now, in epoch milliseconds, on the clock leases are judged by. */
|
|
20
|
+
protected nowMs(): import("kysely").RawBuilder<number>;
|
|
21
|
+
/** Create the key's row, lapsed, unless it already has one. */
|
|
22
|
+
protected insertIfAbsent(key: string, holder: string): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* The row is created lapsed and then taken by the same guarded `UPDATE` as
|
|
25
|
+
* any other lapsed lease, so there is one path to holding a key. `token` is
|
|
26
|
+
* assigned first: MySQL evaluates `SET` left to right against the values
|
|
27
|
+
* already assigned, and the holder test must see the previous holder.
|
|
28
|
+
*
|
|
29
|
+
* Whether the update won is read back rather than taken from its row count,
|
|
30
|
+
* because MySQL counts rows changed, not rows matched.
|
|
31
|
+
*/
|
|
32
|
+
acquire(key: string, holder: string, ttlMs: number): Promise<Lease | null>;
|
|
33
|
+
refresh(lease: Lease, ttlMs: number): Promise<Lease | null>;
|
|
34
|
+
release(lease: Lease): Promise<void>;
|
|
35
|
+
get(key: string): Promise<Lease | null>;
|
|
36
|
+
private readHeld;
|
|
37
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { appNowMs, leaseUntil } from './kysely-lease-clock.js';
|
|
2
|
+
import { requirePikkuSchema } from './schema/index.js';
|
|
3
|
+
import { leaseSchema } from './schema/lease.schema.js';
|
|
4
|
+
const toLease = (row) => ({
|
|
5
|
+
key: row.key,
|
|
6
|
+
holder: row.holder,
|
|
7
|
+
token: Number(row.token),
|
|
8
|
+
expiresAt: new Date(Number(row.expiresAt)),
|
|
9
|
+
});
|
|
10
|
+
/**
|
|
11
|
+
* Leases on the `pikku_lease` table.
|
|
12
|
+
*
|
|
13
|
+
* Use it directly on SQLite. On PostgreSQL and MySQL use
|
|
14
|
+
* `PgKyselyLeaseService` and `MySQLKyselyLeaseService`, which judge every lease
|
|
15
|
+
* on the database's clock rather than on each worker's own.
|
|
16
|
+
*
|
|
17
|
+
* Every statement is one MySQL can run too — no `RETURNING`, no conditional
|
|
18
|
+
* upsert — so the dialects differ only in `nowMs` and `insertIfAbsent`.
|
|
19
|
+
*/
|
|
20
|
+
export class KyselyLeaseService {
|
|
21
|
+
db;
|
|
22
|
+
initialized = false;
|
|
23
|
+
constructor(db) {
|
|
24
|
+
this.db = db;
|
|
25
|
+
}
|
|
26
|
+
async init() {
|
|
27
|
+
if (this.initialized)
|
|
28
|
+
return;
|
|
29
|
+
await requirePikkuSchema(this.db, leaseSchema);
|
|
30
|
+
this.initialized = true;
|
|
31
|
+
}
|
|
32
|
+
/** Now, in epoch milliseconds, on the clock leases are judged by. */
|
|
33
|
+
nowMs() {
|
|
34
|
+
return appNowMs();
|
|
35
|
+
}
|
|
36
|
+
/** Create the key's row, lapsed, unless it already has one. */
|
|
37
|
+
async insertIfAbsent(key, holder) {
|
|
38
|
+
await this.db
|
|
39
|
+
.insertInto('pikkuLease')
|
|
40
|
+
.values({ key, holder, token: 0, expiresAt: 0 })
|
|
41
|
+
.onConflict((oc) => oc.column('key').doNothing())
|
|
42
|
+
.execute();
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The row is created lapsed and then taken by the same guarded `UPDATE` as
|
|
46
|
+
* any other lapsed lease, so there is one path to holding a key. `token` is
|
|
47
|
+
* assigned first: MySQL evaluates `SET` left to right against the values
|
|
48
|
+
* already assigned, and the holder test must see the previous holder.
|
|
49
|
+
*
|
|
50
|
+
* Whether the update won is read back rather than taken from its row count,
|
|
51
|
+
* because MySQL counts rows changed, not rows matched.
|
|
52
|
+
*/
|
|
53
|
+
async acquire(key, holder, ttlMs) {
|
|
54
|
+
await this.insertIfAbsent(key, holder);
|
|
55
|
+
const now = this.nowMs();
|
|
56
|
+
await this.db
|
|
57
|
+
.updateTable('pikkuLease')
|
|
58
|
+
.set((eb) => ({
|
|
59
|
+
token: eb
|
|
60
|
+
.case()
|
|
61
|
+
.when(eb.and([eb('holder', '=', holder), eb('expiresAt', '>', now)]))
|
|
62
|
+
.then(eb.ref('token'))
|
|
63
|
+
.else(eb('token', '+', 1))
|
|
64
|
+
.end(),
|
|
65
|
+
holder,
|
|
66
|
+
expiresAt: leaseUntil(now, ttlMs),
|
|
67
|
+
}))
|
|
68
|
+
.where('key', '=', key)
|
|
69
|
+
.where((eb) => eb.or([eb('holder', '=', holder), eb('expiresAt', '<=', now)]))
|
|
70
|
+
.execute();
|
|
71
|
+
return this.readHeld(key, holder);
|
|
72
|
+
}
|
|
73
|
+
async refresh(lease, ttlMs) {
|
|
74
|
+
const now = this.nowMs();
|
|
75
|
+
await this.db
|
|
76
|
+
.updateTable('pikkuLease')
|
|
77
|
+
.set({ expiresAt: leaseUntil(now, ttlMs) })
|
|
78
|
+
.where('key', '=', lease.key)
|
|
79
|
+
.where('holder', '=', lease.holder)
|
|
80
|
+
.where('token', '=', lease.token)
|
|
81
|
+
.where('expiresAt', '>', now)
|
|
82
|
+
.execute();
|
|
83
|
+
return this.readHeld(lease.key, lease.holder, lease.token);
|
|
84
|
+
}
|
|
85
|
+
async release(lease) {
|
|
86
|
+
await this.db
|
|
87
|
+
.updateTable('pikkuLease')
|
|
88
|
+
.set({ expiresAt: 0 })
|
|
89
|
+
.where('key', '=', lease.key)
|
|
90
|
+
.where('holder', '=', lease.holder)
|
|
91
|
+
.where('token', '=', lease.token)
|
|
92
|
+
.execute();
|
|
93
|
+
}
|
|
94
|
+
async get(key) {
|
|
95
|
+
const row = await this.db
|
|
96
|
+
.selectFrom('pikkuLease')
|
|
97
|
+
.selectAll()
|
|
98
|
+
.where('key', '=', key)
|
|
99
|
+
.where('expiresAt', '>', this.nowMs())
|
|
100
|
+
.executeTakeFirst();
|
|
101
|
+
return row ? toLease(row) : null;
|
|
102
|
+
}
|
|
103
|
+
async readHeld(key, holder, token) {
|
|
104
|
+
let query = this.db
|
|
105
|
+
.selectFrom('pikkuLease')
|
|
106
|
+
.selectAll()
|
|
107
|
+
.where('key', '=', key)
|
|
108
|
+
.where('holder', '=', holder)
|
|
109
|
+
.where('expiresAt', '>', this.nowMs());
|
|
110
|
+
if (token !== undefined) {
|
|
111
|
+
query = query.where('token', '=', token);
|
|
112
|
+
}
|
|
113
|
+
const row = await query.executeTakeFirst();
|
|
114
|
+
return row ? toLease(row) : null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -46,6 +46,13 @@ export interface WorkflowStepTable {
|
|
|
46
46
|
fromStepName: string | null;
|
|
47
47
|
/** The history attempt currently in flight; see WorkflowStepHistoryTable.attempt. */
|
|
48
48
|
currentAttempt: number | null;
|
|
49
|
+
/**
|
|
50
|
+
* When the dispatch holding a `running` step stops owning it. Null is a step
|
|
51
|
+
* nothing is expected to hand back — one parked on a child run, or written
|
|
52
|
+
* before it was ever claimed. Epoch milliseconds, on the clock leases are
|
|
53
|
+
* judged by; see `appNowMs`.
|
|
54
|
+
*/
|
|
55
|
+
leaseExpiresAt: number | null;
|
|
49
56
|
createdAt: Generated<Date>;
|
|
50
57
|
updatedAt: Generated<Date>;
|
|
51
58
|
}
|
|
@@ -250,6 +257,14 @@ export interface PikkuTriggerSourceTable {
|
|
|
250
257
|
detail: string | null;
|
|
251
258
|
updatedAt: Generated<Date>;
|
|
252
259
|
}
|
|
260
|
+
/** A named lease, current or last. */
|
|
261
|
+
export interface PikkuLeaseTable {
|
|
262
|
+
key: string;
|
|
263
|
+
holder: string;
|
|
264
|
+
token: number;
|
|
265
|
+
/** Epoch milliseconds on the clock leases are judged by; see `appNowMs`. */
|
|
266
|
+
expiresAt: number;
|
|
267
|
+
}
|
|
253
268
|
/** One subject's short-circuit, in either direction, over a flag's rollout. */
|
|
254
269
|
export interface PikkuFeatureFlagOverridesTable {
|
|
255
270
|
flag: string;
|
|
@@ -436,6 +451,7 @@ export interface KyselyPikkuDB {
|
|
|
436
451
|
pikkuFeatureFlags: PikkuFeatureFlagsTable;
|
|
437
452
|
pikkuFeatureFlagOverrides: PikkuFeatureFlagOverridesTable;
|
|
438
453
|
pikkuTriggerSource: PikkuTriggerSourceTable;
|
|
454
|
+
pikkuLease: PikkuLeaseTable;
|
|
439
455
|
channels: ChannelsTable;
|
|
440
456
|
channelSubscriptions: ChannelSubscriptionsTable;
|
|
441
457
|
workflowRuns: WorkflowRunsTable;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { SerializedError } from '@pikku/core/errors';
|
|
2
2
|
import { PikkuWorkflowService } from '@pikku/core/workflow';
|
|
3
|
-
import type { WorkflowPlannedStep,
|
|
3
|
+
import type { WorkflowPlannedStep, WorkflowServiceOptions, WorkflowRun, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus } from '@pikku/core/workflow';
|
|
4
4
|
import { type Kysely } from 'kysely';
|
|
5
5
|
import type { KyselyPikkuDB } from './kysely-tables.js';
|
|
6
6
|
export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
7
7
|
protected db: Kysely<KyselyPikkuDB>;
|
|
8
8
|
private initialized;
|
|
9
9
|
private runService;
|
|
10
|
-
constructor(db: Kysely<KyselyPikkuDB>, options
|
|
10
|
+
constructor(db: Kysely<KyselyPikkuDB>, options: WorkflowServiceOptions);
|
|
11
11
|
init(): Promise<void>;
|
|
12
12
|
protected createRunImpl(workflowName: string, input: any, inline: boolean, graphHash: string, wire: WorkflowRunWire, options?: {
|
|
13
13
|
deterministic?: boolean;
|
|
@@ -27,6 +27,9 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
27
27
|
stepName: string;
|
|
28
28
|
}>>;
|
|
29
29
|
protected setStepRunningImpl(stepId: string): Promise<void>;
|
|
30
|
+
refreshStepLease(stepId: string, leaseMs: number | null, attempt?: number): Promise<boolean>;
|
|
31
|
+
/** Now, in epoch milliseconds, on the clock step leases are judged by. */
|
|
32
|
+
protected nowMs(): import("kysely").RawBuilder<number>;
|
|
30
33
|
protected setStepScheduledImpl(stepId: string): Promise<void>;
|
|
31
34
|
/**
|
|
32
35
|
* Move a step and its current history attempt to the same status in one
|
|
@@ -43,21 +46,10 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
43
46
|
private insertHistoryRecord;
|
|
44
47
|
private getTimestampFieldForStatus;
|
|
45
48
|
protected setStepChildRunIdImpl(stepId: string, childRunId: string): Promise<void>;
|
|
46
|
-
protected setStepResultImpl(stepId: string, result: any): Promise<void>;
|
|
47
|
-
protected setStepErrorImpl(stepId: string, error: Error): Promise<void>;
|
|
49
|
+
protected setStepResultImpl(stepId: string, result: any, attempt?: number): Promise<void>;
|
|
50
|
+
protected setStepErrorImpl(stepId: string, error: Error, attempt?: number): Promise<void>;
|
|
48
51
|
protected createRetryAttemptImpl(stepId: string, status: 'pending' | 'running'): Promise<StepState>;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* A pass-through, and deliberately so: the one decision that must exclude —
|
|
52
|
-
* claiming a step to execute it — is made by `claimStepForExecution` below as
|
|
53
|
-
* a single conditional write, which needs no lock to be exclusive.
|
|
54
|
-
*
|
|
55
|
-
* What is left are the suspend and approval sections in the engine, where the
|
|
56
|
-
* lock is genuine mutual exclusion rather than a claim. A dialect with a real
|
|
57
|
-
* primitive overrides this to cover them — `kysely-postgres` with
|
|
58
|
-
* `pg_advisory_xact_lock`, `kysely-mysql` with `GET_LOCK`.
|
|
59
|
-
*/
|
|
60
|
-
withStepLock<T>(_runId: string, _stepName: string, fn: () => Promise<T>): Promise<T>;
|
|
52
|
+
private readStepAttempt;
|
|
61
53
|
/**
|
|
62
54
|
* Claim the step with a status-guarded `UPDATE` and read the affected-row
|
|
63
55
|
* count: the database decides the winner in one statement, so two dispatches
|
|
@@ -70,11 +62,29 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
70
62
|
*
|
|
71
63
|
* The winner then goes through the ordinary transition methods, so history
|
|
72
64
|
* rows and the mirror see exactly what they saw before.
|
|
65
|
+
*
|
|
66
|
+
* A `running` step is taken back only once its lease has lapsed, and the
|
|
67
|
+
* lease is part of the same guarded `UPDATE` — checking it here and writing
|
|
68
|
+
* it afterwards would let two dispatches read one lapsed lease and both
|
|
69
|
+
* proceed. Whether it has lapsed is the database's call, on its own clock:
|
|
70
|
+
* a live lease simply matches no row.
|
|
73
71
|
*/
|
|
74
|
-
protected claimStepForExecution(runId: string, stepName: string, rpcName: string): Promise<StepState | null>;
|
|
72
|
+
protected claimStepForExecution(runId: string, stepName: string, rpcName: string, leaseMs: number): Promise<StepState | null>;
|
|
75
73
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* Take back a failed or lapsed step as a new attempt. The attempt number is
|
|
75
|
+
* advanced by the same guarded `UPDATE` that grants the lease, so the worker
|
|
76
|
+
* it is taken from is fenced out of its step at once — deferring that to
|
|
77
|
+
* `createRetryAttempt` would leave a window in which its result still lands,
|
|
78
|
+
* only to be cleared and the step run again.
|
|
79
|
+
*/
|
|
80
|
+
private reclaimStepAttempt;
|
|
81
|
+
/**
|
|
82
|
+
* Move a step to `running` under a fresh lease, only if it is still in one of
|
|
83
|
+
* `from`, reporting whether this caller is the one that moved it.
|
|
84
|
+
*
|
|
85
|
+
* Claiming back a `running` step additionally requires its lease to have
|
|
86
|
+
* lapsed. A null lease never matches, which is what keeps a step parked on a
|
|
87
|
+
* child run — running, with no worker on it by design — out of reach.
|
|
78
88
|
*/
|
|
79
89
|
private claimStepStatus;
|
|
80
90
|
getCompletedGraphState(runId: string): Promise<{
|
|
@@ -86,6 +96,7 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
86
96
|
stepName: string;
|
|
87
97
|
status: StepStatus;
|
|
88
98
|
fromStepName?: string;
|
|
99
|
+
leaseExpiresAt?: Date;
|
|
89
100
|
}>>;
|
|
90
101
|
getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
|
|
91
102
|
protected setBranchTakenImpl(stepId: string, branchKey: string): Promise<void>;
|
|
@@ -113,7 +124,7 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
113
124
|
* The undispatched-step query, shared by every dialect.
|
|
114
125
|
*
|
|
115
126
|
* It used to be deliberately NOT an override, because this class's
|
|
116
|
-
* `withStepLock`
|
|
127
|
+
* `withStepLock` was a pass-through: a subclass inheriting it (kysely-sqlite)
|
|
117
128
|
* had no atomic claim, so the relay's redundant dispatches would have become
|
|
118
129
|
* double executions, and only `kysely-postgres` and `kysely-mysql` opted in
|
|
119
130
|
* on the strength of their real locks. `claimStepForExecution` no longer
|
|
@@ -1,14 +1,52 @@
|
|
|
1
|
-
import { PikkuWorkflowService, WorkflowStepFunctionMismatchError, } from '@pikku/core/workflow';
|
|
1
|
+
import { PikkuWorkflowService, WorkflowStepFunctionMismatchError, WorkflowStepLeaseExpiredError, WorkflowStepSupersededError, leaseAttemptsExhausted, } from '@pikku/core/workflow';
|
|
2
2
|
import { sql } from 'kysely';
|
|
3
|
+
import { appNowMs, leaseUntil } from './kysely-lease-clock.js';
|
|
3
4
|
import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js';
|
|
4
5
|
import { parseJson } from './kysely-json.js';
|
|
5
6
|
import { requirePikkuSchema } from './schema/index.js';
|
|
6
7
|
import { workflowSchema } from './schema/workflow.schema.js';
|
|
8
|
+
/**
|
|
9
|
+
* What a step read returns, one list for the single read and the whole-run
|
|
10
|
+
* read alike, so the replay snapshot cannot drift from `getStepState`.
|
|
11
|
+
*/
|
|
12
|
+
const STEP_STATE_COLUMNS = [
|
|
13
|
+
'workflowStepId',
|
|
14
|
+
'status',
|
|
15
|
+
'rpcName',
|
|
16
|
+
'result',
|
|
17
|
+
'error',
|
|
18
|
+
'retries',
|
|
19
|
+
'retryDelay',
|
|
20
|
+
'fromStepName',
|
|
21
|
+
// `current_attempt` is bumped alongside every history insert, so it is
|
|
22
|
+
// already the count this used to aggregate — and reading it keeps the
|
|
23
|
+
// engine's hottest query off a table that grows for the life of the run.
|
|
24
|
+
'currentAttempt',
|
|
25
|
+
'leaseExpiresAt',
|
|
26
|
+
'createdAt',
|
|
27
|
+
'updatedAt',
|
|
28
|
+
];
|
|
29
|
+
const toStepState = (row) => ({
|
|
30
|
+
stepId: row.workflowStepId,
|
|
31
|
+
status: row.status,
|
|
32
|
+
rpcName: row.rpcName ?? null,
|
|
33
|
+
result: parseJson(row.result),
|
|
34
|
+
error: parseJson(row.error),
|
|
35
|
+
attemptCount: Number(row.currentAttempt ?? 1),
|
|
36
|
+
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
37
|
+
retryDelay: row.retryDelay ?? undefined,
|
|
38
|
+
fromStepName: row.fromStepName ?? undefined,
|
|
39
|
+
leaseExpiresAt: row.leaseExpiresAt != null
|
|
40
|
+
? new Date(Number(row.leaseExpiresAt))
|
|
41
|
+
: undefined,
|
|
42
|
+
createdAt: new Date(row.createdAt),
|
|
43
|
+
updatedAt: new Date(row.updatedAt),
|
|
44
|
+
});
|
|
7
45
|
export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
8
46
|
db;
|
|
9
47
|
initialized = false;
|
|
10
48
|
runService;
|
|
11
|
-
constructor(db, options
|
|
49
|
+
constructor(db, options) {
|
|
12
50
|
super(options);
|
|
13
51
|
this.db = db;
|
|
14
52
|
this.runService = new KyselyWorkflowRunService(db);
|
|
@@ -92,73 +130,22 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
92
130
|
async getStepState(runId, stepName) {
|
|
93
131
|
const row = await this.db
|
|
94
132
|
.selectFrom('workflowStep')
|
|
95
|
-
.select(
|
|
96
|
-
'workflowStepId',
|
|
97
|
-
'status',
|
|
98
|
-
'rpcName',
|
|
99
|
-
'result',
|
|
100
|
-
'error',
|
|
101
|
-
'retries',
|
|
102
|
-
'retryDelay',
|
|
103
|
-
'fromStepName',
|
|
104
|
-
// `current_attempt` is bumped alongside every history insert, so it is
|
|
105
|
-
// already the count this used to aggregate — and reading it keeps the
|
|
106
|
-
// engine's hottest query off a table that grows for the life of the run.
|
|
107
|
-
'currentAttempt',
|
|
108
|
-
'createdAt',
|
|
109
|
-
'updatedAt',
|
|
110
|
-
])
|
|
133
|
+
.select(STEP_STATE_COLUMNS)
|
|
111
134
|
.where('workflowRunId', '=', runId)
|
|
112
135
|
.where('stepName', '=', stepName)
|
|
113
136
|
.executeTakeFirst();
|
|
114
137
|
if (!row) {
|
|
115
138
|
throw new Error(`Step not found: runId=${runId}, stepName=${stepName}. Use insertStepState to create it.`);
|
|
116
139
|
}
|
|
117
|
-
return
|
|
118
|
-
stepId: row.workflowStepId,
|
|
119
|
-
status: row.status,
|
|
120
|
-
rpcName: row.rpcName ?? null,
|
|
121
|
-
result: parseJson(row.result),
|
|
122
|
-
error: parseJson(row.error),
|
|
123
|
-
attemptCount: Number(row.currentAttempt ?? 1),
|
|
124
|
-
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
125
|
-
retryDelay: row.retryDelay ?? undefined,
|
|
126
|
-
fromStepName: row.fromStepName ?? undefined,
|
|
127
|
-
createdAt: new Date(row.createdAt),
|
|
128
|
-
updatedAt: new Date(row.updatedAt),
|
|
129
|
-
};
|
|
140
|
+
return toStepState(row);
|
|
130
141
|
}
|
|
131
142
|
async listStepStates(runId) {
|
|
132
143
|
const rows = await this.db
|
|
133
144
|
.selectFrom('workflowStep')
|
|
134
|
-
.select([
|
|
135
|
-
'workflowStepId',
|
|
136
|
-
'stepName',
|
|
137
|
-
'status',
|
|
138
|
-
'result',
|
|
139
|
-
'error',
|
|
140
|
-
'retries',
|
|
141
|
-
'retryDelay',
|
|
142
|
-
'fromStepName',
|
|
143
|
-
'currentAttempt',
|
|
144
|
-
'createdAt',
|
|
145
|
-
'updatedAt',
|
|
146
|
-
])
|
|
145
|
+
.select([...STEP_STATE_COLUMNS, 'stepName'])
|
|
147
146
|
.where('workflowRunId', '=', runId)
|
|
148
147
|
.execute();
|
|
149
|
-
return rows.map((row) => ({
|
|
150
|
-
stepId: row.workflowStepId,
|
|
151
|
-
stepName: row.stepName,
|
|
152
|
-
status: row.status,
|
|
153
|
-
result: parseJson(row.result),
|
|
154
|
-
error: parseJson(row.error),
|
|
155
|
-
attemptCount: Number(row.currentAttempt ?? 1),
|
|
156
|
-
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
157
|
-
retryDelay: row.retryDelay ?? undefined,
|
|
158
|
-
fromStepName: row.fromStepName ?? undefined,
|
|
159
|
-
createdAt: new Date(row.createdAt),
|
|
160
|
-
updatedAt: new Date(row.updatedAt),
|
|
161
|
-
}));
|
|
148
|
+
return rows.map((row) => ({ ...toStepState(row), stepName: row.stepName }));
|
|
162
149
|
}
|
|
163
150
|
async getRunHistory(runId) {
|
|
164
151
|
return this.runService.getRunHistory(runId);
|
|
@@ -168,6 +155,24 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
168
155
|
runningAt: new Date(),
|
|
169
156
|
});
|
|
170
157
|
}
|
|
158
|
+
async refreshStepLease(stepId, leaseMs, attempt) {
|
|
159
|
+
let query = this.db
|
|
160
|
+
.updateTable('workflowStep')
|
|
161
|
+
.set({
|
|
162
|
+
leaseExpiresAt: leaseMs === null ? null : leaseUntil(this.nowMs(), leaseMs),
|
|
163
|
+
updatedAt: new Date(),
|
|
164
|
+
})
|
|
165
|
+
.where('workflowStepId', '=', stepId);
|
|
166
|
+
if (attempt !== undefined) {
|
|
167
|
+
query = query.where(sql `coalesce(current_attempt, 1)`, '=', attempt);
|
|
168
|
+
}
|
|
169
|
+
const renewed = await query.executeTakeFirst();
|
|
170
|
+
return Number(renewed?.numUpdatedRows ?? 0n) > 0;
|
|
171
|
+
}
|
|
172
|
+
/** Now, in epoch milliseconds, on the clock step leases are judged by. */
|
|
173
|
+
nowMs() {
|
|
174
|
+
return appNowMs();
|
|
175
|
+
}
|
|
171
176
|
async setStepScheduledImpl(stepId) {
|
|
172
177
|
await this.db
|
|
173
178
|
.updateTable('workflowStep')
|
|
@@ -186,7 +191,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
186
191
|
* over `created_at`, so a retry that lands in the same millisecond as the
|
|
187
192
|
* attempt it replaces still resolves the newer row.
|
|
188
193
|
*/
|
|
189
|
-
async writeStepTransition(stepId, status, historyValues) {
|
|
194
|
+
async writeStepTransition(stepId, status, historyValues, attempt) {
|
|
190
195
|
const now = new Date();
|
|
191
196
|
const stepValues = { status, updatedAt: now };
|
|
192
197
|
if ('result' in historyValues)
|
|
@@ -199,7 +204,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
199
204
|
if (status === 'failed')
|
|
200
205
|
stepValues.result = null;
|
|
201
206
|
await this.db.transaction().execute(async (trx) => {
|
|
202
|
-
|
|
207
|
+
let step = trx
|
|
203
208
|
.updateTable('workflowStep')
|
|
204
209
|
.set({
|
|
205
210
|
...stepValues,
|
|
@@ -209,8 +214,17 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
209
214
|
// MySQL would reject against the table being updated.
|
|
210
215
|
currentAttempt: sql `coalesce(current_attempt, 1)`,
|
|
211
216
|
})
|
|
212
|
-
.where('workflowStepId', '=', stepId)
|
|
213
|
-
|
|
217
|
+
.where('workflowStepId', '=', stepId);
|
|
218
|
+
// Fenced to the attempt that made the write, so a dispatch whose step
|
|
219
|
+
// was claimed again after its lease lapsed cannot record over the newer
|
|
220
|
+
// attempt. Throwing rolls the transaction back.
|
|
221
|
+
if (attempt !== undefined) {
|
|
222
|
+
step = step.where(sql `coalesce(current_attempt, 1)`, '=', attempt);
|
|
223
|
+
}
|
|
224
|
+
const moved = await step.executeTakeFirst();
|
|
225
|
+
if (attempt !== undefined && Number(moved?.numUpdatedRows ?? 0n) === 0) {
|
|
226
|
+
throw new WorkflowStepSupersededError(stepId, attempt);
|
|
227
|
+
}
|
|
214
228
|
const written = await trx
|
|
215
229
|
.updateTable('workflowStepHistory')
|
|
216
230
|
.set({ status, ...historyValues })
|
|
@@ -247,7 +261,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
247
261
|
}
|
|
248
262
|
});
|
|
249
263
|
}
|
|
250
|
-
async insertHistoryRecord(stepId, status, attempt, result, error) {
|
|
264
|
+
async insertHistoryRecord(stepId, status, attempt, result, error, db = this.db) {
|
|
251
265
|
const now = new Date();
|
|
252
266
|
const values = {
|
|
253
267
|
historyId: crypto.randomUUID(),
|
|
@@ -262,7 +276,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
262
276
|
if (timestampField !== 'createdAt') {
|
|
263
277
|
values[timestampField] = now;
|
|
264
278
|
}
|
|
265
|
-
await
|
|
279
|
+
await db
|
|
266
280
|
.insertInto('workflowStepHistory')
|
|
267
281
|
.values(values)
|
|
268
282
|
.execute();
|
|
@@ -291,13 +305,13 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
291
305
|
.where('workflowStepId', '=', stepId)
|
|
292
306
|
.execute();
|
|
293
307
|
}
|
|
294
|
-
async setStepResultImpl(stepId, result) {
|
|
308
|
+
async setStepResultImpl(stepId, result, attempt) {
|
|
295
309
|
await this.writeStepTransition(stepId, 'succeeded', {
|
|
296
310
|
result: JSON.stringify(result),
|
|
297
311
|
succeededAt: new Date(),
|
|
298
|
-
});
|
|
312
|
+
}, attempt);
|
|
299
313
|
}
|
|
300
|
-
async setStepErrorImpl(stepId, error) {
|
|
314
|
+
async setStepErrorImpl(stepId, error, attempt) {
|
|
301
315
|
const serializedError = {
|
|
302
316
|
message: error.message,
|
|
303
317
|
stack: error.stack,
|
|
@@ -306,7 +320,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
306
320
|
await this.writeStepTransition(stepId, 'failed', {
|
|
307
321
|
error: JSON.stringify(serializedError),
|
|
308
322
|
failedAt: new Date(),
|
|
309
|
-
});
|
|
323
|
+
}, attempt);
|
|
310
324
|
}
|
|
311
325
|
async createRetryAttemptImpl(stepId, status) {
|
|
312
326
|
// Read before writing: the new attempt number comes from the history this
|
|
@@ -329,6 +343,9 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
329
343
|
.where('workflowStepId', '=', stepId)
|
|
330
344
|
.execute();
|
|
331
345
|
await this.insertHistoryRecord(stepId, status, attempt);
|
|
346
|
+
return this.readStepAttempt(stepId);
|
|
347
|
+
}
|
|
348
|
+
async readStepAttempt(stepId) {
|
|
332
349
|
const row = await this.db
|
|
333
350
|
.selectFrom('workflowStep')
|
|
334
351
|
.select([
|
|
@@ -360,22 +377,6 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
360
377
|
updatedAt: new Date(row.updatedAt),
|
|
361
378
|
};
|
|
362
379
|
}
|
|
363
|
-
async withRunLock(_id, fn) {
|
|
364
|
-
return fn();
|
|
365
|
-
}
|
|
366
|
-
/**
|
|
367
|
-
* A pass-through, and deliberately so: the one decision that must exclude —
|
|
368
|
-
* claiming a step to execute it — is made by `claimStepForExecution` below as
|
|
369
|
-
* a single conditional write, which needs no lock to be exclusive.
|
|
370
|
-
*
|
|
371
|
-
* What is left are the suspend and approval sections in the engine, where the
|
|
372
|
-
* lock is genuine mutual exclusion rather than a claim. A dialect with a real
|
|
373
|
-
* primitive overrides this to cover them — `kysely-postgres` with
|
|
374
|
-
* `pg_advisory_xact_lock`, `kysely-mysql` with `GET_LOCK`.
|
|
375
|
-
*/
|
|
376
|
-
async withStepLock(_runId, _stepName, fn) {
|
|
377
|
-
return fn();
|
|
378
|
-
}
|
|
379
380
|
/**
|
|
380
381
|
* Claim the step with a status-guarded `UPDATE` and read the affected-row
|
|
381
382
|
* count: the database decides the winner in one statement, so two dispatches
|
|
@@ -388,28 +389,56 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
388
389
|
*
|
|
389
390
|
* The winner then goes through the ordinary transition methods, so history
|
|
390
391
|
* rows and the mirror see exactly what they saw before.
|
|
392
|
+
*
|
|
393
|
+
* A `running` step is taken back only once its lease has lapsed, and the
|
|
394
|
+
* lease is part of the same guarded `UPDATE` — checking it here and writing
|
|
395
|
+
* it afterwards would let two dispatches read one lapsed lease and both
|
|
396
|
+
* proceed. Whether it has lapsed is the database's call, on its own clock:
|
|
397
|
+
* a live lease simply matches no row.
|
|
391
398
|
*/
|
|
392
|
-
async claimStepForExecution(runId, stepName, rpcName) {
|
|
399
|
+
async claimStepForExecution(runId, stepName, rpcName, leaseMs) {
|
|
393
400
|
const stepState = await this.getStepState(runId, stepName);
|
|
394
401
|
// knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
|
|
395
402
|
if (stepState.rpcName !== undefined &&
|
|
396
403
|
stepState.rpcName !== (rpcName ?? null)) {
|
|
397
404
|
throw new WorkflowStepFunctionMismatchError(runId, stepName);
|
|
398
405
|
}
|
|
399
|
-
if (stepState.status === 'succeeded'
|
|
406
|
+
if (stepState.status === 'succeeded') {
|
|
400
407
|
return null;
|
|
401
408
|
}
|
|
402
|
-
if (stepState.status === '
|
|
403
|
-
if (
|
|
409
|
+
if (stepState.status === 'running') {
|
|
410
|
+
if (leaseAttemptsExhausted(stepState)) {
|
|
411
|
+
// Fail it only if its lease is still lapsed at the moment of writing:
|
|
412
|
+
// a worker that renewed since the read above still owns the step.
|
|
413
|
+
if (!(await this.claimStepStatus(stepState.stepId, ['running'], leaseMs))) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
await this.setStepError(stepState.stepId, new WorkflowStepLeaseExpiredError(runId, stepName, stepState.attemptCount), stepState.attemptCount);
|
|
417
|
+
// `executeWorkflowStepInner` reads a null claim as "another dispatch
|
|
418
|
+
// owns it" and returns without resuming, so the run would stay
|
|
419
|
+
// `running` until a stalled sweep. Requeue the orchestrator here.
|
|
420
|
+
await this.resumeWorkflow(runId);
|
|
404
421
|
return null;
|
|
405
422
|
}
|
|
406
|
-
|
|
423
|
+
}
|
|
424
|
+
// A failed step that has spent every attempt is settled, however it failed;
|
|
425
|
+
// a redelivered message must not buy it another one.
|
|
426
|
+
if (stepState.status === 'failed' && leaseAttemptsExhausted(stepState)) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
if (stepState.status === 'failed' || stepState.status === 'running') {
|
|
430
|
+
const attempt = stepState.attemptCount + 1;
|
|
431
|
+
return this.mirrored(() => this.reclaimStepAttempt(stepState, attempt, leaseMs), async (mirror, newStep) => {
|
|
432
|
+
if (newStep) {
|
|
433
|
+
await mirror.createRetryAttempt(stepState.stepId, {
|
|
434
|
+
...newStep,
|
|
435
|
+
stepName,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
});
|
|
407
439
|
}
|
|
408
440
|
if (stepState.status === 'pending' || stepState.status === 'scheduled') {
|
|
409
|
-
if (!(await this.claimStepStatus(stepState.stepId, [
|
|
410
|
-
'pending',
|
|
411
|
-
'scheduled',
|
|
412
|
-
]))) {
|
|
441
|
+
if (!(await this.claimStepStatus(stepState.stepId, ['pending', 'scheduled'], leaseMs))) {
|
|
413
442
|
return null;
|
|
414
443
|
}
|
|
415
444
|
await this.setStepRunning(stepState.stepId);
|
|
@@ -418,16 +447,62 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
418
447
|
return stepState;
|
|
419
448
|
}
|
|
420
449
|
/**
|
|
421
|
-
*
|
|
422
|
-
*
|
|
450
|
+
* Take back a failed or lapsed step as a new attempt. The attempt number is
|
|
451
|
+
* advanced by the same guarded `UPDATE` that grants the lease, so the worker
|
|
452
|
+
* it is taken from is fenced out of its step at once — deferring that to
|
|
453
|
+
* `createRetryAttempt` would leave a window in which its result still lands,
|
|
454
|
+
* only to be cleared and the step run again.
|
|
455
|
+
*/
|
|
456
|
+
async reclaimStepAttempt(stepState, attempt, leaseMs) {
|
|
457
|
+
const now = this.nowMs();
|
|
458
|
+
const claimed = await this.db.transaction().execute(async (trx) => {
|
|
459
|
+
let query = trx
|
|
460
|
+
.updateTable('workflowStep')
|
|
461
|
+
.set({
|
|
462
|
+
status: 'running',
|
|
463
|
+
result: null,
|
|
464
|
+
error: null,
|
|
465
|
+
currentAttempt: attempt,
|
|
466
|
+
leaseExpiresAt: leaseUntil(now, leaseMs),
|
|
467
|
+
updatedAt: new Date(),
|
|
468
|
+
})
|
|
469
|
+
.where('workflowStepId', '=', stepState.stepId)
|
|
470
|
+
.where('status', '=', stepState.status)
|
|
471
|
+
.where(sql `coalesce(current_attempt, 1)`, '=', stepState.attemptCount);
|
|
472
|
+
if (stepState.status === 'running') {
|
|
473
|
+
query = query.where('leaseExpiresAt', '<', now);
|
|
474
|
+
}
|
|
475
|
+
const moved = await query.executeTakeFirst();
|
|
476
|
+
if (Number(moved?.numUpdatedRows ?? 0n) === 0)
|
|
477
|
+
return false;
|
|
478
|
+
await this.insertHistoryRecord(stepState.stepId, 'running', attempt, undefined, undefined, trx);
|
|
479
|
+
return true;
|
|
480
|
+
});
|
|
481
|
+
return claimed ? this.readStepAttempt(stepState.stepId) : null;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Move a step to `running` under a fresh lease, only if it is still in one of
|
|
485
|
+
* `from`, reporting whether this caller is the one that moved it.
|
|
486
|
+
*
|
|
487
|
+
* Claiming back a `running` step additionally requires its lease to have
|
|
488
|
+
* lapsed. A null lease never matches, which is what keeps a step parked on a
|
|
489
|
+
* child run — running, with no worker on it by design — out of reach.
|
|
423
490
|
*/
|
|
424
|
-
async claimStepStatus(stepId, from) {
|
|
425
|
-
const
|
|
491
|
+
async claimStepStatus(stepId, from, leaseMs) {
|
|
492
|
+
const now = this.nowMs();
|
|
493
|
+
let query = this.db
|
|
426
494
|
.updateTable('workflowStep')
|
|
427
|
-
.set({
|
|
495
|
+
.set({
|
|
496
|
+
status: 'running',
|
|
497
|
+
leaseExpiresAt: leaseUntil(now, leaseMs),
|
|
498
|
+
updatedAt: new Date(),
|
|
499
|
+
})
|
|
428
500
|
.where('workflowStepId', '=', stepId)
|
|
429
|
-
.where('status', 'in', from)
|
|
430
|
-
|
|
501
|
+
.where('status', 'in', from);
|
|
502
|
+
if (from.includes('running')) {
|
|
503
|
+
query = query.where('leaseExpiresAt', '<', now);
|
|
504
|
+
}
|
|
505
|
+
const claimed = await query.executeTakeFirst();
|
|
431
506
|
return Number(claimed?.numUpdatedRows ?? 0n) > 0;
|
|
432
507
|
}
|
|
433
508
|
async getCompletedGraphState(runId) {
|
|
@@ -465,13 +540,16 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
465
540
|
async getStepInstances(runId) {
|
|
466
541
|
const rows = await this.db
|
|
467
542
|
.selectFrom('workflowStep')
|
|
468
|
-
.select(['stepName', 'status', 'fromStepName'])
|
|
543
|
+
.select(['stepName', 'status', 'fromStepName', 'leaseExpiresAt'])
|
|
469
544
|
.where('workflowRunId', '=', runId)
|
|
470
545
|
.execute();
|
|
471
546
|
return rows.map((r) => ({
|
|
472
547
|
stepName: r.stepName,
|
|
473
548
|
status: r.status,
|
|
474
549
|
fromStepName: r.fromStepName ?? undefined,
|
|
550
|
+
leaseExpiresAt: r.leaseExpiresAt != null
|
|
551
|
+
? new Date(Number(r.leaseExpiresAt))
|
|
552
|
+
: undefined,
|
|
475
553
|
}));
|
|
476
554
|
}
|
|
477
555
|
async getNodeResults(runId, nodeIds) {
|
|
@@ -537,7 +615,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
537
615
|
* The undispatched-step query, shared by every dialect.
|
|
538
616
|
*
|
|
539
617
|
* It used to be deliberately NOT an override, because this class's
|
|
540
|
-
* `withStepLock`
|
|
618
|
+
* `withStepLock` was a pass-through: a subclass inheriting it (kysely-sqlite)
|
|
541
619
|
* had no atomic claim, so the relay's redundant dispatches would have become
|
|
542
620
|
* double executions, and only `kysely-postgres` and `kysely-mysql` opted in
|
|
543
621
|
* on the strength of their real locks. `claimStepForExecution` no longer
|
|
@@ -571,7 +649,19 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
571
649
|
.where(({ not, exists, selectFrom }) => not(exists(selectFrom('workflowStep as s')
|
|
572
650
|
.select('s.workflowStepId')
|
|
573
651
|
.whereRef('s.workflowRunId', '=', 'r.workflowRunId')
|
|
574
|
-
.where(
|
|
652
|
+
.where((eb) => eb.or([
|
|
653
|
+
eb('s.status', 'in', ['scheduled', 'suspended']),
|
|
654
|
+
// A `running` step counts as in flight only while someone
|
|
655
|
+
// still holds it. A lapsed lease is a dispatch that died, and
|
|
656
|
+
// a null one a step nothing was expected to hand back.
|
|
657
|
+
eb.and([
|
|
658
|
+
eb('s.status', '=', 'running'),
|
|
659
|
+
eb.or([
|
|
660
|
+
eb('s.leaseExpiresAt', 'is', null),
|
|
661
|
+
eb('s.leaseExpiresAt', '>', this.nowMs()),
|
|
662
|
+
]),
|
|
663
|
+
]),
|
|
664
|
+
])))))
|
|
575
665
|
// The run row only moves on a status change, so idleness has to hold for
|
|
576
666
|
// the steps too — expressed as "no step touched since `before`" rather
|
|
577
667
|
// than max(updated_at) so it stays one index-friendly NOT EXISTS.
|
|
@@ -24,6 +24,7 @@ export { channelSchema } from './channel.schema.js';
|
|
|
24
24
|
export { credentialSchema } from './credential.schema.js';
|
|
25
25
|
export { deploymentSchema } from './deployment.schema.js';
|
|
26
26
|
export { flagSchema } from './flag.schema.js';
|
|
27
|
+
export { leaseSchema } from './lease.schema.js';
|
|
27
28
|
export { triggerSourceSchema } from './trigger-source.schema.js';
|
|
28
29
|
export { incomingWebhookSchema } from './incoming-webhook.schema.js';
|
|
29
30
|
export { scopeSchema } from './scope.schema.js';
|
package/dist/src/schema/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { scopeSchema } from './scope.schema.js';
|
|
|
10
10
|
import { flagSchema } from './flag.schema.js';
|
|
11
11
|
import { triggerSourceSchema } from './trigger-source.schema.js';
|
|
12
12
|
import { incomingWebhookSchema } from './incoming-webhook.schema.js';
|
|
13
|
+
import { leaseSchema } from './lease.schema.js';
|
|
13
14
|
import { secretSchema } from './secret.schema.js';
|
|
14
15
|
import { sessionSchema } from './session.schema.js';
|
|
15
16
|
import { webhookSchema } from './webhook.schema.js';
|
|
@@ -44,6 +45,7 @@ export const pikkuSchemas = [
|
|
|
44
45
|
scopeSchema,
|
|
45
46
|
flagSchema,
|
|
46
47
|
triggerSourceSchema,
|
|
48
|
+
leaseSchema,
|
|
47
49
|
auditSchema,
|
|
48
50
|
analyticsSchema,
|
|
49
51
|
virtualUserSchema,
|
|
@@ -56,6 +58,7 @@ export { channelSchema } from './channel.schema.js';
|
|
|
56
58
|
export { credentialSchema } from './credential.schema.js';
|
|
57
59
|
export { deploymentSchema } from './deployment.schema.js';
|
|
58
60
|
export { flagSchema } from './flag.schema.js';
|
|
61
|
+
export { leaseSchema } from './lease.schema.js';
|
|
59
62
|
export { triggerSourceSchema } from './trigger-source.schema.js';
|
|
60
63
|
export { incomingWebhookSchema } from './incoming-webhook.schema.js';
|
|
61
64
|
export { scopeSchema } from './scope.schema.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const leaseSchema = {
|
|
2
|
+
name: 'lease',
|
|
3
|
+
ownedBy: ['leaseService', 'workflowService'],
|
|
4
|
+
statements: [
|
|
5
|
+
(db) => db.schema
|
|
6
|
+
.createTable('pikkuLease')
|
|
7
|
+
.addColumn('key', 'text', (col) => col.primaryKey())
|
|
8
|
+
.addColumn('holder', 'text', (col) => col.notNull())
|
|
9
|
+
.addColumn('token', 'integer', (col) => col.notNull())
|
|
10
|
+
.addColumn('expiresAt', 'bigint', (col) => col.notNull()),
|
|
11
|
+
],
|
|
12
|
+
};
|
|
@@ -53,6 +53,7 @@ export const workflowSchema = {
|
|
|
53
53
|
.addColumn('retryDelay', 'text')
|
|
54
54
|
.addColumn('fromStepName', 'text')
|
|
55
55
|
.addColumn('currentAttempt', 'integer')
|
|
56
|
+
.addColumn('leaseExpiresAt', 'bigint')
|
|
56
57
|
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
57
58
|
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
58
59
|
.addUniqueConstraint('workflow_step_run_name_unique', [
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: A lease is taken by one guarded UPDATE
|
|
4
|
+
description: Acquire inserts the key's row already lapsed, takes it with a guarded UPDATE, and reads the row back, because that is the one shape that behaves the same on SQLite, Postgres and MySQL
|
|
5
|
+
tags: [leases, kysely, mysql]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# A lease is taken by one guarded UPDATE
|
|
9
|
+
|
|
10
|
+
`acquire` is three statements, in an order that looks roundabout:
|
|
11
|
+
|
|
12
|
+
1. insert the key's row with `token = 0, expires_at = 0` — already lapsed —
|
|
13
|
+
ignoring a conflict;
|
|
14
|
+
2. `UPDATE` it to this holder where it is this holder's or has lapsed, raising
|
|
15
|
+
`token` unless this holder is re-acquiring a live lease;
|
|
16
|
+
3. read the row back and answer whether this holder now has it.
|
|
17
|
+
|
|
18
|
+
The natural shape, `INSERT … ON CONFLICT DO UPDATE … WHERE`, does not exist on
|
|
19
|
+
MySQL, whose `ON DUPLICATE KEY UPDATE` has no guard. Inserting the row lapsed
|
|
20
|
+
means there is a single path to holding a key — the guarded update — whether the
|
|
21
|
+
key is new or not.
|
|
22
|
+
|
|
23
|
+
Two MySQL behaviours shape the rest. `SET` is evaluated left to right against
|
|
24
|
+
values already assigned in the same statement, so `token` is assigned before
|
|
25
|
+
`holder`: the re-acquire test has to see the previous holder. And MySQL reports
|
|
26
|
+
rows _changed_, not rows _matched_, so a holder re-acquiring with an unchanged
|
|
27
|
+
row would look like a loss; the row is read back instead of trusting the count.
|
|
28
|
+
|
|
29
|
+
**What this rules out:** a dialect-specific upsert per database, and deciding
|
|
30
|
+
the outcome from `numUpdatedRows`.
|
|
@@ -8,8 +8,11 @@ description: How encrypted columns are wrapped, and which key wraps them
|
|
|
8
8
|
|
|
9
9
|
<!-- pikku:knowledge-index -->
|
|
10
10
|
|
|
11
|
+
- [A lease is taken by one guarded UPDATE](a-lease-is-taken-by-one-guarded-update.md) — Acquire inserts the key's row already lapsed, takes it with a guarded UPDATE, and reads the row back, because that is the one shape that behaves the same on SQLite, Postgres and MySQL
|
|
11
12
|
- [Classification-driven encryption happens in a Kysely plugin](classification-driven-encryption-happens-in-a-plugin.md) — The existing column classification manifest drives a transparent query plugin, rather than encrypting the whole database file or asking call sites to encrypt
|
|
12
13
|
- [KEKs are scoped by purpose as well as version](keks-are-scoped-by-purpose-as-well-as-version.md) — Rows carry a keyId, and the plugin resolves KEKs through a seam rather than holding one; v1 ships a single key, but scoping never becomes a migration
|
|
14
|
+
- [Leases are judged by the database's clock](leases-are-judged-by-the-database-clock.md) — A lease and a step lease are written and compared in SQL against the server's own clock, stored as epoch milliseconds, so no worker's clock decides who holds anything
|
|
13
15
|
- [The plugin is given a resolver, never a key](the-plugin-is-given-a-resolver-never-a-key.md) — Assembly order for classified columns — a plain Kysely handle, then a plugin that asks a `KEKResolver` per operation rather than being handed a key it holds for the life of the process
|
|
16
|
+
- [Workflows own the lease table too](workflows-own-the-lease-table-too.md) — `leaseSchema` is owned by `workflowService` as well as `leaseService`, because the engine takes the run lease without any function ever naming leaseService
|
|
14
17
|
|
|
15
18
|
<!-- /pikku:knowledge-index -->
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: Leases are judged by the database's clock
|
|
4
|
+
description: A lease and a step lease are written and compared in SQL against the server's own clock, stored as epoch milliseconds, so no worker's clock decides who holds anything
|
|
5
|
+
tags: [leases, workflows, kysely, clocks]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Leases are judged by the database's clock
|
|
9
|
+
|
|
10
|
+
A lease is only as good as the clock that decides it has lapsed. When each
|
|
11
|
+
worker computed `new Date(Date.now() + ttl)` and compared against its own
|
|
12
|
+
`Date.now()`, a worker whose clock ran an hour fast saw every live lease as
|
|
13
|
+
expired and took it — a lease and a step claim both — and wrote leases that
|
|
14
|
+
outlived their ttl by the same hour for everyone else.
|
|
15
|
+
|
|
16
|
+
So both leases are computed in SQL: `expires_at` is set to _now + ttl_ and
|
|
17
|
+
compared against _now_, where _now_ is the database's —
|
|
18
|
+
`clock_timestamp()` on Postgres, `utc_timestamp(6)` on MySQL. There is one clock,
|
|
19
|
+
and every worker reads it. The columns are `bigint` epoch milliseconds rather
|
|
20
|
+
than `timestamp`, because the arithmetic then means the same thing in every
|
|
21
|
+
dialect and no driver gets to reinterpret a time zone.
|
|
22
|
+
|
|
23
|
+
On MySQL the epoch is counted from `utc_timestamp`, never
|
|
24
|
+
`unix_timestamp(now())`. `now()` is local to the session's time zone, and when
|
|
25
|
+
that zone falls back an hour, two real instants share one local time: the
|
|
26
|
+
epoch stood still for an hour, then jumped, so a dead holder's lease lived an
|
|
27
|
+
hour longer and every lease written in the repeated hour lapsed at once.
|
|
28
|
+
|
|
29
|
+
`PgKyselyLeaseService`, `MySQLKyselyLeaseService` and the dialect workflow
|
|
30
|
+
services override `nowMs()`; `KyselyLeaseService` and `KyselyWorkflowService`
|
|
31
|
+
keep the process clock, which is only correct when every process shares a host,
|
|
32
|
+
as they do on SQLite.
|
|
33
|
+
|
|
34
|
+
**What this rules out:** comparing a lease in JavaScript after reading it — the
|
|
35
|
+
claim used to pre-check `isStepLeaseLive` in the app, and that check is exactly
|
|
36
|
+
where a skewed worker decided wrongly.
|
|
37
|
+
|
|
38
|
+
**Testing it:** PGlite runs in-process and takes its clock from the JS
|
|
39
|
+
`Date.now`, so skewing `Date.now` skews the database too and a skew test on
|
|
40
|
+
PGlite proves nothing. The skew assertions live in
|
|
41
|
+
`verifiers/workflows/src/tests/lease-db.assert.ts`, against real Postgres and
|
|
42
|
+
MySQL servers.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: Workflows own the lease table too
|
|
4
|
+
description: `leaseSchema` is owned by `workflowService` as well as `leaseService`, because the engine takes the run lease without any function ever naming leaseService
|
|
5
|
+
tags: [leases, workflows, schema]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Workflows own the lease table too
|
|
9
|
+
|
|
10
|
+
`pikku db generate` writes a schema for a project only when a service in
|
|
11
|
+
`ownedBy` is one the inspector found the project's code reaching. The workflow
|
|
12
|
+
engine takes `workflow-run:<id>` from `leaseService` on every orchestration pass,
|
|
13
|
+
but no function destructures `leaseService` to make that happen, so a workflow
|
|
14
|
+
project was never given `pikku_lease` — and registering a lease service then
|
|
15
|
+
failed at boot against a table nothing had migrated.
|
|
16
|
+
|
|
17
|
+
`leaseSchema` is therefore owned by `workflowService` as well. A workflow project
|
|
18
|
+
gets the table whether or not it registers a lease service yet.
|
|
19
|
+
|
|
20
|
+
**What this rules out:** making `leaseService` a required service of every
|
|
21
|
+
workflow project. That would force the in-memory and single-process apps, which
|
|
22
|
+
have nothing to serialise, to wire a lease they cannot use.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/kysely",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.33",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/pikkujs/pikku.git",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"prepublishOnly": "bun run build"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"@pikku/core": "^0.12.
|
|
29
|
+
"@pikku/core": "^0.12.133"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"kysely": "^0.29.5"
|