Skip to content

feat: recurring workflow schedules - #7

Merged
SokratisVidros merged 1 commit into
SokratisVidros:mainfrom
marcelom97:feat/cron-workflows
Jun 3, 2026
Merged

feat: recurring workflow schedules#7
SokratisVidros merged 1 commit into
SokratisVidros:mainfrom
marcelom97:feat/cron-workflows

Conversation

@marcelom97

@marcelom97 marcelom97 commented Feb 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a schedule option to workflow() for recurring runs. Accepts:

  • a cron expression: '0 9 * * 1-5'
  • a parse-duration string: '5m', '1 hour', '1 day'
  • a 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

import { workflow, WorkflowEngine } from 'pg-workflows';

const sync = workflow(
  'sync-orders',
  async ({ step, schedule, workflowId }) => {
    // ctx.schedule is only defined for schedule-triggered runs
    if (schedule) {
      console.log(`Fire scheduled for: ${schedule.timestamp.toISOString()}`);
    }

    // For incremental syncs, fetch the previous run as a cursor
    const lastRun = await engine.getWorkflowLastRun({ workflowId });
    const since = lastRun?.completedAt ?? new Date(0);

    await step.run('fetch', async () => fetchSince(since));
  },
  {
    schedule: '5m',                             // duration → '*/5 * * * *'
    // schedule: '0 9 * * 1-5',                 // explicit cron
    // timezone: 'America/New_York',            // optional, cron-only (default: UTC)
  }
);

const engine = new WorkflowEngine({ connectionString: '...', workflows: [sync] });
await engine.start();

Design

  • Single option, not two. schedule does the work of both a cron and an every option — one thing to learn.
  • Flat timezone sibling, not nested. Timezone is only meaningful for cron, but its position next to schedule is unambiguous.
  • Minimal ctx.schedule. Only timestamp (the dynamic per-fire value). Timezone is static config the author already wrote; previous-run state is derived, not echoed back into context.
  • No trigger_source column. A single scheduled_for timestamp with time zone column on workflow_runs is set when the run came from a schedule; existence acts as the flag.
  • History via a generic helper. engine.getWorkflowLastRun({ workflowId, resourceId? }) returns the latest run for a workflow — useful beyond cron (debugging, manual cursoring).
  • Singleton overlap policy. Scheduled runs don't overlap (pg-boss schedule() semantics). Configurable overlap: 'skip' | 'queue' | 'allow' is deferred to a follow-up.

Migration

Schema v5 adds:

ALTER TABLE workflow_runs ADD COLUMN IF NOT EXISTS scheduled_for timestamp with time zone;

Runs automatically on engine.start().

Test plan

  • Unit (14 tests, 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, DurationObject form.
  • Unit (5 tests, src/engine.test.ts)registerWorkflow schedule validation: cron string + timezone, duration string, DurationObject, rejects invalid cron, rejects non-divisible duration.
  • Unit (3 tests, src/engine.test.ts)getWorkflowLastRun: null when no runs, returns latest, scopes by resourceId.
  • All 161 unit tests pass; lint clean; build green.

@SokratisVidros

Copy link
Copy Markdown
Owner

@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.

Comment thread src/db/queries.ts Outdated
max_retries: number;
job_id: string | null;
trigger_source: 'api' | 'cron';
schedule_context: string | { timestamp: string; lastTimestamp?: string; timezone: string } | null;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about using a cron: string column and a timezone, both being optional to avoid a nesting object?

Comment thread src/db/queries.ts Outdated
return mapRowToWorkflowRun(run);
}

export async function getLastCronCompletedAt(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/engine.ts Outdated
};
}

private async setupCronSchedule(wf: InternalWorkflowDefinition): Promise<void> {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
private async setupCronSchedule(wf: InternalWorkflowDefinition): Promise<void> {
private async scheduleCronWorkflow(wf: InternalWorkflowDefinition): Promise<void> {

Comment thread src/engine.ts Outdated

let run = await this.getRun({ runId, resourceId });

const schedule: ScheduleContext | undefined = run.scheduleContext

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please refer to my previous comment about modeling scheduleContext.

Comment thread src/engine.ts Outdated
const schedule: ScheduleContext | undefined = run.scheduleContext
? {
timestamp: run.scheduleContext.timestamp,
lastTimestamp: run.scheduleContext.lastTimestamp ?? undefined,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following on the modeling, lastTimestamp comes from the workflow_runs table. We can replace it with the timestamp of the latest record.

Comment thread src/engine.ts Outdated
limit?: number;
statuses?: WorkflowStatus[];
workflowId?: string;
triggerSource?: 'api' | 'cron';

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need triggerSource? If not let's remove it.

Comment thread AGENTS.md Outdated
| `WORKFLOW_RUN_WORKERS` | Number of worker processes | `3` |
| `WORKFLOW_RUN_EXPIRE_IN_SECONDS` | Job expiration time in seconds | `300` |

## Cron Workflows

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's create the following DX:

  1. 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.

  2. 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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").

@SokratisVidros

Copy link
Copy Markdown
Owner

@marcelom97 Any updates on this?

@marcelom97

Copy link
Copy Markdown
Contributor Author

Hey @SokratisVidros, thanks for the review and for your suggestions.

I will take care of this over the weekend.

@marcelom97

Copy link
Copy Markdown
Contributor Author

@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.

@SokratisVidros

Copy link
Copy Markdown
Owner

@marcelom97 Thanks for the updates. I will review them by the end of this week and get back to you.

@marcelom97
marcelom97 force-pushed the feat/cron-workflows branch from f158d41 to 913e073 Compare March 14, 2026 17:15
@marcelom97

Copy link
Copy Markdown
Contributor Author

@SokratisVidros any news for this PR?

SokratisVidros added a commit to marcelom97/pg-workflows that referenced this pull request May 28, 2026
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>
@SokratisVidros SokratisVidros changed the title feat: add cron workflow scheduling with overlap protection and schedule context feat: recurring workflow schedules May 28, 2026
SokratisVidros added a commit to marcelom97/pg-workflows that referenced this pull request May 28, 2026
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>
SokratisVidros added a commit to marcelom97/pg-workflows that referenced this pull request May 28, 2026
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>
SokratisVidros added a commit to marcelom97/pg-workflows that referenced this pull request May 28, 2026
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>
@SokratisVidros

Copy link
Copy Markdown
Owner

@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.

SokratisVidros added a commit to marcelom97/pg-workflows that referenced this pull request May 28, 2026
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>
SokratisVidros added a commit to marcelom97/pg-workflows that referenced this pull request Jun 2, 2026
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>
@SokratisVidros
SokratisVidros merged commit a9eca7b into SokratisVidros:main Jun 3, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants