# Scheduled channel sync Archilyzer channels can sync automatically on a per-channel cadence (hourly, daily, every N minutes, …), inspired by the Laravel scheduler: a single dumb heartbeat runs often, and the **server** decides which channels are actually due. ## How it works The heartbeat can be driven two ways — pick one: ``` ┌─ internal timer editor instrumentation hook heartbeat ──┤ │ (editor/instrumentation.ts -> heartbeat.ts) └─ external cron ──┐ │ calls runSchedulerTick() directly, in-process pnpm sync:tick │ │ └─ POST /api/scheduler/tick editor server └─ runSchedulerTick() └─ selectDueChannels() common/jobs/syncScheduler.ts └─ syncAction(slug) existing job queue + worker pool ``` - **Internal heartbeat (recommended, no cron needed):** the editor's Next.js instrumentation hook arms an in-process timer at server startup that calls the scheduler tick directly. Enable it by setting a cadence in **Operations → Sync → Internal heartbeat** (see below). This is the simplest setup — nothing external to install. - **External cron heartbeat (fallback):** leave the internal heartbeat off (0) and have an OS cron job POST to the tick endpoint via `pnpm sync:tick`. Useful when the editor runs behind something that drives schedules centrally, or for multi-host setups. Both paths call the same `runSchedulerTick()`, so they're interchangeable and may even coexist (the tick's overlap guard keeps them from colliding). - A channel is **due** when `now - lastSyncedAt >= its interval`. A missed tick (server was down, machine asleep) just runs at the next tick — no catch-up storms. - All work runs inside the editor server process, so scheduled syncs reuse the per-channel queue lock (no collisions with a manual "Sync" click), show up live in the editor UI, and feed the same transcription worker pool. ## Configuration Per-channel cadence lives on the channel (channel editor → **Auto-sync**), and is stored as `syncIntervalMinutes` in `channels//config.json`: - *Default* — inherit the global default interval. - *Off* — never auto-sync this channel (manual sync still works). - A concrete interval (10m / 30m / hourly / 6h / daily / …). All of the scheduler's settings live on the sync operation's own page — **Operations → Sync** (`/operations/sync`), below the schedule it drives. (They were split between that page and Settings until 2026-08-29; `/scheduler`, the schedule's old address, redirects to `/operations/sync`.) - **Enabled** — master on/off (off by default). - **Internal heartbeat (seconds)** — cadence for the in-process timer. `0` = off (use an external cron heartbeat instead). Any positive value is clamped to `[15, 3600]` seconds. Set this no larger than your smallest channel interval. Changing it takes effect on the next tick; turning it on **from 0 requires a server restart** (the timer is armed once at startup). The `SYNC_HEARTBEAT_SECONDS` env var overrides this setting at runtime. - **Default interval** — fallback for channels without an override. - **Max concurrent syncs** — cap on simultaneous sync jobs. A tick queues at most `cap - running` channels (most-overdue first); the rest roll to the next tick. This both bounds load and staggers a large due-batch over several ticks. - **Quiet hours** — optional local-clock window when auto-sync is suppressed. - **Failure backoff** — after consecutive failures a channel waits `base · 2^(n-1)` minutes (capped) before retrying, so a broken channel doesn't retry every tick. Channels already marked **Exclude from sync** are never auto-synced. ## Internal heartbeat (no cron) Set **Operations → Sync → Internal heartbeat** to a cadence (e.g. `300` for every 5 minutes) and restart the editor. The instrumentation hook (`editor/instrumentation.ts`) arms a single in-process timer that calls `runSchedulerTick()` directly — no cron, no `sync:tick` client, no token. Caveats: - The timer is armed **once per server instance** at startup. If you run multiple editor instances against the same data directory (e.g. a process-manager cluster), each one would beat and race on the scheduler state file — the same single-trigger assumption the cron model had. In that case set `SYNC_HEARTBEAT_SECONDS=0` on all-but-one instance (or use the external cron heartbeat against a single instance). - Development and test servers leave the cadence at `0`, so nothing auto-ticks there unless you explicitly configure it. ## External cron heartbeat (fallback) Leave the internal heartbeat at `0` and run `pnpm sync:tick` from cron at an interval no larger than your smallest channel interval (`*/5` comfortably serves a 10-minute minimum): ```cron */5 * * * * cd /path/to/repo && pnpm sync:tick >> /var/log/ytt-sync.log 2>&1 ``` The client targets `http://127.0.0.1:3001/api/scheduler/tick` by default (the editor's dev/start port). Override with env vars: - `SYNC_TICK_URL` — full endpoint URL (if the editor runs on another host/port). - `SYNC_TICK_TOKEN` — bearer token. When set, the editor server must have the **same** `SYNC_TICK_TOKEN` in its environment, and the tick route rejects requests without a matching `Authorization: Bearer ` header. Recommended if the editor is reachable beyond localhost. When unset, the route is open (consistent with the otherwise-unauthenticated editor admin surface). `pnpm sync:tick` prints a one-line summary and exits non-zero on a network/HTTP error so cron can surface failures. ## Observability **Channels → Sync schedule** shows each channel's resolved interval, last sync, next-due time, recent outcome, and any active backoff, plus a log of recent ticks. The header also reports how ticks are driven — "internal heartbeat every N" or "external heartbeat (cron)". The same data is available as JSON at `GET /api/scheduler/status` (including the effective `heartbeatSeconds`).