@pikku/kysely 0.13.30 → 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 CHANGED
@@ -1,3 +1,89 @@
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
+
70
+ ## 0.13.31
71
+
72
+ ### Patch Changes
73
+
74
+ - 01c0209: `@pikku/kysely` re-exports `Kysely`, `PostgresDialect` and `CamelCasePlugin` from its own copy of kysely. The local-services codegen writes these imports into a project, so it now opens and types the database with the same kysely copy the generated services were built against, instead of a second copy the project may resolve on its own.
75
+ - 5bce779: Webhook trigger sources are off until someone turns them on, and addons declare their own.
76
+
77
+ An addon calls `wireTriggerWebhookSource` in its own package, and an app that wires the addon gets the source (its route included) without declaring it: named and routed after the addon's namespace, so two instances get one each. A source the app declares under the same name wins.
78
+
79
+ Every source now has an `enabled` switch in the `triggerSourceStore`, off by default. `reconcileTriggerSources` registers only enabled sources and records each one's `baseUrl` and `labelPrefix`; a disabled source's route answers 404 without running `receive`. New in core: `enableTriggerSource` registers a source with its provider and `disableTriggerSource` stops it receiving, then tears it down, both at the recorded address unless one is given. The admin addon exposes them as `triggerSourceEnable` and `triggerSourceDisable`. The `pikkuTriggerSource` table gains `enabled`, `baseUrl` and `labelPrefix`: run `pikku db generate` for the migration.
80
+
81
+ - Updated dependencies [dfcd351]
82
+ - Updated dependencies [cf40182]
83
+ - Updated dependencies [5bce779]
84
+ - Updated dependencies [5bce779]
85
+ - @pikku/core@0.12.130
86
+
1
87
  ## 0.13.30
2
88
 
3
89
  ### 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 { KyselyWorkflowService, KyselySecretService } from '@pikku/kysely'
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 workflowService = new KyselyWorkflowService(db)
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
@@ -15,17 +15,19 @@ 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';
21
22
  export { KyselyAnalyticsService } from './kysely-analytics-service.js';
22
23
  export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js';
23
24
  export { KyselyVirtualUserScheduleStore } from './kysely-virtual-user-schedule-store.js';
25
+ export { CamelCasePlugin, Kysely, PostgresDialect } from 'kysely';
24
26
  export { SerializePlugin, BaseSerializePlugin, type Serializer, type Deserializer, } from './serialize-plugin.js';
25
27
  export { createCoercionPlugin, type ColumnKind, type CoercionMap, type CreateCoercionPluginOptions, } from './coercion-plugin.js';
26
28
  export { createClassificationPlugin, type CreateClassificationPluginOptions, } from './classification-plugin.js';
27
29
  export { ClassificationCrypto, DEFAULT_KEY_ID, isColumnEnvelope, parseColumnEnvelope, type ClassificationCryptoOptions, type ColumnEnvelope, type KEKResolver, type ResolvedKEK, } from './classification-crypto.js';
28
- 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';
29
31
  export type { KyselyPikkuDB } from './kysely-tables.js';
30
32
  export type { WorkflowRunService } from '@pikku/core/workflow';
31
33
  export type { AgentRunService, AgentRunRow } from '@pikku/core/agent';
package/dist/src/index.js CHANGED
@@ -14,14 +14,19 @@ 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';
20
21
  export { KyselyAnalyticsService } from './kysely-analytics-service.js';
21
22
  export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js';
22
23
  export { KyselyVirtualUserScheduleStore } from './kysely-virtual-user-schedule-store.js';
24
+ // Re-exported so a generated file that constructs the services above opens and
25
+ // types its database with the same copy of kysely those services were built
26
+ // against, rather than a second copy the project may resolve on its own.
27
+ export { CamelCasePlugin, Kysely, PostgresDialect } from 'kysely';
23
28
  export { SerializePlugin, BaseSerializePlugin, } from './serialize-plugin.js';
24
29
  export { createCoercionPlugin, } from './coercion-plugin.js';
25
30
  export { createClassificationPlugin, } from './classification-plugin.js';
26
31
  export { ClassificationCrypto, DEFAULT_KEY_ID, isColumnEnvelope, parseColumnEnvelope, } from './classification-crypto.js';
27
- 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
  }
@@ -240,13 +247,24 @@ export interface PikkuFeatureFlagsTable {
240
247
  export interface PikkuTriggerSourceTable {
241
248
  name: string;
242
249
  kind: string;
250
+ baseUrl: string | null;
251
+ labelPrefix: string | null;
243
252
  declared: Generated<boolean>;
253
+ enabled: Generated<boolean>;
244
254
  status: string | null;
245
255
  /** The last setup's provider identifiers as JSON; never a secret. */
246
256
  state: string | null;
247
257
  detail: string | null;
248
258
  updatedAt: Generated<Date>;
249
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
+ }
250
268
  /** One subject's short-circuit, in either direction, over a flag's rollout. */
251
269
  export interface PikkuFeatureFlagOverridesTable {
252
270
  flag: string;
@@ -433,6 +451,7 @@ export interface KyselyPikkuDB {
433
451
  pikkuFeatureFlags: PikkuFeatureFlagsTable;
434
452
  pikkuFeatureFlagOverrides: PikkuFeatureFlagOverridesTable;
435
453
  pikkuTriggerSource: PikkuTriggerSourceTable;
454
+ pikkuLease: PikkuLeaseTable;
436
455
  channels: ChannelsTable;
437
456
  channelSubscriptions: ChannelSubscriptionsTable;
438
457
  workflowRuns: WorkflowRunsTable;
@@ -10,5 +10,6 @@ export declare class KyselyTriggerSourceStore implements TriggerSourceStore {
10
10
  listTriggerSources(): Promise<TriggerSourceRow[]>;
11
11
  getTriggerSource(name: string): Promise<TriggerSourceRow | null>;
12
12
  recordTriggerSource(name: string, result: TriggerSourceResult): Promise<void>;
13
+ setTriggerSourceEnabled(name: string, enabled: boolean): Promise<void>;
13
14
  deleteTriggerSource(name: string): Promise<void>;
14
15
  }
@@ -15,7 +15,10 @@ const parseState = (value) => {
15
15
  const toRow = (row) => ({
16
16
  name: row.name,
17
17
  kind: row.kind,
18
+ baseUrl: row.baseUrl,
19
+ labelPrefix: row.labelPrefix,
18
20
  declared: !!row.declared,
21
+ enabled: !!row.enabled,
19
22
  status: row.status,
20
23
  state: parseState(row.state),
21
24
  detail: row.detail,
@@ -35,11 +38,15 @@ export class KyselyTriggerSourceStore {
35
38
  }
36
39
  async syncTriggerSources(sources) {
37
40
  await this.db.transaction().execute(async (trx) => {
38
- for (const { name, kind } of sources) {
41
+ for (const { name, kind, baseUrl, labelPrefix } of sources) {
42
+ const address = {
43
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
44
+ ...(labelPrefix !== undefined ? { labelPrefix } : {}),
45
+ };
39
46
  await trx
40
47
  .insertInto('pikkuTriggerSource')
41
- .values({ name, kind, declared: true })
42
- .onConflict((oc) => oc.column('name').doUpdateSet({ kind, declared: true }))
48
+ .values({ name, kind, declared: true, ...address })
49
+ .onConflict((oc) => oc.column('name').doUpdateSet({ kind, declared: true, ...address }))
43
50
  .execute();
44
51
  }
45
52
  const markStale = trx
@@ -83,6 +90,16 @@ export class KyselyTriggerSourceStore {
83
90
  throw new Error(`Unknown trigger source: ${name}`);
84
91
  }
85
92
  }
93
+ async setTriggerSourceEnabled(name, enabled) {
94
+ const { numUpdatedRows } = await this.db
95
+ .updateTable('pikkuTriggerSource')
96
+ .set({ enabled })
97
+ .where('name', '=', name)
98
+ .executeTakeFirst();
99
+ if (!numUpdatedRows) {
100
+ throw new Error(`Unknown trigger source: ${name}`);
101
+ }
102
+ }
86
103
  async deleteTriggerSource(name) {
87
104
  await this.db
88
105
  .deleteFrom('pikkuTriggerSource')
@@ -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, WorkflowQueueOptions, WorkflowRun, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus } from '@pikku/core/workflow';
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?: WorkflowQueueOptions);
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
- withRunLock<T>(_id: string, fn: () => Promise<T>): Promise<T>;
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
- * Move a step to `running` only if it is still in one of `from`, reporting
77
- * whether this caller is the one that moved it.
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` is a pass-through: a subclass inheriting it (kysely-sqlite)
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
- await trx
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
- .execute();
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 this.db
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' || stepState.status === 'running') {
406
+ if (stepState.status === 'succeeded') {
400
407
  return null;
401
408
  }
402
- if (stepState.status === 'failed') {
403
- if (!(await this.claimStepStatus(stepState.stepId, ['failed']))) {
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
- return this.createRetryAttempt(stepState.stepId, 'running');
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
- * Move a step to `running` only if it is still in one of `from`, reporting
422
- * whether this caller is the one that moved it.
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 claimed = await this.db
491
+ async claimStepStatus(stepId, from, leaseMs) {
492
+ const now = this.nowMs();
493
+ let query = this.db
426
494
  .updateTable('workflowStep')
427
- .set({ status: 'running', updatedAt: new Date() })
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
- .executeTakeFirst();
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` is a pass-through: a subclass inheriting it (kysely-sqlite)
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('s.status', 'in', ['running', 'scheduled', 'suspended']))))
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';
@@ -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,2 @@
1
+ import type { PikkuSchema } from './pikku-schema.types.js';
2
+ export declare const leaseSchema: PikkuSchema;
@@ -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
+ };
@@ -11,7 +11,10 @@ export const triggerSourceSchema = {
11
11
  .createTable('pikkuTriggerSource')
12
12
  .addColumn('name', 'text', (col) => col.primaryKey())
13
13
  .addColumn('kind', 'text', (col) => col.notNull())
14
+ .addColumn('baseUrl', 'text')
15
+ .addColumn('labelPrefix', 'text')
14
16
  .addColumn('declared', 'boolean', (col) => col.defaultTo(true).notNull())
17
+ .addColumn('enabled', 'boolean', (col) => col.defaultTo(false).notNull())
15
18
  .addColumn('status', 'text')
16
19
  .addColumn('state', 'text')
17
20
  .addColumn('detail', 'text')
@@ -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.30",
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.128"
29
+ "@pikku/core": "^0.12.133"
30
30
  },
31
31
  "dependencies": {
32
32
  "kysely": "^0.29.5"