Skip to content

Commit 02585b8

Browse files
jlalmeskraftp
andauthored
Add configurable polling intervals for wait APIs (#1265)
## Summary - add per-call pollingIntervalMs options for DBOS result, waitFirst, recv, and getEvent APIs - thread the interval through system database polling and durable sleep waits while preserving numeric timeout compatibility - add coverage for DBOS, workflow handle, and DBOSClient polling options plus validation errors --------- Co-authored-by: Peter Kraft <petereliaskraft@gmail.com>
1 parent b5b4989 commit 02585b8

7 files changed

Lines changed: 329 additions & 33 deletions

File tree

src/client.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ import {
2323
import { cancellableSleep } from './utils';
2424
import {
2525
type GetEventOptions,
26+
type PollingOptions,
27+
type WaitFirstOptions,
2628
type SetWorkflowDelayOptions,
2729
type CancelWorkflowsOptions,
30+
resolvePollingIntervalMs,
2831
resolveTimeoutSeconds,
2932
resolveDelayEpochMS,
3033
} from './dbos';
@@ -173,8 +176,15 @@ export class ClientHandle<R> implements WorkflowHandle<R> {
173176
return status ? toWorkflowStatus(status, this.systemDatabase.getSerializer()) : null;
174177
}
175178

176-
async getResult(): Promise<R> {
177-
const res = await this.systemDatabase.awaitWorkflowResult(this.workflowID);
179+
async getResult(options?: PollingOptions): Promise<R> {
180+
const pollingIntervalMs = resolvePollingIntervalMs(options);
181+
const res = await this.systemDatabase.awaitWorkflowResult(
182+
this.workflowID,
183+
undefined,
184+
undefined,
185+
undefined,
186+
pollingIntervalMs,
187+
);
178188
if (res?.cancelled) {
179189
throw new DBOSAwaitedWorkflowCancelledError(this.workflowID);
180190
}
@@ -479,7 +489,8 @@ export class DBOSClient {
479489
*/
480490
async getEvent<T>(workflowID: string, key: string, options?: number | GetEventOptions): Promise<T | null> {
481491
const timeoutSeconds = resolveTimeoutSeconds(options);
482-
const evt = await this.systemDatabase.getEvent(workflowID, key, timeoutSeconds ?? 60);
492+
const pollingIntervalMs = resolvePollingIntervalMs(options);
493+
const evt = await this.systemDatabase.getEvent(workflowID, key, timeoutSeconds ?? 60, undefined, pollingIntervalMs);
483494
return (await deserializeValue(evt.serializedValue, evt.serialization, this.serializer)) as T;
484495
}
485496

@@ -556,7 +567,8 @@ export class DBOSClient {
556567
return listWorkflowSteps(this.systemDatabase, workflowID, true, options);
557568
}
558569

559-
async waitFirst(handles: WorkflowHandle<unknown>[]): Promise<WorkflowHandle<unknown>> {
570+
async waitFirst(handles: WorkflowHandle<unknown>[], options?: WaitFirstOptions): Promise<WorkflowHandle<unknown>> {
571+
const pollingIntervalMs = resolvePollingIntervalMs(options);
560572
if (handles.length === 0) {
561573
throw new Error('handles must not be empty');
562574
}
@@ -567,7 +579,11 @@ export class DBOSClient {
567579
}
568580
handleMap.set(handle.workflowID, handle);
569581
}
570-
const completedId = await this.systemDatabase.awaitFirstWorkflowId([...handleMap.keys()]);
582+
const completedId = await this.systemDatabase.awaitFirstWorkflowId(
583+
[...handleMap.keys()],
584+
undefined,
585+
pollingIntervalMs,
586+
);
571587
return handleMap.get(completedId)!;
572588
}
573589

src/dbos-executor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -921,8 +921,9 @@ export class DBOSExecutor {
921921
workflowUUID: string,
922922
key: string,
923923
timeoutSeconds: number = DBOSExecutor.defaultNotificationTimeoutSec,
924+
pollingIntervalMs?: number,
924925
): Promise<T | null> {
925-
const evt = await this.systemDatabase.getEvent(workflowUUID, key, timeoutSeconds);
926+
const evt = await this.systemDatabase.getEvent(workflowUUID, key, timeoutSeconds, undefined, pollingIntervalMs);
926927
return (await deserializeValue(evt.serializedValue, evt.serialization, this.serializer)) as T;
927928
}
928929

src/dbos.ts

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,38 @@ export interface WriteStreamOptions {
189189
serializationType?: WorkflowSerializationFormat;
190190
}
191191

192+
/**
193+
* Options for DBOS operations that poll the system database while waiting.
194+
*/
195+
export interface PollingOptions {
196+
/**
197+
* Milliseconds to wait between system database polls. Must be positive and finite.
198+
*
199+
* This affects DB-backed polling waits. Live in-process workflow handles that await
200+
* an already-running promise do not poll.
201+
*/
202+
pollingIntervalMs?: number;
203+
}
204+
205+
/**
206+
* Options for `DBOS.getResult` and workflow handle `getResult`.
207+
*/
208+
export interface GetResultOptions extends PollingOptions {
209+
/** Timeout in seconds; if the workflow does not complete before the timeout, `null` will be returned */
210+
timeoutSeconds?: number;
211+
/** Absolute deadline as a UNIX epoch timestamp in milliseconds; if the workflow does not complete before this time, `null` will be returned */
212+
deadlineEpochMS?: number;
213+
}
214+
215+
/**
216+
* Options for `DBOS.waitFirst`.
217+
*/
218+
export type WaitFirstOptions = PollingOptions;
219+
192220
/**
193221
* Options for `DBOS.recv`
194222
*/
195-
export interface RecvOptions {
223+
export interface RecvOptions extends PollingOptions {
196224
/** Timeout in seconds; if no message is received before the timeout (default 60 seconds), `null` will be returned */
197225
timeoutSeconds?: number;
198226
/** Absolute deadline as a UNIX epoch timestamp in milliseconds; if no message is received before this time, `null` will be returned */
@@ -202,7 +230,7 @@ export interface RecvOptions {
202230
/**
203231
* Options for `DBOS.getEvent`
204232
*/
205-
export interface GetEventOptions {
233+
export interface GetEventOptions extends PollingOptions {
206234
/** Timeout in seconds; if no event is received before the timeout (default 60 seconds), `null` will be returned */
207235
timeoutSeconds?: number;
208236
/** Absolute deadline as a UNIX epoch timestamp in milliseconds; if no event is received before this time, `null` will be returned */
@@ -238,7 +266,9 @@ export interface SetEventOptions {
238266
serializationType?: WorkflowSerializationFormat;
239267
}
240268

241-
export function resolveTimeoutSeconds(options?: number | RecvOptions | GetEventOptions): number | undefined {
269+
export function resolveTimeoutSeconds(
270+
options?: number | RecvOptions | GetEventOptions | GetResultOptions,
271+
): number | undefined {
242272
if (options === undefined) return undefined;
243273
if (typeof options === 'number') return options;
244274
if (options.deadlineEpochMS !== undefined) {
@@ -247,6 +277,16 @@ export function resolveTimeoutSeconds(options?: number | RecvOptions | GetEventO
247277
return options.timeoutSeconds;
248278
}
249279

280+
export function resolvePollingIntervalMs(options?: number | PollingOptions): number | undefined {
281+
if (options === undefined || typeof options === 'number') return undefined;
282+
const interval = options.pollingIntervalMs;
283+
if (interval === undefined) return undefined;
284+
if (!Number.isFinite(interval) || interval <= 0) {
285+
throw new DBOSError('pollingIntervalMs must be a positive finite number');
286+
}
287+
return interval;
288+
}
289+
250290
export function resolveDelayEpochMS(options: number | SetWorkflowDelayOptions): number {
251291
if (typeof options === 'number') {
252292
if (options <= 0) throw new DBOSError('delaySeconds must be greater than 0');
@@ -774,21 +814,24 @@ export class DBOS {
774814
* @param timeoutSeconds - Maximum time to wait for result; if not provided, the operation does not time out
775815
* @returns The return value of the workflow, or throws the exception thrown by the workflow, or `null` if times out
776816
*/
777-
static async getResult<T>(workflowID: string, timeoutSeconds?: number): Promise<T | null> {
817+
static async getResult<T>(workflowID: string, options?: number | GetResultOptions): Promise<T | null> {
778818
ensureDBOSIsLaunched('getResult');
819+
const timeoutSeconds = resolveTimeoutSeconds(options);
820+
const pollingIntervalMs = resolvePollingIntervalMs(options);
779821
let timerFuncID: number | undefined = undefined;
780822
if (DBOS.isWithinWorkflow() && timeoutSeconds !== undefined) {
781823
// Reserve the function ID synchronously, before any await.
782824
timerFuncID = functionIDGetIncrement();
783825
}
784-
return await DBOS.getResultInternal(workflowID, timeoutSeconds, timerFuncID, undefined);
826+
return await DBOS.getResultInternal(workflowID, timeoutSeconds, timerFuncID, undefined, pollingIntervalMs);
785827
}
786828

787829
static async getResultInternal<T>(
788830
workflowID: string,
789831
timeoutSeconds?: number,
790832
timerFuncID?: number,
791833
assignedFuncID?: number,
834+
pollingIntervalMs?: number,
792835
): Promise<T | null> {
793836
return await runInternalStep(
794837
async () => {
@@ -797,6 +840,7 @@ export class DBOS {
797840
timeoutSeconds,
798841
DBOS.workflowID,
799842
timerFuncID,
843+
pollingIntervalMs,
800844
);
801845
if (!rres) return null;
802846
if (rres?.cancelled) {
@@ -820,8 +864,12 @@ export class DBOS {
820864
* @param handles - Non-empty array of workflow handles to wait on
821865
* @returns The first handle whose workflow has completed
822866
*/
823-
static async waitFirst(handles: WorkflowHandle<unknown>[]): Promise<WorkflowHandle<unknown>> {
867+
static async waitFirst(
868+
handles: WorkflowHandle<unknown>[],
869+
options?: WaitFirstOptions,
870+
): Promise<WorkflowHandle<unknown>> {
824871
ensureDBOSIsLaunched('waitFirst');
872+
const pollingIntervalMs = resolvePollingIntervalMs(options);
825873
if (handles.length === 0) {
826874
throw new Error('handles must not be empty');
827875
}
@@ -836,7 +884,11 @@ export class DBOS {
836884
const workflowIds = [...handleMap.keys()];
837885

838886
const completedId = await runInternalStep(async () => {
839-
return await DBOSExecutor.globalInstance!.systemDatabase.awaitFirstWorkflowId(workflowIds, DBOS.workflowID);
887+
return await DBOSExecutor.globalInstance!.systemDatabase.awaitFirstWorkflowId(
888+
workflowIds,
889+
DBOS.workflowID,
890+
pollingIntervalMs,
891+
);
840892
}, 'DBOS.waitFirst');
841893

842894
return handleMap.get(completedId)!;
@@ -1362,12 +1414,14 @@ export class DBOS {
13621414
const functionID: number = functionIDGetIncrement();
13631415
const timeoutFunctionID: number = functionIDGetIncrement();
13641416
const timeoutSeconds = resolveTimeoutSeconds(options);
1417+
const pollingIntervalMs = resolvePollingIntervalMs(options);
13651418
const msg = await DBOSExecutor.globalInstance!.systemDatabase.recv(
13661419
DBOS.workflowID!,
13671420
functionID,
13681421
timeoutFunctionID,
13691422
topic,
13701423
timeoutSeconds,
1424+
pollingIntervalMs,
13711425
);
13721426

13731427
return (await deserializeValue(msg.serializedValue, msg.serialization, DBOS.#executor.serializer)) as T;
@@ -1426,6 +1480,7 @@ export class DBOS {
14261480
static async getEvent<T>(workflowID: string, key: string, options?: number | GetEventOptions): Promise<T | null> {
14271481
ensureDBOSIsLaunched('getEvent');
14281482
const timeoutSeconds = resolveTimeoutSeconds(options);
1483+
const pollingIntervalMs = resolvePollingIntervalMs(options);
14291484
if (DBOS.isWithinWorkflow()) {
14301485
if (!DBOS.isInWorkflow()) {
14311486
throw new DBOSInvalidWorkflowTransitionError(
@@ -1445,10 +1500,11 @@ export class DBOS {
14451500
key,
14461501
timeoutSeconds ?? DBOSExecutor.defaultNotificationTimeoutSec,
14471502
params,
1503+
pollingIntervalMs,
14481504
);
14491505
return (await deserializeValue(evt.serializedValue, evt.serialization, DBOS.#executor.serializer)) as T;
14501506
}
1451-
return DBOS.#executor.getEvent(workflowID, key, timeoutSeconds);
1507+
return DBOS.#executor.getEvent(workflowID, key, timeoutSeconds, pollingIntervalMs);
14521508
}
14531509

14541510
/**

src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
export { DBOS, RecvOptions, GetEventOptions, SetWorkflowDelayOptions } from './dbos';
1+
export {
2+
DBOS,
3+
RecvOptions,
4+
GetEventOptions,
5+
GetResultOptions,
6+
PollingOptions,
7+
WaitFirstOptions,
8+
SetWorkflowDelayOptions,
9+
} from './dbos';
210

311
export { DBOSClient } from './client';
412

src/system_database.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1703,9 +1703,11 @@ export class SystemDatabase {
17031703
timeoutSeconds?: number,
17041704
callerID?: string,
17051705
timerFuncID?: number,
1706+
pollingIntervalMs?: number,
17061707
): Promise<SystemDatabaseStoredResult | undefined> {
17071708
const timeoutms = timeoutSeconds !== undefined ? timeoutSeconds * 1000 : undefined;
17081709
let finishTime = timeoutms !== undefined ? Date.now() + timeoutms : undefined;
1710+
const pollIntervalMs = pollingIntervalMs ?? this.dbPollingIntervalResultMs;
17091711

17101712
while (true) {
17111713
let resolveNotification: () => void;
@@ -1758,14 +1760,14 @@ export class SystemDatabase {
17581760
callerID,
17591761
timerFuncID,
17601762
timeoutms,
1761-
this.dbPollingIntervalResultMs,
1763+
pollIntervalMs,
17621764
);
17631765
finishTime = endTime;
17641766
timeoutPromise = promise;
17651767
timeoutCancel = cancel;
17661768
} else {
1767-
let poll = finishTime ? finishTime - ct : this.dbPollingIntervalResultMs;
1768-
poll = Math.min(this.dbPollingIntervalResultMs, poll);
1769+
let poll = finishTime ? finishTime - ct : pollIntervalMs;
1770+
poll = Math.min(pollIntervalMs, poll);
17691771
const { promise, cancel } = cancellableSleep(poll);
17701772
timeoutPromise = promise;
17711773
timeoutCancel = cancel;
@@ -1784,8 +1786,9 @@ export class SystemDatabase {
17841786
}
17851787

17861788
@dbRetry()
1787-
async awaitFirstWorkflowId(workflowIds: string[], callerID?: string): Promise<string> {
1789+
async awaitFirstWorkflowId(workflowIds: string[], callerID?: string, pollingIntervalMs?: number): Promise<string> {
17881790
const placeholders = workflowIds.map((_, i) => `$${i + 1}`).join(', ');
1791+
const pollIntervalMs = pollingIntervalMs ?? this.dbPollingIntervalResultMs;
17891792

17901793
while (true) {
17911794
let resolveNotification: () => void;
@@ -1816,7 +1819,7 @@ export class SystemDatabase {
18161819
return rows[0].workflow_uuid;
18171820
}
18181821

1819-
const { promise: sleepPromise, cancel: sleepCancel } = cancellableSleep(this.dbPollingIntervalResultMs);
1822+
const { promise: sleepPromise, cancel: sleepCancel } = cancellableSleep(pollIntervalMs);
18201823
try {
18211824
await Promise.race([wakeupPromise, sleepPromise]);
18221825
} finally {
@@ -1939,6 +1942,7 @@ export class SystemDatabase {
19391942
timeoutFunctionID: number,
19401943
topic?: string,
19411944
timeoutSeconds: number = DBOSExecutor.defaultNotificationTimeoutSec,
1945+
pollingIntervalMs?: number,
19421946
): Promise<{ serializedValue: string | null; serialization: string | null }> {
19431947
topic = topic ?? this.nullTopic;
19441948
const startTime = Date.now();
@@ -1953,6 +1957,7 @@ export class SystemDatabase {
19531957

19541958
const timeoutms = timeoutSeconds !== undefined ? timeoutSeconds * 1000 : undefined;
19551959
let finishTime = timeoutms !== undefined ? Date.now() + timeoutms : undefined;
1960+
const pollIntervalMs = pollingIntervalMs ?? this.dbPollingIntervalEventMs;
19561961

19571962
while (true) {
19581963
// register the key with the global notifications listener.
@@ -1989,14 +1994,14 @@ export class SystemDatabase {
19891994
workflowID,
19901995
timeoutFunctionID,
19911996
timeoutms,
1992-
this.dbPollingIntervalEventMs,
1997+
pollIntervalMs,
19931998
);
19941999
timeoutPromise = promise;
19952000
timeoutCancel = cancel;
19962001
finishTime = endTime;
19972002
} else {
1998-
let poll = finishTime ? finishTime - ct : this.dbPollingIntervalEventMs;
1999-
poll = Math.min(this.dbPollingIntervalEventMs, poll);
2003+
let poll = finishTime ? finishTime - ct : pollIntervalMs;
2004+
poll = Math.min(pollIntervalMs, poll);
20002005
const { promise, cancel } = cancellableSleep(poll);
20012006
timeoutPromise = promise;
20022007
timeoutCancel = cancel;
@@ -2121,6 +2126,7 @@ export class SystemDatabase {
21212126
functionID: number;
21222127
timeoutFunctionID: number;
21232128
},
2129+
pollingIntervalMs?: number,
21242130
): Promise<{ serializedValue: string | null; serialization: string | null }> {
21252131
const startTime = Date.now();
21262132
// Check if the operation has been done before for OAOO (only do this inside a workflow).
@@ -2148,6 +2154,7 @@ export class SystemDatabase {
21482154
const payloadKey = `${workflowID}::${key}`;
21492155
const timeoutms = timeoutSeconds !== undefined ? timeoutSeconds * 1000 : undefined;
21502156
let finishTime = timeoutms !== undefined ? Date.now() + timeoutms : undefined;
2157+
const pollIntervalMs = pollingIntervalMs ?? this.dbPollingIntervalEventMs;
21512158

21522159
// Register the key with the global notifications listener first... we do not want to look in the DB first
21532160
// or that would cause a timing hole.
@@ -2192,14 +2199,14 @@ export class SystemDatabase {
21922199
callerWorkflow.workflowID,
21932200
callerWorkflow.timeoutFunctionID ?? -1,
21942201
timeoutms,
2195-
this.dbPollingIntervalEventMs,
2202+
pollIntervalMs,
21962203
);
21972204
timeoutPromise = promise;
21982205
timeoutCancel = cancel;
21992206
finishTime = endTime;
22002207
} else {
2201-
let poll = finishTime ? finishTime - ct : this.dbPollingIntervalEventMs;
2202-
poll = Math.min(this.dbPollingIntervalEventMs, poll);
2208+
let poll = finishTime ? finishTime - ct : pollIntervalMs;
2209+
poll = Math.min(pollIntervalMs, poll);
22032210
const { promise, cancel } = cancellableSleep(poll);
22042211
timeoutPromise = promise;
22052212
timeoutCancel = cancel;

0 commit comments

Comments
 (0)