@dbos-inc/kysely-datasource 4.26.5-preview → 4.26.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
@@ -1,10 +1,12 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  // using https://kysely.dev/
3
- import { DBOS, DBOSWorkflowConflictError, FunctionName } from '@dbos-inc/dbos-sdk';
3
+ import { DBOS, FunctionName } from '@dbos-inc/dbos-sdk';
4
4
  import {
5
5
  type DataSourceTransactionHandler,
6
6
  isPGRetriableTransactionError,
7
- isPGKeyConflictError,
7
+ DBOSError,
8
+ DBOSStepAlreadyRecordedError,
9
+ replayRecordedStep,
8
10
  registerTransaction,
9
11
  runTransaction,
10
12
  DBOSDataSource,
@@ -136,23 +138,31 @@ class KyselyTransactionHandler implements DataSourceTransactionHandler {
136
138
  }
137
139
 
138
140
  async #recordError(workflowID: string, stepID: number, error: string): Promise<void> {
139
- try {
140
- await this.#kyselyDB
141
- .insertInto('dbos.transaction_completion')
142
- .values({
143
- workflow_id: workflowID,
144
- function_num: stepID,
145
- error,
146
- output: null,
147
- })
148
- .execute();
149
- } catch (error) {
150
- if (isPGKeyConflictError(error)) {
151
- throw new DBOSWorkflowConflictError(workflowID);
152
- } else {
153
- throw error;
154
- }
141
+ const inserted = await this.#kyselyDB
142
+ .insertInto('dbos.transaction_completion')
143
+ .values({
144
+ workflow_id: workflowID,
145
+ function_num: stepID,
146
+ error,
147
+ output: null,
148
+ })
149
+ .onConflict((oc) => oc.columns(['workflow_id', 'function_num']).doNothing())
150
+ .returning('workflow_id')
151
+ .executeTakeFirst();
152
+ if (inserted === undefined) {
153
+ throw new DBOSStepAlreadyRecordedError(workflowID, stepID);
154
+ }
155
+ }
156
+
157
+ // A duplicate execution won the race, so its recorded outcome is the durable one.
158
+ async #replayConflictingStep<Return>(workflowID: string, stepID: number): Promise<Return> {
159
+ const recorded = await this.#checkExecution(workflowID, stepID);
160
+ if (recorded === undefined) {
161
+ throw new DBOSError(
162
+ `Step ${stepID} of workflow ${workflowID} conflicted with a concurrent execution, but no recorded outcome was found`,
163
+ );
155
164
  }
165
+ return replayRecordedStep<Return>(recorded);
156
166
  }
157
167
 
158
168
  static async #recordOutput(
@@ -161,22 +171,19 @@ class KyselyTransactionHandler implements DataSourceTransactionHandler {
161
171
  stepID: number,
162
172
  output: string | null,
163
173
  ): Promise<void> {
164
- try {
165
- await client
166
- .insertInto('dbos.transaction_completion')
167
- .values({
168
- workflow_id: workflowID,
169
- function_num: stepID,
170
- output,
171
- error: null,
172
- })
173
- .execute();
174
- } catch (error) {
175
- if (isPGKeyConflictError(error)) {
176
- throw new DBOSWorkflowConflictError(workflowID);
177
- } else {
178
- throw error;
179
- }
174
+ const inserted = await client
175
+ .insertInto('dbos.transaction_completion')
176
+ .values({
177
+ workflow_id: workflowID,
178
+ function_num: stepID,
179
+ output,
180
+ error: null,
181
+ })
182
+ .onConflict((oc) => oc.columns(['workflow_id', 'function_num']).doNothing())
183
+ .returning('workflow_id')
184
+ .executeTakeFirst();
185
+ if (inserted === undefined) {
186
+ throw new DBOSStepAlreadyRecordedError(workflowID, stepID);
180
187
  }
181
188
  }
182
189
 
@@ -204,12 +211,7 @@ class KyselyTransactionHandler implements DataSourceTransactionHandler {
204
211
  // Check to see if this tx has already been executed
205
212
  const previousResult = saveResults ? await this.#checkExecution(workflowID, stepID!) : undefined;
206
213
  if (previousResult) {
207
- DBOS.span?.setAttribute('cached', true);
208
-
209
- if ('error' in previousResult) {
210
- throw SuperJSON.parse(previousResult.error);
211
- }
212
- return (previousResult.output ? SuperJSON.parse(previousResult.output) : null) as Return;
214
+ return replayRecordedStep<Return>(previousResult);
213
215
  }
214
216
 
215
217
  try {
@@ -236,6 +238,9 @@ class KyselyTransactionHandler implements DataSourceTransactionHandler {
236
238
 
237
239
  return result;
238
240
  } catch (error) {
241
+ if (saveResults && error instanceof DBOSStepAlreadyRecordedError) {
242
+ return await this.#replayConflictingStep<Return>(workflowID, stepID!);
243
+ }
239
244
  if (isPGRetriableTransactionError(error)) {
240
245
  DBOS.span?.addEvent('TXN SERIALIZATION FAILURE', { retryWaitMillis: retryWaitMS }, performance.now());
241
246
  await new Promise((resolve) => setTimeout(resolve, retryWaitMS));
@@ -244,7 +249,14 @@ class KyselyTransactionHandler implements DataSourceTransactionHandler {
244
249
  } else {
245
250
  if (saveResults) {
246
251
  const message = SuperJSON.stringify(error);
247
- await this.#recordError(workflowID, stepID!, message);
252
+ try {
253
+ await this.#recordError(workflowID, stepID!, message);
254
+ } catch (recordError) {
255
+ if (recordError instanceof DBOSStepAlreadyRecordedError) {
256
+ return await this.#replayConflictingStep<Return>(workflowID, stepID!);
257
+ }
258
+ throw recordError;
259
+ }
248
260
  }
249
261
 
250
262
  throw error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dbos-inc/kysely-datasource",
3
- "version": "4.26.5-preview",
3
+ "version": "4.26.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",
@@ -298,6 +298,57 @@ describe('KyselyDataSource', () => {
298
298
  ]);
299
299
  expect(txResults.rows.length).toBe(0);
300
300
  });
301
+
302
+ test('duplicate execution replays the winner result', async () => {
303
+ await userDB.query('DELETE FROM race_side_effects');
304
+ raceState.callCount = 0;
305
+
306
+ // A loser whose body succeeds: the collision happens on the result-recording insert.
307
+ raceState.plantWinner = true;
308
+ raceState.plantWinnerError = false;
309
+ raceState.fail = false;
310
+ const wfid1 = randomUUID();
311
+ await expect(DBOS.withNextWorkflowID(wfid1, () => regRaceWorkflow())).resolves.toBe('winner-result');
312
+ expect(raceState.callCount).toBe(1); // the loser did run its body
313
+
314
+ // A loser whose body fails: the collision moves to the error-recording insert,
315
+ // and the winner's result still wins over the loser's own error.
316
+ raceState.plantWinner = true;
317
+ raceState.plantWinnerError = false;
318
+ raceState.fail = true;
319
+ const wfid2 = randomUUID();
320
+ await expect(DBOS.withNextWorkflowID(wfid2, () => regRaceWorkflow())).resolves.toBe('winner-result');
321
+ expect(raceState.callCount).toBe(2);
322
+
323
+ // A winner that recorded an error: the loser replays that throw instead of its own result.
324
+ raceState.plantWinner = true;
325
+ raceState.plantWinnerError = true;
326
+ raceState.fail = false;
327
+ const wfid3 = randomUUID();
328
+ const winnerError = await throws(() => DBOS.withNextWorkflowID(wfid3, () => regRaceWorkflow()));
329
+ expect((winnerError as Error).message).toBe('winner-error');
330
+ expect(raceState.callCount).toBe(3);
331
+
332
+ // Every loser's writes were discarded and the winners' records still stand.
333
+ const { rows: tags } = await userDB.query<{ tag: string }>('SELECT tag FROM race_side_effects');
334
+ expect(tags).toHaveLength(0);
335
+ const { rows: txOutput } = await userDB.query<transaction_completion>(
336
+ 'SELECT * FROM dbos.transaction_completion WHERE workflow_id = ANY($1)',
337
+ [[wfid1, wfid2]],
338
+ );
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');
343
+ }
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
+ });
301
352
  });
302
353
 
303
354
  export interface greetings {
@@ -325,6 +376,7 @@ async function createDatabases(userDB: Pool, createTxCompletion: boolean) {
325
376
  await client.query(
326
377
  'CREATE TABLE greetings(name text NOT NULL, greet_count integer DEFAULT 0, PRIMARY KEY(name))',
327
378
  );
379
+ await client.query('CREATE TABLE race_side_effects(tag text NOT NULL)');
328
380
  } finally {
329
381
  client.release();
330
382
  }
@@ -351,6 +403,47 @@ async function errorFunction(user: string) {
351
403
  throw new Error(`test error ${Date.now()}`);
352
404
  }
353
405
 
406
+ const raceState = { callCount: 0, plantWinner: false, plantWinnerError: false, fail: false };
407
+
408
+ async function raceFunction() {
409
+ raceState.callCount += 1;
410
+ await sql`INSERT INTO race_side_effects(tag) VALUES (${`run-${raceState.callCount}`})`.execute(dataSource.client);
411
+ if (raceState.plantWinner) {
412
+ raceState.plantWinner = false;
413
+ await plantWinnerCompletion();
414
+ }
415
+ if (raceState.fail) {
416
+ throw new Error("loser's own failure");
417
+ }
418
+ return `result-${raceState.callCount}`;
419
+ }
420
+
421
+ // A duplicate execution commits its result while this transaction is still open, so this
422
+ // execution's pre-check missed it but its completion insert will collide with it.
423
+ async function plantWinnerCompletion() {
424
+ const winner = new Client(config.connection);
425
+ try {
426
+ await winner.connect();
427
+ const [column, value] = raceState.plantWinnerError
428
+ ? ['error', SuperJSON.stringify(new Error('winner-error'))]
429
+ : ['output', SuperJSON.stringify('winner-result')];
430
+ await winner.query(
431
+ `INSERT INTO dbos.transaction_completion (workflow_id, function_num, ${column}) VALUES ($1, $2, $3)`,
432
+ [DBOS.workflowID, DBOS.stepID, value],
433
+ );
434
+ } finally {
435
+ await winner.end();
436
+ }
437
+ }
438
+
439
+ const regRaceFunction = dataSource.registerTransaction(raceFunction);
440
+
441
+ async function raceWorkflow() {
442
+ return await regRaceFunction();
443
+ }
444
+
445
+ const regRaceWorkflow = DBOS.registerWorkflow(raceWorkflow);
446
+
354
447
  async function readFunction(user: string) {
355
448
  const row = await dataSource.client
356
449
  .selectFrom('greetings')