DEV Community

Dakota Huang
Dakota Huang

Posted on

Free Servers Kill Processes. Make Your Model Jobs Survive.

Free server tiers recycle idle processes without warning. A single deploy kills your running worker. Memory limits kill long model calls before they finish. Your job dies in the middle of a request. The fix is not a longer timeout or a retry. The fix is a durable job table. Separate the job from the process that runs it. Then the process can die at any time. The job survives. This tutorial builds that runner from zero, step by step. Every stage ends with a verification step. The full pattern is about 80 lines of Python.

This pattern fits free model access and a free server option. MonkeyCode offers both. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The code runs anywhere Python 3 exists.

The problem

A free server is not a reliable process host. It sleeps when idle. It restarts on deploy. It kills long-running tasks. A model call can take thirty seconds. Your worker rarely gets those thirty seconds.

A job table changes the contract. The worker claims a job. The worker calls the model. The worker records the result. If the worker dies, the claim expires. The next worker reclaims the job. This is the lease pattern. It is boring. It works.

What we build

One SQLite table. One worker loop. One wake endpoint. That is the whole system.

The table stores job state. The loop claims and executes jobs. The endpoint lets an external scheduler wake a sleeping server. No queue service. No Redis. No new dependencies.

Stage 1: Get a server

Create a free server with Python 3. MonkeyCode's free server option works here. Any free tier with Python 3 works too.

Save this as server.py:

from http.server import BaseHTTPRequestHandler, HTTPServer

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/health":
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"ok")
        else:
            self.send_response(404)
            self.end_headers()

HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
Enter fullscreen mode Exit fullscreen mode

Start it:

python3 server.py
Enter fullscreen mode Exit fullscreen mode

Verify:

curl -s http://localhost:8080/health
Enter fullscreen mode Exit fullscreen mode

You should see ok. If you do not, the platform blocks port 8080. Use the port your provider assigns.

Stage 2: Create the job table

SQLite ships with Python. There is no install step. Create init_db.py:

import sqlite3, time

conn = sqlite3.connect("jobs.db")
conn.execute("""
    CREATE TABLE IF NOT EXISTS jobs (
        id TEXT PRIMARY KEY,
        idempotency_key TEXT UNIQUE,
        payload TEXT,
        status TEXT DEFAULT 'pending',
        attempts INTEGER DEFAULT 0,
        lease_until REAL,
        next_attempt_at REAL,
        result TEXT,
        created_at REAL,
        updated_at REAL
    )
""")
conn.commit()
conn.close()
Enter fullscreen mode Exit fullscreen mode

Run it:

python3 init_db.py
Enter fullscreen mode Exit fullscreen mode

Verify:

sqlite3 jobs.db ".schema jobs"
Enter fullscreen mode Exit fullscreen mode

You should see the full table definition. The lease_until column is the key. It stores when a claim expires.

Stage 3: Write the worker loop

The worker claims one job. It calls the model endpoint. It stores the result. Save this as worker.py:

import json, sqlite3, time, urllib.request

DB = "jobs.db"
MODEL_URL = "https://your-model-endpoint.example/v1/complete"

def claim(conn):
    now = time.time()
    conn.execute("""
        UPDATE jobs SET status='running', lease_until=?, updated_at=?
        WHERE id = (
            SELECT id FROM jobs
            WHERE status='pending' AND next_attempt_at <= ?
            ORDER BY created_at LIMIT 1
        )
    """, (now + 60, now))
    return conn.execute(
        "SELECT * FROM jobs WHERE status='running' AND lease_until > ?",
        (now,),
    ).fetchone()

def call_model(payload):
    req = urllib.request.Request(
        MODEL_URL,
        data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return resp.read().decode()

def run_once():
    conn = sqlite3.connect(DB)
    conn.row_factory = sqlite3.Row
    job = claim(conn)
    if not job:
        conn.close()
        return
    result = call_model(json.loads(job["payload"]))
    conn.execute(
        "UPDATE jobs SET status='done', result=?, updated_at=? WHERE id=?",
        (result, time.time(), job["id"]),
    )
    conn.commit()
    conn.close()

if __name__ == "__main__":
    run_once()
Enter fullscreen mode Exit fullscreen mode

Insert a test job:

sqlite3 jobs.db "INSERT INTO jobs (id, idempotency_key, payload, created_at, updated_at, next_attempt_at) VALUES ('job-1', 'key-1', '{\"prompt\": \"hello\"}', strftime('%s','now'), strftime('%s','now'), strftime('%s','now'));"
Enter fullscreen mode Exit fullscreen mode

Run the worker:

python3 worker.py
Enter fullscreen mode Exit fullscreen mode

Verify:

sqlite3 jobs.db "SELECT id, status, result FROM jobs;"
Enter fullscreen mode Exit fullscreen mode

You should see job-1|done|.... The model response sits in the result column. Read the full body before parsing. A streaming response is not a payload.

Stage 4: Survive a kill

Now break it. Point MODEL_URL at a stub that sleeps for 90 seconds. Start the worker. Kill it after five seconds.

python3 worker.py &
PID=$!
sleep 5
kill -9 $PID
Enter fullscreen mode Exit fullscreen mode

Check the job:

sqlite3 jobs.db "SELECT id, status FROM jobs;"
Enter fullscreen mode Exit fullscreen mode

You will see running. The claim has not expired. Wait 60 seconds. It stays running forever. That is the bug.

Fix it with a reclaim step. Add this to worker.py:

def reclaim(conn):
    now = time.time()
    conn.execute("""
        UPDATE jobs SET status='pending'
        WHERE status='running' AND lease_until < ?
    """, (now,))
Enter fullscreen mode Exit fullscreen mode

Call reclaim(conn) before claim(conn) inside run_once(). Restart the worker after the lease expires:

python3 worker.py
Enter fullscreen mode Exit fullscreen mode

Verify:

sqlite3 jobs.db "SELECT id, status FROM jobs;"
Enter fullscreen mode Exit fullscreen mode

The job goes pending, then running, then done. The process died. The job did not.

The idempotency_key protects the consumer. If the same logical job is submitted twice, the second insert fails. The unique constraint rejects it. Note the limit: the model call itself may run twice. At-least-once is not exactly-once.

Stage 5: Retry with backoff

Free model endpoints rate-limit you. A 429 is normal. The worker should retry. Add a failure path:

def fail(conn, job):
    now = time.time()
    backoff = min(60 * (2 ** job["attempts"]), 900)
    conn.execute("""
        UPDATE jobs
        SET status='pending', attempts=attempts+1, next_attempt_at=?, updated_at=?
        WHERE id=?
    """, (now + backoff, now, job["id"]))
Enter fullscreen mode Exit fullscreen mode

Wrap the model call in a try/except. On HTTPError with code 429, call fail(). On success, run the done update.

try:
    result = call_model(json.loads(job["payload"]))
    conn.execute(
        "UPDATE jobs SET status='done', result=?, updated_at=? WHERE id=?",
        (result, time.time(), job["id"]),
    )
except urllib.error.HTTPError as e:
    if e.code == 429:
        fail(conn, job)
Enter fullscreen mode Exit fullscreen mode

Add import urllib.error at the top. Verify with a stub that returns 429 twice, then 200. Run the worker once. Wait past the backoff. Run it again. Check the row:

sqlite3 jobs.db "SELECT id, status, attempts FROM jobs;"
Enter fullscreen mode Exit fullscreen mode

You should see done with attempts=2. The backoff grows: 60, 120, 240 seconds. It caps at 900.

Stage 6: Wake a sleeping server

Free servers sleep. A sleeping worker claims nothing. Add a wake endpoint to server.py. Import run_once from worker.py.

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/wake":
            run_once()
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"ran")
        else:
            self.send_response(404)
            self.end_headers()
Enter fullscreen mode Exit fullscreen mode

Point any external cron at the endpoint. A ping every minute is enough. Verify:

curl -s http://localhost:8080/wake
sqlite3 jobs.db "SELECT id, status FROM jobs ORDER BY created_at DESC LIMIT 3;"
Enter fullscreen mode Exit fullscreen mode

Each ping runs one worker pass. Pending jobs drain. The server can still sleep between pings. The wake request blocks until the job finishes. That is fine for a cron ping.

Limitations

This is a single-worker design. SQLite locks under concurrent writers. Do not run two workers on one database.

The lease must exceed your longest model call. A 60-second lease breaks on a 90-second call. Set the lease to your timeout plus a buffer.

At-least-once means double execution is possible. A killed process may have sent the request already. The retry sends it again. Quota is spent twice. Design your consumer to tolerate duplicates.

Who should not use this

Skip this pattern if you need exactly-once semantics. Skip it if you run multiple workers. Skip it if your jobs are tiny and fast. A plain loop is enough then.

Use it when the process is unreliable. That is every free server. The job table costs 80 lines. It turns a crash into a delay. That is a good trade. Try the kill test yourself. Your current worker will fail it.

Top comments (0)