Skip to content

feat(server): add background scheduling engine (COD-377) - #4

Merged
shivros merged 1 commit into
mainfrom
runner/cod-377-scheduling-engine
Jul 24, 2026
Merged

feat(server): add background scheduling engine (COD-377)#4
shivros merged 1 commit into
mainfrom
runner/cod-377-scheduling-engine

Conversation

@shivros

@shivros shivros commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements COD-377: background scheduling engine for PostGhost. Schedules stored in SQLite are now automatically picked up and published via Iris when their scheduled_for time arrives.

Closes #COD-377

Changes

New module: scheduler.rs

  • Poll loop: spawns a tokio task during server startup that wakes every 30 seconds, queries list_pending_schedules(), and processes any schedule whose scheduled_for <= now
  • Atomic claim: claim_schedule() transitions Pending → InProgress atomically (UPDATE ... WHERE status = 'pending'), preventing double-publishing under concurrent poll cycles or multiple server instances
  • Per-schedule processing: claims the schedule → fetches content → selects the best variant (matching platform variant or raw body fallback) → dispatches to Iris with a 30s timeout → marks Published on success, Failed on error/timeout
  • Graceful shutdown: uses a tokio::sync::watch channel; the loop exits cleanly when SIGINT or SIGTERM is received
  • Robustness: invalid timestamps, missing content, and Iris failures are all logged via tracing and marked Failed; the loop never panics on individual schedule failures

Modified: storage.rs

  • claim_schedule(id) — atomic Pending→InProgress claim with status precondition guard
  • update_schedule_status(id, status) — general status transition
  • get_schedule_status(id) — read schedule status (used by tests)
  • insert_schedule_raw_for_test()#[cfg(test)] helper for FK-bypass test fixtures

Modified: lib.rs

  • Spawns scheduler after listener binds (port-in-use failure doesn't leak a detached task)
  • wait_for_shutdown_signal() handles both SIGINT and SIGTERM via tokio::signal::unix
  • sched.await bounded with 10s timeout so a hung Iris call can't pin shutdown

Modified: api.rs

  • build_router_from_arc(state: Arc<AppState>) — accepts pre-Arc'd state for sharing between server and scheduler

Review Panel

Both reviewers (GPT-5.5, Gemini 3 Flash) independently flagged three issues that were addressed before this PR was opened:

  1. SIGTERM not handled — original code used ctrl_c() which only catches SIGINT. Fixed with tokio::signal::unix handling both SIGTERM and SIGINT.
  2. Non-atomic claim — original update_schedule_status had no status precondition, risking double-publish. Fixed with claim_schedule() using WHERE status = 'pending'.
  3. No Iris timeout — a hung Iris call would block the entire scheduler. Fixed with tokio::time::timeout(30s).

Verification

cargo build --all-targets    ✅
cargo test                   ✅ (12 passed, 0 failed)
cargo clippy --all-targets -- -D warnings  ✅
cargo fmt --all -- --check   ✅

New tests:

  • test_update_schedule_status_round_trip — storage method round-trip
  • test_poll_once_marks_failed_when_iris_unreachable — Iris failure → Failed status
  • test_poll_once_skips_not_yet_due — future schedules remain Pending
  • test_poll_once_marks_failed_when_content_missing — missing content → Failed status

Spawn a tokio task during server startup that polls list_pending_schedules()
every 30 seconds and publishes due content via Iris.

- scheduler.rs (new): poll loop with tokio::select for shutdown signal,
  per-schedule processing with atomic claim, Iris timeout, variant selection
- storage.rs: add claim_schedule (atomic Pending→InProgress with status guard),
  update_schedule_status, get_schedule_status
- lib.rs: spawn scheduler after listener bind, graceful shutdown via
  watch channel driven by SIGTERM + SIGINT, bounded sched.await with timeout
- api.rs: add build_router_from_arc for Arc<AppState> sharing

Co-authored-by: Archon <archon@purelymail.com>
@shivros

shivros commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Automated Review Panel

Two-model review was run before opening this PR. Both reviewers independently identified the same three issues, which were all fixed before the PR was opened.


GPT-5.5 (openai/gpt-5.5)

Verdict: Well-structured, mostly achieves the goal. Three issues found (all addressed):

🔴 SIGTERM not handled (acceptance criterion miss): tokio::signal::ctrl_c() only catches SIGINT, not SIGTERM. Docker stop, systemd, and k8s all send SIGTERM. → Fixed: now uses tokio::signal::unix with both SignalKind::terminate() and SignalKind::interrupt().

🟠 Non-atomic Pending→InProgress claim: The original update_schedule_status had no status precondition, meaning a row in a terminal state could be clobbered. Violates the TOCTOU convention. → Fixed: added claim_schedule() with WHERE id = ? AND status = 'pending'.

🟠 No Iris timeout: IrisClient::send_message could hang indefinitely, blocking the entire scheduler loop and shutdown. → Fixed: wrapped in tokio::time::timeout(30s).

✅ Correct: poll interval, shutdown break logic, variant selection, enum serialization, error handling per-schedule, test coverage.


Gemini 3 Flash (google/gemini-3-flash-preview)

Verdict: Largely achieves the goal, compiles, all CI gates green. Same three issues identified:

🔴 SIGTERM handling: Confirmed ctrl_c() is SIGINT-only. Normal production shutdown paths would never trigger clean shutdown. → Fixed (same as above).

🟠 Atomic claim needed: Identified the same TOCTOU race and recommended UPDATE ... WHERE status = 'pending' returning rows_affected. → Fixed (same as above).

🟡 Bound sched.await: Recommended timeout on shutdown join. → Fixed: 10s timeout added.

✅ Additional positive notes: first-tick correctly deferred, variant selection matches spec, enum DB serialization consistent with conventions, per-schedule errors caught and logged.


Summary: All blocking issues from both reviewers were addressed before PR creation. No outstanding concerns.

@shivros
shivros marked this pull request as ready for review July 24, 2026 22:28
@shivros

shivros commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Auto-Merge Gate — Approval

Confidence: 0.92 (threshold: 0.80)
Ticket: COD-377 — Scheduling engine — background task for scheduled content publishing

Rationale

This PR cleanly implements all 5 acceptance criteria from COD-377:

  1. ✅ Background poller starts with server, polls every 30s (POLL_INTERVAL)
  2. ✅ Scheduled content published automatically when scheduled_for <= now
  3. ✅ Status transitions Pending → InProgress → Published/Failed persisted atomically (claim_schedule with WHERE status = 'pending' guard)
  4. ✅ Errors logged via tracing::error!/tracing::info!, never silently swallowed
  5. ✅ Graceful shutdown handles both SIGINT and SIGTERM; scheduler bounded with 10s shutdown timeout

Checks observed (all green)

  • Build ✅
  • Clippy ✅
  • Formatting ✅
  • Secret Scanning ✅
  • Tests ✅ (12 passed, including 4 new scheduler tests)

Review panel outcome

Both GPT-5.5 and Gemini 3 Flash independently flagged 3 issues (SIGTERM handling, non-atomic claim, missing Iris timeout) — all addressed before this PR was opened.

Scope limits

  • No secrets, auth, or production deployment/cutover changes
  • Focused feature: 1 new module (scheduler.rs, 334 lines) + storage methods + server wiring
  • No manual-review markers in linked Linear ticket

Auto-merged by CodeFold Auto-Merge Gate (cron ceb0befd1f30)

@shivros
shivros merged commit 43da0a9 into main Jul 24, 2026
5 checks passed
@shivros
shivros deleted the runner/cod-377-scheduling-engine branch July 24, 2026 22:28
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.

1 participant