Skip to content

Commit f2db337

Browse files
authored
Custom Workflow Attributes (#1275)
Analogous to dbos-inc/dbos-transact-py#720
1 parent 4e879eb commit f2db337

13 files changed

Lines changed: 389 additions & 6 deletions

File tree

schemas/system_db_schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export interface workflow_status {
3434
delay_until_epoch_ms?: number | null;
3535
rate_limited?: boolean;
3636
completed_at?: number | null;
37+
attributes?: Record<string, unknown> | null; // Custom key-value attributes attached at creation, stored as JSONB.
3738
}
3839

3940
export interface notifications {

src/client.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
type WorkflowHandle,
2020
WorkflowSerializationFormat,
2121
type WorkflowStatus,
22+
validateWorkflowAttributes,
2223
} from './workflow';
2324
import { cancellableSleep } from './utils';
2425
import {
@@ -143,6 +144,11 @@ export interface ClientEnqueueOptions {
143144
* and the handle resolves with the original workflow's result.
144145
*/
145146
duplicationPolicy?: DuplicationPolicy;
147+
/**
148+
* Custom key-value attributes to attach to the workflow at creation.
149+
* Attributes are searchable via the `attributes` filter of `listWorkflows`.
150+
*/
151+
attributes?: Record<string, unknown>;
146152
}
147153

148154
/**
@@ -292,6 +298,7 @@ export class DBOSClient {
292298
options: ClientEnqueueOptions,
293299
...args: Parameters<T>
294300
): Promise<WorkflowHandle<Awaited<ReturnType<T>>>> {
301+
validateWorkflowAttributes(options.attributes);
295302
const { workflowName, workflowClassName, workflowConfigName, queueName, appVersion } = options;
296303
const workflowUUID = options.workflowID ?? randomUUID();
297304

@@ -325,6 +332,7 @@ export class DBOSClient {
325332
queuePartitionKey: options.queuePartitionKey,
326333
serialization: serparam.serialization,
327334
delayUntilEpochMS,
335+
attributes: options.attributes,
328336
};
329337

330338
let finalID: string;
@@ -376,6 +384,7 @@ export class DBOSClient {
376384
positionalArgs: unknown[],
377385
namedArgs?: { [key: string]: unknown },
378386
): Promise<WorkflowHandle<T>> {
387+
validateWorkflowAttributes(options.attributes);
379388
const { workflowName, workflowClassName, workflowConfigName, queueName, appVersion } = options;
380389
const workflowUUID = options.workflowID ?? randomUUID();
381390

@@ -414,6 +423,7 @@ export class DBOSClient {
414423
queuePartitionKey: options.queuePartitionKey,
415424
serialization: serparam.serialization,
416425
delayUntilEpochMS,
426+
attributes: options.attributes,
417427
};
418428

419429
let finalID: string;

src/conductor/conductor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ export class Conductor {
284284
queuesOnly: body.queues_only,
285285
wasForkedFrom: body.was_forked_from,
286286
hasParent: body.has_parent,
287+
attributes: body.attributes,
287288
};
288289
let workflowsOutput: protocol.WorkflowsOutput[] = [];
289290
try {
@@ -323,6 +324,7 @@ export class Conductor {
323324
executorId: bodyQueued.executor_id,
324325
wasForkedFrom: bodyQueued.was_forked_from,
325326
hasParent: bodyQueued.has_parent,
327+
attributes: bodyQueued.attributes,
326328
};
327329
let queuedWFOutput: protocol.WorkflowsOutput[] = [];
328330
try {

src/conductor/protocol.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ export interface ListWorkflowsBody {
203203
queues_only?: boolean;
204204
was_forked_from?: boolean;
205205
has_parent?: boolean;
206+
attributes?: Record<string, unknown>;
206207
}
207208

208209
export class WorkflowsOutput {
@@ -233,6 +234,7 @@ export class WorkflowsOutput {
233234
ParentWorkflowID?: string;
234235
DelayUntilEpochMS?: string;
235236
CompletedAt?: string;
237+
Attributes?: string;
236238

237239
constructor(info: WorkflowStatus) {
238240
// Mark empty fields as undefined
@@ -264,6 +266,8 @@ export class WorkflowsOutput {
264266
this.ParentWorkflowID = info.parentWorkflowID;
265267
this.DelayUntilEpochMS = info.delayUntilEpochMS !== undefined ? String(info.delayUntilEpochMS) : undefined;
266268
this.CompletedAt = info.completedAt !== undefined ? String(info.completedAt) : undefined;
269+
// JSON rather than inspect() so the wire format stays parseable by Conductor.
270+
this.Attributes = info.attributes !== undefined ? JSON.stringify(info.attributes) : undefined;
267271
}
268272
}
269273

@@ -329,6 +333,7 @@ export interface ListQueuedWorkflowsBody {
329333
executor_id?: string | string[];
330334
was_forked_from?: boolean;
331335
has_parent?: boolean;
336+
attributes?: Record<string, unknown>;
332337
}
333338

334339
export class ListQueuedWorkflowsRequest implements BaseMessage {

src/dbos-executor.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,7 @@ export class DBOSExecutor {
519519
parentWorkflowID: callerID,
520520
serialization: funcArgs.sername,
521521
delayUntilEpochMS,
522+
attributes: params.workflowAttributes,
522523
};
523524

524525
if (isTempWorkflow) {

src/dbos.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
WorkflowParams,
3030
WorkflowSerializationFormat,
3131
WorkflowStatus,
32+
validateWorkflowAttributes,
3233
} from './workflow';
3334
import { DLogger, GlobalLogger } from './telemetry/logs';
3435
import {
@@ -171,6 +172,10 @@ export interface StartWorkflowParams {
171172
// Requires `queueName` and `enqueueOptions.deduplicationID`. Arguments passed by the
172173
// colliding caller are discarded and the handle resolves with the original workflow's result.
173174
duplicationPolicy?: DuplicationPolicy;
175+
// Custom key-value attributes to attach to the workflow at creation. Recorded in the
176+
// workflow status and searchable via the `attributes` filter of `DBOS.listWorkflows`.
177+
// Not inherited by child workflows.
178+
workflowAttributes?: Record<string, unknown>;
174179
}
175180

176181
/**
@@ -1265,8 +1270,10 @@ export class DBOS {
12651270
const existing: any = {};
12661271
for (const k of Object.keys(options) as (keyof DBOSContextOptions)[]) {
12671272
if (Object.hasOwn(pctx, k))
1273+
// Save the current value (not the incoming one) so the finally block can restore it,
1274+
// letting nested `with*` blocks correctly roll back to the outer value.
12681275
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
1269-
existing[k] = options[k];
1276+
existing[k] = pctx[k];
12701277
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
12711278
(pctx as any)[k] = options[k];
12721279
}
@@ -1773,6 +1780,7 @@ export class DBOS {
17731780
startWfFuncId?: number,
17741781
): Promise<InternalWFHandle<Return>> {
17751782
ensureDBOSIsLaunched('workflows');
1783+
validateWorkflowAttributes(params.workflowAttributes);
17761784
const wfId = getNextWFID(params.workflowID);
17771785
const ppctx = getCurrentContextStore();
17781786

@@ -1839,6 +1847,7 @@ export class DBOS {
18391847
params.timeoutMS === null || ppctx?.workflowTimeoutMS === null ? undefined : ppctx?.deadlineEpochMS,
18401848
enqueueOptions: params.enqueueOptions,
18411849
duplicationPolicy: params.duplicationPolicy,
1850+
workflowAttributes: params.workflowAttributes,
18421851
};
18431852

18441853
return await invokeRegOp(wfParams, pwfid, funcId);
@@ -1850,6 +1859,7 @@ export class DBOS {
18501859
configuredInstance: instance,
18511860
timeoutMS,
18521861
duplicationPolicy: params.duplicationPolicy,
1862+
workflowAttributes: params.workflowAttributes,
18531863
};
18541864

18551865
return await invokeRegOp(wfParams, undefined, undefined);

src/scheduler/scheduler.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ interface ScheduleLoopEntry {
6565
scheduleId: string;
6666
}
6767

68+
// During the first minute after startup, poll for schedules every second so schedules
69+
// registered around launch are picked up promptly rather than after a full polling interval.
70+
const STARTUP_FAST_POLL_DURATION_MS = 60_000;
71+
const STARTUP_FAST_POLL_INTERVAL_MS = 1_000;
72+
6873
export class DynamicSchedulerLoop implements DBOSLifecycleCallback {
6974
readonly #mainController = new AbortController();
7075
#pollingPromise: Promise<void> | undefined;
@@ -99,14 +104,20 @@ export class DynamicSchedulerLoop implements DBOSLifecycleCallback {
99104
}
100105

101106
async #pollingLoop(signal: AbortSignal): Promise<void> {
107+
const startupDeadline = Date.now() + STARTUP_FAST_POLL_DURATION_MS;
108+
const pollTimeout = () =>
109+
Date.now() < startupDeadline
110+
? Math.min(STARTUP_FAST_POLL_INTERVAL_MS, this.#pollingIntervalMs)
111+
: this.#pollingIntervalMs;
112+
102113
while (!signal.aborted) {
103114
let schedules: WorkflowScheduleInternal[];
104115
try {
105116
const executor = DBOSExecutor.globalInstance!;
106117
schedules = await executor.systemDatabase.listSchedules();
107118
} catch (e) {
108119
DBOS.logger.warn(`Dynamic scheduler: error listing schedules: ${(e as Error).message}`);
109-
await interruptibleSleep(this.#pollingIntervalMs, signal);
120+
await interruptibleSleep(pollTimeout(), signal);
110121
continue;
111122
}
112123

@@ -179,7 +190,7 @@ export class DynamicSchedulerLoop implements DBOSLifecycleCallback {
179190
}
180191
}
181192

182-
await interruptibleSleep(this.#pollingIntervalMs, signal);
193+
await interruptibleSleep(pollTimeout(), signal);
183194
}
184195
}
185196

src/sysdb_migrations/internal/migrations.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,5 +657,15 @@ export function allMigrations(
657657
]
658658
: [],
659659
},
660+
// ADD COLUMN with no default is catalog-only; the GIN index built in the same
661+
// transaction covers zero rows, so no CONCURRENTLY is needed. The index serves
662+
// containment (@>) filters on workflow attributes; on CockroachDB, USING GIN
663+
// creates an inverted index.
664+
{
665+
pg: [
666+
`ALTER TABLE "${schemaName}"."workflow_status" ADD COLUMN IF NOT EXISTS "attributes" JSONB`,
667+
`CREATE INDEX IF NOT EXISTS "idx_workflow_status_attributes" ON "${schemaName}"."workflow_status" USING GIN ("attributes") WHERE "attributes" IS NOT NULL`,
668+
],
669+
},
660670
];
661671
}

src/system_database.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,8 @@ export interface WorkflowStatusInternal {
215215
serialization: string | null;
216216
delayUntilEpochMS?: number;
217217
completedAt?: number;
218+
// Custom key-value attributes attached to the workflow at creation. Not inherited by child workflows.
219+
attributes?: Record<string, unknown>;
218220
}
219221

220222
export interface EnqueueOptions {
@@ -429,6 +431,7 @@ function mapWorkflowStatus(row: workflow_status): WorkflowStatusInternal {
429431
serialization: row.serialization,
430432
delayUntilEpochMS: row.delay_until_epoch_ms ? Number(row.delay_until_epoch_ms) : undefined,
431433
completedAt: row.completed_at ? Number(row.completed_at) : undefined,
434+
attributes: row.attributes ?? undefined,
432435
};
433436
}
434437

@@ -1301,7 +1304,7 @@ export class SystemDatabase {
13011304
const { rows: statusRows } = await client.query<workflow_status>(
13021305
`SELECT workflow_uuid, name, class_name, config_name, application_id,
13031306
authenticated_user, authenticated_roles, assumed_role, inputs, serialization,
1304-
request, application_version
1307+
request, application_version, attributes
13051308
FROM "${this.schemaName}".workflow_status
13061309
WHERE workflow_uuid = ANY($1)`,
13071310
[originalWorkflowIDs],
@@ -1334,6 +1337,7 @@ export class SystemDatabase {
13341337
'queue_partition_key',
13351338
'forked_from',
13361339
'serialization',
1340+
'attributes',
13371341
];
13381342
if (options.timeoutMS !== undefined) {
13391343
insertCols.push('workflow_timeout_ms');
@@ -1364,6 +1368,7 @@ export class SystemDatabase {
13641368
options.queuePartitionKey ?? null,
13651369
origID,
13661370
ws.serialization,
1371+
ws.attributes ? JSON.stringify(ws.attributes) : null,
13671372
);
13681373
if (options.timeoutMS !== undefined) {
13691374
params.push(options.timeoutMS);
@@ -2724,6 +2729,7 @@ export class SystemDatabase {
27242729
'parent_workflow_id',
27252730
'delay_until_epoch_ms',
27262731
'completed_at',
2732+
'attributes',
27272733
];
27282734

27292735
input.loadInput = input.loadInput ?? true;
@@ -2811,6 +2817,14 @@ export class SystemDatabase {
28112817
}
28122818
}
28132819

2820+
// Match workflows whose attributes JSONB contains all the given key-value pairs.
2821+
// The `@>` containment operator is served by the GIN index on the attributes column.
2822+
if (input.attributes && Object.keys(input.attributes).length > 0) {
2823+
whereClauses.push(`attributes @> $${paramCounter}::jsonb`);
2824+
params.push(JSON.stringify(input.attributes));
2825+
paramCounter++;
2826+
}
2827+
28142828
if (input.startTime) {
28152829
whereClauses.push(`created_at >= $${paramCounter}`);
28162830
params.push(new Date(input.startTime).getTime());
@@ -3555,8 +3569,9 @@ export class SystemDatabase {
35553569
parent_workflow_id,
35563570
serialization,
35573571
owner_xid,
3558-
delay_until_epoch_ms
3559-
) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $26, $27, $28)
3572+
delay_until_epoch_ms,
3573+
attributes
3574+
) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $26, $27, $28, $29)
35603575
ON CONFLICT (workflow_uuid)
35613576
DO UPDATE SET
35623577
recovery_attempts = CASE
@@ -3601,6 +3616,7 @@ export class SystemDatabase {
36013616
initStatus.serialization,
36023617
ownerXid,
36033618
initStatus.delayUntilEpochMS ?? null,
3619+
initStatus.attributes ? JSON.stringify(initStatus.attributes) : null,
36043620
],
36053621
);
36063622
if (rows.length === 0) {

src/workflow.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,40 @@ import { deserializePositionalArgs, registerSerializationRecipe } from './serial
55
import { DBOS, resolvePollingIntervalMs, runInternalStep, type PollingOptions } from './dbos';
66
import { DuplicationPolicy, EnqueueOptions } from './system_database';
77
import { DBOSExecutor } from './dbos-executor';
8+
import { DBOSError } from './error';
9+
10+
/**
11+
* Validate that custom workflow attributes, if provided, are a plain key-value object that
12+
* can be serialized to JSON for storage in the JSONB `attributes` column. A key-value object
13+
* is required because attributes are queried with the `@>` containment filter; scalars and
14+
* arrays would store but never match meaningfully. Called at the workflow-creation entry
15+
* points (`startWorkflow`/enqueue) so invalid input fails fast at the call site rather than
16+
* surfacing as a database error at insert time.
17+
*/
18+
export function validateWorkflowAttributes(attributes: unknown): void {
19+
if (attributes === undefined || attributes === null) {
20+
return;
21+
}
22+
if (typeof attributes !== 'object' || Array.isArray(attributes)) {
23+
throw new DBOSError(
24+
`Invalid workflow attributes: must be a key-value object, got ${
25+
Array.isArray(attributes) ? 'array' : typeof attributes
26+
}.`,
27+
);
28+
}
29+
let serialized: string;
30+
try {
31+
serialized = JSON.stringify(attributes);
32+
} catch (e) {
33+
throw new DBOSError(`Invalid workflow attributes: must be JSON-serializable. ${(e as Error).message}`);
34+
}
35+
// JSON.stringify silently drops keys whose values are functions or undefined, and returns
36+
// "{}" for objects with no serializable keys (e.g. a class instance). Reject these rather
37+
// than store an empty object that does not reflect what the caller passed.
38+
if (serialized === '{}' && Object.keys(attributes).length > 0) {
39+
throw new DBOSError('Invalid workflow attributes: object has no JSON-serializable properties.');
40+
}
41+
}
842

943
export interface WorkflowParams {
1044
workflowUUID?: string;
@@ -18,6 +52,8 @@ export interface WorkflowParams {
1852
// executor skips its dedup-error pre-recording at the parent's funcID — the wrapper records the
1953
// child mapping itself after attaching to the existing workflow.
2054
duplicationPolicy?: DuplicationPolicy;
55+
// Custom key-value attributes to attach to the workflow at creation. Not inherited by child workflows.
56+
workflowAttributes?: Record<string, unknown>;
2157
}
2258

2359
export const DEFAULT_MAX_RECOVERY_ATTEMPTS = 100;
@@ -116,6 +152,9 @@ export interface WorkflowStatus {
116152
// If this workflow was started by another workflow, that workflow's ID.
117153
readonly parentWorkflowID?: string;
118154

155+
// Custom key-value attributes attached to the workflow at creation, if any.
156+
readonly attributes?: Record<string, unknown>;
157+
119158
// INTERNAL
120159
// Deprecated field
121160
readonly applicationID: string;
@@ -145,6 +184,7 @@ export interface GetWorkflowsInput {
145184
wasForkedFrom?: boolean; // Filter workflows that have (or have not) been forked from.
146185
parentWorkflowID?: string | string[]; // Get workflows started by this parent workflow ID (or any of these parent workflow IDs).
147186
hasParent?: boolean; // Filter workflows that have (or do not have) a parent workflow.
187+
attributes?: Record<string, unknown>; // Retrieve workflows whose custom attributes contain all of these key-value pairs.
148188
limit?: number; // Return up to this many workflows IDs. IDs are ordered by workflow creation time.
149189
offset?: number; // Skip this many workflows IDs. IDs are ordered by workflow creation time.
150190
sortDesc?: boolean; // Sort the workflows in descending order by creation time (default ascending order).

0 commit comments

Comments
 (0)