@dbos-inc/node-pg-datasource 5.3.4-preview → 5.3.5-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/package.json +1 -1
- package/index.ts +0 -366
- package/jest.config.js +0 -8
- package/tests/config.test.ts +0 -31
- package/tests/datasource.test.ts +0 -813
- package/tests/test-helpers.ts +0 -13
- package/tsconfig.json +0 -9
package/package.json
CHANGED
package/index.ts
DELETED
|
@@ -1,366 +0,0 @@
|
|
|
1
|
-
// using https://github.com/brianc/node-postgres
|
|
2
|
-
|
|
3
|
-
import { DBOS, FunctionName } from '@dbos-inc/dbos-sdk';
|
|
4
|
-
import {
|
|
5
|
-
type DataSourceTransactionHandler,
|
|
6
|
-
isPGRetriableTransactionError,
|
|
7
|
-
DBOSError,
|
|
8
|
-
DBOSStepAlreadyRecordedError,
|
|
9
|
-
DBOSWorkflowConflictError,
|
|
10
|
-
assertStillOwnsWorkflow,
|
|
11
|
-
replayRecordedStep,
|
|
12
|
-
registerTransaction,
|
|
13
|
-
runTransaction,
|
|
14
|
-
PGTransactionConfig,
|
|
15
|
-
DBOSDataSource,
|
|
16
|
-
registerDataSource,
|
|
17
|
-
DataSourceMigrationOptions,
|
|
18
|
-
DataSourceSQLExecutor,
|
|
19
|
-
initializeDataSourceSchemaPG,
|
|
20
|
-
migrateDataSourcePG,
|
|
21
|
-
verifyDataSourcePG,
|
|
22
|
-
} from '@dbos-inc/dbos-sdk/datasource';
|
|
23
|
-
import { Client, type ClientBase, type ClientConfig, Pool, type PoolConfig } from 'pg';
|
|
24
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
25
|
-
import { SuperJSON } from 'superjson';
|
|
26
|
-
|
|
27
|
-
interface NodePostgresDataSourceContext {
|
|
28
|
-
client: ClientBase;
|
|
29
|
-
owner: NodePostgresTransactionHandler;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
interface NodePostgresTransactionOptions extends PGTransactionConfig {
|
|
33
|
-
name?: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export { NodePostgresTransactionOptions };
|
|
37
|
-
|
|
38
|
-
const asyncLocalCtx = new AsyncLocalStorage<NodePostgresDataSourceContext>();
|
|
39
|
-
|
|
40
|
-
function clientExecutor(client: ClientBase): DataSourceSQLExecutor {
|
|
41
|
-
return async (sql) => (await client.query<Record<string, unknown>>(sql)).rows;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
class NodePostgresTransactionHandler implements DataSourceTransactionHandler {
|
|
45
|
-
#poolField: Pool | undefined;
|
|
46
|
-
readonly schemaName: string;
|
|
47
|
-
|
|
48
|
-
constructor(
|
|
49
|
-
readonly name: string,
|
|
50
|
-
private readonly config: PoolConfig,
|
|
51
|
-
schemaName: string = 'dbos',
|
|
52
|
-
private readonly options: DataSourceMigrationOptions = {},
|
|
53
|
-
) {
|
|
54
|
-
this.schemaName = schemaName;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async initialize(): Promise<void> {
|
|
58
|
-
const pool = this.#poolField;
|
|
59
|
-
this.#poolField = new Pool(this.config);
|
|
60
|
-
await pool?.end();
|
|
61
|
-
|
|
62
|
-
if (this.options.runMigrations === false) {
|
|
63
|
-
const client = await this.#poolField.connect();
|
|
64
|
-
try {
|
|
65
|
-
await verifyDataSourcePG(clientExecutor(client), this.schemaName, this.name);
|
|
66
|
-
} finally {
|
|
67
|
-
client.release();
|
|
68
|
-
}
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
await this.#transaction((client) => migrateDataSourcePG(clientExecutor(client), this.schemaName));
|
|
74
|
-
} catch (err) {
|
|
75
|
-
throw new Error(
|
|
76
|
-
`In initialization of 'NodePostgresDataSource' ${this.name}: The '${this.schemaName}' transaction schema could not be migrated: ${(err as Error).message}. This should be added to your database migrations.
|
|
77
|
-
See: https://docs.dbos.dev/typescript/tutorials/transaction-tutorial#installing-the-dbos-schema`,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async destroy(): Promise<void> {
|
|
83
|
-
const pool = this.#poolField;
|
|
84
|
-
this.#poolField = undefined;
|
|
85
|
-
await pool?.end();
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
get #pool(): Pool {
|
|
89
|
-
if (!this.#poolField) {
|
|
90
|
-
throw new Error(`DataSource ${this.name} is not initialized.`);
|
|
91
|
-
}
|
|
92
|
-
return this.#poolField;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async deleteCheckpoints(workflowID: string, startStep: number, beforeCommit?: () => Promise<void>): Promise<void> {
|
|
96
|
-
await this.#transaction(async (client) => {
|
|
97
|
-
await client.query(
|
|
98
|
-
/*sql*/
|
|
99
|
-
`DELETE FROM "${this.schemaName}".transaction_completion
|
|
100
|
-
WHERE workflow_id = $1 AND function_num >= $2`,
|
|
101
|
-
[workflowID, startStep],
|
|
102
|
-
);
|
|
103
|
-
await beforeCommit?.();
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
async #checkExecution(
|
|
108
|
-
workflowID: string,
|
|
109
|
-
stepID: number,
|
|
110
|
-
): Promise<{ output: string | null } | { error: string } | undefined> {
|
|
111
|
-
type Result = { output: string | null; error: string | null };
|
|
112
|
-
const { rows } = await this.#pool.query<Result>(
|
|
113
|
-
/*sql*/
|
|
114
|
-
`SELECT output, error FROM "${this.schemaName}".transaction_completion
|
|
115
|
-
WHERE workflow_id = $1 AND function_num = $2`,
|
|
116
|
-
[workflowID, stepID],
|
|
117
|
-
);
|
|
118
|
-
if (rows.length === 0) {
|
|
119
|
-
return undefined;
|
|
120
|
-
}
|
|
121
|
-
const { output, error } = rows[0];
|
|
122
|
-
return error !== null ? { error } : { output };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
static async #recordOutput(
|
|
126
|
-
client: ClientBase,
|
|
127
|
-
workflowID: string,
|
|
128
|
-
stepID: number,
|
|
129
|
-
output: string | null,
|
|
130
|
-
schemaName: string,
|
|
131
|
-
): Promise<void> {
|
|
132
|
-
const { rows } = await client.query(
|
|
133
|
-
/*sql*/
|
|
134
|
-
`INSERT INTO "${schemaName}".transaction_completion (workflow_id, function_num, output)
|
|
135
|
-
VALUES ($1, $2, $3)
|
|
136
|
-
ON CONFLICT (workflow_id, function_num) DO NOTHING
|
|
137
|
-
RETURNING workflow_id`,
|
|
138
|
-
[workflowID, stepID, output],
|
|
139
|
-
);
|
|
140
|
-
if (rows.length === 0) {
|
|
141
|
-
throw new DBOSStepAlreadyRecordedError(workflowID, stepID);
|
|
142
|
-
}
|
|
143
|
-
// Holding this step's row, so a later owner's insert waits on our commit.
|
|
144
|
-
await assertStillOwnsWorkflow(workflowID);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async #recordError(workflowID: string, stepID: number, error: string): Promise<void> {
|
|
148
|
-
const { rows } = await this.#pool.query(
|
|
149
|
-
/*sql*/
|
|
150
|
-
`INSERT INTO "${this.schemaName}".transaction_completion (workflow_id, function_num, error)
|
|
151
|
-
VALUES ($1, $2, $3)
|
|
152
|
-
ON CONFLICT (workflow_id, function_num) DO NOTHING
|
|
153
|
-
RETURNING workflow_id`,
|
|
154
|
-
[workflowID, stepID, error],
|
|
155
|
-
);
|
|
156
|
-
if (rows.length === 0) {
|
|
157
|
-
throw new DBOSStepAlreadyRecordedError(workflowID, stepID);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// A duplicate execution won the race, so its recorded outcome is the durable one.
|
|
162
|
-
async #replayConflictingStep<Return>(workflowID: string, stepID: number): Promise<Return> {
|
|
163
|
-
const recorded = await this.#checkExecution(workflowID, stepID);
|
|
164
|
-
if (recorded === undefined) {
|
|
165
|
-
throw new DBOSError(
|
|
166
|
-
`Step ${stepID} of workflow ${workflowID} conflicted with a concurrent execution, but no recorded outcome was found`,
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
return replayRecordedStep<Return>(recorded);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async #transaction<Return>(
|
|
173
|
-
func: (client: ClientBase) => Promise<Return>,
|
|
174
|
-
config: NodePostgresTransactionOptions = {},
|
|
175
|
-
): Promise<Return> {
|
|
176
|
-
const isolationLevel = config?.isolationLevel ? `ISOLATION LEVEL ${config.isolationLevel}` : '';
|
|
177
|
-
const readOnly = config?.readOnly ?? false;
|
|
178
|
-
const accessMode = config?.readOnly === undefined ? '' : readOnly ? 'READ ONLY' : 'READ WRITE';
|
|
179
|
-
|
|
180
|
-
const client = await this.#pool.connect();
|
|
181
|
-
try {
|
|
182
|
-
await client.query(/*sql*/ `BEGIN ${isolationLevel} ${accessMode}`);
|
|
183
|
-
const result = await func(client);
|
|
184
|
-
await client.query(/*sql*/ `COMMIT`);
|
|
185
|
-
return result;
|
|
186
|
-
} catch (error) {
|
|
187
|
-
await client.query(/*sql*/ `ROLLBACK`);
|
|
188
|
-
throw error;
|
|
189
|
-
} finally {
|
|
190
|
-
client.release();
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
async invokeTransactionFunction<This, Args extends unknown[], Return>(
|
|
195
|
-
config: NodePostgresTransactionOptions | undefined,
|
|
196
|
-
target: This,
|
|
197
|
-
func: (this: This, ...args: Args) => Promise<Return>,
|
|
198
|
-
...args: Args
|
|
199
|
-
): Promise<Return> {
|
|
200
|
-
const workflowID = DBOS.workflowID;
|
|
201
|
-
const stepID = DBOS.stepID;
|
|
202
|
-
if (workflowID !== undefined && stepID === undefined) {
|
|
203
|
-
throw new Error('DBOS.stepID is undefined inside a workflow.');
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const readOnly = config?.readOnly ?? false;
|
|
207
|
-
const saveResults = !readOnly && workflowID !== undefined;
|
|
208
|
-
|
|
209
|
-
// Retry loop if appropriate
|
|
210
|
-
let retryWaitMS = 1;
|
|
211
|
-
const backoffFactor = 1.5;
|
|
212
|
-
const maxRetryWaitMS = 2000; // Maximum wait 2 seconds.
|
|
213
|
-
|
|
214
|
-
while (true) {
|
|
215
|
-
// Check to see if this tx has already been executed
|
|
216
|
-
const previousResult = saveResults ? await this.#checkExecution(workflowID, stepID!) : undefined;
|
|
217
|
-
if (previousResult) {
|
|
218
|
-
return replayRecordedStep<Return>(previousResult);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
try {
|
|
222
|
-
const result = await this.#transaction<Return>(async (client) => {
|
|
223
|
-
// execute user's transaction function
|
|
224
|
-
const result = await asyncLocalCtx.run({ client, owner: this }, async () => {
|
|
225
|
-
return (await func.call(target, ...args)) as Return;
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
// save the output of read/write transactions
|
|
229
|
-
if (saveResults) {
|
|
230
|
-
await NodePostgresTransactionHandler.#recordOutput(
|
|
231
|
-
client,
|
|
232
|
-
workflowID,
|
|
233
|
-
stepID!,
|
|
234
|
-
SuperJSON.stringify(result),
|
|
235
|
-
this.schemaName,
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
return result;
|
|
240
|
-
}, config);
|
|
241
|
-
|
|
242
|
-
return result;
|
|
243
|
-
} catch (error) {
|
|
244
|
-
if (saveResults && error instanceof DBOSStepAlreadyRecordedError) {
|
|
245
|
-
return await this.#replayConflictingStep<Return>(workflowID, stepID!);
|
|
246
|
-
}
|
|
247
|
-
// The new owner wins; recording an error here would replay it as this step's outcome.
|
|
248
|
-
if (error instanceof DBOSWorkflowConflictError) throw error;
|
|
249
|
-
if (isPGRetriableTransactionError(error)) {
|
|
250
|
-
DBOS.span?.addEvent('TXN SERIALIZATION FAILURE', { retryWaitMillis: retryWaitMS }, performance.now());
|
|
251
|
-
await new Promise((resolve) => setTimeout(resolve, retryWaitMS));
|
|
252
|
-
retryWaitMS = Math.min(retryWaitMS * backoffFactor, maxRetryWaitMS);
|
|
253
|
-
continue;
|
|
254
|
-
} else {
|
|
255
|
-
if (saveResults) {
|
|
256
|
-
const message = SuperJSON.stringify(error);
|
|
257
|
-
try {
|
|
258
|
-
await this.#recordError(workflowID, stepID!, message);
|
|
259
|
-
} catch (recordError) {
|
|
260
|
-
if (recordError instanceof DBOSStepAlreadyRecordedError) {
|
|
261
|
-
return await this.#replayConflictingStep<Return>(workflowID, stepID!);
|
|
262
|
-
}
|
|
263
|
-
throw recordError;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
throw error;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
export class NodePostgresDataSource implements DBOSDataSource<NodePostgresTransactionOptions> {
|
|
275
|
-
static #getClient(p?: NodePostgresTransactionHandler): ClientBase {
|
|
276
|
-
if (!DBOS.isInTransaction()) {
|
|
277
|
-
throw new Error('Invalid use of NodePostgresDataSource.client outside of a DBOS transaction.');
|
|
278
|
-
}
|
|
279
|
-
const ctx = asyncLocalCtx.getStore();
|
|
280
|
-
if (!ctx) {
|
|
281
|
-
throw new Error('Invalid use of NodePostgresDataSource.client outside of a DBOS transaction.');
|
|
282
|
-
}
|
|
283
|
-
if (p && p !== ctx.owner) {
|
|
284
|
-
throw new Error('Use of `NodePostgresDataSource.client` from the wrong object');
|
|
285
|
-
}
|
|
286
|
-
return ctx.client;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
static get client() {
|
|
290
|
-
return NodePostgresDataSource.#getClient(undefined);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
get client() {
|
|
294
|
-
return NodePostgresDataSource.#getClient(this.#provider);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Create or migrate the DBOS transaction schema, typically with a privileged role,
|
|
299
|
-
* optionally granting `applicationRole` the minimal permissions to use it.
|
|
300
|
-
*/
|
|
301
|
-
static async initializeDBOSSchema(
|
|
302
|
-
config: ClientConfig,
|
|
303
|
-
schemaName: string = 'dbos',
|
|
304
|
-
options: { applicationRole?: string } = {},
|
|
305
|
-
): Promise<void> {
|
|
306
|
-
const client = new Client(config);
|
|
307
|
-
try {
|
|
308
|
-
await client.connect();
|
|
309
|
-
await client.query('BEGIN');
|
|
310
|
-
try {
|
|
311
|
-
await initializeDataSourceSchemaPG(clientExecutor(client), schemaName, options.applicationRole);
|
|
312
|
-
await client.query('COMMIT');
|
|
313
|
-
} catch (err) {
|
|
314
|
-
await client.query('ROLLBACK');
|
|
315
|
-
throw err;
|
|
316
|
-
}
|
|
317
|
-
} finally {
|
|
318
|
-
await client.end();
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
#provider: NodePostgresTransactionHandler;
|
|
323
|
-
|
|
324
|
-
constructor(
|
|
325
|
-
readonly name: string,
|
|
326
|
-
config: PoolConfig,
|
|
327
|
-
schemaName: string = 'dbos',
|
|
328
|
-
options: DataSourceMigrationOptions = {},
|
|
329
|
-
) {
|
|
330
|
-
this.#provider = new NodePostgresTransactionHandler(name, config, schemaName, options);
|
|
331
|
-
registerDataSource(this.#provider);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
async runTransaction<T>(func: () => Promise<T>, config?: NodePostgresTransactionOptions) {
|
|
335
|
-
return await runTransaction(func, config?.name ?? func.name, { dsName: this.name, config });
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
registerTransaction<This, Args extends unknown[], Return>(
|
|
339
|
-
func: (this: This, ...args: Args) => Promise<Return>,
|
|
340
|
-
config?: NodePostgresTransactionOptions & FunctionName,
|
|
341
|
-
): (this: This, ...args: Args) => Promise<Return> {
|
|
342
|
-
return registerTransaction(this.name, func, config);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
transaction(config?: NodePostgresTransactionOptions) {
|
|
346
|
-
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
347
|
-
const ds = this;
|
|
348
|
-
return function decorator<This, Args extends unknown[], Return>(
|
|
349
|
-
target: object,
|
|
350
|
-
propertyKey: PropertyKey,
|
|
351
|
-
descriptor: TypedPropertyDescriptor<(this: This, ...args: Args) => Promise<Return>>,
|
|
352
|
-
) {
|
|
353
|
-
if (!descriptor.value) {
|
|
354
|
-
throw Error('Use of decorator when original method is undefined');
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
descriptor.value = ds.registerTransaction(descriptor.value, {
|
|
358
|
-
...config,
|
|
359
|
-
name: config?.name ?? String(propertyKey),
|
|
360
|
-
ctorOrProto: target,
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
return descriptor;
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
}
|
package/jest.config.js
DELETED
package/tests/config.test.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import { Client } from 'pg';
|
|
2
|
-
import { NodePostgresDataSource } from '../index';
|
|
3
|
-
import { dropDB, ensureDB } from './test-helpers';
|
|
4
|
-
|
|
5
|
-
describe('NodePostgresDataSource.configure', () => {
|
|
6
|
-
const config = { user: 'postgres', database: 'nodepg_ds_config_test' };
|
|
7
|
-
|
|
8
|
-
beforeAll(async () => {
|
|
9
|
-
const client = new Client({ ...config, database: 'postgres' });
|
|
10
|
-
try {
|
|
11
|
-
await client.connect();
|
|
12
|
-
await dropDB(client, config.database, true);
|
|
13
|
-
await ensureDB(client, config.database);
|
|
14
|
-
} finally {
|
|
15
|
-
await client.end();
|
|
16
|
-
}
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test('configure creates tx outputs table', async () => {
|
|
20
|
-
await NodePostgresDataSource.initializeDBOSSchema(config);
|
|
21
|
-
|
|
22
|
-
const client = new Client(config);
|
|
23
|
-
try {
|
|
24
|
-
await client.connect();
|
|
25
|
-
const result = await client.query('SELECT workflow_id, function_num, output FROM dbos.transaction_completion');
|
|
26
|
-
expect(result.rows.length).toBe(0);
|
|
27
|
-
} finally {
|
|
28
|
-
await client.end();
|
|
29
|
-
}
|
|
30
|
-
});
|
|
31
|
-
});
|
package/tests/datasource.test.ts
DELETED
|
@@ -1,813 +0,0 @@
|
|
|
1
|
-
import { DBOS, DBOSWorkflowConflictError } from '@dbos-inc/dbos-sdk';
|
|
2
|
-
import { Client, Pool } from 'pg';
|
|
3
|
-
import { NodePostgresDataSource } from '..';
|
|
4
|
-
import { dropDB, ensureDB } from './test-helpers';
|
|
5
|
-
import { randomUUID } from 'crypto';
|
|
6
|
-
import { SuperJSON } from 'superjson';
|
|
7
|
-
|
|
8
|
-
const config = { user: 'postgres', database: 'node_pg_ds_test_datasource' };
|
|
9
|
-
const dataSource = new NodePostgresDataSource('app-db', config);
|
|
10
|
-
|
|
11
|
-
interface transaction_completion {
|
|
12
|
-
workflow_id: string;
|
|
13
|
-
function_num: number;
|
|
14
|
-
output: string | null;
|
|
15
|
-
error: string | null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
describe('NodePostgresDataSource', () => {
|
|
19
|
-
const userDB = new Pool(config);
|
|
20
|
-
|
|
21
|
-
beforeAll(async () => {
|
|
22
|
-
await createDatabases(userDB, true);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
afterAll(async () => {
|
|
26
|
-
await userDB.end();
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
beforeEach(async () => {
|
|
30
|
-
DBOS.setConfig({ name: 'node-pg-ds-test' });
|
|
31
|
-
await DBOS.launch();
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
afterEach(async () => {
|
|
35
|
-
await DBOS.shutdown();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test('insert dataSource.register function', async () => {
|
|
39
|
-
const user = 'helloTest1';
|
|
40
|
-
|
|
41
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
42
|
-
const workflowID = randomUUID();
|
|
43
|
-
|
|
44
|
-
await expect(DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowReg(user))).resolves.toMatchObject({
|
|
45
|
-
user,
|
|
46
|
-
greet_count: 1,
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const { rows } = await userDB.query<transaction_completion>(
|
|
50
|
-
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
|
|
51
|
-
[workflowID],
|
|
52
|
-
);
|
|
53
|
-
// Completion cleared the checkpoint the transaction wrote.
|
|
54
|
-
expect(rows).toHaveLength(0);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('rewind drops the checkpoints past the cut', async () => {
|
|
58
|
-
const user = 'rewindTest';
|
|
59
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
60
|
-
rewindState = newRewindState();
|
|
61
|
-
const workflowID = randomUUID();
|
|
62
|
-
|
|
63
|
-
// A cancelled workflow keeps its checkpoints, so the rewind has some to cut.
|
|
64
|
-
const execution = DBOS.withNextWorkflowID(workflowID, () => regGatedTwoInsertWorkflow(user));
|
|
65
|
-
await Promise.race([rewindState.atGate.opened, execution]);
|
|
66
|
-
await DBOS.cancelWorkflow(workflowID);
|
|
67
|
-
rewindState.release.open();
|
|
68
|
-
await expect(execution).rejects.toThrow(/has been cancelled/);
|
|
69
|
-
expect((await completions(workflowID)).map((r) => r.function_num)).toEqual([0, 1]);
|
|
70
|
-
|
|
71
|
-
// Hold the queue's only worker so the rewound workflow waits while its checkpoints are inspected.
|
|
72
|
-
await DBOS.registerQueue('nodepg_rewind_gate', { workerConcurrency: 1, onConflict: 'always_update' });
|
|
73
|
-
const blocker = await DBOS.startWorkflow(regQueueBlocker, { queueName: 'nodepg_rewind_gate' })();
|
|
74
|
-
await rewindState.blockerStarted.opened;
|
|
75
|
-
const handle = await DBOS.rewindWorkflow<number[]>(workflowID, {
|
|
76
|
-
startStep: 1,
|
|
77
|
-
queueName: 'nodepg_rewind_gate',
|
|
78
|
-
});
|
|
79
|
-
expect((await completions(workflowID)).map((r) => r.function_num)).toEqual([0]);
|
|
80
|
-
|
|
81
|
-
// The first transaction replays and still reports 1; the second really runs again, taking the table to 3.
|
|
82
|
-
rewindState.unblock.open();
|
|
83
|
-
await blocker.getResult();
|
|
84
|
-
await expect(handle.getResult()).resolves.toEqual([1, 3]);
|
|
85
|
-
expect(await completions(workflowID)).toHaveLength(0);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
async function completions(workflowID: string) {
|
|
89
|
-
const { rows } = await userDB.query<transaction_completion>(
|
|
90
|
-
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1 ORDER BY function_num',
|
|
91
|
-
[workflowID],
|
|
92
|
-
);
|
|
93
|
-
return rows;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
test('rerun insert dataSource.register function', async () => {
|
|
97
|
-
const user = 'rerunTest1';
|
|
98
|
-
|
|
99
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
100
|
-
const workflowID = randomUUID();
|
|
101
|
-
|
|
102
|
-
const result = await DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowReg(user));
|
|
103
|
-
expect(result).toMatchObject({ user, greet_count: 1 });
|
|
104
|
-
|
|
105
|
-
await expect(DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowReg(user))).resolves.toMatchObject(result);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test('insert dataSource.runAsTx function', async () => {
|
|
109
|
-
const user = 'helloTest2';
|
|
110
|
-
|
|
111
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
112
|
-
const workflowID = randomUUID();
|
|
113
|
-
|
|
114
|
-
await expect(DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowRunTx(user))).resolves.toMatchObject({
|
|
115
|
-
user,
|
|
116
|
-
greet_count: 1,
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
const { rows } = await userDB.query<transaction_completion>(
|
|
120
|
-
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
|
|
121
|
-
[workflowID],
|
|
122
|
-
);
|
|
123
|
-
// Completion cleared the checkpoint the transaction wrote.
|
|
124
|
-
expect(rows).toHaveLength(0);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test('rerun insert dataSource.runAsTx function', async () => {
|
|
128
|
-
const user = 'rerunTest2';
|
|
129
|
-
|
|
130
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
131
|
-
const workflowID = randomUUID();
|
|
132
|
-
|
|
133
|
-
const result = await DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowRunTx(user));
|
|
134
|
-
expect(result).toMatchObject({ user, greet_count: 1 });
|
|
135
|
-
|
|
136
|
-
await expect(DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowRunTx(user))).resolves.toMatchObject(
|
|
137
|
-
result,
|
|
138
|
-
);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
async function throws<R>(func: () => Promise<R>): Promise<unknown> {
|
|
142
|
-
try {
|
|
143
|
-
await func();
|
|
144
|
-
fail('Expected function to throw an error');
|
|
145
|
-
} catch (error) {
|
|
146
|
-
return error;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
test('error dataSource.register function', async () => {
|
|
151
|
-
const user = 'errorTest1';
|
|
152
|
-
|
|
153
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
154
|
-
await userDB.query('INSERT INTO greetings("name","greet_count") VALUES($1,10);', [user]);
|
|
155
|
-
const workflowID = randomUUID();
|
|
156
|
-
|
|
157
|
-
const error = await throws(() => DBOS.withNextWorkflowID(workflowID, () => regErrorWorkflowReg(user)));
|
|
158
|
-
expect(error).toBeInstanceOf(Error);
|
|
159
|
-
expect((error as Error).message).toMatch(/^test error \d+$/);
|
|
160
|
-
|
|
161
|
-
const { rows } = await userDB.query<greetings>('SELECT * FROM greetings WHERE name = $1', [user]);
|
|
162
|
-
expect(rows.length).toBe(1);
|
|
163
|
-
expect(rows[0].greet_count).toBe(10);
|
|
164
|
-
|
|
165
|
-
const { rows: txOutput } = await userDB.query<transaction_completion>(
|
|
166
|
-
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
|
|
167
|
-
[workflowID],
|
|
168
|
-
);
|
|
169
|
-
// Completion cleared the checkpoint the transaction wrote.
|
|
170
|
-
expect(txOutput).toHaveLength(0);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test('rerun error dataSource.register function', async () => {
|
|
174
|
-
const user = 'rerunErrorTest1';
|
|
175
|
-
|
|
176
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
177
|
-
await userDB.query('INSERT INTO greetings("name","greet_count") VALUES($1,10);', [user]);
|
|
178
|
-
const workflowID = randomUUID();
|
|
179
|
-
|
|
180
|
-
const error = await throws(() => DBOS.withNextWorkflowID(workflowID, () => regErrorWorkflowReg(user)));
|
|
181
|
-
expect(error).toBeInstanceOf(Error);
|
|
182
|
-
expect((error as Error).message).toMatch(/^test error \d+$/);
|
|
183
|
-
|
|
184
|
-
const error2 = await throws(() => DBOS.withNextWorkflowID(workflowID, () => regErrorWorkflowReg(user)));
|
|
185
|
-
expect(error2).toBeInstanceOf(Error);
|
|
186
|
-
expect((error2 as Error).message).toMatch((error as Error).message);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
test('error dataSource.runAsTx function', async () => {
|
|
190
|
-
const user = 'errorTest2';
|
|
191
|
-
|
|
192
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
193
|
-
await userDB.query('INSERT INTO greetings("name","greet_count") VALUES($1,10);', [user]);
|
|
194
|
-
const workflowID = randomUUID();
|
|
195
|
-
|
|
196
|
-
const error = await throws(() => DBOS.withNextWorkflowID(workflowID, () => regErrorWorkflowRunTx(user)));
|
|
197
|
-
expect(error).toBeInstanceOf(Error);
|
|
198
|
-
expect((error as Error).message).toMatch(/^test error \d+$/);
|
|
199
|
-
|
|
200
|
-
const { rows } = await userDB.query<greetings>('SELECT * FROM greetings WHERE name = $1', [user]);
|
|
201
|
-
expect(rows.length).toBe(1);
|
|
202
|
-
expect(rows[0].greet_count).toBe(10);
|
|
203
|
-
|
|
204
|
-
const { rows: txOutput } = await userDB.query<transaction_completion>(
|
|
205
|
-
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
|
|
206
|
-
[workflowID],
|
|
207
|
-
);
|
|
208
|
-
// Completion cleared the checkpoint the transaction wrote.
|
|
209
|
-
expect(txOutput).toHaveLength(0);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test('rerun error dataSource.runAsTx function', async () => {
|
|
213
|
-
const user = 'rerunErrorTest2';
|
|
214
|
-
|
|
215
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
216
|
-
await userDB.query('INSERT INTO greetings("name","greet_count") VALUES($1,10);', [user]);
|
|
217
|
-
const workflowID = randomUUID();
|
|
218
|
-
|
|
219
|
-
const error = await throws(() => DBOS.withNextWorkflowID(workflowID, () => regErrorWorkflowRunTx(user)));
|
|
220
|
-
expect(error).toBeInstanceOf(Error);
|
|
221
|
-
expect((error as Error).message).toMatch(/^test error \d+$/);
|
|
222
|
-
|
|
223
|
-
const error2 = await throws(() => DBOS.withNextWorkflowID(workflowID, () => regErrorWorkflowRunTx(user)));
|
|
224
|
-
expect(error2).toBeInstanceOf(Error);
|
|
225
|
-
expect((error2 as Error).message).toMatch((error as Error).message);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test('readonly dataSource.register function', async () => {
|
|
229
|
-
const user = 'readTest1';
|
|
230
|
-
|
|
231
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
232
|
-
await userDB.query('INSERT INTO greetings("name","greet_count") VALUES($1,10);', [user]);
|
|
233
|
-
|
|
234
|
-
const workflowID = randomUUID();
|
|
235
|
-
await expect(DBOS.withNextWorkflowID(workflowID, () => regReadWorkflowReg(user))).resolves.toMatchObject({
|
|
236
|
-
user,
|
|
237
|
-
greet_count: 10,
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
const { rows } = await userDB.query('SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1', [
|
|
241
|
-
workflowID,
|
|
242
|
-
]);
|
|
243
|
-
expect(rows.length).toBe(0);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
test('readonly dataSource.runAsTx function', async () => {
|
|
247
|
-
const user = 'readTest2';
|
|
248
|
-
|
|
249
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
250
|
-
await userDB.query('INSERT INTO greetings("name","greet_count") VALUES($1,10);', [user]);
|
|
251
|
-
|
|
252
|
-
const workflowID = randomUUID();
|
|
253
|
-
await expect(DBOS.withNextWorkflowID(workflowID, () => regReadWorkflowRunTx(user))).resolves.toMatchObject({
|
|
254
|
-
user,
|
|
255
|
-
greet_count: 10,
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
const { rows } = await userDB.query('SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1', [
|
|
259
|
-
workflowID,
|
|
260
|
-
]);
|
|
261
|
-
expect(rows.length).toBe(0);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
test('static dataSource.register methods', async () => {
|
|
265
|
-
const user = 'staticTest1';
|
|
266
|
-
|
|
267
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
268
|
-
|
|
269
|
-
const workflowID = randomUUID();
|
|
270
|
-
await expect(DBOS.withNextWorkflowID(workflowID, () => regStaticWorkflow(user))).resolves.toMatchObject([
|
|
271
|
-
{ user, greet_count: 1 },
|
|
272
|
-
{ user, greet_count: 1 },
|
|
273
|
-
]);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
test('instance dataSource.register methods', async () => {
|
|
277
|
-
const user = 'instanceTest1';
|
|
278
|
-
|
|
279
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
280
|
-
|
|
281
|
-
const workflowID = randomUUID();
|
|
282
|
-
await expect(DBOS.withNextWorkflowID(workflowID, () => regInstanceWorkflow(user))).resolves.toMatchObject([
|
|
283
|
-
{ user, greet_count: 1 },
|
|
284
|
-
{ user, greet_count: 1 },
|
|
285
|
-
]);
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
test('invoke-reg-tx-fun-outside-wf', async () => {
|
|
289
|
-
const user = 'outsideWfUser' + Date.now();
|
|
290
|
-
const result = await regInsertFunction(user);
|
|
291
|
-
expect(result).toMatchObject({ user, greet_count: 1 });
|
|
292
|
-
|
|
293
|
-
const txResults = await userDB.query('SELECT * FROM dbos.transaction_completion WHERE output LIKE $1', [
|
|
294
|
-
`%${user}%`,
|
|
295
|
-
]);
|
|
296
|
-
expect(txResults.rows.length).toBe(0);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
test('invoke-reg-tx-static-method-outside-wf', async () => {
|
|
300
|
-
const user = 'outsideWfUser' + Date.now();
|
|
301
|
-
const result = await StaticClass.insertFunction(user);
|
|
302
|
-
expect(result).toMatchObject({ user, greet_count: 1 });
|
|
303
|
-
|
|
304
|
-
const txResults = await userDB.query('SELECT * FROM dbos.transaction_completion WHERE output LIKE $1', [
|
|
305
|
-
`%${user}%`,
|
|
306
|
-
]);
|
|
307
|
-
expect(txResults.rows.length).toBe(0);
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
test('invoke-reg-tx-inst-method-outside-wf', async () => {
|
|
311
|
-
const user = 'outsideWfUser' + Date.now();
|
|
312
|
-
const instance = new InstanceClass();
|
|
313
|
-
const result = await instance.insertFunction(user);
|
|
314
|
-
expect(result).toMatchObject({ user, greet_count: 1 });
|
|
315
|
-
|
|
316
|
-
const txResults = await userDB.query('SELECT * FROM dbos.transaction_completion WHERE output LIKE $1', [
|
|
317
|
-
`%${user}%`,
|
|
318
|
-
]);
|
|
319
|
-
expect(txResults.rows.length).toBe(0);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
test('duplicate execution replays the winner result', async () => {
|
|
323
|
-
await userDB.query('DELETE FROM race_side_effects');
|
|
324
|
-
raceState.callCount = 0;
|
|
325
|
-
|
|
326
|
-
// A loser whose body succeeds: the collision happens on the result-recording insert.
|
|
327
|
-
raceState.plantWinner = true;
|
|
328
|
-
raceState.plantWinnerError = false;
|
|
329
|
-
raceState.fail = false;
|
|
330
|
-
const wfid1 = randomUUID();
|
|
331
|
-
await expect(DBOS.withNextWorkflowID(wfid1, () => regRaceWorkflow())).resolves.toBe('winner-result');
|
|
332
|
-
expect(raceState.callCount).toBe(1); // the loser did run its body
|
|
333
|
-
|
|
334
|
-
// A loser whose body fails: the collision moves to the error-recording insert,
|
|
335
|
-
// and the winner's result still wins over the loser's own error.
|
|
336
|
-
raceState.plantWinner = true;
|
|
337
|
-
raceState.plantWinnerError = false;
|
|
338
|
-
raceState.fail = true;
|
|
339
|
-
const wfid2 = randomUUID();
|
|
340
|
-
await expect(DBOS.withNextWorkflowID(wfid2, () => regRaceWorkflow())).resolves.toBe('winner-result');
|
|
341
|
-
expect(raceState.callCount).toBe(2);
|
|
342
|
-
|
|
343
|
-
// A winner that recorded an error: the loser replays that throw instead of its own result.
|
|
344
|
-
raceState.plantWinner = true;
|
|
345
|
-
raceState.plantWinnerError = true;
|
|
346
|
-
raceState.fail = false;
|
|
347
|
-
const wfid3 = randomUUID();
|
|
348
|
-
const winnerError = await throws(() => DBOS.withNextWorkflowID(wfid3, () => regRaceWorkflow()));
|
|
349
|
-
expect((winnerError as Error).message).toBe('winner-error');
|
|
350
|
-
expect(raceState.callCount).toBe(3);
|
|
351
|
-
|
|
352
|
-
// Every loser's writes were discarded, and completion cleared the winners' checkpoints.
|
|
353
|
-
const { rows: tags } = await userDB.query<{ tag: string }>('SELECT tag FROM race_side_effects');
|
|
354
|
-
expect(tags).toHaveLength(0);
|
|
355
|
-
const { rows: txOutput } = await userDB.query<transaction_completion>(
|
|
356
|
-
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = ANY($1)',
|
|
357
|
-
[[wfid1, wfid2, wfid3]],
|
|
358
|
-
);
|
|
359
|
-
expect(txOutput).toHaveLength(0);
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
/** Completion clears the checkpoints, so the workflow reads them back while it still runs. */
|
|
363
|
-
test('transactions record their outcomes as checkpoints', async () => {
|
|
364
|
-
const user = 'checkpointProbe';
|
|
365
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
366
|
-
const workflowID = randomUUID();
|
|
367
|
-
const message = await DBOS.withNextWorkflowID(workflowID, () => regCheckpointProbeWorkflow(user));
|
|
368
|
-
expect(message).toMatch(/^test error \d+$/);
|
|
369
|
-
|
|
370
|
-
expect(probeState.rows).toHaveLength(2);
|
|
371
|
-
const [output, error] = probeState.rows;
|
|
372
|
-
expect(output).toMatchObject({ workflow_id: workflowID, function_num: 0, error: null });
|
|
373
|
-
expect(SuperJSON.parse(output.output!)).toMatchObject({ user, greet_count: 1 });
|
|
374
|
-
expect(error).toMatchObject({ workflow_id: workflowID, function_num: 1, output: null });
|
|
375
|
-
expect(SuperJSON.parse<Error>(error.error!).message).toBe(message);
|
|
376
|
-
expect(await readCompletions(workflowID)).toHaveLength(0);
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
/** The checkpoint may be the new owner's only record of the transaction, so a stale execution's cleanup rolls back. */
|
|
380
|
-
test('a stale execution keeps the checkpoints at completion', async () => {
|
|
381
|
-
const user = 'handOffTest';
|
|
382
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
383
|
-
const handedOff = new Promise<void>((resolve) => (handOffState.signal = resolve));
|
|
384
|
-
const workflowID = randomUUID();
|
|
385
|
-
const execution = DBOS.withNextWorkflowID(workflowID, () => regHandOffWorkflow(user));
|
|
386
|
-
// Surfaces the workflow's own error if it fails before handing off.
|
|
387
|
-
await Promise.race([handedOff, execution]);
|
|
388
|
-
|
|
389
|
-
// Its outcome is refused too, so the execution parks until the workflow is cancelled.
|
|
390
|
-
await DBOS.cancelWorkflow(workflowID);
|
|
391
|
-
await expect(execution).rejects.toThrow(/has been cancelled/);
|
|
392
|
-
expect(await readCompletions(workflowID)).toHaveLength(1);
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
/** A stale execution cannot apply a step the new owner also runs, nor leave an outcome for it. */
|
|
396
|
-
test('an execution that loses the workflow mid-transaction rolls back', async () => {
|
|
397
|
-
await userDB.query('DELETE FROM race_side_effects');
|
|
398
|
-
const workflowID = randomUUID();
|
|
399
|
-
await expect(DBOS.withNextWorkflowID(workflowID, () => regStaleWorkflow())).resolves.toBe('conflicted');
|
|
400
|
-
|
|
401
|
-
const { rows: tags } = await userDB.query<{ tag: string }>('SELECT tag FROM race_side_effects');
|
|
402
|
-
expect(tags).toHaveLength(0);
|
|
403
|
-
expect(staleState.completionsAtConflict).toBe(0);
|
|
404
|
-
const sysDB = new Client(sysConfig);
|
|
405
|
-
try {
|
|
406
|
-
await sysDB.connect();
|
|
407
|
-
const { rows: steps } = await sysDB.query('SELECT * FROM dbos.operation_outputs WHERE workflow_uuid = $1', [
|
|
408
|
-
workflowID,
|
|
409
|
-
]);
|
|
410
|
-
expect(steps).toHaveLength(0);
|
|
411
|
-
} finally {
|
|
412
|
-
await sysDB.end();
|
|
413
|
-
}
|
|
414
|
-
});
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
export interface greetings {
|
|
418
|
-
name: string;
|
|
419
|
-
greet_count: number;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
async function createDatabases(userDB: Pool, createTxCompletion: boolean) {
|
|
423
|
-
{
|
|
424
|
-
const client = new Client({ ...config, database: 'postgres' });
|
|
425
|
-
try {
|
|
426
|
-
await client.connect();
|
|
427
|
-
await dropDB(client, 'node_pg_ds_test', true);
|
|
428
|
-
await dropDB(client, 'node_pg_ds_test_dbos_sys', true);
|
|
429
|
-
await dropDB(client, config.database, true);
|
|
430
|
-
await ensureDB(client, config.database);
|
|
431
|
-
} finally {
|
|
432
|
-
await client.end();
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
{
|
|
437
|
-
const client = await userDB.connect();
|
|
438
|
-
try {
|
|
439
|
-
await client.query(
|
|
440
|
-
'CREATE TABLE greetings(name text NOT NULL, greet_count integer DEFAULT 0, PRIMARY KEY(name))',
|
|
441
|
-
);
|
|
442
|
-
await client.query('CREATE TABLE race_side_effects(tag text NOT NULL)');
|
|
443
|
-
} finally {
|
|
444
|
-
client.release();
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
if (createTxCompletion) {
|
|
449
|
-
await NodePostgresDataSource.initializeDBOSSchema(config);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
async function insertFunction(user: string) {
|
|
454
|
-
const { rows } = await NodePostgresDataSource.client.query<Pick<greetings, 'greet_count'>>(
|
|
455
|
-
`INSERT INTO greetings(name, greet_count)
|
|
456
|
-
VALUES($1, 1)
|
|
457
|
-
ON CONFLICT(name)
|
|
458
|
-
DO UPDATE SET greet_count = greetings.greet_count + 1
|
|
459
|
-
RETURNING greet_count`,
|
|
460
|
-
[user],
|
|
461
|
-
);
|
|
462
|
-
const row = rows.length > 0 ? rows[0] : undefined;
|
|
463
|
-
return { user, greet_count: row?.greet_count, now: Date.now() };
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
async function errorFunction(user: string) {
|
|
467
|
-
const _result = await insertFunction(user);
|
|
468
|
-
throw new Error(`test error ${Date.now()}`);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
const raceState = { callCount: 0, plantWinner: false, plantWinnerError: false, fail: false };
|
|
472
|
-
|
|
473
|
-
async function raceFunction() {
|
|
474
|
-
raceState.callCount += 1;
|
|
475
|
-
await NodePostgresDataSource.client.query('INSERT INTO race_side_effects(tag) VALUES ($1)', [
|
|
476
|
-
`run-${raceState.callCount}`,
|
|
477
|
-
]);
|
|
478
|
-
if (raceState.plantWinner) {
|
|
479
|
-
raceState.plantWinner = false;
|
|
480
|
-
await plantWinnerCompletion();
|
|
481
|
-
}
|
|
482
|
-
if (raceState.fail) {
|
|
483
|
-
throw new Error("loser's own failure");
|
|
484
|
-
}
|
|
485
|
-
return `result-${raceState.callCount}`;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// A duplicate execution commits its result while this transaction is still open, so this
|
|
489
|
-
// execution's pre-check missed it but its completion insert will collide with it.
|
|
490
|
-
async function plantWinnerCompletion() {
|
|
491
|
-
const winner = new Client(config);
|
|
492
|
-
try {
|
|
493
|
-
await winner.connect();
|
|
494
|
-
const [column, value] = raceState.plantWinnerError
|
|
495
|
-
? ['error', SuperJSON.stringify(new Error('winner-error'))]
|
|
496
|
-
: ['output', SuperJSON.stringify('winner-result')];
|
|
497
|
-
await winner.query(
|
|
498
|
-
`INSERT INTO dbos.transaction_completion (workflow_id, function_num, ${column}) VALUES ($1, $2, $3)`,
|
|
499
|
-
[DBOS.workflowID, DBOS.stepID, value],
|
|
500
|
-
);
|
|
501
|
-
} finally {
|
|
502
|
-
await winner.end();
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
async function readFunction(user: string) {
|
|
507
|
-
const { rows } = await dataSource.client.query<Pick<greetings, 'greet_count'>>(
|
|
508
|
-
`SELECT greet_count
|
|
509
|
-
FROM greetings
|
|
510
|
-
WHERE name = $1`,
|
|
511
|
-
[user],
|
|
512
|
-
);
|
|
513
|
-
const row = rows.length > 0 ? rows[0] : undefined;
|
|
514
|
-
return { user, greet_count: row?.greet_count, now: Date.now() };
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
const regRaceFunction = dataSource.registerTransaction(raceFunction);
|
|
518
|
-
|
|
519
|
-
async function raceWorkflow() {
|
|
520
|
-
return await regRaceFunction();
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const regRaceWorkflow = DBOS.registerWorkflow(raceWorkflow);
|
|
524
|
-
|
|
525
|
-
const regInsertFunction = dataSource.registerTransaction(insertFunction);
|
|
526
|
-
|
|
527
|
-
const regErrorFunction = dataSource.registerTransaction(errorFunction);
|
|
528
|
-
|
|
529
|
-
const sysConfig = { ...config, database: 'node_pg_ds_test_dbos_sys' };
|
|
530
|
-
|
|
531
|
-
/** Set the workflow's ownership token, returning the one it replaced. */
|
|
532
|
-
async function setOwner(workflowID: string, ownerXid: string | null): Promise<string | null> {
|
|
533
|
-
const sysDB = new Client(sysConfig);
|
|
534
|
-
try {
|
|
535
|
-
await sysDB.connect();
|
|
536
|
-
const { rows } = await sysDB.query<{ owner_xid: string | null }>(
|
|
537
|
-
'SELECT owner_xid FROM dbos.workflow_status WHERE workflow_uuid = $1',
|
|
538
|
-
[workflowID],
|
|
539
|
-
);
|
|
540
|
-
await sysDB.query('UPDATE dbos.workflow_status SET owner_xid = $2 WHERE workflow_uuid = $1', [
|
|
541
|
-
workflowID,
|
|
542
|
-
ownerXid,
|
|
543
|
-
]);
|
|
544
|
-
return rows[0].owner_xid;
|
|
545
|
-
} finally {
|
|
546
|
-
await sysDB.end();
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
/** The checkpoints this data source holds for the workflow, read on a connection of their own. */
|
|
551
|
-
async function readCompletions(workflowID: string): Promise<transaction_completion[]> {
|
|
552
|
-
const client = new Client(config);
|
|
553
|
-
try {
|
|
554
|
-
await client.connect();
|
|
555
|
-
const { rows } = await client.query<transaction_completion>(
|
|
556
|
-
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1 ORDER BY function_num',
|
|
557
|
-
[workflowID],
|
|
558
|
-
);
|
|
559
|
-
return rows;
|
|
560
|
-
} finally {
|
|
561
|
-
await client.end();
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
const probeState = { rows: [] as transaction_completion[] };
|
|
566
|
-
|
|
567
|
-
// Reads back the checkpoints its transactions wrote, before completion clears them.
|
|
568
|
-
async function checkpointProbeWorkflow(user: string) {
|
|
569
|
-
await regInsertFunction(user);
|
|
570
|
-
const error = await regErrorFunction(user).then(
|
|
571
|
-
() => undefined,
|
|
572
|
-
(e: Error) => e,
|
|
573
|
-
);
|
|
574
|
-
probeState.rows = await readCompletions(DBOS.workflowID!);
|
|
575
|
-
return error?.message;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
const regCheckpointProbeWorkflow = DBOS.registerWorkflow(checkpointProbeWorkflow);
|
|
579
|
-
|
|
580
|
-
const staleState = { originalOwner: null as string | null, completionsAtConflict: -1 };
|
|
581
|
-
|
|
582
|
-
// Another execution claims the workflow while this transaction is still open.
|
|
583
|
-
async function staleFunction() {
|
|
584
|
-
await NodePostgresDataSource.client.query("INSERT INTO race_side_effects(tag) VALUES ('stale')");
|
|
585
|
-
staleState.originalOwner = await setOwner(DBOS.workflowID!, 'another-execution');
|
|
586
|
-
return 'stale';
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
const regStaleFunction = dataSource.registerTransaction(staleFunction);
|
|
590
|
-
|
|
591
|
-
async function staleWorkflow() {
|
|
592
|
-
try {
|
|
593
|
-
return await regStaleFunction();
|
|
594
|
-
} catch (e) {
|
|
595
|
-
if (!(e instanceof DBOSWorkflowConflictError)) throw e;
|
|
596
|
-
// Read before completion clears it: the conflict must not have been recorded as the step's outcome.
|
|
597
|
-
staleState.completionsAtConflict = (await readCompletions(DBOS.workflowID!)).length;
|
|
598
|
-
// A real duplicate parks here instead; reclaimed so this execution's outcome lands.
|
|
599
|
-
await setOwner(DBOS.workflowID!, staleState.originalOwner);
|
|
600
|
-
return 'conflicted';
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
const regStaleWorkflow = DBOS.registerWorkflow(staleWorkflow);
|
|
605
|
-
|
|
606
|
-
const handOffState = { signal: () => {} };
|
|
607
|
-
|
|
608
|
-
// Loses the workflow after its transaction, so its completion is a stale execution's.
|
|
609
|
-
async function handOffWorkflow(user: string) {
|
|
610
|
-
const result = await regInsertFunction(user);
|
|
611
|
-
await setOwner(DBOS.workflowID!, 'another-execution');
|
|
612
|
-
handOffState.signal();
|
|
613
|
-
return result;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const regHandOffWorkflow = DBOS.registerWorkflow(handOffWorkflow);
|
|
617
|
-
|
|
618
|
-
/** A promise with its resolver, so a test and a workflow can hand control back and forth. */
|
|
619
|
-
function latch() {
|
|
620
|
-
let open = () => {};
|
|
621
|
-
const opened = new Promise<void>((resolve) => (open = resolve));
|
|
622
|
-
return { open, opened };
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
function newRewindState() {
|
|
626
|
-
return { atGate: latch(), release: latch(), blockerStarted: latch(), unblock: latch() };
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
let rewindState = newRewindState();
|
|
630
|
-
|
|
631
|
-
// Waits after its transactions, so the test can cancel it with both checkpoints in place.
|
|
632
|
-
async function gatedTwoInsertWorkflow(user: string) {
|
|
633
|
-
const first = await regInsertFunction(user);
|
|
634
|
-
const second = await regInsertFunction(user);
|
|
635
|
-
rewindState.atGate.open();
|
|
636
|
-
await rewindState.release.opened;
|
|
637
|
-
return [first.greet_count, second.greet_count];
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
const regGatedTwoInsertWorkflow = DBOS.registerWorkflow(gatedTwoInsertWorkflow);
|
|
641
|
-
|
|
642
|
-
async function queueBlocker() {
|
|
643
|
-
rewindState.blockerStarted.open();
|
|
644
|
-
await rewindState.unblock.opened;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
const regQueueBlocker = DBOS.registerWorkflow(queueBlocker);
|
|
648
|
-
const regReadFunction = dataSource.registerTransaction(readFunction, { readOnly: true });
|
|
649
|
-
|
|
650
|
-
class StaticClass {
|
|
651
|
-
static async insertFunction(user: string) {
|
|
652
|
-
return await insertFunction(user);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
static async readFunction(user: string) {
|
|
656
|
-
return await readFunction(user);
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
StaticClass.insertFunction = dataSource.registerTransaction(StaticClass.insertFunction);
|
|
661
|
-
StaticClass.readFunction = dataSource.registerTransaction(StaticClass.readFunction, { readOnly: true });
|
|
662
|
-
|
|
663
|
-
class InstanceClass {
|
|
664
|
-
async insertFunction(user: string) {
|
|
665
|
-
return await insertFunction(user);
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
async readFunction(user: string) {
|
|
669
|
-
return await readFunction(user);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
InstanceClass.prototype.insertFunction = dataSource.registerTransaction(
|
|
674
|
-
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
675
|
-
InstanceClass.prototype.insertFunction,
|
|
676
|
-
);
|
|
677
|
-
InstanceClass.prototype.readFunction = dataSource.registerTransaction(
|
|
678
|
-
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
679
|
-
InstanceClass.prototype.readFunction,
|
|
680
|
-
{ readOnly: true },
|
|
681
|
-
);
|
|
682
|
-
|
|
683
|
-
async function insertWorkflowReg(user: string) {
|
|
684
|
-
return await regInsertFunction(user);
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
async function insertWorkflowRunTx(user: string) {
|
|
688
|
-
return await dataSource.runTransaction(() => insertFunction(user), { name: 'insertFunction' });
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
async function errorWorkflowReg(user: string) {
|
|
692
|
-
return await regErrorFunction(user);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
async function errorWorkflowRunTx(user: string) {
|
|
696
|
-
return await dataSource.runTransaction(() => errorFunction(user), { name: 'errorFunction' });
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
async function readWorkflowReg(user: string) {
|
|
700
|
-
return await regReadFunction(user);
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
async function readWorkflowRunTx(user: string) {
|
|
704
|
-
return await dataSource.runTransaction(() => readFunction(user), { name: 'readFunction', readOnly: true });
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
async function staticWorkflow(user: string) {
|
|
708
|
-
const result = await StaticClass.insertFunction(user);
|
|
709
|
-
const readResult = await StaticClass.readFunction(user);
|
|
710
|
-
return [result, readResult];
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
async function instanceWorkflow(user: string) {
|
|
714
|
-
const instance = new InstanceClass();
|
|
715
|
-
const result = await instance.insertFunction(user);
|
|
716
|
-
const readResult = await instance.readFunction(user);
|
|
717
|
-
return [result, readResult];
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
const regInsertWorkflowReg = DBOS.registerWorkflow(insertWorkflowReg);
|
|
721
|
-
const regInsertWorkflowRunTx = DBOS.registerWorkflow(insertWorkflowRunTx);
|
|
722
|
-
const regErrorWorkflowReg = DBOS.registerWorkflow(errorWorkflowReg);
|
|
723
|
-
const regErrorWorkflowRunTx = DBOS.registerWorkflow(errorWorkflowRunTx);
|
|
724
|
-
const regReadWorkflowReg = DBOS.registerWorkflow(readWorkflowReg);
|
|
725
|
-
const regReadWorkflowRunTx = DBOS.registerWorkflow(readWorkflowRunTx);
|
|
726
|
-
const regStaticWorkflow = DBOS.registerWorkflow(staticWorkflow);
|
|
727
|
-
const regInstanceWorkflow = DBOS.registerWorkflow(instanceWorkflow);
|
|
728
|
-
|
|
729
|
-
describe('NodePostgresDataSourceCreateTxC', () => {
|
|
730
|
-
const userDB = new Pool(config);
|
|
731
|
-
|
|
732
|
-
beforeAll(async () => {
|
|
733
|
-
await createDatabases(userDB, false);
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
afterAll(async () => {
|
|
737
|
-
await userDB.end();
|
|
738
|
-
});
|
|
739
|
-
|
|
740
|
-
beforeEach(async () => {
|
|
741
|
-
DBOS.setConfig({ name: 'node-pg-ds-test' });
|
|
742
|
-
await DBOS.launch();
|
|
743
|
-
});
|
|
744
|
-
|
|
745
|
-
afterEach(async () => {
|
|
746
|
-
await DBOS.shutdown();
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
test('insert dataSource.register function', async () => {
|
|
750
|
-
const user = 'helloTest1';
|
|
751
|
-
|
|
752
|
-
await userDB.query('DELETE FROM greetings WHERE name = $1', [user]);
|
|
753
|
-
const workflowID = randomUUID();
|
|
754
|
-
|
|
755
|
-
await expect(DBOS.withNextWorkflowID(workflowID, () => regInsertWorkflowReg(user))).resolves.toMatchObject({
|
|
756
|
-
user,
|
|
757
|
-
greet_count: 1,
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
const { rows } = await userDB.query<transaction_completion>(
|
|
761
|
-
'SELECT * FROM dbos.transaction_completion WHERE workflow_id = $1',
|
|
762
|
-
[workflowID],
|
|
763
|
-
);
|
|
764
|
-
// Completion cleared the checkpoint the transaction wrote.
|
|
765
|
-
expect(rows).toHaveLength(0);
|
|
766
|
-
});
|
|
767
|
-
});
|
|
768
|
-
|
|
769
|
-
describe('NodePostgresDataSource migrations', () => {
|
|
770
|
-
const userDB = new Pool(config);
|
|
771
|
-
|
|
772
|
-
afterAll(async () => {
|
|
773
|
-
await userDB.end();
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
afterEach(async () => {
|
|
777
|
-
await DBOS.shutdown();
|
|
778
|
-
});
|
|
779
|
-
|
|
780
|
-
test('verify-only initialization requires the schema to be migrated out of band', async () => {
|
|
781
|
-
const suffix = randomUUID().replace(/-/g, '').slice(0, 8);
|
|
782
|
-
const schemaName = `ds_mig_${suffix}`;
|
|
783
|
-
const role = `ds_app_${suffix}`;
|
|
784
|
-
const admin = new Client({ ...config, database: 'postgres' });
|
|
785
|
-
await admin.connect();
|
|
786
|
-
try {
|
|
787
|
-
await ensureDB(admin, config.database);
|
|
788
|
-
} finally {
|
|
789
|
-
await admin.end();
|
|
790
|
-
}
|
|
791
|
-
await userDB.query(`CREATE ROLE "${role}"`);
|
|
792
|
-
try {
|
|
793
|
-
new NodePostgresDataSource(`verify-only-${suffix}`, config, schemaName, { runMigrations: false });
|
|
794
|
-
DBOS.setConfig({ name: 'node-pg-ds-test' });
|
|
795
|
-
await expect(DBOS.launch()).rejects.toThrow(/is at transaction schema version 0/);
|
|
796
|
-
await DBOS.shutdown();
|
|
797
|
-
const { rowCount } = await userDB.query('SELECT 1 FROM pg_namespace WHERE nspname = $1', [schemaName]);
|
|
798
|
-
expect(rowCount).toBe(0);
|
|
799
|
-
|
|
800
|
-
await NodePostgresDataSource.initializeDBOSSchema(config, schemaName, { applicationRole: role });
|
|
801
|
-
await DBOS.launch();
|
|
802
|
-
const { rows } = await userDB.query<{ allowed: boolean }>(
|
|
803
|
-
`SELECT has_table_privilege($1, $2, 'INSERT') AS allowed`,
|
|
804
|
-
[role, `"${schemaName}".transaction_completion`],
|
|
805
|
-
);
|
|
806
|
-
expect(rows).toEqual([{ allowed: true }]);
|
|
807
|
-
} finally {
|
|
808
|
-
await userDB.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
|
|
809
|
-
await userDB.query(`DROP OWNED BY "${role}"`);
|
|
810
|
-
await userDB.query(`DROP ROLE "${role}"`);
|
|
811
|
-
}
|
|
812
|
-
});
|
|
813
|
-
});
|
package/tests/test-helpers.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { Client } from 'pg';
|
|
2
|
-
|
|
3
|
-
export async function ensureDB(client: Client, name: string) {
|
|
4
|
-
const results = await client.query('SELECT 1 FROM pg_database WHERE datname = $1', [name]);
|
|
5
|
-
if (results.rows.length === 0) {
|
|
6
|
-
await client.query(`CREATE DATABASE ${name}`);
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export async function dropDB(client: Client, name: string, force: boolean = false) {
|
|
11
|
-
const withForce = force ? ' WITH (FORCE)' : '';
|
|
12
|
-
await client.query(`DROP DATABASE IF EXISTS ${name} ${withForce}`);
|
|
13
|
-
}
|