miniAF is a learning-first, from-scratch implementation of a job scheduler inspired by Apache Airflow.
The goal is not feature parity, but to deeply understand scheduling, concurrency, reliability, and failure handling in distributed systems.
This project is intentionally built step by step, documenting design decisions, mistakes, and improvements along the way.
- Python 3.11
- FastAPI (API layer)
- PostgreSQL (state & coordination)
- SQLAlchemy ORM
- Docker & Docker Compose
- croniter (cron scheduling)
- Redis (planned, not yet used)
Worker(s) ───▶ API ───▶ PostgreSQL ◀─── Scheduler
- API: Create / manage jobs
- Scheduler: Decides when jobs should run
- Worker(s): Execute jobs
- Postgres: Source of truth for state & coordination
Goal: Define the domain correctly.
Job- name
- cron schedule
- execution_time_sec
- failure_probability
- max_retries
- retry_delay_sec
- is_active
JobRun- scheduled_time
- status (PENDING, RUNNING, SUCCESS, FAILED, RETRY)
- attempt_number
- timestamps
- worker_id
- REST API using FastAPI
- SQLAlchemy models
- DB schema auto-creation on startup
- DB defaults vs ORM defaults
- Enum handling in Postgres
- Why schema creation must be centralized
Goal: Convert cron jobs into concrete executions.
- Scheduler process
- Reads active jobs
- Uses
croniterto compute next run - Inserts
JobRunrecords - Idempotency via
(job_id, scheduled_time)unique constraint - Timezone normalization (moved to UTC later)
- Timezone mismatches
- Duplicate JobRuns
- Scheduler running before schema exists
- UTC everywhere
- DB-level uniqueness
- Startup retries
Goal: Make services reproducible.
- Dockerfiles for:
- API
- Scheduler
- Worker
- Docker Compose setup
- Shared
common/package .envbased configuration
- psycopg2 build failures
- import path issues
- services starting before DB ready
psycopg2-binary- explicit
PYTHONPATH - DB wait utilities
Goal: Execute JobRuns reliably.
- Worker process
- Polls DB for
PENDINGorRETRYJobRuns - Marks RUNNING
- Simulates execution
- Handles:
- SUCCESS
- FAILURE
- RETRY with delay
- Retry scheduling by updating
scheduled_time - UTC-only timestamps
- Retry logic handled by worker, not scheduler
- No Redis yet
- One DB as coordinator
- Execution happens outside DB transactions
- Multiple commits & rollbacks are normal
- State transitions must be explicit
- Time-based scheduling beats sleep-based retry
Goal: Understand real distributed problems.
- Inconsistent job status counts
- Race conditions when multiple workers claim jobs
- Double execution risk
Postgres must decide the winner, not Python
This led to understanding:
- Atomic job claiming
- Row-level locking
- Why naive polling breaks under concurrency
Status: ✅ Done (design-first)
Implemented:
- Scheduler
- Workers (scalable)
- Atomic job claiming
- Retries with delay
- Heartbeat mechanism
- Zombie job reaper
- Dockerized setup
Workers claim jobs using PostgreSQL row locks:
SELECT *
FROM job_runs
WHERE status IN ('PENDING', 'RETRY')
AND scheduled_time <= now()
ORDER BY scheduled_time
FOR UPDATE SKIP LOCKED
LIMIT 1;Only Postgres decides the winner, preventing race conditions.
Workers periodically update:
job_run.last_heartbeat_at = now()
This happens while the job is executing.
Scheduler detects jobs stuck in RUNNING:
status = RUNNING
AND last_heartbeat_at < now() - HEARTBEAT_TIMEOUT
Action taken:
- Retry if attempts left
- Else mark FAILED
- Same job_run reused
- scheduled_time updated
- attempt_number incremented
- No new rows created
- Worker crash
- Worker kill
- Multiple workers racing
- Stuck RUNNING jobs
- Duplicate execution prevention
Goal: Make the system observable and debuggable under concurrent, distributed execution.
- Structured JSON logging across:
- API
- Scheduler
- Workers
- Scheduler startup
- Job scheduling
- Job claiming
- Job execution start
- Heartbeats
- Job success / retry / failure
- Active worker count
- Running job count
- Logs are append-only and immutable
- Logging is non-blocking and does not affect execution flow
- No external log aggregator yet (stdout-first design)
- Scheduler observes system state via DB + logs, not worker RPCs
- Structured logs make distributed flows traceable
- Heartbeats are essential for detecting stalled or zombie executions
- Observability must be built before scaling workers
- Logs double as both debugging tool and future metrics source
Scale workers:
docker-compose up --scale worker=2- Database > Application for coordination
- Crashes are normal
- Recovery must be automatic
- Metrics & monitoring
- Watchdog service
- Graceful shutdown
- DAG support
This is not about:
- Building Airflow
- Optimizing performance
- Production readiness
This is about:
- Understanding distributed systems
- Learning failure modes
- Thinking in leases, not locks
- Designing for crashes, not happy paths
- Phase 7A: Deploy this project
- Phase 7B: Redis-based queue
This repo intentionally documents mistakes and evolution.
Every phase exists because something broke.
That’s the point.