DEV Community

Cover image for How Failing at Fantasy Baseball Made Me Fix My Cron Jobs with Temporal
Prithwish Nath
Prithwish Nath

Posted on • Originally published at Medium

How Failing at Fantasy Baseball Made Me Fix My Cron Jobs with Temporal

Image

So I made a bad trade in my fantasy baseball league. Dropped Kaz Okamoto because — according to my data — he’d been cold for two weeks. In reality, he’s been on a tear for the last 9 days. 😅 This was a bad decision made because of bad data — my stats cron job had hit a rate limit, exited with no errors, and my FastAPI backend kept serving a stale JSON snapshot.

Well, I’d been meaning to fix that setup anyway. This time I did — and instead of patching the script, I tried out Temporal and…it worked embarrassingly well. Retries, backoff, execution history — things I’d normally bolt on manually were just… there. And if the network layer itself was flaky — rate limits, geo blocks — I could just add a proxy as a hardening layer.

This actually prompted me to go look at some of our production ingest jobs at work, and I thought: these are the same pattern, just with more surface area! I ended up swapping out one of them, tentatively, then another. Same pattern, just more scale.

This is my (admittedly very casual) write-up of what I learned. I hope it’s useful!

💡 I use Temporal’s Python SDK here, but they have one for TypeScript too — if that’s your thing.

How Cron Jobs Can Burn You

Here’s the brittle script I was using:

# One-shot MLB.com player fetch + write  

def main() -> None:
    player_url = os.getenv(
        "PLAYER_URL",
        "https://www.mlb.com/player/kazuma-okamoto-672960",
    )
    out_dir = Path(os.getenv("OUTPUT_DIR", "./data/runs"))
    out = out_dir / "latest.json"

    try:
        r = requests.get(player_url, timeout=60)
        if r.status_code != 200:
            print(f"WARN: HTTP {r.status_code}, leaving {out} unchanged")
            return
        stats = extract_stats_datatable(r.text)
        out_dir.mkdir(parents=True, exist_ok=True)
        out.write_text(json.dumps(stats), encoding="utf-8")
        print(f"Wrote stats to {out}")
    except Exception as e:
        print(f"WARN: ingest failed ({e!r}), leaving {out} unchanged")

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

That script lived behind a super simple crontab line — once a night, fixed schedule, stdout/stderr logs:

# crontab -l (excerpt)
0 2 * * * cd /home/me/fantasy-stats && .venv/bin/python scripts/fetch_player_stats.py >> /var/log/mlb_fetch.log 2>&1
Enter fullscreen mode Exit fullscreen mode

The script is about ~20 lines of Python plus one line of schedule. How many potential failures can you spot here?

The first is the thing I thought was prudent: if the fetch looks wrong, don’t overwrite the snapshot. So any 429, timeout, or 200 with a layout that no longer contains the marker extract_stats_datatable expects becomes a printed WARN, a no-op, and main() returns — exit code 0. No raise_for_status(); no sys.exit(1). Cron is “happy”; the one-line warning vanishes in a log I wasn’t tailing; latest.json never updates. Nine “successful” runs later…I made a bad decision because I had bad data. (Flip it to raise_for_status() and you get the opposite smell: a non-zero exit, still no retry, still stale data until someone fixes the feed — pick your poison 😬)

The other two are subtler — and I didn’t personally run into them — but upon review, they were just as likely to have burnt me.

  • Fixed output path. Every run writes to latest.json. If two runs overlap — which happens the moment a run is slow and the next cron tick fires — it’s a race condition. One overwrites the other mid-write. You might read a corrupted file, or never know which run's data you actually have.
  • Non-atomic write. out.write_text() is not atomic. If the process dies mid-write — OOM, signal, anything — you get a partial JSON file. The next reader gets a parse error and now has to figure out if the file is corrupted or just empty. This is the exactly kind of bug that shows up at 2am on a production system.

The real problem isn’t any ONE of these — it’s really that cron gives you exactly one bit of feedback: exit zero or exit non-zero — and as this script shows, exit zero can lie. It can’t give you a retry policy, overlap protection, or artifact history. No way to answer “what did this job actually do at 3am last Tuesday?”

And yes, you can try to patch around that. Add a retry loop and exponential backoff, ship logs somewhere. But now your retry state lives in the process memory that disappears on crash, your backoff is hand-rolled, and your observability is still pretty much log spelunking. At that point you’re not using cron anymore. You’re rebuilding a tiny, worse workflow engine around cron.

What is Temporal?

Temporal is a “durable execution platform”. What that really means in practice is that you’ll write ordinary functions — a Workflow that orchestrates things, and Activities that do the actual work — and Temporal will:

  • Make the execution survive process crashes,
  • Retry failed steps with backoff,
  • Prevent overlapping runs, and
  • Record the full history of every execution.

💡 Durable execution is the simple idea that your code should keep running to completion even if the machine running it doesn’t. The mental model is that your workflow is a function call that cannot be interrupted, even if the worker reboots halfway through. State lives in Temporal’s history, not in the worker’s memory.

The architecture for our project will look like this (by default you get the Temporal Web UI on localhost:8233):

To grok Temporal properly, understand that the Workflow owns when things happen, while the Activity owns what happens — i.e the page fetch, the stats extraction, the file write. Workflows must stay deterministic; all side effects belong in Activities. Why does that matter? We’ll come back to that in a second.

Getting player data from MLB.com

Before any of the Temporal machinery, you need a reliable data ingestion. For us, that means loading an MLB.com player page and extracting the stats blob embedded in the initial HTML.

MLB.com currently renders a player page with a JavaScript object that starts with stats: {"statsDatatable"...}. That's convenient: no browser, no Playwright, no screenshot automation needed.

Critical to understand that this does not tell you that the stats blob is still there or that the row you care about was parsed correctly.

mlb_player_stats.py

"""  
MLB.com player page: HTTP fetch + embedded JSON extraction.  
"""  
from __future__ import annotations  
import os  
import re  
from json import JSONDecoder  
from typing import Any, Dict, List  
from urllib.parse import quote  
import requests  

def _strip_tags(s: Any) -> Any:  
    if not isinstance(s, str):  
        return s  
    s = re.sub(r"`<[^>`]+>", "", s)  
    return s.strip()  

def _sanitize_row(row: Dict[str, Any]) -> Dict[str, Any]:  
    out: Dict[str, Any] = {}  
    for k, v in row.items():  
        out[k] = _strip_tags(v)  
    return out  

def extract_stats_datatable(html: str) -> Dict[str, Any]:  
    needle = 'stats: {"statsDatatable"'  
    i = html.find(needle)  
    if i == -1:  
        raise ValueError(  
            "Could not find stats JSON marker (page layout may have changed)."  
        )  
    start = i + len("stats: ")  
    obj, _ = JSONDecoder().raw_decode(html[start:])  
    return obj  

def pick_current_season_row(rows: List[Dict[str, Any]]) -> Dict[str, Any] | None:  
    for row in rows:  
        h = row.get("header", "")  
        if isinstance(h, str) and "Regular Season" in h and "Career" not in h:  
            return row  
    return rows[0] if rows else None  

def build_requests_proxies() -> Dict[str, str] | None:  
    # Bright Data super proxy; returns None if credentials unset  
    explicit = os.getenv("BRIGHT_DATA_PROXY_URL", "").strip()  
    if explicit:  
        return {"http": explicit, "https": explicit}  
    host = os.getenv("BRIGHT_DATA_PROXY_HOST", "brd.superproxy.io").strip()  
    port = os.getenv("BRIGHT_DATA_PROXY_PORT", "33335").strip()  
    username = os.getenv("BRIGHT_DATA_PROXY_USERNAME", "").strip()  
    password = os.getenv("BRIGHT_DATA_PROXY_PASSWORD", "").strip()  
    if not username or not password:  
        return None  
    user_enc = quote(username, safe="")  
    pass_enc = quote(password, safe="")  
    proxy_url = f"http://{user_enc}:{pass_enc}@{host}:{port}"  
    return {"http": proxy_url, "https": proxy_url}  

def fetch_player_page(player_url: str, *, timeout: int = 60) -> requests.Response:  
    proxies = build_requests_proxies()  
    return requests.get(  
        player_url,  
        timeout=timeout,  
        proxies=proxies  
    )  

def build_stats_payload(player_url: str, *, timeout: int = 60) -> Dict[str, Any]:  
    # Fetch page, parse embedded hitting summary rows (sanitized)  
    r = fetch_player_page(player_url, timeout=timeout)  
    r.raise_for_status()  
    blob = extract_stats_datatable(r.text)  
    hitting_large = blob["statsDatatable"]["hitting"]["large"]  
    block = hitting_large[0] if isinstance(hitting_large, list) else hitting_large  
    filtered = block["filteredRows"]  
    current = pick_current_season_row(filtered)  
    career = next(  
        (row for row in filtered if row.get("header") == "Career Regular Season"),  
        None,  
    )  
    via_proxy = build_requests_proxies() is not None  
    return {  
        "source_url": player_url,  
        "http_status": r.status_code,  
        "via_bright_data_proxy": via_proxy,  
        "current_regular_season": _sanitize_row(current) if current else None,  
        "career_regular_season_row": _sanitize_row(career) if career else None,  
        "all_summary_rows": [_sanitize_row(row) for row in filtered],  
    }
Enter fullscreen mode Exit fullscreen mode

Keeping the fetch inside an activity means the execution model stays unchanged while the network path can evolve independently. The same fetch_player_page call can run directly or be routed through a proxy layer without touching the workflow logic.

Note that Temporal can only give you execution reliability: retries, timeouts, and visibility. It can not make a failing network succeed. If every attempt returns 429 from the same IP, Temporal will reliably retry a failing request until the policy is exhausted — you won’t know how valuable our proxy layer is until you really need it (if you’re following along, get it here.)

Data Extraction with Temporal.io

The Temporal Workflow

@workflow.defn  
class StatsCollectionWorkflow:  
    @workflow.run  
    async def run(self, job: StatsJob) -> CollectStatsResult:  
        info = workflow.info()  
        return await workflow.execute_activity(  
            collect_stats,  
            CollectStatsInput(  
                player_url=job.player_url,  
                workflow_id=info.workflow_id,  
                run_id=info.run_id,  
                output_dir=job.output_dir,  
            ),  
            start_to_close_timeout=timedelta(minutes=10),  
            retry_policy=RetryPolicy(  
                initial_interval=timedelta(seconds=3),  
                backoff_coefficient=2.0,  
                maximum_interval=timedelta(minutes=2),  
                maximum_attempts=8,  
            ),  
        )
Enter fullscreen mode Exit fullscreen mode

That RetryPolicy block is the part most of us have written manually at some point — a while loop, a try/except, a time.sleep, a counter, hopefully a max attempts check. Here it's declared once, lives outside the business logic, and survives worker crashes. If the worker process dies on attempt 3 of 8, the next worker that comes up picks up at attempt 4. The state is in Temporal, not in memory.

The start_to_close_timeout is the hard limit on how long a single activity attempt can run. Without it, a stalled HTTP request holds a worker slot indefinitely. Decidedly not what we want.

The Temporal Activity

Here’s activities.py:

"""Activities: MLB.com fetch + stats extraction + artifact write (all side effects here)."""  
from __future__ import annotations  
import json  
import os  
from dataclasses import dataclass  
from pathlib import Path  
from typing import Any, Dict, Union  
from temporalio import activity  
from temporal_cron.mlb_player_stats import build_stats_payload  

@dataclass  
class CollectStatsInput:  
    player_url: str  
    workflow_id: str  
    run_id: str  
    output_dir: str  

@dataclass  
class CollectStatsResult:  
    artifact_path: str  
    home_runs: Union[str, int]  
    player_url: str  

def _atomic_write_json(path: Path, data: Dict[str, Any]) -> None:  
    path.parent.mkdir(parents=True, exist_ok=True)  
    tmp = path.with_suffix(path.suffix + ".tmp")  
    tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")  
    tmp.replace(path)  

@activity.defn  
def collect_stats(input: CollectStatsInput) -> CollectStatsResult:  
    # One HTTP try per activity attempt; workflow RetryPolicy owns backoff/attempts.  
    data = build_stats_payload(input.player_url, timeout=60)  
    current = data.get("current_regular_season") or {}  
    hr = current.get("homeRuns", 0)  
    base = Path(input.output_dir or os.getenv("OUTPUT_DIR", "./data/runs"))  
    safe_wid = input.workflow_id.replace(os.sep, "_").replace(":", "_")  
    safe_rid = input.run_id.replace(os.sep, "_").replace(":", "_")  
    out_path = base / f"{safe_wid}__{safe_rid}.json"  
    payload = {  
        "workflow_id": input.workflow_id,  
        "run_id": input.run_id,  
        "player_url": input.player_url,  
        "data": data,  
    }  
    _atomic_write_json(out_path, payload)  
    return CollectStatsResult(  
        artifact_path=str(out_path.resolve()),  
        home_runs=hr if hr is not None else 0,  
        player_url=input.player_url,  
    )
Enter fullscreen mode Exit fullscreen mode

The output path uses the run ID, not a fixed filename. Every execution gets its own artifact — stats-manual-abc123__run456.json. No races, no overwrites, and you have a full history of every run. You can diff two runs. You can see exactly what data you had on any given night. This alone would have saved me.

The file write is atomic: _atomic_write_json (in the excerpt above) writes to a .tmp file first, then replace() on the same filesystem. A reader either sees the old file or the new file — never a partial write. The brittle script calls write_text() directly; if the process died mid-write, you got corrupted JSON and a confusing parse error at the worst possible time.

💡 Note that collect_stats is a sync function, not async. That's intentional — sync activities run in a thread pool, so blocking I/O doesn't block the event loop. The Temporal SDK supports both; sync is the right call when your activity is mostly waiting on a network request.

The Temporal Worker

async def _main() -> None:  
    client = await Client.connect(host, namespace=namespace)  
    worker = Worker(  
        client,  
        task_queue=task_queue,  
        workflows=[StatsCollectionWorkflow],  
        activities=[collect_stats],  
    )  
    await worker.run()
Enter fullscreen mode Exit fullscreen mode

The Worker is the only process that ever touches MLB.com. The workflow and scheduler are pure orchestration — they tell Temporal what to do, they don’t do any work themselves. You can scale workers horizontally without touching the scheduling layer. You’ll want that when you take this pattern to production.

The worker view shows the stats-pipeline worker polling alongside Temporal's own system worker.

The Temporal Schedule

Putting this on a schedule is one function call:

schedule = Schedule(  
    action=ScheduleActionStartWorkflow(  
        StatsCollectionWorkflow.run,  
        job,  
        id=workflow_id,  
        task_queue=task_queue,  
    ),  
    spec=ScheduleSpec(cron_expressions=[cron]),  
    policy=SchedulePolicy(overlap=ScheduleOverlapPolicy.SKIP),  
)
Enter fullscreen mode Exit fullscreen mode

SKIP is the thing cron simply cannot do.

So, if MLB.com is slow today, or your fetches are getting rate-limited harder than usual, and your run takes 12 minutes. Your schedule fires every 15. Eventually the slow run bleeds into the next tick. With cron, you now have two instances running simultaneously, both writing to latest.json, racing each other. With SKIP, the new scheduled run sees the previous one is still active and does nothing. When the previous run finishes, the schedule resumes normally at the next tick. That's an entire class of bug you stop thinking about.

The schedule script also handles create-or-update correctly — describe() first, catch NOT_FOUND, then either create or update:

try:  
    await handle.describe()  
except RPCError as err:  
    if err.status != RPCStatusCode.NOT_FOUND:  
        raise  
    await client.create_schedule(schedule_id, schedule)  
else:  
    await handle.update(lambda _input: ScheduleUpdate(schedule=schedule))
Enter fullscreen mode Exit fullscreen mode

Run it once to create the schedule. Run it again to change the cron expression or job parameters. Same command either way.

What you actually see in the UI

Pop open http://localhost:8233 while a workflow is running. Every step is there — which activity ran, how many attempts it took, what the retry intervals were, what came back. If it failed on attempt 2 and succeeded on attempt 5, you can see that. You can see the exact input that went in and the exact output that came out. You can see how long each attempt took.

The workflow list gives you the first-level answer cron never gives cleanly: what ran, when, and whether it completed.

The timeline connects the workflow input, activity execution, and output artifact in one place. The event history is the audit trail: scheduled tasks, activity start/completion, workflow task transitions, and final result.

The activity details will also show the successful third attempt and preserve the previous failure: 429 Too Many Requests.

Temporal activity event showing attempt #3 with previous HTTP 429 failure details

Compare that to the cron version — just a process exit code and whatever you print() to stdout. If the job failed at 3am and you weren't tailing logs, that information is gone.

This observability is honestly why teams end up on Temporal even for jobs that aren’t that complicated. This removes entire categories of debugging work. No log spelunking to figure out whether a retry happened. No guessing how many attempts ran. No reconstructing a timeline from scattered stdout. You won’t have to infer from fragments; all this is something you can just look up.

Running it yourself

Commands below assume uv is available with a venv set up.

# Start the local Temporal server  
temporal server start-dev  
# In another terminal, start the worker  
uv run temporal-cron-worker  
# Trigger a single run  
uv run temporal-cron-start  
# Or put it on a schedule (defaults to every 15 minutes, set SCHEDULE_CRON in .env to change)  
uv run temporal-cron-schedule
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8233 and you'll see the workflow execution. Artifacts land under data/runs/, one file per run, named by workflow and run ID.

A Note on Production

This just runs against a local Temporal dev server with a single worker. Taking it to production means picking Temporal Cloud or running your own cluster, adding proper secrets management, structured logging, metrics, and deployment automation.

Cron is all you need for simple, isolated tasks. Probably not so much when you have to introduce retries, external dependencies, or jobs that can overlap or run longer than their schedule.

When you get into that zone, Temporal gives you a durable execution layer around ingest: retries, timeouts, overlap control, and a history you can audit. And then when that layer needs to scale up or if you start hitting IP-based (or geo-based, even) friction, Bright Data’s proxies give you the hardened network path you’ll need. It’s been a pretty natural pairing, in my experience. You can route the same requests.get() call through a proxy and let Temporal keep owning retries, timeouts, and audit history.

Regardless, this pattern is a cheat code right here for Temporal: Workflows own orchestration, and Activities own side effects.

Top comments (0)