ADR-0102 — Founders Promo Scheduler: APScheduler (supersedes ADR-0016)
Status: Accepted
Date: 2026-05-20 UTC
Deciders: software-architect
Supersedes: ADR-0016 — Founders Trial: Celery beat for daily sweep
Refs: #204, docs/architecture/founders-promo-system-reconciliation-2026-05-20.md
Context
ADR-0016 (2026-04-22) chose Celery beat for the Founders daily sweep. Its rationale: Celery + Redis was already required for MBT background jobs (mbt.eod_mark_all, mbt.purge_expired), so adding a Founders task would have no new infrastructure cost.
As of 2026-05-20 UTC, Celery + Redis is not deployed on any Raptor (raxx-api-*) dyno. MBT's Celery stack has not shipped. There is no founders.daily_sweep Celery task in the codebase and no Celery worker configuration in backend_v2/.
Raptor uses APScheduler for in-process background jobs (consistent with the Console service, which also uses APScheduler — see flag-reconciler-bidirectional-sync-2026-05-13.md). Adding Celery solely for the Founders daily sweep would introduce a new Redis dependency, a new worker dyno type on Heroku, and a new operational surface — none of which exist anywhere in the current deployment.
The Founders daily sweep in the reconceived pricing model (see founders-promo-system-reconciliation-2026-05-20.md §3.1) scans founder_subscription rows for pricing-lock expiry warnings. It is a low-frequency, low-stakes scan (nightly, small cohort). It does not require a distributed task queue.
Decision
The Founders pricing-lock warning sweep uses APScheduler, consistent with the rest of Raptor's background job infrastructure. Celery is not introduced for this feature.
The sweep is registered as an APScheduler IntervalTrigger job at app-factory initialization time:
# Pseudo-Python — feature-developer implements the real registration
scheduler.add_job(
func=run_founders_pricing_lock_sweep,
trigger="cron",
hour=1, minute=0, # 01:00 UTC nightly
id="founders_pricing_lock_sweep",
replace_existing=True,
misfire_grace_time=3600, # 1-hour window; missed run acceptable for a nightly job
)
Registration is conditional: if FOUNDERS_PROMO_SCHEDULER_DISABLED=1, the job is not registered. Checking the env var at registration time (not at call time) means the dyno must be restarted to re-enable after a kill. This is acceptable for a kill switch that should be sticky until an operator decision.
For the flag check at call time as an additional layer: the sweep method checks FLAG_FOUNDERS_PROMO and exits immediately if off. This allows flag-only pauses without a dyno restart.
Consequences
Positive: - No new infrastructure (Redis, Celery worker dyno) required. - Consistent with Console's APScheduler pattern. Ops monitors one scheduler surface. - Simpler deployment surface pre-v1.
Negative:
- APScheduler in a multi-worker gunicorn setup (multiple processes) fires from every worker process simultaneously unless a distributed lock is added. Mitigation: Raptor runs a single web worker for the scheduler-carrying process (or uses APScheduler's coalesce=True + DB job store for multi-process safety). This must be addressed in the sub-card implementation notes.
- If MBT's Celery stack eventually ships, the Founders sweep must be migrated at that point (separate card). This creates future migration work.
Multi-process guard: The implementing feature-developer must either (a) use APScheduler's SQLAlchemy job store (backed by the Founders schema DB) to ensure only one worker fires the job per schedule tick, or (b) add a DB advisory-lock check at the start of run_founders_pricing_lock_sweep() that returns immediately if another process holds the lock. Option (b) is the simpler path; advisory lock example:
# Pseudo-Python advisory lock pattern — prevents multi-worker double-fire
def run_founders_pricing_lock_sweep():
with db.engine.connect() as conn:
acquired = conn.execute(
text("SELECT pg_try_advisory_lock(hashtext('founders_pricing_lock_sweep'))")
).scalar()
if not acquired:
return # another worker is running this sweep
try:
_do_sweep(conn)
finally:
conn.execute(
text("SELECT pg_advisory_unlock(hashtext('founders_pricing_lock_sweep'))")
)
This pattern requires Postgres (not SQLite). On SQLite (local dev / unit tests), skip the advisory lock entirely — the dev environment is single-process.
Alternatives considered
Celery beat (ADR-0016 original choice)
Rejected because the assumed infrastructure (Celery + Redis, MBT stack) does not exist in the current Raptor deployment. Introducing Celery solely for a nightly Founders sweep would add a Redis add-on (~$30+/mo on Heroku), a worker dyno type, and a Flower monitoring surface. At T-3 days before v1 launch, this is out of scope.
GitHub Actions scheduled workflow
Rejected. GH Actions scheduled workflows have ±30 min jitter, are not designed for operational DB sweeps, and have no access to Raptor's application DB connection pool. Suitable for CI/ops scripts, not for application-layer DB jobs.
Temporal
Rejected. Overkill for a nightly scan at v1 cohort sizes. See ADR-0016 §Alternatives for full rationale (unchanged).
Revisit when
- MBT ships Celery + Redis on Raptor. When that stack is live, migrate the Founders sweep to a Celery task (separate card).
- Founders cohort grows large enough that the nightly sweep takes more than 60 seconds. At that scale, chunked APScheduler + DB cursor or Celery is warranted.