Skip to content

Commit 809e0fe

Browse files
authored
Allow specifying milliseconds to sleep() (#165)
Signed-off-by: David Khourshid <[email protected]>
1 parent a28bc37 commit 809e0fe

File tree

7 files changed

+255
-30
lines changed

7 files changed

+255
-30
lines changed

.changeset/sixty-baboons-wonder.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/core": patch
3+
---
4+
5+
Add support for specifying milliseconds in `sleep()`

packages/core/e2e/e2e.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ async function triggerWorkflow(
2727
});
2828
if (!res.ok) {
2929
throw new Error(
30-
`Failed to trigger workflow: ${res.url} ${res.status}: ${await res.text()}`
30+
`Failed to trigger workflow: ${res.url} ${
31+
res.status
32+
}: ${await res.text()}`
3133
);
3234
}
3335
const run = await res.json();

packages/core/src/sleep.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,18 @@ export async function sleep(duration: StringValue): Promise<void>;
2424
*/
2525
export async function sleep(date: Date): Promise<void>;
2626

27-
export async function sleep(param: StringValue | Date): Promise<void> {
27+
/**
28+
* Sleep within a workflow for a given duration in milliseconds.
29+
*
30+
* This is a built-in runtime function that uses timer events in the event log.
31+
*
32+
* @param durationMs - The duration to sleep for in milliseconds.
33+
* @overload
34+
* @returns A promise that resolves when the sleep is complete.
35+
*/
36+
export async function sleep(durationMs: number): Promise<void>;
37+
38+
export async function sleep(param: StringValue | Date | number): Promise<void> {
2839
// Inside the workflow VM, the sleep function is stored in the globalThis object behind a symbol
2940
const sleepFn = (globalThis as any)[WORKFLOW_SLEEP];
3041
if (!sleepFn) {

packages/core/src/util.test.ts

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from 'vitest';
2-
import { buildWorkflowSuspensionMessage, getWorkflowRunStreamId } from './util';
2+
import {
3+
buildWorkflowSuspensionMessage,
4+
getWorkflowRunStreamId,
5+
parseDurationToDate,
6+
} from './util';
37

48
describe('buildWorkflowSuspensionMessage', () => {
59
const runId = 'test-run-123';
@@ -165,3 +169,174 @@ describe('getWorkflowRunStreamId', () => {
165169
expect(result.includes('_user')).toBe(true);
166170
});
167171
});
172+
173+
describe('parseDurationToDate', () => {
174+
describe('string durations', () => {
175+
it('should parse seconds', () => {
176+
const before = Date.now();
177+
const result = parseDurationToDate('5s');
178+
const after = Date.now();
179+
expect(result.getTime()).toBeGreaterThanOrEqual(before + 5000);
180+
expect(result.getTime()).toBeLessThanOrEqual(after + 5000);
181+
});
182+
183+
it('should parse minutes', () => {
184+
const before = Date.now();
185+
const result = parseDurationToDate('2m');
186+
const after = Date.now();
187+
const expected = before + 120000;
188+
expect(result.getTime()).toBeGreaterThanOrEqual(expected);
189+
expect(result.getTime()).toBeLessThanOrEqual(after + 120000);
190+
});
191+
192+
it('should parse hours', () => {
193+
const before = Date.now();
194+
const result = parseDurationToDate('1h');
195+
const after = Date.now();
196+
const expected = before + 3600000;
197+
expect(result.getTime()).toBeGreaterThanOrEqual(expected);
198+
expect(result.getTime()).toBeLessThanOrEqual(after + 3600000);
199+
});
200+
201+
it('should parse days', () => {
202+
const before = Date.now();
203+
const result = parseDurationToDate('1d');
204+
const after = Date.now();
205+
const expected = before + 86400000;
206+
expect(result.getTime()).toBeGreaterThanOrEqual(expected);
207+
expect(result.getTime()).toBeLessThanOrEqual(after + 86400000);
208+
});
209+
210+
it('should parse milliseconds', () => {
211+
const before = Date.now();
212+
const result = parseDurationToDate('500ms');
213+
const after = Date.now();
214+
const expected = before + 500;
215+
expect(result.getTime()).toBeGreaterThanOrEqual(expected);
216+
expect(result.getTime()).toBeLessThanOrEqual(after + 500);
217+
});
218+
219+
it('should throw error for invalid string', () => {
220+
expect(() =>
221+
parseDurationToDate(
222+
// @ts-expect-error
223+
'invalid'
224+
)
225+
).toThrow(
226+
'Invalid duration: "invalid". Expected a valid duration string like "1s", "1m", "1h", etc.'
227+
);
228+
});
229+
230+
it('should throw error for negative duration string', () => {
231+
expect(() => parseDurationToDate('-1s')).toThrow(
232+
'Invalid duration: "-1s". Expected a valid duration string like "1s", "1m", "1h", etc.'
233+
);
234+
});
235+
});
236+
237+
describe('number durations (milliseconds)', () => {
238+
it('should parse zero milliseconds', () => {
239+
const before = Date.now();
240+
const result = parseDurationToDate(0);
241+
const after = Date.now();
242+
expect(result.getTime()).toBeGreaterThanOrEqual(before);
243+
expect(result.getTime()).toBeLessThanOrEqual(after);
244+
});
245+
246+
it('should parse positive milliseconds', () => {
247+
const before = Date.now();
248+
const result = parseDurationToDate(10000);
249+
const after = Date.now();
250+
const expected = before + 10000;
251+
expect(result.getTime()).toBeGreaterThanOrEqual(expected);
252+
expect(result.getTime()).toBeLessThanOrEqual(after + 10000);
253+
});
254+
255+
it('should parse large milliseconds', () => {
256+
const before = Date.now();
257+
const result = parseDurationToDate(1000000);
258+
const after = Date.now();
259+
const expected = before + 1000000;
260+
expect(result.getTime()).toBeGreaterThanOrEqual(expected);
261+
expect(result.getTime()).toBeLessThanOrEqual(after + 1000000);
262+
});
263+
264+
it('should throw error for negative number', () => {
265+
expect(() => parseDurationToDate(-1000)).toThrow(
266+
'Invalid duration: -1000. Expected a non-negative finite number of milliseconds.'
267+
);
268+
});
269+
270+
it('should throw error for NaN', () => {
271+
expect(() => parseDurationToDate(NaN)).toThrow(
272+
'Invalid duration: NaN. Expected a non-negative finite number of milliseconds.'
273+
);
274+
});
275+
276+
it('should throw error for Infinity', () => {
277+
expect(() => parseDurationToDate(Infinity)).toThrow(
278+
'Invalid duration: Infinity. Expected a non-negative finite number of milliseconds.'
279+
);
280+
});
281+
282+
it('should throw error for -Infinity', () => {
283+
expect(() => parseDurationToDate(-Infinity)).toThrow(
284+
'Invalid duration: -Infinity. Expected a non-negative finite number of milliseconds.'
285+
);
286+
});
287+
});
288+
289+
describe('Date objects', () => {
290+
it('should return Date instance directly', () => {
291+
const targetTime = Date.now() + 60000;
292+
const futureDate = new Date(targetTime);
293+
const result = parseDurationToDate(futureDate);
294+
expect(result).toBe(futureDate);
295+
expect(result.getTime()).toBe(targetTime);
296+
});
297+
298+
it('should handle past dates', () => {
299+
const targetTime = Date.now() - 60000;
300+
const pastDate = new Date(targetTime);
301+
const result = parseDurationToDate(pastDate);
302+
expect(result).toBe(pastDate);
303+
expect(result.getTime()).toBe(targetTime);
304+
});
305+
306+
it('should handle date-like objects from deserialization', () => {
307+
const targetTime = Date.now() + 30000;
308+
const dateLike = {
309+
getTime: () => targetTime,
310+
};
311+
const result = parseDurationToDate(dateLike as any);
312+
expect(result.getTime()).toBe(targetTime);
313+
expect(result instanceof Date).toBe(true);
314+
});
315+
});
316+
317+
describe('invalid inputs', () => {
318+
it('should throw error for null', () => {
319+
expect(() => parseDurationToDate(null as any)).toThrow(
320+
'Invalid duration parameter. Expected a duration string, number (milliseconds), or Date object.'
321+
);
322+
});
323+
324+
it('should throw error for undefined', () => {
325+
expect(() => parseDurationToDate(undefined as any)).toThrow(
326+
'Invalid duration parameter. Expected a duration string, number (milliseconds), or Date object.'
327+
);
328+
});
329+
330+
it('should throw error for boolean', () => {
331+
expect(() => parseDurationToDate(true as any)).toThrow(
332+
'Invalid duration parameter. Expected a duration string, number (milliseconds), or Date object.'
333+
);
334+
});
335+
336+
it('should throw error for object without getTime', () => {
337+
expect(() => parseDurationToDate({} as any)).toThrow(
338+
'Invalid duration parameter. Expected a duration string, number (milliseconds), or Date object.'
339+
);
340+
});
341+
});
342+
});

packages/core/src/util.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { StringValue } from 'ms';
2+
import ms from 'ms';
3+
14
export interface PromiseWithResolvers<T> {
25
promise: Promise<T>;
36
resolve: (value: T) => void;
@@ -104,3 +107,46 @@ export function getWorkflowRunStreamId(runId: string, namespace?: string) {
104107
);
105108
return `${streamId}_${encodedNamespace}`;
106109
}
110+
111+
/**
112+
* Parses a duration parameter (string, number, or Date) and returns a Date object
113+
* representing when the duration should elapse.
114+
*
115+
* - For strings: Parses duration strings like "1s", "5m", "1h", etc. using the `ms` library
116+
* - For numbers: Treats as milliseconds from now
117+
* - For Date objects: Returns the date directly (handles both Date instances and date-like objects from deserialization)
118+
*
119+
* @param param - The duration parameter (StringValue, Date, or number of milliseconds)
120+
* @returns A Date object representing when the duration should elapse
121+
* @throws {Error} If the parameter is invalid or cannot be parsed
122+
*/
123+
export function parseDurationToDate(param: StringValue | Date | number): Date {
124+
if (typeof param === 'string') {
125+
const durationMs = ms(param);
126+
if (typeof durationMs !== 'number' || durationMs < 0) {
127+
throw new Error(
128+
`Invalid duration: "${param}". Expected a valid duration string like "1s", "1m", "1h", etc.`
129+
);
130+
}
131+
return new Date(Date.now() + durationMs);
132+
} else if (typeof param === 'number') {
133+
if (param < 0 || !Number.isFinite(param)) {
134+
throw new Error(
135+
`Invalid duration: ${param}. Expected a non-negative finite number of milliseconds.`
136+
);
137+
}
138+
return new Date(Date.now() + param);
139+
} else if (
140+
param instanceof Date ||
141+
(param &&
142+
typeof param === 'object' &&
143+
typeof (param as any).getTime === 'function')
144+
) {
145+
// Handle both Date instances and date-like objects (from deserialization)
146+
return param instanceof Date ? param : new Date((param as any).getTime());
147+
} else {
148+
throw new Error(
149+
`Invalid duration parameter. Expected a duration string, number (milliseconds), or Date object.`
150+
);
151+
}
152+
}

packages/core/src/workflow/sleep.ts

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,18 @@
11
import type { StringValue } from 'ms';
2-
import ms from 'ms';
32
import { EventConsumerResult } from '../events-consumer.js';
43
import { type WaitInvocationQueueItem, WorkflowSuspension } from '../global.js';
54
import type { WorkflowOrchestratorContext } from '../private.js';
6-
import { withResolvers } from '../util.js';
5+
import { parseDurationToDate, withResolvers } from '../util.js';
76

87
export function createSleep(ctx: WorkflowOrchestratorContext) {
9-
return async function sleepImpl(param: StringValue | Date): Promise<void> {
8+
return async function sleepImpl(
9+
param: StringValue | Date | number
10+
): Promise<void> {
1011
const { promise, resolve } = withResolvers<void>();
1112
const correlationId = `wait_${ctx.generateUlid()}`;
1213

1314
// Calculate the resume time
14-
let resumeAt: Date;
15-
if (typeof param === 'string') {
16-
const durationMs = ms(param);
17-
if (typeof durationMs !== 'number' || durationMs < 0) {
18-
throw new Error(
19-
`Invalid sleep duration: "${param}". Expected a valid duration string like "1s", "1m", "1h", etc.`
20-
);
21-
}
22-
resumeAt = new Date(Date.now() + durationMs);
23-
} else if (
24-
param instanceof Date ||
25-
(param &&
26-
typeof param === 'object' &&
27-
typeof (param as any).getTime === 'function')
28-
) {
29-
// Handle both Date instances and date-like objects (from deserialization)
30-
const dateParam =
31-
param instanceof Date ? param : new Date((param as any).getTime());
32-
resumeAt = dateParam;
33-
} else {
34-
throw new Error(
35-
`Invalid sleep parameter. Expected a duration string or Date object.`
36-
);
37-
}
15+
const resumeAt = parseDurationToDate(param);
3816

3917
// Add wait to invocations queue
4018
ctx.invocationsQueue.push({

workbench/example/workflows/99_e2e.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,14 @@ export async function sleepingDateWorkflow(endDate: Date) {
205205
return { startTime, endTime };
206206
}
207207

208+
export async function sleepingDurationMsWorkflow() {
209+
'use workflow';
210+
const startTime = Date.now();
211+
await sleep(10000);
212+
const endTime = Date.now();
213+
return { startTime, endTime };
214+
}
215+
208216
//////////////////////////////////////////////////////////
209217

210218
async function nullByteStep() {

0 commit comments

Comments
 (0)