Skip to content

Dispatcher job — idempotent task generation, backfill-capable #2

Description

@os-warren

The spine. Turns active recurring duties into tasks, once per period, forever, without a lock.

Blocked-by: #1 (period engine).

Files you own

  • src/jobs/dispatch.job.ts (new), added to dulyJobs in src/jobs/index.ts
  • test/dispatch.test.ts (new)

Do not touch objectstack.config.ts.

Shape

defineJob({
  name: 'duly_dispatch',
  label: 'Dispatch due tasks',
  schedule: { type: 'cron', expression: '0 1 * * *', timezone: 'UTC' },
  handler: /* … */,
  retryPolicy: /* … */,
  timeout: /* … */,
})

Runs hourly-or-daily in UTC and resolves each duty in its own timezone — a single UTC pass covers every zone because the question asked per duty is "is this period's task due to exist yet", not "is it midnight here".

What it does, per active duty

Select duly_duty where status = 'active', form = 'recurring', and today is inside [effective_from, effective_to] (either bound may be null).

For each, for the current period and any period whose visible_from <= today:

Field Value
subject duty.name copied, so renaming the duty never rewrites history
duty, owner from the duty
business_unit denormalised from the duty, so a later transfer does not move historical rollups
source duty.source
period_key periodKeyFor(...)
due_date dueDateFor(...)
visible_from visibleFromFor(due_date, duty.lead_days)
status open

Then set duty.last_dispatched_period to the latest key it created.

Idempotency — the part that matters

duly_task carries a unique index duly_task_dispatch_identity on (duty, owner, period_key), scoped to the organization. Rely on it. Attempt the insert and swallow the uniqueness violation; do not read-then-write, which is a race that two overlapping runs will lose.

This is the constraint that lets the job be ordinary: re-runnable, killable mid-run, safe to invoke twice concurrently. A run that inserts nothing because everything already exists is a successful run.

Explicitly not dispatched

Backfill

Accept optional job input { from?: string; to?: string } (ISO dates). When present, generate every period in the window via periodsBetween instead of just the current one. Same insert path, so backfilling twice is a no-op.

Acceptance

  • running the job twice over the same clock produces zero second-pass inserts
  • a duty in Asia/Shanghai and one in America/Los_Angeles get the correct local period on the same UTC run
  • standing duties produce nothing, in any run
  • a paused duty produces nothing; un-pausing it and re-running produces only the current period, not the gap
  • last_dispatched_period advances and never regresses
  • backfill over a 13-month window produces exactly the expected key set, and a second backfill inserts nothing

Gates

pnpm validate && pnpm typecheck && pnpm test && pnpm build.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions