@pikku/kysely 0.13.5 → 0.13.6
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 +81 -0
- package/LICENSE +21 -0
- package/README.md +29 -2
- package/dist/src/kysely-tables.d.ts +8 -0
- package/dist/src/kysely-workflow-mirror.js +37 -25
- package/dist/src/kysely-workflow-run-service.d.ts +0 -5
- package/dist/src/kysely-workflow-run-service.js +4 -16
- package/dist/src/kysely-workflow-service.d.ts +35 -6
- package/dist/src/kysely-workflow-service.js +194 -134
- package/dist/src/schema/workflow.schema.d.ts +4 -2
- package/dist/src/schema/workflow.schema.js +28 -2
- package/package.json +3 -2
- package/src/kysely-tables.ts +8 -0
- package/src/kysely-workflow-mirror.test.ts +1 -13
- package/src/kysely-workflow-mirror.ts +44 -20
- package/src/kysely-workflow-run-service.ts +7 -20
- package/src/kysely-workflow-service.test.ts +431 -0
- package/src/kysely-workflow-service.ts +212 -161
- package/src/schema/workflow.schema.ts +38 -2
|
@@ -114,12 +114,13 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
|
|
|
114
114
|
retries: step.retries ?? null,
|
|
115
115
|
retryDelay: step.retryDelay?.toString() ?? null,
|
|
116
116
|
fromStepName: step.fromStepName ?? null,
|
|
117
|
+
currentAttempt: 1,
|
|
117
118
|
createdAt: now,
|
|
118
119
|
updatedAt: now,
|
|
119
120
|
})
|
|
120
121
|
.execute()
|
|
121
122
|
|
|
122
|
-
await this.insertHistoryRecord(step.stepId, step.status)
|
|
123
|
+
await this.insertHistoryRecord(step.stepId, step.status, 1)
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
async setStepRunning(stepId: string): Promise<void> {
|
|
@@ -178,17 +179,25 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
|
|
|
178
179
|
failedStepId: string,
|
|
179
180
|
newStep: StepState & { stepName: string }
|
|
180
181
|
): Promise<void> {
|
|
182
|
+
const previous = await this.db
|
|
183
|
+
.selectFrom('workflowStep')
|
|
184
|
+
.select('currentAttempt')
|
|
185
|
+
.where('workflowStepId', '=', failedStepId)
|
|
186
|
+
.executeTakeFirst()
|
|
187
|
+
const attempt = (previous?.currentAttempt ?? 0) + 1
|
|
188
|
+
|
|
181
189
|
await this.db
|
|
182
190
|
.updateTable('workflowStep')
|
|
183
191
|
.set({
|
|
184
192
|
status: newStep.status,
|
|
185
193
|
result: null,
|
|
186
194
|
error: null,
|
|
195
|
+
currentAttempt: attempt,
|
|
187
196
|
updatedAt: new Date(),
|
|
188
197
|
})
|
|
189
198
|
.where('workflowStepId', '=', failedStepId)
|
|
190
199
|
.execute()
|
|
191
|
-
await this.insertHistoryRecord(failedStepId, newStep.status)
|
|
200
|
+
await this.insertHistoryRecord(failedStepId, newStep.status, attempt)
|
|
192
201
|
}
|
|
193
202
|
|
|
194
203
|
async setBranchTaken(stepId: string, branchKey: string): Promise<void> {
|
|
@@ -274,6 +283,7 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
|
|
|
274
283
|
private async insertHistoryRecord(
|
|
275
284
|
stepId: string,
|
|
276
285
|
status: string,
|
|
286
|
+
attempt: number,
|
|
277
287
|
resultJson?: string | null,
|
|
278
288
|
errorJson?: string | null
|
|
279
289
|
): Promise<void> {
|
|
@@ -282,6 +292,7 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
|
|
|
282
292
|
historyId: crypto.randomUUID(),
|
|
283
293
|
workflowStepId: stepId,
|
|
284
294
|
status,
|
|
295
|
+
attempt,
|
|
285
296
|
result: resultJson ?? null,
|
|
286
297
|
error: errorJson ?? null,
|
|
287
298
|
createdAt: now,
|
|
@@ -309,27 +320,40 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
|
|
|
309
320
|
resultJson?: string,
|
|
310
321
|
errorJson?: string
|
|
311
322
|
): Promise<void> {
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
323
|
+
const update: Record<string, any> = { status }
|
|
324
|
+
if (resultJson !== undefined) update.result = resultJson
|
|
325
|
+
if (errorJson !== undefined) update.error = errorJson
|
|
326
|
+
const tsField = timestampFieldFor(status)
|
|
327
|
+
if (tsField) update[tsField] = new Date()
|
|
328
|
+
|
|
329
|
+
const result = await this.db
|
|
330
|
+
.updateTable('workflowStepHistory')
|
|
331
|
+
.set(update)
|
|
315
332
|
.where('workflowStepId', '=', stepId)
|
|
316
|
-
|
|
317
|
-
|
|
333
|
+
// Correlate through the step row rather than sorting this table by
|
|
334
|
+
// createdAt: two attempts can land in the same millisecond, and the
|
|
335
|
+
// step row already knows which attempt is current.
|
|
336
|
+
.where('attempt', '=', (eb) =>
|
|
337
|
+
eb
|
|
338
|
+
.selectFrom('workflowStep')
|
|
339
|
+
.select('currentAttempt')
|
|
340
|
+
.where('workflowStepId', '=', stepId)
|
|
341
|
+
)
|
|
318
342
|
.executeTakeFirst()
|
|
319
343
|
|
|
320
|
-
if (
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
await this.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
344
|
+
if (Number(result?.numUpdatedRows ?? 0n) === 0) {
|
|
345
|
+
const step = await this.db
|
|
346
|
+
.selectFrom('workflowStep')
|
|
347
|
+
.select('currentAttempt')
|
|
348
|
+
.where('workflowStepId', '=', stepId)
|
|
349
|
+
.executeTakeFirst()
|
|
350
|
+
await this.insertHistoryRecord(
|
|
351
|
+
stepId,
|
|
352
|
+
status,
|
|
353
|
+
step?.currentAttempt ?? 1,
|
|
354
|
+
resultJson,
|
|
355
|
+
errorJson
|
|
356
|
+
)
|
|
333
357
|
}
|
|
334
358
|
}
|
|
335
359
|
}
|
|
@@ -130,7 +130,9 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
|
|
|
130
130
|
.select((eb) =>
|
|
131
131
|
eb
|
|
132
132
|
.selectFrom('workflowStepHistory')
|
|
133
|
-
.select((eb2) =>
|
|
133
|
+
.select((eb2) =>
|
|
134
|
+
eb2.fn.max('workflowStepHistory.succeededAt').as('m')
|
|
135
|
+
)
|
|
134
136
|
.whereRef(
|
|
135
137
|
'workflowStepHistory.workflowStepId',
|
|
136
138
|
'=',
|
|
@@ -198,6 +200,7 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
|
|
|
198
200
|
'h.status',
|
|
199
201
|
'h.result',
|
|
200
202
|
'h.error',
|
|
203
|
+
'h.attempt',
|
|
201
204
|
'h.createdAt',
|
|
202
205
|
'h.runningAt',
|
|
203
206
|
'h.scheduledAt',
|
|
@@ -205,7 +208,10 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
|
|
|
205
208
|
'h.failedAt',
|
|
206
209
|
])
|
|
207
210
|
.where('s.workflowRunId', '=', runId)
|
|
211
|
+
// `attempt` breaks the tie when a retry lands in the same millisecond as
|
|
212
|
+
// the attempt it replaces, which `createdAt` alone cannot order.
|
|
208
213
|
.orderBy('h.createdAt', 'asc')
|
|
214
|
+
.orderBy('h.attempt', 'asc')
|
|
209
215
|
.execute()
|
|
210
216
|
|
|
211
217
|
let attemptCounters: Record<string, number> = {}
|
|
@@ -270,25 +276,6 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
|
|
|
270
276
|
}
|
|
271
277
|
}
|
|
272
278
|
|
|
273
|
-
async getAIGeneratedWorkflows(
|
|
274
|
-
agentName?: string
|
|
275
|
-
): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>> {
|
|
276
|
-
let query = this.db
|
|
277
|
-
.selectFrom('workflowVersions')
|
|
278
|
-
.select(['workflowName', 'graphHash', 'graph'])
|
|
279
|
-
.where('source', '=', 'dynamic-workflow')
|
|
280
|
-
.where('status', '=', 'active')
|
|
281
|
-
if (agentName) {
|
|
282
|
-
query = query.where('workflowName', 'like', `ai:${agentName}:%`)
|
|
283
|
-
}
|
|
284
|
-
const rows = await query.execute()
|
|
285
|
-
return rows.map((row) => ({
|
|
286
|
-
workflowName: row.workflowName,
|
|
287
|
-
graphHash: row.graphHash,
|
|
288
|
-
graph: parseJson(row.graph),
|
|
289
|
-
}))
|
|
290
|
-
}
|
|
291
|
-
|
|
292
279
|
async deleteRun(id: string): Promise<boolean> {
|
|
293
280
|
const result = await this.db
|
|
294
281
|
.deleteFrom('workflowRuns')
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { describe, test, beforeEach, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
|
|
4
|
+
import Database from 'better-sqlite3'
|
|
5
|
+
|
|
6
|
+
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
7
|
+
import { SerializePlugin } from './serialize-plugin.js'
|
|
8
|
+
import { KyselyWorkflowService } from './kysely-workflow-service.js'
|
|
9
|
+
|
|
10
|
+
let db: Kysely<KyselyPikkuDB>
|
|
11
|
+
let service: KyselyWorkflowService
|
|
12
|
+
let queries: string[]
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every statement the service issues, so a test can assert on round-trip count
|
|
16
|
+
* rather than on wall-clock — the only measure that stays stable in CI.
|
|
17
|
+
*/
|
|
18
|
+
const createDb = () => {
|
|
19
|
+
queries = []
|
|
20
|
+
return new Kysely<KyselyPikkuDB>({
|
|
21
|
+
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
22
|
+
plugins: [new CamelCasePlugin(), new SerializePlugin()],
|
|
23
|
+
log: (event) => {
|
|
24
|
+
if (event.level === 'query') {
|
|
25
|
+
queries.push(event.query.sql)
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
db = createDb()
|
|
33
|
+
service = new KyselyWorkflowService(db, { wireQueues: false } as any)
|
|
34
|
+
await service.init()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await db.destroy()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const listIndexes = async (table: string): Promise<string[]> => {
|
|
42
|
+
const rows = await sql<{
|
|
43
|
+
name: string
|
|
44
|
+
}>`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ${table} AND name NOT LIKE 'sqlite_%'`.execute(
|
|
45
|
+
db
|
|
46
|
+
)
|
|
47
|
+
return rows.rows.map((r) => r.name)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const indexedColumns = async (indexName: string): Promise<string[]> => {
|
|
51
|
+
const rows = await sql<{
|
|
52
|
+
name: string
|
|
53
|
+
}>`SELECT name FROM pragma_index_info(${indexName})`.execute(db)
|
|
54
|
+
return rows.rows.map((r) => r.name)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const seedRun = (name = 'wf') =>
|
|
58
|
+
service.createRun(name, { foo: 1 }, false, 'hash1', {
|
|
59
|
+
type: 'internal',
|
|
60
|
+
} as any)
|
|
61
|
+
|
|
62
|
+
describe('KyselyWorkflowService — schema indexes', () => {
|
|
63
|
+
test('workflow_step_history is indexed by its step, the column every hot query filters on', async () => {
|
|
64
|
+
const indexes = await listIndexes('workflow_step_history')
|
|
65
|
+
assert.notEqual(
|
|
66
|
+
indexes.length,
|
|
67
|
+
0,
|
|
68
|
+
'workflow_step_history has no indexes; the attempt-count subquery and the latest-attempt lookup both seq-scan it'
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const columnsPerIndex = await Promise.all(
|
|
72
|
+
indexes.map((name) => indexedColumns(name))
|
|
73
|
+
)
|
|
74
|
+
assert.ok(
|
|
75
|
+
columnsPerIndex.some((cols) => cols[0] === 'workflow_step_id'),
|
|
76
|
+
`expected an index led by workflow_step_id, got ${JSON.stringify(columnsPerIndex)}`
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('the step-history index also orders by created_at, so the latest attempt is a lookup not a sort', async () => {
|
|
81
|
+
const indexes = await listIndexes('workflow_step_history')
|
|
82
|
+
const columnsPerIndex = await Promise.all(
|
|
83
|
+
indexes.map((name) => indexedColumns(name))
|
|
84
|
+
)
|
|
85
|
+
assert.ok(
|
|
86
|
+
columnsPerIndex.some(
|
|
87
|
+
(cols) => cols[0] === 'workflow_step_id' && cols[1] === 'created_at'
|
|
88
|
+
),
|
|
89
|
+
`expected a (workflow_step_id, created_at) index, got ${JSON.stringify(columnsPerIndex)}`
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('workflow_runs is indexed for the status + recency listing', async () => {
|
|
94
|
+
const indexes = await listIndexes('workflow_runs')
|
|
95
|
+
const columnsPerIndex = await Promise.all(
|
|
96
|
+
indexes.map((name) => indexedColumns(name))
|
|
97
|
+
)
|
|
98
|
+
assert.ok(
|
|
99
|
+
columnsPerIndex.some((cols) => cols.includes('status')),
|
|
100
|
+
`expected an index covering status, got ${JSON.stringify(columnsPerIndex)}`
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('workflow_step is indexed for the per-run status scan the graph runner does every tick', async () => {
|
|
105
|
+
const indexes = await listIndexes('workflow_step')
|
|
106
|
+
const columnsPerIndex = await Promise.all(
|
|
107
|
+
indexes.map((name) => indexedColumns(name))
|
|
108
|
+
)
|
|
109
|
+
assert.ok(
|
|
110
|
+
columnsPerIndex.some(
|
|
111
|
+
(cols) => cols[0] === 'workflow_run_id' && cols.includes('status')
|
|
112
|
+
),
|
|
113
|
+
`expected a (workflow_run_id, status) index, got ${JSON.stringify(columnsPerIndex)}`
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('workflow_versions is indexed for the version lookup', async () => {
|
|
118
|
+
const indexes = await listIndexes('workflow_versions')
|
|
119
|
+
assert.notEqual(
|
|
120
|
+
indexes.length,
|
|
121
|
+
0,
|
|
122
|
+
'workflow_versions is queried on every version-mismatch replay with no index'
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('init() is idempotent — a second boot does not throw on existing indexes', async () => {
|
|
127
|
+
// A fresh instance, because `init()` returns at its `initialized` guard on
|
|
128
|
+
// the same one — so calling it twice re-issues no DDL and never exercises
|
|
129
|
+
// the duplicate-index path this is here to cover.
|
|
130
|
+
await new KyselyWorkflowService(db, { wireQueues: false } as any).init()
|
|
131
|
+
|
|
132
|
+
const indexes = await listIndexes('workflow_step_history')
|
|
133
|
+
assert.ok(indexes.length > 0)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A step transition writes the step row and its history row, and nothing else.
|
|
139
|
+
* BEGIN/COMMIT are excluded: they are the price of writing the pair atomically,
|
|
140
|
+
* and counting them would make the assertion about transaction framing rather
|
|
141
|
+
* than about the work being done.
|
|
142
|
+
*/
|
|
143
|
+
const dataStatements = () =>
|
|
144
|
+
queries.filter((q) => !/^\s*(begin|commit|rollback)/i.test(q))
|
|
145
|
+
|
|
146
|
+
describe('KyselyWorkflowService — step transition round-trips', () => {
|
|
147
|
+
const transitions: Array<[string, (stepId: string) => Promise<void>]> = [
|
|
148
|
+
['setStepRunning', (id) => service.setStepRunning(id)],
|
|
149
|
+
['setStepResult', (id) => service.setStepResult(id, { ok: true })],
|
|
150
|
+
['setStepError', (id) => service.setStepError(id, new Error('boom'))],
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
for (const [name, run] of transitions) {
|
|
154
|
+
test(`${name} writes two rows and reads none`, async () => {
|
|
155
|
+
const runId = await seedRun()
|
|
156
|
+
const step = await service.insertStepState(runId, 's1', 'rpc.fn', {
|
|
157
|
+
x: 1,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
queries.length = 0
|
|
161
|
+
await run(step.stepId)
|
|
162
|
+
|
|
163
|
+
const statements = dataStatements()
|
|
164
|
+
assert.equal(
|
|
165
|
+
statements.length,
|
|
166
|
+
2,
|
|
167
|
+
`${name} issued ${statements.length} statements:\n${statements.join('\n')}`
|
|
168
|
+
)
|
|
169
|
+
assert.ok(
|
|
170
|
+
statements.every((q) => /^\s*update/i.test(q)),
|
|
171
|
+
`${name} still reads before it writes — the latest-attempt lookup used to sort an unindexed history table:\n${statements.join('\n')}`
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test(`${name} writes both rows atomically`, async () => {
|
|
176
|
+
const runId = await seedRun()
|
|
177
|
+
const step = await service.insertStepState(runId, 's1', 'rpc.fn', {
|
|
178
|
+
x: 1,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
queries.length = 0
|
|
182
|
+
await run(step.stepId)
|
|
183
|
+
|
|
184
|
+
assert.ok(
|
|
185
|
+
/^\s*begin/i.test(queries[0] ?? ''),
|
|
186
|
+
`${name} wrote the step and its history outside a transaction; a crash between them leaves the two disagreeing`
|
|
187
|
+
)
|
|
188
|
+
assert.ok(/^\s*commit/i.test(queries.at(-1) ?? ''))
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
describe('KyselyWorkflowService — step transitions stay consistent', () => {
|
|
194
|
+
test('setStepResult writes the step row and its history row together', async () => {
|
|
195
|
+
const runId = await seedRun()
|
|
196
|
+
const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
|
|
197
|
+
await service.setStepRunning(step.stepId)
|
|
198
|
+
await service.setStepResult(step.stepId, { ok: true })
|
|
199
|
+
|
|
200
|
+
const state = await service.getStepState(runId, 's1')
|
|
201
|
+
assert.equal(state.status, 'succeeded')
|
|
202
|
+
assert.deepEqual(state.result, { ok: true })
|
|
203
|
+
|
|
204
|
+
const history = await service.getRunHistory(runId)
|
|
205
|
+
const latest = history.at(-1)
|
|
206
|
+
assert.equal(
|
|
207
|
+
latest?.status,
|
|
208
|
+
'succeeded',
|
|
209
|
+
'the step row says succeeded but its latest history row does not'
|
|
210
|
+
)
|
|
211
|
+
assert.deepEqual(latest?.result, { ok: true })
|
|
212
|
+
assert.ok(latest?.succeededAt, 'succeededAt was not stamped on history')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('setStepError records the failure on both the step and its history', async () => {
|
|
216
|
+
const runId = await seedRun()
|
|
217
|
+
const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
|
|
218
|
+
await service.setStepRunning(step.stepId)
|
|
219
|
+
await service.setStepError(step.stepId, new Error('boom'))
|
|
220
|
+
|
|
221
|
+
const state = await service.getStepState(runId, 's1')
|
|
222
|
+
assert.equal(state.status, 'failed')
|
|
223
|
+
assert.equal(state.error?.message, 'boom')
|
|
224
|
+
|
|
225
|
+
const history = await service.getRunHistory(runId)
|
|
226
|
+
const latest = history.at(-1)
|
|
227
|
+
assert.equal(latest?.status, 'failed')
|
|
228
|
+
assert.equal(latest?.error?.message, 'boom')
|
|
229
|
+
assert.ok(latest?.failedAt, 'failedAt was not stamped on history')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('a same-millisecond retry resolves the retry attempt, not the failed one', async () => {
|
|
233
|
+
const runId = await seedRun()
|
|
234
|
+
const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
|
|
235
|
+
await service.setStepRunning(step.stepId)
|
|
236
|
+
await service.setStepError(step.stepId, new Error('first'))
|
|
237
|
+
|
|
238
|
+
// No delay: both history rows land in the same millisecond, so created_at
|
|
239
|
+
// alone cannot say which is newest.
|
|
240
|
+
const retry = await service.createRetryAttempt(step.stepId, 'running')
|
|
241
|
+
await service.setStepResult(retry.stepId, { ok: true })
|
|
242
|
+
|
|
243
|
+
const history = await service.getRunHistory(runId)
|
|
244
|
+
assert.equal(
|
|
245
|
+
history.length,
|
|
246
|
+
2,
|
|
247
|
+
'expected one attempt row plus one retry row'
|
|
248
|
+
)
|
|
249
|
+
assert.deepEqual(
|
|
250
|
+
history.map((h) => h.status),
|
|
251
|
+
['failed', 'succeeded'],
|
|
252
|
+
`the retry resolved the wrong history row: ${JSON.stringify(
|
|
253
|
+
history.map((h) => h.status)
|
|
254
|
+
)}`
|
|
255
|
+
)
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
describe('KyselyWorkflowService — attempt counting', () => {
|
|
260
|
+
test('attemptCount tracks retries', async () => {
|
|
261
|
+
const runId = await seedRun()
|
|
262
|
+
const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
|
|
263
|
+
assert.equal((await service.getStepState(runId, 's1')).attemptCount, 1)
|
|
264
|
+
|
|
265
|
+
await service.setStepError(step.stepId, new Error('one'))
|
|
266
|
+
await service.createRetryAttempt(step.stepId, 'running')
|
|
267
|
+
assert.equal((await service.getStepState(runId, 's1')).attemptCount, 2)
|
|
268
|
+
|
|
269
|
+
await service.setStepError(step.stepId, new Error('two'))
|
|
270
|
+
const third = await service.createRetryAttempt(step.stepId, 'running')
|
|
271
|
+
assert.equal(third.attemptCount, 3)
|
|
272
|
+
assert.equal((await service.getStepState(runId, 's1')).attemptCount, 3)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('reading a step does not touch the history table', async () => {
|
|
276
|
+
const runId = await seedRun()
|
|
277
|
+
await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
|
|
278
|
+
|
|
279
|
+
queries.length = 0
|
|
280
|
+
await service.getStepState(runId, 's1')
|
|
281
|
+
assert.ok(
|
|
282
|
+
!queries.join('\n').includes('workflow_step_history'),
|
|
283
|
+
`the hottest read in the engine still aggregates over the history table:\n${queries.join('\n')}`
|
|
284
|
+
)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('a replay reads every step of a run in one query', async () => {
|
|
288
|
+
const runId = await seedRun()
|
|
289
|
+
for (let i = 0; i < 6; i++) {
|
|
290
|
+
await service.insertStepState(runId, `s${i}`, 'rpc.fn', { i })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
queries.length = 0
|
|
294
|
+
const steps = await (service as any).listStepStates(runId)
|
|
295
|
+
assert.equal(steps.length, 6)
|
|
296
|
+
assert.equal(
|
|
297
|
+
queries.length,
|
|
298
|
+
1,
|
|
299
|
+
`expected one query for the whole run, got ${queries.length}`
|
|
300
|
+
)
|
|
301
|
+
assert.deepEqual(steps.map((s: any) => s.stepName).sort(), [
|
|
302
|
+
's0',
|
|
303
|
+
's1',
|
|
304
|
+
's2',
|
|
305
|
+
's3',
|
|
306
|
+
's4',
|
|
307
|
+
's5',
|
|
308
|
+
])
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
describe('KyselyWorkflowService — run state writes', () => {
|
|
313
|
+
test('concurrent updateRunState calls for different keys do not clobber each other', async () => {
|
|
314
|
+
const runId = await seedRun('wf2')
|
|
315
|
+
|
|
316
|
+
await Promise.all([
|
|
317
|
+
service.updateRunState(runId, 'alpha', 1),
|
|
318
|
+
service.updateRunState(runId, 'beta', 2),
|
|
319
|
+
service.updateRunState(runId, 'gamma', 3),
|
|
320
|
+
])
|
|
321
|
+
|
|
322
|
+
assert.deepEqual(
|
|
323
|
+
await service.getRunState(runId),
|
|
324
|
+
{ alpha: 1, beta: 2, gamma: 3 },
|
|
325
|
+
'a concurrent read-modify-write over the whole state blob lost a key'
|
|
326
|
+
)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('updateRunState costs a single statement', async () => {
|
|
330
|
+
const runId = await seedRun('wf3')
|
|
331
|
+
queries.length = 0
|
|
332
|
+
await service.updateRunState(runId, 'alpha', 1)
|
|
333
|
+
assert.ok(
|
|
334
|
+
queries.length <= 1,
|
|
335
|
+
`updateRunState issued ${queries.length} statements:\n${queries.join('\n')}`
|
|
336
|
+
)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
test('updateRunState still replaces an existing key', async () => {
|
|
340
|
+
const runId = await seedRun('wf4')
|
|
341
|
+
await service.updateRunState(runId, 'alpha', 1)
|
|
342
|
+
await service.updateRunState(runId, 'alpha', { nested: true })
|
|
343
|
+
assert.deepEqual(await service.getRunState(runId), {
|
|
344
|
+
alpha: { nested: true },
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test('values keep their JSON type instead of becoming strings', async () => {
|
|
349
|
+
const runId = await seedRun('wf5')
|
|
350
|
+
|
|
351
|
+
await service.updateRunState(runId, 'empty', [])
|
|
352
|
+
await service.updateRunState(runId, 'zero', 0)
|
|
353
|
+
await service.updateRunState(runId, 'no', false)
|
|
354
|
+
await service.updateRunState(runId, 'nothing', null)
|
|
355
|
+
await service.updateRunState(runId, 'deep', { a: [1, { b: true }] })
|
|
356
|
+
|
|
357
|
+
assert.deepEqual(await service.getRunState(runId), {
|
|
358
|
+
empty: [],
|
|
359
|
+
zero: 0,
|
|
360
|
+
no: false,
|
|
361
|
+
nothing: null,
|
|
362
|
+
deep: { a: [1, { b: true }] },
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('a key containing a dot stays one key', async () => {
|
|
367
|
+
const runId = await seedRun('wf6')
|
|
368
|
+
|
|
369
|
+
await service.updateRunState(runId, 'a.b', 'flat')
|
|
370
|
+
|
|
371
|
+
assert.deepEqual(
|
|
372
|
+
await service.getRunState(runId),
|
|
373
|
+
{ 'a.b': 'flat' },
|
|
374
|
+
'the key was read as a nested path instead of a literal name'
|
|
375
|
+
)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
test('a key containing a quote does not break the statement', async () => {
|
|
379
|
+
const runId = await seedRun('wf7')
|
|
380
|
+
|
|
381
|
+
await service.updateRunState(runId, 'it\'s "quoted"', 1)
|
|
382
|
+
|
|
383
|
+
assert.deepEqual(await service.getRunState(runId), { 'it\'s "quoted"': 1 })
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* A step whose `current_attempt` is NULL addresses no history row, so the
|
|
389
|
+
* history half of a transition silently writes nothing while the step half
|
|
390
|
+
* commits — the divergence the transaction exists to prevent.
|
|
391
|
+
*/
|
|
392
|
+
describe('KyselyWorkflowService — a transition that addresses no history row', () => {
|
|
393
|
+
const orphanStep = async () => {
|
|
394
|
+
const runId = await seedRun('wf-orphan')
|
|
395
|
+
const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
|
|
396
|
+
await sql`UPDATE workflow_step SET current_attempt = NULL WHERE workflow_step_id = ${step.stepId}`.execute(
|
|
397
|
+
db
|
|
398
|
+
)
|
|
399
|
+
return { runId, step }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
test('the history still records the outcome', async () => {
|
|
403
|
+
const { runId, step } = await orphanStep()
|
|
404
|
+
|
|
405
|
+
await service.setStepResult(step.stepId, { ok: true })
|
|
406
|
+
|
|
407
|
+
const history = await service.getRunHistory(runId)
|
|
408
|
+
assert.equal(
|
|
409
|
+
history.at(-1)?.status,
|
|
410
|
+
'succeeded',
|
|
411
|
+
'the step row says succeeded but no history row does'
|
|
412
|
+
)
|
|
413
|
+
assert.deepEqual(history.at(-1)?.result, { ok: true })
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('the step is repaired, so the next transition lands in place', async () => {
|
|
417
|
+
const { runId, step } = await orphanStep()
|
|
418
|
+
|
|
419
|
+
await service.setStepRunning(step.stepId)
|
|
420
|
+
await service.setStepResult(step.stepId, { ok: true })
|
|
421
|
+
|
|
422
|
+
const history = await service.getRunHistory(runId)
|
|
423
|
+
assert.equal(
|
|
424
|
+
history.length,
|
|
425
|
+
1,
|
|
426
|
+
'each transition appended a row instead of updating the one already there'
|
|
427
|
+
)
|
|
428
|
+
assert.equal(history[0]!.status, 'succeeded')
|
|
429
|
+
})
|
|
430
|
+
})
|
|
431
|
+
|