@dbos-inc/kysely-datasource 4.26.6-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/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -41
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/index.ts +53 -41
- package/package.json +1 -1
- package/tests/datasource.test.ts +93 -0
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,
|
|
3
|
+
import { DBOS, FunctionName } from '@dbos-inc/dbos-sdk';
|
|
4
4
|
import {
|
|
5
5
|
type DataSourceTransactionHandler,
|
|
6
6
|
isPGRetriableTransactionError,
|
|
7
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/tests/datasource.test.ts
CHANGED
|
@@ -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')
|