@dbos-inc/kysely-datasource 5.2.5-preview → 5.2.6-preview
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/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -6
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/index.ts +15 -6
- package/package.json +1 -1
- package/tests/datasource.test.ts +158 -49
package/index.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
isPGRetriableTransactionError,
|
|
7
7
|
DBOSError,
|
|
8
8
|
DBOSStepAlreadyRecordedError,
|
|
9
|
+
DBOSWorkflowConflictError,
|
|
10
|
+
assertStillOwnsWorkflow,
|
|
9
11
|
replayRecordedStep,
|
|
10
12
|
registerTransaction,
|
|
11
13
|
runTransaction,
|
|
@@ -119,12 +121,15 @@ class KyselyTransactionHandler implements DataSourceTransactionHandler {
|
|
|
119
121
|
return this.#kyselyDBField;
|
|
120
122
|
}
|
|
121
123
|
|
|
122
|
-
async deleteCheckpoints(workflowID: string, startStep: number): Promise<void> {
|
|
123
|
-
await this.#kyselyDB
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
124
|
+
async deleteCheckpoints(workflowID: string, startStep: number, beforeCommit?: () => Promise<void>): Promise<void> {
|
|
125
|
+
await this.#kyselyDB.transaction().execute(async (client) => {
|
|
126
|
+
await client
|
|
127
|
+
.deleteFrom('dbos.transaction_completion')
|
|
128
|
+
.where('workflow_id', '=', workflowID)
|
|
129
|
+
.where('function_num', '>=', startStep)
|
|
130
|
+
.execute();
|
|
131
|
+
await beforeCommit?.();
|
|
132
|
+
});
|
|
128
133
|
}
|
|
129
134
|
|
|
130
135
|
async #checkExecution(
|
|
@@ -192,6 +197,8 @@ class KyselyTransactionHandler implements DataSourceTransactionHandler {
|
|
|
192
197
|
if (inserted === undefined) {
|
|
193
198
|
throw new DBOSStepAlreadyRecordedError(workflowID, stepID);
|
|
194
199
|
}
|
|
200
|
+
// Holding this step's row, so a later owner's insert waits on our commit.
|
|
201
|
+
await assertStillOwnsWorkflow(workflowID);
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
async invokeTransactionFunction<This, Args extends unknown[], Return>(
|
|
@@ -248,6 +255,8 @@ class KyselyTransactionHandler implements DataSourceTransactionHandler {
|
|
|
248
255
|
if (saveResults && error instanceof DBOSStepAlreadyRecordedError) {
|
|
249
256
|
return await this.#replayConflictingStep<Return>(workflowID, stepID!);
|
|
250
257
|
}
|
|
258
|
+
// The new owner wins; recording an error here would replay it as this step's outcome.
|
|
259
|
+
if (error instanceof DBOSWorkflowConflictError) throw error;
|
|
251
260
|
if (isPGRetriableTransactionError(error)) {
|
|
252
261
|
DBOS.span?.addEvent('TXN SERIALIZATION FAILURE', { retryWaitMillis: retryWaitMS }, performance.now());
|
|
253
262
|
await new Promise((resolve) => setTimeout(resolve, retryWaitMS));
|
package/package.json
CHANGED
package/tests/datasource.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DBOS } from '@dbos-inc/dbos-sdk';
|
|
1
|
+
import { DBOS, DBOSWorkflowConflictError } from '@dbos-inc/dbos-sdk';
|
|
2
2
|
import { Client, Pool } from 'pg';
|
|
3
3
|
import { KyselyDataSource } from '..';
|
|
4
4
|
import { Database, dropDB, ensureDB } from './test-helpers';
|
|
@@ -51,11 +51,8 @@ describe('KyselyDataSource', () => {
|
|
|
51
51
|
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
|
|
52
52
|
[workflowID],
|
|
53
53
|
);
|
|
54
|
-
|
|
55
|
-
expect(rows
|
|
56
|
-
expect(rows[0].function_num).toBe(0);
|
|
57
|
-
expect(rows[0].output).not.toBeNull();
|
|
58
|
-
expect(SuperJSON.parse(rows[0].output!)).toMatchObject({ user, greet_count: 1 });
|
|
54
|
+
// Completion cleared the checkpoint the transaction wrote.
|
|
55
|
+
expect(rows).toHaveLength(0);
|
|
59
56
|
});
|
|
60
57
|
|
|
61
58
|
test('rerun insert dataSource.register function', async () => {
|
|
@@ -85,11 +82,8 @@ describe('KyselyDataSource', () => {
|
|
|
85
82
|
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
|
|
86
83
|
[workflowID],
|
|
87
84
|
);
|
|
88
|
-
|
|
89
|
-
expect(rows
|
|
90
|
-
expect(rows[0].function_num).toBe(0);
|
|
91
|
-
expect(rows[0].output).not.toBeNull();
|
|
92
|
-
expect(SuperJSON.parse(rows[0].output!)).toMatchObject({ user, greet_count: 1 });
|
|
85
|
+
// Completion cleared the checkpoint the transaction wrote.
|
|
86
|
+
expect(rows).toHaveLength(0);
|
|
93
87
|
});
|
|
94
88
|
|
|
95
89
|
test('rerun insert dataSource.runAsTx function', async () => {
|
|
@@ -134,14 +128,8 @@ describe('KyselyDataSource', () => {
|
|
|
134
128
|
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
|
|
135
129
|
[workflowID],
|
|
136
130
|
);
|
|
137
|
-
|
|
138
|
-
expect(txOutput
|
|
139
|
-
expect(txOutput[0].function_num).toBe(0);
|
|
140
|
-
expect(txOutput[0].output).toBeNull();
|
|
141
|
-
expect(txOutput[0].error).not.toBeNull();
|
|
142
|
-
const $error = SuperJSON.parse(txOutput[0].error!);
|
|
143
|
-
expect($error).toBeInstanceOf(Error);
|
|
144
|
-
expect(($error as Error).message).toMatch(/^test error \d+$/);
|
|
131
|
+
// Completion cleared the checkpoint the transaction wrote.
|
|
132
|
+
expect(txOutput).toHaveLength(0);
|
|
145
133
|
});
|
|
146
134
|
|
|
147
135
|
test('rerun error dataSource.register function', async () => {
|
|
@@ -179,14 +167,8 @@ describe('KyselyDataSource', () => {
|
|
|
179
167
|
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
|
|
180
168
|
[workflowID],
|
|
181
169
|
);
|
|
182
|
-
|
|
183
|
-
expect(txOutput
|
|
184
|
-
expect(txOutput[0].function_num).toBe(0);
|
|
185
|
-
expect(txOutput[0].output).toBeNull();
|
|
186
|
-
expect(txOutput[0].error).not.toBeNull();
|
|
187
|
-
const $error = SuperJSON.parse(txOutput[0].error!);
|
|
188
|
-
expect($error).toBeInstanceOf(Error);
|
|
189
|
-
expect(($error as Error).message).toMatch(/^test error \d+$/);
|
|
170
|
+
// Completion cleared the checkpoint the transaction wrote.
|
|
171
|
+
expect(txOutput).toHaveLength(0);
|
|
190
172
|
});
|
|
191
173
|
|
|
192
174
|
test('rerun error dataSource.runAsTx function', async () => {
|
|
@@ -329,25 +311,68 @@ describe('KyselyDataSource', () => {
|
|
|
329
311
|
expect((winnerError as Error).message).toBe('winner-error');
|
|
330
312
|
expect(raceState.callCount).toBe(3);
|
|
331
313
|
|
|
332
|
-
// Every loser's writes were discarded and the winners'
|
|
314
|
+
// Every loser's writes were discarded, and completion cleared the winners' checkpoints.
|
|
333
315
|
const { rows: tags } = await userDB.query<{ tag: string }>('SELECT tag FROM race_side_effects');
|
|
334
316
|
expect(tags).toHaveLength(0);
|
|
335
317
|
const { rows: txOutput } = await userDB.query<transaction_completion>(
|
|
336
318
|
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = ANY($1)',
|
|
337
|
-
[[wfid1, wfid2]],
|
|
319
|
+
[[wfid1, wfid2, wfid3]],
|
|
338
320
|
);
|
|
339
|
-
expect(txOutput).toHaveLength(
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
321
|
+
expect(txOutput).toHaveLength(0);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
/** Completion clears the checkpoints, so the workflow reads them back while it still runs. */
|
|
325
|
+
test('transactions record their outcomes as checkpoints', async () => {
|
|
326
|
+
const user = 'checkpointProbe';
|
|
327
|
+
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
328
|
+
const workflowID = randomUUID();
|
|
329
|
+
const message = await DBOS.withNextWorkflowID(workflowID, () => regCheckpointProbeWorkflow(user));
|
|
330
|
+
expect(message).toMatch(/^test error \d+$/);
|
|
331
|
+
|
|
332
|
+
expect(probeState.rows).toHaveLength(2);
|
|
333
|
+
const [output, error] = probeState.rows;
|
|
334
|
+
expect(output).toMatchObject({ workflow_id: workflowID, function_num: 0, error: null });
|
|
335
|
+
expect(SuperJSON.parse(output.output!)).toMatchObject({ user, greet_count: 1 });
|
|
336
|
+
expect(error).toMatchObject({ workflow_id: workflowID, function_num: 1, output: null });
|
|
337
|
+
expect(SuperJSON.parse<Error>(error.error!).message).toBe(message);
|
|
338
|
+
expect(await readCompletions(workflowID)).toHaveLength(0);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
/** The checkpoint may be the new owner's only record of the transaction, so a stale execution's cleanup rolls back. */
|
|
342
|
+
test('a stale execution keeps the checkpoints at completion', async () => {
|
|
343
|
+
const user = 'handOffTest';
|
|
344
|
+
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
345
|
+
const handedOff = new Promise<void>((resolve) => (handOffState.signal = resolve));
|
|
346
|
+
const workflowID = randomUUID();
|
|
347
|
+
const execution = DBOS.withNextWorkflowID(workflowID, () => regHandOffWorkflow(user));
|
|
348
|
+
// Surfaces the workflow's own error if it fails before handing off.
|
|
349
|
+
await Promise.race([handedOff, execution]);
|
|
350
|
+
|
|
351
|
+
// Its outcome is refused too, so the execution parks until the workflow is cancelled.
|
|
352
|
+
await DBOS.cancelWorkflow(workflowID);
|
|
353
|
+
await expect(execution).rejects.toThrow(/has been cancelled/);
|
|
354
|
+
expect(await readCompletions(workflowID)).toHaveLength(1);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
/** A stale execution cannot apply a step the new owner also runs, nor leave an outcome for it. */
|
|
358
|
+
test('an execution that loses the workflow mid-transaction rolls back', async () => {
|
|
359
|
+
await userDB.query('DELETE FROM race_side_effects');
|
|
360
|
+
const workflowID = randomUUID();
|
|
361
|
+
await expect(DBOS.withNextWorkflowID(workflowID, () => regStaleWorkflow())).resolves.toBe('conflicted');
|
|
362
|
+
|
|
363
|
+
const { rows: tags } = await userDB.query<{ tag: string }>('SELECT tag FROM race_side_effects');
|
|
364
|
+
expect(tags).toHaveLength(0);
|
|
365
|
+
expect(staleState.completionsAtConflict).toBe(0);
|
|
366
|
+
const sysDB = new Client(sysConfig);
|
|
367
|
+
try {
|
|
368
|
+
await sysDB.connect();
|
|
369
|
+
const { rows: steps } = await sysDB.query('SELECT * FROM dbos.operation_outputs WHERE workflow_uuid = $1', [
|
|
370
|
+
workflowID,
|
|
371
|
+
]);
|
|
372
|
+
expect(steps).toHaveLength(0);
|
|
373
|
+
} finally {
|
|
374
|
+
await sysDB.end();
|
|
343
375
|
}
|
|
344
|
-
const { rows: txError } = await userDB.query<transaction_completion>(
|
|
345
|
-
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
|
|
346
|
-
[wfid3],
|
|
347
|
-
);
|
|
348
|
-
expect(txError).toHaveLength(1);
|
|
349
|
-
expect(txError[0].output).toBeNull(); // the loser never overwrote the winner's error with its own result
|
|
350
|
-
expect(SuperJSON.parse<Error>(txError[0].error!).message).toBe('winner-error');
|
|
351
376
|
});
|
|
352
377
|
});
|
|
353
378
|
|
|
@@ -455,6 +480,95 @@ async function readFunction(user: string) {
|
|
|
455
480
|
|
|
456
481
|
const regInsertFunction = dataSource.registerTransaction(insertFunction);
|
|
457
482
|
const regErrorFunction = dataSource.registerTransaction(errorFunction);
|
|
483
|
+
|
|
484
|
+
const sysConfig = { ...config.connection, database: 'kysely_ds_test_dbos_sys' };
|
|
485
|
+
|
|
486
|
+
/** Set the workflow's ownership token, returning the one it replaced. */
|
|
487
|
+
async function setOwner(workflowID: string, ownerXid: string | null): Promise<string | null> {
|
|
488
|
+
const sysDB = new Client(sysConfig);
|
|
489
|
+
try {
|
|
490
|
+
await sysDB.connect();
|
|
491
|
+
const { rows } = await sysDB.query<{ owner_xid: string | null }>(
|
|
492
|
+
'SELECT owner_xid FROM dbos.workflow_status WHERE workflow_uuid = $1',
|
|
493
|
+
[workflowID],
|
|
494
|
+
);
|
|
495
|
+
await sysDB.query('UPDATE dbos.workflow_status SET owner_xid = $2 WHERE workflow_uuid = $1', [
|
|
496
|
+
workflowID,
|
|
497
|
+
ownerXid,
|
|
498
|
+
]);
|
|
499
|
+
return rows[0].owner_xid;
|
|
500
|
+
} finally {
|
|
501
|
+
await sysDB.end();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The checkpoints this data source holds for the workflow, read on a connection of their own. */
|
|
506
|
+
async function readCompletions(workflowID: string): Promise<transaction_completion[]> {
|
|
507
|
+
const client = new Client(config.connection);
|
|
508
|
+
try {
|
|
509
|
+
await client.connect();
|
|
510
|
+
const { rows } = await client.query<transaction_completion>(
|
|
511
|
+
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1 ORDER BY function_num',
|
|
512
|
+
[workflowID],
|
|
513
|
+
);
|
|
514
|
+
return rows;
|
|
515
|
+
} finally {
|
|
516
|
+
await client.end();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const probeState = { rows: [] as transaction_completion[] };
|
|
521
|
+
|
|
522
|
+
// Reads back the checkpoints its transactions wrote, before completion clears them.
|
|
523
|
+
async function checkpointProbeWorkflow(user: string) {
|
|
524
|
+
await regInsertFunction(user);
|
|
525
|
+
const error = await regErrorFunction(user).then(
|
|
526
|
+
() => undefined,
|
|
527
|
+
(e: Error) => e,
|
|
528
|
+
);
|
|
529
|
+
probeState.rows = await readCompletions(DBOS.workflowID!);
|
|
530
|
+
return error?.message;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const regCheckpointProbeWorkflow = DBOS.registerWorkflow(checkpointProbeWorkflow);
|
|
534
|
+
|
|
535
|
+
const staleState = { originalOwner: null as string | null, completionsAtConflict: -1 };
|
|
536
|
+
|
|
537
|
+
// Another execution claims the workflow while this transaction is still open.
|
|
538
|
+
async function staleFunction() {
|
|
539
|
+
await sql`INSERT INTO race_side_effects(tag) VALUES ('stale')`.execute(dataSource.client);
|
|
540
|
+
staleState.originalOwner = await setOwner(DBOS.workflowID!, 'another-execution');
|
|
541
|
+
return 'stale';
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const regStaleFunction = dataSource.registerTransaction(staleFunction);
|
|
545
|
+
|
|
546
|
+
async function staleWorkflow() {
|
|
547
|
+
try {
|
|
548
|
+
return await regStaleFunction();
|
|
549
|
+
} catch (e) {
|
|
550
|
+
if (!(e instanceof DBOSWorkflowConflictError)) throw e;
|
|
551
|
+
// Read before completion clears it: the conflict must not have been recorded as the step's outcome.
|
|
552
|
+
staleState.completionsAtConflict = (await readCompletions(DBOS.workflowID!)).length;
|
|
553
|
+
// A real duplicate parks here instead; reclaimed so this execution's outcome lands.
|
|
554
|
+
await setOwner(DBOS.workflowID!, staleState.originalOwner);
|
|
555
|
+
return 'conflicted';
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const regStaleWorkflow = DBOS.registerWorkflow(staleWorkflow);
|
|
560
|
+
|
|
561
|
+
const handOffState = { signal: () => {} };
|
|
562
|
+
|
|
563
|
+
// Loses the workflow after its transaction, so its completion is a stale execution's.
|
|
564
|
+
async function handOffWorkflow(user: string) {
|
|
565
|
+
const result = await regInsertFunction(user);
|
|
566
|
+
await setOwner(DBOS.workflowID!, 'another-execution');
|
|
567
|
+
handOffState.signal();
|
|
568
|
+
return result;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const regHandOffWorkflow = DBOS.registerWorkflow(handOffWorkflow);
|
|
458
572
|
const regReadFunction = dataSource.registerTransaction(readFunction, { readOnly: true });
|
|
459
573
|
|
|
460
574
|
class StaticClass {
|
|
@@ -571,11 +685,8 @@ describe('KyselyDataSourceCreateTxCompletion', () => {
|
|
|
571
685
|
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
|
|
572
686
|
[workflowID],
|
|
573
687
|
);
|
|
574
|
-
|
|
575
|
-
expect(rows
|
|
576
|
-
expect(rows[0].function_num).toBe(0);
|
|
577
|
-
expect(rows[0].output).not.toBeNull();
|
|
578
|
-
expect(SuperJSON.parse(rows[0].output!)).toMatchObject({ user, greet_count: 1 });
|
|
688
|
+
// Completion cleared the checkpoint the transaction wrote.
|
|
689
|
+
expect(rows).toHaveLength(0);
|
|
579
690
|
});
|
|
580
691
|
});
|
|
581
692
|
|
|
@@ -636,10 +747,8 @@ describe('KyselyDataSourceWithCustomKysely', () => {
|
|
|
636
747
|
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
|
|
637
748
|
[workflowID],
|
|
638
749
|
);
|
|
639
|
-
|
|
640
|
-
expect(rows
|
|
641
|
-
expect(rows[0].output).not.toBeNull();
|
|
642
|
-
expect(SuperJSON.parse(rows[0].output!)).toMatchObject({ user, greet_count: 1 });
|
|
750
|
+
// Completion cleared the checkpoint the transaction wrote.
|
|
751
|
+
expect(rows).toHaveLength(0);
|
|
643
752
|
|
|
644
753
|
// Clean up the custom Kysely instance
|
|
645
754
|
await customKysely.destroy();
|