@dbos-inc/kysely-datasource 5.2.5-preview → 5.2.7-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/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
- .deleteFrom('dbos.transaction_completion')
125
- .where('workflow_id', '=', workflowID)
126
- .where('function_num', '>=', startStep)
127
- .execute();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dbos-inc/kysely-datasource",
3
- "version": "5.2.5-preview",
3
+ "version": "5.2.7-preview",
4
4
  "description": "DBOS DataSource library for Kysely Query Builder with PostgreSQL support",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -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
- expect(rows.length).toBe(1);
55
- expect(rows[0].workflow_id).toBe(workflowID);
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
- expect(rows.length).toBe(1);
89
- expect(rows[0].workflow_id).toBe(workflowID);
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
- expect(txOutput.length).toBe(1);
138
- expect(txOutput[0].workflow_id).toBe(workflowID);
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
- expect(txOutput.length).toBe(1);
183
- expect(txOutput[0].workflow_id).toBe(workflowID);
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' records still stand.
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(2);
340
- for (const row of txOutput) {
341
- expect(row.error).toBeNull(); // no loser error was ever recorded
342
- expect(SuperJSON.parse(row.output!)).toBe('winner-result');
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
- expect(rows.length).toBe(1);
575
- expect(rows[0].workflow_id).toBe(workflowID);
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
- expect(rows.length).toBe(1);
640
- expect(rows[0].workflow_id).toBe(workflowID);
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();