Skip to content

Commit dce7c21

Browse files
authored
Cron & Serialization Fixes (#1282)
- Fixed inaccurate cron decoding - Fix an issue where message serialization was ignored during replay - Fix an issue where `getResult` mishandled `MAX_RECOVERY_ATTEMPTS_EXCEEDED`
1 parent cae1e22 commit dce7c21

6 files changed

Lines changed: 166 additions & 8 deletions

File tree

src/client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
import { DBOSExecutor } from './dbos-executor';
5353
import {
5454
DBOSAwaitedWorkflowCancelledError,
55+
DBOSAwaitedWorkflowExceededMaxRecoveryAttempts,
5556
DBOSInvalidWorkflowTransitionError,
5657
DBOSQueueDuplicatedError,
5758
} from './error';
@@ -195,6 +196,9 @@ export class ClientHandle<R> implements WorkflowHandle<R> {
195196
if (res?.cancelled) {
196197
throw new DBOSAwaitedWorkflowCancelledError(this.workflowID);
197198
}
199+
if (res?.maxRecoveryAttemptsExceeded) {
200+
throw new DBOSAwaitedWorkflowExceededMaxRecoveryAttempts(this.workflowID);
201+
}
198202
return await DBOSExecutor.reviveResultOrError<R>(res!, this.systemDatabase.getSerializer());
199203
}
200204

src/scheduler/crontab.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,26 @@ function convertWeekDayName(weekExpression: string, items: string[]) {
6868
}
6969

7070
function weekDayNamesConversion(expression: string) {
71-
expression = expression.replace('7', '0');
71+
// Don't fold 7 (Sunday) into 0 here: pre-expansion it corrupts ranges like 5-7. See convertSundaySeven.
7272
expression = convertWeekDayName(expression, weekDays);
7373
return convertWeekDayName(expression, shortWeekDays);
7474
}
7575

76+
// Fold day-of-week 7 (Sunday) into 0 after range/step expansion, removing resulting duplicates.
77+
function convertSundaySeven(expressions: string[]) {
78+
const seen = new Set<string>();
79+
const folded: string[] = [];
80+
for (const value of expressions[5].split(',')) {
81+
const day = value === '7' ? '0' : value;
82+
if (!seen.has(day)) {
83+
seen.add(day);
84+
folded.push(day);
85+
}
86+
}
87+
expressions[5] = folded.join(',');
88+
return expressions;
89+
}
90+
7691
function convertAsterisk(expression: string, replacement: string) {
7792
if (expression.indexOf('*') !== -1) {
7893
return expression.replace('*', replacement);
@@ -268,6 +283,7 @@ export function convertExpression(crontab: string) {
268283
expressions = convertSteps(expressions);
269284

270285
expressions = normalizeIntegers(expressions);
286+
expressions = convertSundaySeven(expressions);
271287

272288
return expressions.join(' ');
273289
}

src/system_database.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export interface SystemDatabaseStoredResult {
4848
maxRecoveryAttemptsExceeded?: boolean;
4949
childWorkflowID?: string | null;
5050
functionName?: string;
51-
serialization?: string | null; // Only for WF result, not step
51+
serialization?: string | null; // For WF result, and for steps that persist a format (recv/getEvent)
5252
}
5353

5454
/* Exported workflow format for import/export */
@@ -2099,7 +2099,7 @@ export class SystemDatabase {
20992099
`INSERT INTO "${this.schemaName}".workflow_events (workflow_uuid, key, value, serialization)
21002100
VALUES ($1, $2, $3, $4)
21012101
ON CONFLICT (workflow_uuid, key)
2102-
DO UPDATE SET value = $3
2102+
DO UPDATE SET value = $3, serialization = $4
21032103
RETURNING workflow_uuid;`,
21042104
[workflowID, key, message, serialization],
21052105
);
@@ -2108,7 +2108,7 @@ export class SystemDatabase {
21082108
`INSERT INTO "${this.schemaName}".workflow_events_history (workflow_uuid, function_id, key, value, serialization)
21092109
VALUES ($1, $2, $3, $4, $5)
21102110
ON CONFLICT (workflow_uuid, function_id, key)
2111-
DO UPDATE SET value = $4;`,
2111+
DO UPDATE SET value = $4, serialization = $5;`,
21122112
[workflowID, functionID, key, message, serialization],
21132113
);
21142114
return undefined;
@@ -2151,7 +2151,7 @@ export class SystemDatabase {
21512151
res.functionName!,
21522152
);
21532153
}
2154-
return { serializedValue: res.output!, serialization: null };
2154+
return { serializedValue: res.output!, serialization: res.serialization ?? null };
21552155
}
21562156
}
21572157

@@ -3857,7 +3857,7 @@ export class SystemDatabase {
38573857
await this.#checkIfCanceled(client, workflowID);
38583858

38593859
const { rows } = await client.query<operation_outputs>(
3860-
`SELECT output, error, child_workflow_id, function_name
3860+
`SELECT output, error, child_workflow_id, function_name, serialization
38613861
FROM "${this.schemaName}".operation_outputs
38623862
WHERE workflow_uuid=$1 AND function_id=$2`,
38633863
[workflowID, functionID],
@@ -3870,6 +3870,8 @@ export class SystemDatabase {
38703870
error: rows[0].error,
38713871
childWorkflowID: rows[0].child_workflow_id,
38723872
functionName: rows[0].function_name,
3873+
// Return serialization so recv/getEvent replay deserializes with the stored format, not the default.
3874+
serialization: rows[0].serialization,
38733875
};
38743876
}
38753877
}

tests/client.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import { workflow_status } from '../schemas/system_db_schema';
22
import { DBOS, DBOSClient, StatusString } from '../src';
33
import { globalParams, sleepms } from '../src/utils';
4-
import { generateDBOSTestConfig, recoverPendingWorkflows, setUpDBOSTestSysDb } from './helpers';
4+
import {
5+
generateDBOSTestConfig,
6+
recoverPendingWorkflows,
7+
setUpDBOSTestSysDb,
8+
setWfAndChildrenToPending,
9+
} from './helpers';
510
import { Client, PoolConfig } from 'pg';
611
import { spawnSync } from 'child_process';
7-
import { DBOSQueueDuplicatedError, DBOSAwaitedWorkflowCancelledError } from '../src/error';
12+
import {
13+
DBOSQueueDuplicatedError,
14+
DBOSAwaitedWorkflowCancelledError,
15+
DBOSAwaitedWorkflowExceededMaxRecoveryAttempts,
16+
} from '../src/error';
817
import { randomUUID } from 'crypto';
918
import { DBOSConfig } from '../src/dbos-executor';
1019
import { DEFAULT_POOL_SIZE } from '../src/system_database';
@@ -91,6 +100,16 @@ class ClientTest {
91100
}
92101
}
93102

103+
const DLQ_MAX_RECOVERY_ATTEMPTS = 2;
104+
105+
class ClientDLQTest {
106+
// Succeeds when run, but is forced back to PENDING repeatedly until it exhausts recovery attempts (DLQ).
107+
@DBOS.workflow({ maxRecoveryAttempts: DLQ_MAX_RECOVERY_ATTEMPTS })
108+
static async dlqWorkflow(): Promise<string> {
109+
return Promise.resolve('should-not-be-returned');
110+
}
111+
}
112+
94113
type EnqueueTest = typeof ClientTest.enqueueTest;
95114

96115
function runClientSendWorker(workflowID: string, topic: string, appVersion: string) {
@@ -394,6 +413,31 @@ describe('DBOSClient', () => {
394413
}
395414
});
396415

416+
test('DBOSClient-getResult-throws-on-max-recovery-attempts-exceeded', async () => {
417+
// A workflow past its max recovery attempts must make client getResult throw, not silently return null.
418+
await DBOS.launch();
419+
const client = await DBOSClient.create({ systemDatabaseUrl });
420+
try {
421+
const handle = await DBOS.startWorkflow(ClientDLQTest).dlqWorkflow();
422+
await handle.getResult();
423+
424+
// Force back to PENDING and recover repeatedly until it exhausts recovery attempts and lands in the DLQ.
425+
for (let i = 0; i < DLQ_MAX_RECOVERY_ATTEMPTS; i++) {
426+
await setWfAndChildrenToPending(handle.workflowID, false);
427+
await (await recoverPendingWorkflows())[0].getResult();
428+
}
429+
await setWfAndChildrenToPending(handle.workflowID, false);
430+
await recoverPendingWorkflows();
431+
expect((await handle.getStatus())?.status).toBe(StatusString.MAX_RECOVERY_ATTEMPTS_EXCEEDED);
432+
433+
await expect(client.retrieveWorkflow(handle.workflowID).getResult()).rejects.toThrow(
434+
DBOSAwaitedWorkflowExceededMaxRecoveryAttempts,
435+
);
436+
} finally {
437+
await client.destroy();
438+
}
439+
});
440+
397441
test('DBOSClient-enqueue-and-get-result-portable', async () => {
398442
await DBOS.launch();
399443
await registerTestQueue();

tests/crontab.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ describe('week-day-names-conversion', () => {
118118
const weekDays = conversion('* * * * 7').split(' ')[5];
119119
expect(weekDays).toBe('0');
120120
});
121+
122+
// Regression: day-of-week 7 (Sunday) must fold to 0 only after ranges expand, so ranges ending in 7 survive.
123+
it('should expand day-of-week ranges that include 7 (Sunday) correctly', () => {
124+
expect(conversion('* * * * 5-7').split(' ')[5]).toBe('5,6,0'); // Fri,Sat,Sun
125+
expect(conversion('* * * * 6-7').split(' ')[5]).toBe('6,0'); // Sat,Sun
126+
expect(conversion('* * * * 1-7').split(' ')[5]).toBe('1,2,3,4,5,6,0'); // every day
127+
expect(conversion('* * * * 0-7').split(' ')[5]).toBe('0,1,2,3,4,5,6'); // every day, deduped
128+
expect(conversion('* * * * 1,7').split(' ')[5]).toBe('1,0'); // Mon,Sun
129+
});
121130
});
122131

123132
/////////////

tests/portableser.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,37 @@ const dateWorkflow = DBOS.registerWorkflow(
133133
},
134134
);
135135

136+
// Re-sets one event key twice with different formats; the second setEvent must overwrite value AND serialization.
137+
const reSetEventWorkflow = DBOS.registerWorkflow(
138+
async () => {
139+
await DBOS.setEvent('rekey', 10n, { serializationType: 'portable' });
140+
await DBOS.setEvent('rekey', 10n, { serializationType: 'native' });
141+
},
142+
{ name: 'reSetEventWorkflow' },
143+
);
144+
145+
// Workflows for the OAOO replay test: recv/getEvent must keep their recorded serialization across replay.
146+
const portableEventSetterWF = DBOS.registerWorkflow(
147+
async (input: string) => {
148+
await DBOS.setEvent('evt', input, { serializationType: 'portable' });
149+
},
150+
{ name: 'portableEventSetterWF' },
151+
);
152+
153+
const eventGetterWF = DBOS.registerWorkflow(
154+
async (setterId: string) => {
155+
return await DBOS.getEvent<string>(setterId, 'evt');
156+
},
157+
{ name: 'eventGetterWF' },
158+
);
159+
160+
const portableRecvWF = DBOS.registerWorkflow(
161+
async () => {
162+
return await DBOS.recv<string>('topic');
163+
},
164+
{ name: 'portableRecvWF' },
165+
);
166+
136167
describe('portable-serizlization-tests', () => {
137168
let config: DBOSConfig;
138169
let systemDBClient: Client;
@@ -333,6 +364,20 @@ describe('portable-serizlization-tests', () => {
333364
}
334365
});
335366

367+
test('test-setevent-reset-updates-serialization', async () => {
368+
// Re-set portable then native: the native format must win so getEvent recovers 10n, not the superjson envelope.
369+
const handle = await DBOS.startWorkflow(reSetEventWorkflow)();
370+
await handle.getResult();
371+
372+
const row = await systemDBClient.query<workflow_events>(
373+
`SELECT * FROM dbos.workflow_events WHERE workflow_uuid = $1 AND key = $2`,
374+
[handle.workflowID, 'rekey'],
375+
);
376+
expect(row.rows[0].serialization).toBe(DBOSJSON.name());
377+
378+
expect(await DBOS.getEvent(handle.workflowID, 'rekey')).toBe(10n);
379+
});
380+
336381
// Reproduces https://github.com/dbos-inc/dbos-transact-ts/issues/1208
337382
test('test-portable-void-workflow', async () => {
338383
// A portable workflow that returns undefined should succeed, not throw
@@ -958,6 +1003,44 @@ describe('custom-serializer-restart-tests', () => {
9581003

9591004
process.env.DBOS__APPVERSION = undefined;
9601005
});
1006+
1007+
test('test-recv-getevent-preserve-serialization-on-replay', async () => {
1008+
// Under the base64 global serializer, replayed recv/getEvent must use the stored portable format or deserialization throws.
1009+
process.env.DBOS__APPVERSION = 'v0';
1010+
await setUpDBOSTestSysDb(config);
1011+
config.serializer = base64Serializer;
1012+
DBOS.setConfig(config);
1013+
await DBOS.launch();
1014+
1015+
systemDBClient = new Client({ connectionString: config.systemDatabaseUrl });
1016+
await systemDBClient.connect();
1017+
1018+
// --- getEvent path ---
1019+
const setter = await DBOS.startWorkflow(portableEventSetterWF)('event-payload');
1020+
await setter.getResult();
1021+
// Sanity check: the event was stored in portable format, not base64.
1022+
const evtRow = await systemDBClient.query<workflow_events>(
1023+
`SELECT * FROM dbos.workflow_events WHERE workflow_uuid = $1 AND key = $2`,
1024+
[setter.workflowID, 'evt'],
1025+
);
1026+
expect(evtRow.rows[0].serialization).toBe(DBOSPortableJSON.name());
1027+
1028+
const getter = await DBOS.startWorkflow(eventGetterWF)(setter.workflowID);
1029+
expect(await getter.getResult()).toBe('event-payload');
1030+
// Replay the getter: getEvent hits OAOO and must still return the value.
1031+
const reGetter = await reexecuteWorkflowById(getter.workflowID);
1032+
expect(await reGetter?.getResult()).toBe('event-payload');
1033+
1034+
// --- recv path ---
1035+
const receiver = await DBOS.startWorkflow(portableRecvWF)();
1036+
await DBOS.send(receiver.workflowID, 'msg-payload', 'topic', undefined, { serializationType: 'portable' });
1037+
expect(await receiver.getResult()).toBe('msg-payload');
1038+
// Replay the receiver: recv hits OAOO and must still return the message.
1039+
const reReceiver = await reexecuteWorkflowById(receiver.workflowID);
1040+
expect(await reReceiver?.getResult()).toBe('msg-payload');
1041+
1042+
process.env.DBOS__APPVERSION = undefined;
1043+
});
9611044
});
9621045

9631046
// Workflows for async-serializer tests (registered at module level)

0 commit comments

Comments
 (0)