Skip to content

feat(scheduler): in-app scheduler; internalize scheduled sync#515

Merged
joryirving merged 2 commits into
mainfrom
feat/in-app-scheduler
Jul 1, 2026
Merged

feat(scheduler): in-app scheduler; internalize scheduled sync#515
joryirving merged 2 commits into
mainfrom
feat/in-app-scheduler

Conversation

@joryirving

Copy link
Copy Markdown
Contributor

Summary

  • Opt-in in-process scheduler (DISPATCH_SCHEDULER_ENABLED) wired from instrumentation.ts register(), replacing the external cronjob that POSTs /api/sync/scheduled. Interval via DISPATCH_SYNC_INTERVAL_MS (default 15m).
  • New src/lib/scheduler.ts — pure + dependency-injected (fetch/timers), unit-tested without a server.

Design

  • Jobs fire as loopback HTTP POSTs to the app's own endpoints, not direct function calls — this routes through the real route module graph (sidestepping the Turbopack standalone chunk-graph isolation the instrumentation.ts/lane-config.ts comments describe) and reuses each endpoint's auth + DB lock for free.
  • /api/sync/scheduled is globally DB-locked, so internalizing it is safe now (concurrent fires just 409, which runJob treats as expected).
  • Node runtime only (dynamic import so the edge bundle skips it). runJob never throws; opt-in so dev/CI don't spin timers and it can be confined to one replica.

Scope / follow-ups

Verification

  • vitest run src/lib/scheduler.test.ts → 10 passing (config parsing, POST+bearer shape, 409-is-fine, non-ok logs, fetch-rejection swallowed, disabled/no-token no-ops, startup-delay→interval).
  • tsc --noEmit → 0. eslint → clean.

Closes #502

Adds an opt-in in-process scheduler (DISPATCH_SCHEDULER_ENABLED) wired from
instrumentation.ts register(), so periodic work runs inside the server instead
of external Kubernetes cronjobs. First job: scheduled issue sync
(/api/sync/scheduled), DISPATCH_SYNC_INTERVAL_MS (default 15m).

Jobs fire as loopback HTTP POSTs to the app's own endpoints, not direct
function calls: this routes through the real route module graph (avoiding the
Turbopack standalone chunk-graph isolation that instrumentation.ts/lane-config
warn about) and reuses each endpoint's auth + DB lock (concurrent fires 409).
The sync endpoint is globally DB-locked, so it's safe to internalize now.

Node runtime only (dynamic import so the edge bundle skips it). runJob never
throws — a transient failure can't kill the interval; 409 is treated as
expected. Scheduler logic is pure + dependency-injected (fetch/timers) so it's
unit-tested without a server (10 tests).

groomer/pr-followup/prune-closed follow in #503/#504 (they need their own
concurrency guards first).

Closes #502

@its-saffron its-saffron Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Automated Review

Full PR review.

Analysis engine: MiniMax-M2.7@https://litellm.jory.dev/v1 (anthropic) — escalated (fast_low_confidence)

PR Review: PR 515 — feat(scheduler): in-app scheduler; internalize scheduled sync

Summary

This PR adds an opt-in in-process scheduler wired from instrumentation.ts register(), enabling Dispatch to self-host the periodic issue sync instead of relying on an external Kubernetes cronjob. The implementation fires loopback HTTP POSTs to existing endpoints, reuses existing auth and DB locking, and is properly guarded for edge/Node runtime differences.

Change-by-Change Findings

AGENTS.md — Two new optional environment variables added to the table:

  • DISPATCH_SCHEDULER_ENABLED (off by default; confine to single replica)
  • DISPATCH_SYNC_INTERVAL_MS (default 900000 = 15m)
  • These match the environment variable naming convention (DISPATCH_*) and the documented semantics from PR 502.

src/instrumentation.ts — Scheduler wired into register():

  • NEXT_RUNTIME !== "nodejs" guard excludes edge runtime (no timers/loopback in edge)
  • Dynamic import (await import("@/lib/scheduler")) prevents the edge bundle from pulling in Node-only code
  • Passes fetch, setInterval, setTimeout, and a typed logger as dependencies

src/lib/scheduler.ts (new) — Pure scheduler implementation:

  • schedulerConfigFromEnv: reads PORT for loopback base URL (defaults 3000), DISPATCH_AGENT_TOKEN, DISPATCH_SYNC_INTERVAL_MS, and DISPATCH_SCHEDULER_STARTUP_DELAY_MS
  • runJob: fires one HTTP POST with bearer auth; treats 409 as non-error (DB lock held); swallows fetch rejections so a transient failure cannot kill the interval
  • startScheduler: no-ops when disabled or token missing; schedules each job once after startupDelayMs, then arms an interval
  • Design rationale (chunk-graph isolation, locking reuse) is documented inline

src/lib/scheduler.test.ts (new) — 10 unit tests covering:

  • Config parsing: disabled unless DISPATCH_SCHEDULER_ENABLED=true; defaults to 15m; honors DISPATCH_SYNC_INTERVAL_MS; falls back on garbage input; uses PORT for baseUrl
  • runJob: POSTs correct JSON with bearer auth; 409 is silent; non-ok logs without throwing; fetch rejection is swallowed
  • startScheduler: disabled → no-op; enabled+no-token → no-op with warning; enabled+token → fires startup timer then arms interval

Standards Compliance

  • Environment variables: DISPATCH_SCHEDULER_ENABLED and DISPATCH_SYNC_INTERVAL_MS follow existing DISPATCH_* naming pattern; both marked optional in AGENTS.md
  • Error handling: runJob uses error instanceof Error pattern via try/catch; meaningful log messages on failure
  • No commit of secrets: DISPATCH_AGENT_TOKEN is referenced but never logged or persisted
  • Node runtime only: guarded with NEXT_RUNTIME check and dynamic import

Linked Issue Fit (PR 502)

Requirement from PR 502 Status
Opt-in via DISPATCH_SCHEDULER_ENABLED (default off) ✅ Implemented
Interval via DISPATCH_SYNC_INTERVAL_MS (~900000) ✅ Implemented with 15m default
Reuse DISPATCH_AGENT_TOKEN for loopback auth ✅ Implemented
Startup delay until server is listening ✅ 5s default (DISPATCH_SCHEDULER_STARTUP_DELAY_MS)
Swallow/log fetch errors so failure can't kill interval runJob swallows all errors; logs failure
Loopback HTTP POST (not direct function call) ✅ Deliberate design; avoids chunk-graph isolation
DB lock makes concurrent fires safe (409 → expected) ✅ 409 explicitly treated as non-error; documented in code and tests
Single replica confinement until per-job locks exist ✅ Documented; enabled gate is the confinement mechanism

Evidence Provider Findings

No evidence providers configured for this PR.

Tool Harness Findings

All tool reads succeeded:

  • src/lib/scheduler.ts: Implementation matches the PR diff; dependencies are correctly typed and injected
  • src/instrumentation.ts: Registration logic matches the PR diff; runtime guard and dynamic import are correct
  • git log / git grep: Confirmed this is the first commit on the feature branch; 409 handling matches existing sync-lock semantics in src/lib/sync-lock.ts

CI Results

All checks passed: Typecheck, Lint, Docker Build, Tests, Build — all at commit f82b0db.

Unknowns / Needs Verification

  • The PR body states a follow-up (PR 504) will internalize groomer/pr-followup/prune-closed once their DB locks exist. This is tracked as out-of-scope for this PR and consistent with the design constraint documented in scheduler.ts.

Recommendation

Approve. The implementation is clean, well-tested, matches all linked issue acceptance criteria, and follows repository conventions. The design decision to use loopback HTTP POSTs (not direct function calls) is explicitly documented and correctly reasoned.

@joryirving joryirving enabled auto-merge (squash) July 1, 2026 21:11
@joryirving joryirving merged commit 29c9bdb into main Jul 1, 2026
6 checks passed
@joryirving joryirving deleted the feat/in-app-scheduler branch July 1, 2026 21:18
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.

feat(scheduler): in-app scheduler in instrumentation register() — internalize scheduled sync

1 participant