@dbos-inc/knex-datasource 3.0.8-preview → 3.0.8-preview.g493d2d1c2b
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 +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +57 -76
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/index.ts +71 -90
- package/package.json +5 -6
- package/tests/config.test.ts +1 -1
- package/tests/datasource.test.ts +41 -164
- package/tests/test-helpers.ts +2 -3
package/index.ts
CHANGED
|
@@ -9,8 +9,6 @@ import {
|
|
|
9
9
|
runTransaction,
|
|
10
10
|
DBOSDataSource,
|
|
11
11
|
registerDataSource,
|
|
12
|
-
createTransactionCompletionSchemaPG,
|
|
13
|
-
createTransactionCompletionTablePG,
|
|
14
12
|
} from '@dbos-inc/dbos-sdk/datasource';
|
|
15
13
|
import { AsyncLocalStorage } from 'async_hooks';
|
|
16
14
|
import knex, { type Knex } from 'knex';
|
|
@@ -20,7 +18,6 @@ interface transaction_completion {
|
|
|
20
18
|
workflow_id: string;
|
|
21
19
|
function_num: number;
|
|
22
20
|
output: string | null;
|
|
23
|
-
error: string | null;
|
|
24
21
|
}
|
|
25
22
|
|
|
26
23
|
interface KnexDataSourceContext {
|
|
@@ -31,79 +28,50 @@ export type TransactionConfig = Pick<Knex.TransactionConfig, 'isolationLevel' |
|
|
|
31
28
|
|
|
32
29
|
const asyncLocalCtx = new AsyncLocalStorage<KnexDataSourceContext>();
|
|
33
30
|
|
|
34
|
-
class
|
|
31
|
+
class KnexDSTH implements DataSourceTransactionHandler {
|
|
32
|
+
readonly name: string;
|
|
35
33
|
readonly dsType = 'KnexDataSource';
|
|
36
|
-
#
|
|
34
|
+
readonly #knexDB: Knex;
|
|
37
35
|
|
|
38
|
-
constructor(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
) {}
|
|
42
|
-
|
|
43
|
-
async initialize(): Promise<void> {
|
|
44
|
-
const knexDB = this.#knexDBField;
|
|
45
|
-
this.#knexDBField = knex(this.config);
|
|
46
|
-
await knexDB?.destroy();
|
|
36
|
+
constructor(name: string, config: Knex.Config) {
|
|
37
|
+
this.name = name;
|
|
38
|
+
this.#knexDB = knex(config);
|
|
47
39
|
}
|
|
48
40
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.#knexDBField = undefined;
|
|
52
|
-
await knexDB?.destroy();
|
|
41
|
+
initialize(): Promise<void> {
|
|
42
|
+
return Promise.resolve();
|
|
53
43
|
}
|
|
54
44
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
throw new Error(`DataSource ${this.name} is not initialized.`);
|
|
58
|
-
}
|
|
59
|
-
return this.#knexDBField;
|
|
45
|
+
destroy(): Promise<void> {
|
|
46
|
+
return this.#knexDB.destroy();
|
|
60
47
|
}
|
|
61
48
|
|
|
62
|
-
async #checkExecution(
|
|
49
|
+
static async #checkExecution(
|
|
50
|
+
client: Knex.Transaction,
|
|
63
51
|
workflowID: string,
|
|
64
|
-
|
|
65
|
-
): Promise<{ output: string | null } |
|
|
66
|
-
const result = await
|
|
52
|
+
functionNum: number,
|
|
53
|
+
): Promise<{ output: string | null } | undefined> {
|
|
54
|
+
const result = await client<transaction_completion>('transaction_completion')
|
|
67
55
|
.withSchema('dbos')
|
|
68
|
-
.select('output'
|
|
56
|
+
.select('output')
|
|
69
57
|
.where({
|
|
70
58
|
workflow_id: workflowID,
|
|
71
|
-
function_num:
|
|
59
|
+
function_num: functionNum,
|
|
72
60
|
})
|
|
73
61
|
.first();
|
|
74
|
-
|
|
75
|
-
return undefined;
|
|
76
|
-
}
|
|
77
|
-
const { output, error } = result;
|
|
78
|
-
return error !== null ? { error } : { output };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async #recordError(workflowID: string, stepID: number, error: string): Promise<void> {
|
|
82
|
-
try {
|
|
83
|
-
await this.#knexDB<transaction_completion>('transaction_completion').withSchema('dbos').insert({
|
|
84
|
-
workflow_id: workflowID,
|
|
85
|
-
function_num: stepID,
|
|
86
|
-
error,
|
|
87
|
-
});
|
|
88
|
-
} catch (error) {
|
|
89
|
-
if (isPGKeyConflictError(error)) {
|
|
90
|
-
throw new DBOSWorkflowConflictError(workflowID);
|
|
91
|
-
} else {
|
|
92
|
-
throw error;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
62
|
+
return result === undefined ? undefined : { output: result.output };
|
|
95
63
|
}
|
|
96
64
|
|
|
97
65
|
static async #recordOutput(
|
|
98
66
|
client: Knex.Transaction,
|
|
99
67
|
workflowID: string,
|
|
100
|
-
|
|
68
|
+
functionNum: number,
|
|
101
69
|
output: string | null,
|
|
102
70
|
): Promise<void> {
|
|
103
71
|
try {
|
|
104
72
|
await client<transaction_completion>('transaction_completion').withSchema('dbos').insert({
|
|
105
73
|
workflow_id: workflowID,
|
|
106
|
-
function_num:
|
|
74
|
+
function_num: functionNum,
|
|
107
75
|
output,
|
|
108
76
|
});
|
|
109
77
|
} catch (error) {
|
|
@@ -122,61 +90,67 @@ class KnexTransactionHandler implements DataSourceTransactionHandler {
|
|
|
122
90
|
...args: Args
|
|
123
91
|
): Promise<Return> {
|
|
124
92
|
const workflowID = DBOS.workflowID;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
93
|
+
if (workflowID === undefined) {
|
|
94
|
+
throw new Error('Workflow ID is not set.');
|
|
95
|
+
}
|
|
96
|
+
const functionNum = DBOS.stepID;
|
|
97
|
+
if (functionNum === undefined) {
|
|
98
|
+
throw new Error('Function Number is not set.');
|
|
128
99
|
}
|
|
129
100
|
|
|
130
101
|
const readOnly = config?.readOnly ?? false;
|
|
131
|
-
const saveResults = !readOnly && workflowID !== undefined;
|
|
132
|
-
|
|
133
|
-
// Retry loop if appropriate
|
|
134
102
|
let retryWaitMS = 1;
|
|
135
103
|
const backoffFactor = 1.5;
|
|
136
|
-
const maxRetryWaitMS = 2000;
|
|
104
|
+
const maxRetryWaitMS = 2000;
|
|
137
105
|
|
|
138
106
|
while (true) {
|
|
139
|
-
// Check to see if this tx has already been executed
|
|
140
|
-
const previousResult = saveResults ? await this.#checkExecution(workflowID, stepID!) : undefined;
|
|
141
|
-
if (previousResult) {
|
|
142
|
-
DBOS.span?.setAttribute('cached', true);
|
|
143
|
-
|
|
144
|
-
if ('error' in previousResult) {
|
|
145
|
-
throw SuperJSON.parse(previousResult.error);
|
|
146
|
-
}
|
|
147
|
-
return (previousResult.output ? SuperJSON.parse(previousResult.output) : null) as Return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
107
|
try {
|
|
151
108
|
const result = await this.#knexDB.transaction<Return>(
|
|
152
109
|
async (client) => {
|
|
110
|
+
// Check to see if this tx has already been executed
|
|
111
|
+
const previousResult =
|
|
112
|
+
readOnly || !workflowID ? undefined : await KnexDSTH.#checkExecution(client, workflowID, functionNum);
|
|
113
|
+
if (previousResult) {
|
|
114
|
+
return (previousResult.output ? SuperJSON.parse(previousResult.output) : null) as Return;
|
|
115
|
+
}
|
|
116
|
+
|
|
153
117
|
// execute user's transaction function
|
|
154
118
|
const result = await asyncLocalCtx.run({ client }, async () => {
|
|
155
119
|
return (await func.call(target, ...args)) as Return;
|
|
156
120
|
});
|
|
157
121
|
|
|
158
122
|
// save the output of read/write transactions
|
|
159
|
-
if (
|
|
160
|
-
await
|
|
123
|
+
if (!readOnly && workflowID) {
|
|
124
|
+
await KnexDSTH.#recordOutput(client, workflowID, functionNum, SuperJSON.stringify(result));
|
|
125
|
+
|
|
126
|
+
// Note, existing code wraps #recordOutput call in a try/catch block that
|
|
127
|
+
// converts DB error with code 25P02 to DBOSFailedSqlTransactionError.
|
|
128
|
+
// However, existing code doesn't make any logic decisions based on that error type.
|
|
129
|
+
// DBOSFailedSqlTransactionError does stored WF ID and function name, so I assume that info is logged out somewhere
|
|
161
130
|
}
|
|
162
131
|
|
|
163
132
|
return result;
|
|
164
133
|
},
|
|
165
134
|
{ isolationLevel: config?.isolationLevel, readOnly: config?.readOnly },
|
|
166
135
|
);
|
|
136
|
+
// TODO: span.setStatus({ code: SpanStatusCode.OK });
|
|
137
|
+
// TODO: this.tracer.endSpan(span);
|
|
167
138
|
|
|
168
139
|
return result;
|
|
169
140
|
} catch (error) {
|
|
170
141
|
if (isPGRetriableTransactionError(error)) {
|
|
171
|
-
|
|
142
|
+
// TODO: span.addEvent('TXN SERIALIZATION FAILURE', { retryWaitMillis: retryWaitMillis }, performance.now());
|
|
172
143
|
await new Promise((resolve) => setTimeout(resolve, retryWaitMS));
|
|
173
144
|
retryWaitMS = Math.min(retryWaitMS * backoffFactor, maxRetryWaitMS);
|
|
174
145
|
continue;
|
|
175
146
|
} else {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
147
|
+
// TODO: span.setStatus({ code: SpanStatusCode.ERROR, message: e.message });
|
|
148
|
+
// TODO: this.tracer.endSpan(span);
|
|
149
|
+
|
|
150
|
+
// TODO: currently, we are *not* recording errors in the txOutput table.
|
|
151
|
+
// For normal execution, this is fine because we also store tx step results (output and error) in the sysdb operation output table.
|
|
152
|
+
// However, I'm concerned that we have a dueling execution hole where one tx fails while another succeeds.
|
|
153
|
+
// This implies that we can end up in a situation where the step output records an error but the txOutput table records success.
|
|
180
154
|
|
|
181
155
|
throw error;
|
|
182
156
|
}
|
|
@@ -192,7 +166,7 @@ export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
|
|
|
192
166
|
}
|
|
193
167
|
const ctx = asyncLocalCtx.getStore();
|
|
194
168
|
if (!ctx) {
|
|
195
|
-
throw new Error('
|
|
169
|
+
throw new Error('No async local context found.');
|
|
196
170
|
}
|
|
197
171
|
return ctx.client;
|
|
198
172
|
}
|
|
@@ -200,20 +174,27 @@ export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
|
|
|
200
174
|
static async initializeInternalSchema(config: Knex.Config) {
|
|
201
175
|
const knexDB = knex(config);
|
|
202
176
|
try {
|
|
203
|
-
await knexDB.
|
|
204
|
-
await knexDB.
|
|
177
|
+
await knexDB.schema.createSchemaIfNotExists('dbos');
|
|
178
|
+
const exists = await knexDB.schema.withSchema('dbos').hasTable('transaction_completion');
|
|
179
|
+
if (!exists) {
|
|
180
|
+
await knexDB.schema.withSchema('dbos').createTable('transaction_completion', (table) => {
|
|
181
|
+
table.string('workflow_id').notNullable();
|
|
182
|
+
table.integer('function_num').notNullable();
|
|
183
|
+
table.string('output').nullable();
|
|
184
|
+
table.primary(['workflow_id', 'function_num']);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
205
187
|
} finally {
|
|
206
188
|
await knexDB.destroy();
|
|
207
189
|
}
|
|
208
190
|
}
|
|
209
191
|
|
|
210
|
-
|
|
192
|
+
readonly name: string;
|
|
193
|
+
#provider: KnexDSTH;
|
|
211
194
|
|
|
212
|
-
constructor(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
) {
|
|
216
|
-
this.#provider = new KnexTransactionHandler(name, config);
|
|
195
|
+
constructor(name: string, config: Knex.Config) {
|
|
196
|
+
this.name = name;
|
|
197
|
+
this.#provider = new KnexDSTH(name, config);
|
|
217
198
|
registerDataSource(this.#provider);
|
|
218
199
|
}
|
|
219
200
|
|
|
@@ -223,10 +204,10 @@ export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
|
|
|
223
204
|
|
|
224
205
|
registerTransaction<This, Args extends unknown[], Return>(
|
|
225
206
|
func: (this: This, ...args: Args) => Promise<Return>,
|
|
207
|
+
name: string,
|
|
226
208
|
config?: TransactionConfig,
|
|
227
|
-
name?: string,
|
|
228
209
|
): (this: This, ...args: Args) => Promise<Return> {
|
|
229
|
-
return registerTransaction(this.name, func, { name
|
|
210
|
+
return registerTransaction(this.name, func, { name }, config);
|
|
230
211
|
}
|
|
231
212
|
|
|
232
213
|
transaction(config?: TransactionConfig) {
|
|
@@ -234,14 +215,14 @@ export class KnexDataSource implements DBOSDataSource<TransactionConfig> {
|
|
|
234
215
|
const ds = this;
|
|
235
216
|
return function decorator<This, Args extends unknown[], Return>(
|
|
236
217
|
_target: object,
|
|
237
|
-
propertyKey:
|
|
218
|
+
propertyKey: string,
|
|
238
219
|
descriptor: TypedPropertyDescriptor<(this: This, ...args: Args) => Promise<Return>>,
|
|
239
220
|
) {
|
|
240
221
|
if (!descriptor.value) {
|
|
241
222
|
throw Error('Use of decorator when original method is undefined');
|
|
242
223
|
}
|
|
243
224
|
|
|
244
|
-
descriptor.value = ds.registerTransaction(descriptor.value,
|
|
225
|
+
descriptor.value = ds.registerTransaction(descriptor.value, propertyKey.toString(), config);
|
|
245
226
|
|
|
246
227
|
return descriptor;
|
|
247
228
|
};
|
package/package.json
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dbos-inc/knex-datasource",
|
|
3
|
-
"version": "3.0.8-preview",
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.0.8-preview.g493d2d1c2b",
|
|
4
|
+
"description": "",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"main": "dist/index.js",
|
|
7
|
-
"types": "dist/index.d.ts",
|
|
8
|
-
"homepage": "https://docs.dbos.dev/",
|
|
9
6
|
"repository": {
|
|
10
7
|
"type": "git",
|
|
11
8
|
"url": "https://github.com/dbos-inc/dbos-transact-ts",
|
|
12
9
|
"directory": "packages/knex-datasource"
|
|
13
10
|
},
|
|
11
|
+
"homepage": "https://docs.dbos.dev/",
|
|
12
|
+
"main": "index.js",
|
|
14
13
|
"scripts": {
|
|
15
14
|
"build": "tsc --project tsconfig.json",
|
|
16
15
|
"test": "jest --detectOpenHandles"
|
|
17
16
|
},
|
|
18
17
|
"dependencies": {
|
|
19
18
|
"knex": "^3.1.0",
|
|
20
|
-
"pg": "^8.11.3",
|
|
21
19
|
"superjson": "^1.13"
|
|
22
20
|
},
|
|
23
21
|
"peerDependencies": {
|
|
@@ -27,6 +25,7 @@
|
|
|
27
25
|
"@types/jest": "^29.5.14",
|
|
28
26
|
"@types/pg": "^8.15.2",
|
|
29
27
|
"jest": "^29.7.0",
|
|
28
|
+
"pg": "^8.11.3",
|
|
30
29
|
"typescript": "^5.4.5"
|
|
31
30
|
}
|
|
32
31
|
}
|
package/tests/config.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ describe('KnexDataSource.configure', () => {
|
|
|
9
9
|
const client = new Client({ ...config, database: 'postgres' });
|
|
10
10
|
try {
|
|
11
11
|
await client.connect();
|
|
12
|
-
await dropDB(client, config.database
|
|
12
|
+
await dropDB(client, config.database);
|
|
13
13
|
await ensureDB(client, config.database);
|
|
14
14
|
} finally {
|
|
15
15
|
await client.end();
|