I Built a Task Scheduler in 75 Minutes (And It Changed How I Think About Systems)

Every production system you’ve ever operated has a scheduler inside it. Kubernetes has one. PostgreSQL has one. Your operating system is one. And yet most backend engineers have never built a scheduler. They use cron — a tool from 1987 that hasn’t meaningfully changed — and fill the gaps with bash scripts, hardcoded sleep commands, and the quiet assumption that tasks won’t overlap. Then they spend their on-call shifts debugging the cascade when they do.

What if you could build a task scheduler that handles deadlines, priorities, dependencies, and resource limits — and actually understand every line? Not a wrapper around cron. A real scheduler with Earliest Deadline First ordering, a binary min-heap, a worker thread pool, and crash recovery via siglongjmp. That’s PTSS. And once you’ve built one, you think about every piece of infrastructure differently.


The part where cron stops working

Everyone starts the same way. cron: run this command at this time. systemd timer: run this service every 5 minutes. Simple. Works fine.

Until it doesn’t.

You need to run a nightly backup (low priority) and sync inventory data (high priority). The backup is already running when the sync job is due. What happens? cron doesn’t know that inventory sync matters more. It fires both. They compete for CPU. The sync misses its window. Revenue is affected. Nobody understands why until someone spends an hour correlating timestamps.

The sync must complete within 30 seconds. The payment reconciliation must finish within 5 minutes. If a task misses its deadline — does the system escalate? Cancel? Retry? cron doesn’t know what a deadline is. It fires the command and walks away. You find out when a customer complains.

The archive job depends on the backup. The backup depends on the snapshot. So you schedule them 20 minutes apart and hope the first one finishes. When the snapshot takes 25 minutes one Tuesday because the dataset grew, the backup reads inconsistent state. The archive ships corrupted data to cold storage. You discover this three weeks later.

Three heavy tasks land at the same time. cron starts all of them. The OOM killer picks one at random. Maybe it picks the important one. cron doesn’t know what resources are.

The engineers who solve these problems with layered bash scripts — sentinel files, polled directories, sleep 300 as a “synchronization mechanism” — are building a scheduler. They’re just building a bad one.

You need something between cron and a full orchestration platform. Something that understands priorities, respects deadlines, resolves dependencies, monitors resources, and recovers from failures. Something you can run on one machine and read in one afternoon.

What PTSS actually does

PTSS is a task scheduler in C with POSIX threads. A single scheduler thread runs on a 100-millisecond tick. Every tick, it does this:

 Scheduler Tick (every 100ms)
┌─────────────────────────────────────────────┐
│ Phase 1: Scan task pool                     │
│   - Which tasks have next_run_time <= now?  │
│   - Are their dependencies satisfied?       │
│   - Move eligible tasks → ready queue       │
│                                             │
│ Phase 2: Drain the ready queue              │
│   - While idle workers exist and heap ≠ ∅:  │
│   - Extract min (earliest deadline)         │
│   - Assign to idle worker                   │
│   - Signal worker's condition variable      │
│                                             │
│ Phase 3: Monitor running tasks              │
│   - Check deadlines against CLOCK_MONOTONIC │
│   - DEADLINE_SOON → warn                    │
│   - DEADLINE_EXCEEDED → kill, mark FAILED   │
│   - Check resource limits via getrusage()   │
└─────────────────────────────────────────────┘

The scheduler never executes tasks. It delegates to workers. This keeps scheduling latency predictable regardless of how long any individual task takes.

The ready queue isn’t a list. It’s a binary min-heap that orders tasks by deadline first, priority second. This is Earliest Deadline First — provably optimal for single-processor deadline scheduling. An urgent task with an imminent deadline doesn’t wait behind everything else. It goes to the top. Insert and extract are both O(log n).

// The entire scheduling comparator. 9 lines.
static int task_compare(const task_t *a, const task_t *b)
{
    int cmp = timespec_cmp(&a->deadline, &b->deadline);
    if (cmp != 0)
        return cmp;  // earlier deadline first

    if (a->priority != b->priority)
        return b->priority - a->priority;  // higher priority wins tie

    return (int)(a->task_id) - (int)(b->task_id);  // FIFO among equals
}

Each task has a hard deadline checked against CLOCK_MONOTONIC — nanosecond precision, immune to wall-clock adjustments. The executor evaluates deadline status continuously:

 Deadline status progression
┌───────────────────────────────────────────────────────┐
│ ██████████████████████░░░░░░░  DEADLINE_OK            │
│ ██████████████████████████░░░  DEADLINE_SOON  (< 30%) │
│ ████████████████████████████░  DEADLINE_CRITICAL (< 5%)│
│ ██████████████████████████████ DEADLINE_EXCEEDED       │
└───────────────────────────────────────────────────────┘

This isn’t a log message you read tomorrow. This is active enforcement. When a deadline exceeds, the task is killed and marked FAILED. Dependents are blocked from running against bad state.

Dependencies, workers, and crash recovery

Dependencies are resolved on every tick. Each task carries an array of up to 32 dependency IDs. The scheduler checks every one:

// All completed? → task is eligible. Any failed? → task is blocked.
for (int i = 0; i < task->dependency_count; i++) {
    task_t *dep = scheduler_find_task(task->dependencies[i]);
    if (dep->status == TASK_STATUS_FAILED ||
        dep->status == TASK_STATUS_CANCELLED)
        return -1;   // dependency failed — can't run
    if (dep->status != TASK_STATUS_COMPLETED)
        return 0;    // still pending — try next tick
}
return 1;  // all met

Task B depends on Task A. Task C depends on both. PTSS won’t run B until A completes. Won’t run C until both A and B succeed. If A fails, B and C are blocked — they don’t run against missing output. No sentinel files. No sleep delays. No hoping.

Workers and crash recovery

Workers are a fixed thread pool — 4 by default, up to 16. Each worker has a mutex, a condition variable, and a state: IDLE, BUSY, or EXITING. The worker loop is pure condition-variable coordination:

 Worker lifecycle
┌─────────┐      task assigned       ┌─────────┐
│  IDLE   │ ──────────────────────►  │  BUSY   │
│ (sleep) │   signal(wake)           │ (exec)  │
└─────────┘                          └────┬────┘
     ▲                                    │
     │         task done / crashed         │
     └────────────────────────────────────┘

No polling. No busy-waiting. The worker sleeps on pthread_cond_wait until the scheduler signals it. Wakes up, sees current_task != NULL, executes, reports result, goes back to sleep.

But what happens when a task crashes?

In most systems, a segfault kills the process. In PTSS, each worker installs sigaction handlers for SIGSEGV, SIGFPE, SIGBUS, and SIGABRT. Before executing a task, the worker sets a jump point with sigsetjmp. If the task crashes, the signal handler calls siglongjmp back to the worker loop. The task is marked FAILED. The worker survives.

// Worker sets jump buffer → executes task → catches crash
if (sigsetjmp(worker_jmpbuf, 1) != 0) {
    // We got here via siglongjmp from crash handler
    // Task crashed — mark FAILED, worker lives on
    task->status = TASK_STATUS_FAILED;
    snprintf(task->error_message, PTSS_ERROR_MSG_LEN,
             "Task crashed (signal caught)");
    goto task_done;
}

worker_jmpbuf_set = 1;
exit_code = task->task_function(task->task_argument);
worker_jmpbuf_set = 0;

No dead workers. No stuck scheduler. The next task runs.

The state machine

The scheduler itself has a state machine that governs its lifecycle:

 INITIALIZED ──► RUNNING ──► PAUSED


              SHUTTING_DOWN ──► STOPPED

Only RUNNING executes tasks. PAUSED queues tasks but doesn’t dispatch them. Shutdown is three phases: stop accepting new tasks, give running tasks 30 seconds to finish, force-quit and persist state to disk. Clean. No hung processes. No data loss.

// Lock ordering — to prevent deadlocks, always in this order:
//   1. task_pool_lock
//   2. ready_queue.lock
//   3. running_tasks_lock
//   4. state_lock
//   5. worker[i].lock
//   6. task[i].lock
//   7. log_lock

Seven levels of locking, documented in the header, enforced in every function. This is how you write concurrent code that doesn’t deadlock — you decide the order once, write it down, and never violate it.

How this maps to real systems

The patterns in PTSS aren’t unique to PTSS. They’re the patterns inside everything.

 PTSS                          Kubernetes
┌──────────────────┐          ┌──────────────────────┐
│ Ready queue       │  ───►   │ Scheduling queue      │
│ (min-heap)        │         │ (priority queue)      │
├──────────────────┤          ├──────────────────────┤
│ task_compare()    │  ───►   │ Filter + Score        │
│ (EDF + priority)  │         │ (resource fit + rank) │
├──────────────────┤          ├──────────────────────┤
│ executor_assign() │  ───►   │ Bind (pod → node)     │
│ (task → worker)   │         │                       │
├──────────────────┤          ├──────────────────────┤
│ check_deadline()  │  ───►   │ activeDeadlineSeconds │
├──────────────────┤          ├──────────────────────┤
│ check_dependencies│  ───►   │ Init containers /     │
│                   │         │ readiness gates       │
└──────────────────┘          └──────────────────────┘

cron says “run command at time X.” PTSS says “run task at time X, complete by deadline Y, with priority Z, after dependencies A and B finish, only if resources allow.” cron is a timer. PTSS is a scheduler. The distinction matters when tasks interact.

systemd adds dependency ordering and resource limits through cgroups — but it’s tied to init. You can’t embed it. PTSS is a library: compile with -DPTSS_NO_MAIN, link the object files, call scheduler_add_task() from your own code.

When you read the Kubernetes scheduler source after building PTSS, you don’t see alien code. You see a distributed version of something you already understand. The SchedulingQueue is a heap. The Filter and Score plugins are resource checks. The Bind step is worker assignment.

The file structure

The entire system is readable in an afternoon:

PTSS/
├── protocol.h     351 lines  — Every struct, constant, error code, declaration
├── scheduler.c    786 lines  — Tick loop, task pool, dependency resolution, CLI
├── executor.c     455 lines  — Worker pool, crash recovery, deadline checking
├── heap.c         244 lines  — Min-heap: sift_up, sift_down, insert, extract
├── log.c          250 lines  — Structured logging, execution log, persistence
├── monitor.c      150 lines  — Metrics queries, dashboard, status reports
├── test_ptss.c    610 lines  — 7 tests: priorities, deadlines, deps, load
└── Makefile        75 lines  — Debug/release builds, test, valgrind

No build system generator. No vendored libraries. protocol.h is the single source of truth. heap.c is a textbook min-heap you’ll want to steal. executor.c has the cleanest siglongjmp crash recovery pattern I’ve written. The whole thing compiles with make — GCC, C11, pthreads, nothing else.

Getting started

Clone it and build it:

git clone https://github.com/devwail/PTSS.git
cd PTSS
make

Run the test suite:

make test

Seven tests pass: priority ordering, deadline enforcement, concurrent execution, dependency chains, graceful shutdown, error recovery, and high load — 100 tasks across 4 workers, ~40 tasks/sec throughput.

Run it standalone:

./scheduler --workers 4 --max-tasks 1000 --log-level DEBUG

Watch the scheduler tick. Tasks enter the ready queue, get assigned to workers, complete or fail, trigger dependents.

Now read the code. Start with heap.c — 244 lines, the cleanest min-heap you’ll read this year. Then executor.c — the worker loop, the sigsetjmp pattern, the deadline checking. Then scheduler_tick in scheduler.c — Phase 1 scans for due tasks, Phase 2 drains the heap into workers, Phase 3 enforces deadlines. The entire scheduling cycle fits in your head.

Forty-five minutes to build and run. Thirty minutes to read and understand. Seventy-five minutes total.


There’s a specific kind of systems knowledge that only comes from building things. You can read about heaps in a textbook. But when you’ve written sift_up and watched it reorder tasks by deadline in a live scheduler, you understand it differently. It becomes machinery you can reason about under pressure — not an algorithm you vaguely remember from school.

Build PTSS this week. Read scheduler_tick. Watch the three phases. See how the heap, the workers, and the dependency checker fit together in one clean loop.

Then look at the Kubernetes scheduler source. Look at Airflow’s DAG processing loop. Look at how systemd evaluates unit dependencies. They’ll look familiar — because you’ll recognize the ready queue, the policy engine, and the executor assignment from code you built yourself.

That’s the difference between running systems and understanding them. And it takes 75 minutes. PTSS is on GitHub.

Subscribe to My Newsletter

Daily technical content, project updates, and automation tips straight to your inbox.