feat: recurring workflow schedules - #7
Conversation
|
@marcelom97 thanks for submitting this. Can you please elaborate on the incremental sync? Other than that, I need to think about the DX a bit more. I will start by adding inline comments. |
| max_retries: number; | ||
| job_id: string | null; | ||
| trigger_source: 'api' | 'cron'; | ||
| schedule_context: string | { timestamp: string; lastTimestamp?: string; timezone: string } | null; |
There was a problem hiding this comment.
How about using a cron: string column and a timezone, both being optional to avoid a nesting object?
| return mapRowToWorkflowRun(run); | ||
| } | ||
|
|
||
| export async function getLastCronCompletedAt( |
There was a problem hiding this comment.
Instead of a specific getLastCronCompletedAt method, I'd suggest introducing a getWorkflowLastRun that works for all workflows. This feels more generic and aligns with the naming of the rest of the methods.
| }; | ||
| } | ||
|
|
||
| private async setupCronSchedule(wf: InternalWorkflowDefinition): Promise<void> { |
There was a problem hiding this comment.
| private async setupCronSchedule(wf: InternalWorkflowDefinition): Promise<void> { | |
| private async scheduleCronWorkflow(wf: InternalWorkflowDefinition): Promise<void> { |
|
|
||
| let run = await this.getRun({ runId, resourceId }); | ||
|
|
||
| const schedule: ScheduleContext | undefined = run.scheduleContext |
There was a problem hiding this comment.
Please refer to my previous comment about modeling scheduleContext.
| const schedule: ScheduleContext | undefined = run.scheduleContext | ||
| ? { | ||
| timestamp: run.scheduleContext.timestamp, | ||
| lastTimestamp: run.scheduleContext.lastTimestamp ?? undefined, |
There was a problem hiding this comment.
Following on the modeling, lastTimestamp comes from the workflow_runs table. We can replace it with the timestamp of the latest record.
| limit?: number; | ||
| statuses?: WorkflowStatus[]; | ||
| workflowId?: string; | ||
| triggerSource?: 'api' | 'cron'; |
There was a problem hiding this comment.
Do we need triggerSource? If not let's remove it.
| | `WORKFLOW_RUN_WORKERS` | Number of worker processes | `3` | | ||
| | `WORKFLOW_RUN_EXPIRE_IN_SECONDS` | Job expiration time in seconds | `300` | | ||
|
|
||
| ## Cron Workflows |
There was a problem hiding this comment.
Let's create the following DX:
-
The cron expression can be a valid cron string or a human friendly cron string such as https://github.com/rainder/human-to-cron. Note, this library is quite old. Let's see if there is a modern one.
-
Cron can be either a string or an object of the expression and the timezone. If the timezone is not specified, we assume UTC or the current timezone of the running container.
There was a problem hiding this comment.
I looked into this — human-to-cron has critical bugs (e.g. "every hour" produces * */1 * * * which fires every minute, and unrecognized input silently becomes * * * * *). I couldn't find a modern,
well-maintained alternative either.
I'd suggest we defer this and stick with standard cron expressions for now. We can revisit if a reliable library comes along, or build a small parser ourselves for a limited set of human-friendly strings (e.g.
"every 5 minutes", "daily at 9am").
|
@marcelom97 Any updates on this? |
|
Hey @SokratisVidros, thanks for the review and for your suggestions. I will take care of this over the weekend. |
|
@SokratisVidros incremental sync is about only processing data that changed since the last cron run, instead of re-processing everything each time. The schedule context gives cron workflows a lastTimestamp (derived from the previous completed run's completedAt), so the workflow can use it as a cursor: const sync = workflow('sync-data', async ({ step, schedule }) => {
const since = schedule?.lastTimestamp ?? new Date(0);
const data = await step.run('fetch', async () => fetchSince(since));
await step.run('write', async () => writeToDB(data));
}, { cron: '*/15 * * * *' });Without this, each cron run would need to either re-process all data or manually track its own high-water mark somewhere. lastTimestamp makes that built-in. If you think it's not useful we can remove it, but I find it quite useful to have it. |
|
@marcelom97 Thanks for the updates. I will review them by the end of this week and get back to you. |
f158d41 to
913e073
Compare
|
@SokratisVidros any news for this PR? |
913e073 to
abe48bb
Compare
Adds a `schedule` option to `workflow()` that accepts a cron expression,
a `parse-duration` string, or a `DurationObject`. Detection is automatic:
5–6 cron-charset tokens are treated as cron; anything else is parsed as a
duration and translated into cron (only when the interval divides cleanly).
The engine registers each scheduled workflow with pg-boss `schedule()` on
start, unschedules on stop/unregister, and stamps the fire timestamp into
the new `workflow_runs.scheduled_for` column. Schedule-triggered handlers
receive `ctx.schedule.timestamp`; manual runs have `ctx.schedule` undefined.
New helper `engine.getWorkflowLastRun({ workflowId, resourceId? })` returns
the most recent run — useful as a cursor for incremental syncs without
denormalizing previous-run state into context.
Supersedes SokratisVidros#7 — keeps the cron-via-pg-boss approach but reshapes the API
per review: single `schedule` option (no nested `{ expression, timezone }`),
flat top-level `timezone` sibling, `ctx.schedule` trimmed to `{ timestamp }`,
no `triggerSource` column, history exposed via a generic helper instead of
denormalized into the run row.
Co-Authored-By: Marcelo Mollaj <marcelomollaj@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `schedule` option to `workflow()` that accepts a cron expression,
a `parse-duration` string, or a `DurationObject`. Detection is automatic:
5–6 cron-charset tokens are treated as cron; anything else is parsed as a
duration and translated into cron (only when the interval divides cleanly).
The engine registers each scheduled workflow with pg-boss `schedule()` on
start, unschedules on stop/unregister, and stamps the fire timestamp into
the new `workflow_runs.scheduled_for` column. Schedule-triggered handlers
receive `ctx.schedule.timestamp`; manual runs have `ctx.schedule` undefined.
New helper `engine.getWorkflowLastRun({ workflowId, resourceId? })` returns
the most recent run — useful as a cursor for incremental syncs without
denormalizing previous-run state into context.
Supersedes SokratisVidros#7 — keeps the cron-via-pg-boss approach but reshapes the API
per review: single `schedule` option (no nested `{ expression, timezone }`),
flat top-level `timezone` sibling, `ctx.schedule` trimmed to `{ timestamp }`,
no `triggerSource` column, history exposed via a generic helper instead of
denormalized into the run row.
Co-Authored-By: Marcelo Mollaj <marcelomollaj@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
abe48bb to
b5fb525
Compare
Adds a `schedule` option to `workflow()` that accepts a cron expression,
a `parse-duration` string, or a `DurationObject`. Detection is automatic:
5–6 cron-charset tokens are treated as cron; anything else is parsed as a
duration and translated into cron (only when the interval divides cleanly).
The engine registers each scheduled workflow with pg-boss `schedule()` on
start, unschedules on stop/unregister, and stamps the fire timestamp into
the new `workflow_runs.scheduled_for` column. Schedule-triggered handlers
receive `ctx.schedule.timestamp`; manual runs have `ctx.schedule` undefined.
New helper `engine.getWorkflowLastRun({ workflowId, resourceId? })` returns
the most recent run — useful as a cursor for incremental syncs without
denormalizing previous-run state into context.
Supersedes SokratisVidros#7 — keeps the cron-via-pg-boss approach but reshapes the API
per review: single `schedule` option (no nested `{ expression, timezone }`),
flat top-level `timezone` sibling, `ctx.schedule` trimmed to `{ timestamp }`,
no `triggerSource` column, history exposed via a generic helper instead of
denormalized into the run row.
Co-Authored-By: Marcelo Mollaj <marcelomollaj@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
b5fb525 to
a12df42
Compare
Adds a `schedule` option to `workflow()` that accepts a cron expression,
a `parse-duration` string, or a `DurationObject`. Detection is automatic:
5–6 cron-charset tokens are treated as cron; anything else is parsed as a
duration and translated into cron (only when the interval divides cleanly).
The engine registers each scheduled workflow with pg-boss `schedule()` on
start, unschedules on stop/unregister, and stamps the fire timestamp into
the new `workflow_runs.scheduled_for` column. Schedule-triggered handlers
receive `ctx.schedule.timestamp`; manual runs have `ctx.schedule` undefined.
New helper `engine.getWorkflowLastRun({ workflowId, resourceId? })` returns
the most recent run — useful as a cursor for incremental syncs without
denormalizing previous-run state into context.
Supersedes SokratisVidros#7 — keeps the cron-via-pg-boss approach but reshapes the API
per review: single `schedule` option (no nested `{ expression, timezone }`),
flat top-level `timezone` sibling, `ctx.schedule` trimmed to `{ timestamp }`,
no `triggerSource` column, history exposed via a generic helper instead of
denormalized into the run row.
Co-Authored-By: Marcelo Mollaj <marcelomollaj@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a12df42 to
854e9a9
Compare
|
@marcelom97 I revised the work on this PR and updated the DX a little bit. Please take a look and let me know if it addresses your usecases. |
Adds a `schedule` option to `workflow()` that accepts a cron expression,
a `parse-duration` string, or a `DurationObject`. Detection is automatic:
5–6 cron-charset tokens are treated as cron; anything else is parsed as a
duration and translated into cron (only when the interval divides cleanly).
The engine registers each scheduled workflow with pg-boss `schedule()` on
start, unschedules on stop/unregister, and stamps the fire timestamp into
the new `workflow_runs.scheduled_for` column. Schedule-triggered handlers
receive `ctx.schedule.timestamp`; manual runs have `ctx.schedule` undefined.
New helper `engine.getWorkflowLastRun({ workflowId, resourceId? })` returns
the most recent run — useful as a cursor for incremental syncs without
denormalizing previous-run state into context.
Supersedes SokratisVidros#7 — keeps the cron-via-pg-boss approach but reshapes the API
per review: single `schedule` option (no nested `{ expression, timezone }`),
flat top-level `timezone` sibling, `ctx.schedule` trimmed to `{ timestamp }`,
no `triggerSource` column, history exposed via a generic helper instead of
denormalized into the run row.
Co-Authored-By: Marcelo Mollaj <marcelomollaj@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
854e9a9 to
9461c1c
Compare
Adds a `schedule` option to `workflow()` that accepts a cron expression,
a `parse-duration` string, or a `DurationObject`. Detection is automatic:
5–6 cron-charset tokens are treated as cron; anything else is parsed as a
duration and translated into cron (only when the interval divides cleanly).
The engine registers each scheduled workflow with pg-boss `schedule()` on
start, unschedules on stop/unregister, and stamps the fire timestamp into
the new `workflow_runs.scheduled_for` column. Schedule-triggered handlers
receive `ctx.schedule.timestamp`; manual runs have `ctx.schedule` undefined.
New helper `engine.getWorkflowLastRun({ workflowId, resourceId? })` returns
the most recent run — useful as a cursor for incremental syncs without
denormalizing previous-run state into context.
Supersedes SokratisVidros#7 — keeps the cron-via-pg-boss approach but reshapes the API
per review: single `schedule` option (no nested `{ expression, timezone }`),
flat top-level `timezone` sibling, `ctx.schedule` trimmed to `{ timestamp }`,
no `triggerSource` column, history exposed via a generic helper instead of
denormalized into the run row.
Co-Authored-By: Marcelo Mollaj <marcelomollaj@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9461c1c to
20b7a0d
Compare
Adds a `schedule` option to `workflow()` that accepts a cron expression,
a `parse-duration` string, or a `DurationObject`. Detection is automatic:
5–6 cron-charset tokens are treated as cron; anything else is parsed as a
duration and translated into cron (only when the interval divides cleanly).
The engine registers each scheduled workflow with pg-boss `schedule()` on
start, unschedules on stop/unregister, and stamps the fire timestamp into
the new `workflow_runs.scheduled_for` column. Schedule-triggered handlers
receive `ctx.schedule.timestamp`; manual runs have `ctx.schedule` undefined.
New helper `engine.getWorkflowLastRun({ workflowId, resourceId? })` returns
the most recent run — useful as a cursor for incremental syncs without
denormalizing previous-run state into context.
Supersedes SokratisVidros#7 — keeps the cron-via-pg-boss approach but reshapes the API
per review: single `schedule` option (no nested `{ expression, timezone }`),
flat top-level `timezone` sibling, `ctx.schedule` trimmed to `{ timestamp }`,
no `triggerSource` column, history exposed via a generic helper instead of
denormalized into the run row.
Co-Authored-By: Marcelo Mollaj <marcelomollaj@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20b7a0d to
26cf8ee
Compare
Summary
Adds a
scheduleoption toworkflow()for recurring runs. Accepts:'0 9 * * 1-5'parse-durationstring:'5m','1 hour','1 day'DurationObject:{ minutes: 5 },{ hours: 6 }Detection is automatic — 5–6 cron-charset tokens go through
cron-parser; anything else is parsed as a duration and translated into a cron expression (only when the interval divides cleanly into 60 minutes, 24 hours, or 1 day; non-divisible intervals throw with a clear message).Usage
Design
scheduledoes the work of both acronand aneveryoption — one thing to learn.timezonesibling, not nested. Timezone is only meaningful for cron, but its position next toscheduleis unambiguous.ctx.schedule. Onlytimestamp(the dynamic per-fire value). Timezone is static config the author already wrote; previous-run state is derived, not echoed back into context.trigger_sourcecolumn. A singlescheduled_for timestamp with time zonecolumn onworkflow_runsis set when the run came from a schedule; existence acts as the flag.engine.getWorkflowLastRun({ workflowId, resourceId? })returns the latest run for a workflow — useful beyond cron (debugging, manual cursoring).schedule()semantics). Configurableoverlap: 'skip' | 'queue' | 'allow'is deferred to a follow-up.Migration
Schema v5 adds:
Runs automatically on
engine.start().Test plan
src/schedule.test.ts) — schedule resolver: cron passthrough, timezone propagation, duration translation for clean divisors, errors for non-divisible intervals ('23m','7h','2d'), errors for sub-minute durations, empty string, invalid cron expressions,DurationObjectform.src/engine.test.ts) —registerWorkflowschedule validation: cron string + timezone, duration string,DurationObject, rejects invalid cron, rejects non-divisible duration.src/engine.test.ts) —getWorkflowLastRun: null when no runs, returns latest, scopes byresourceId.