Scheduled Batch Report Generation
Rendering one spatial report on demand is straightforward; rendering four hundred of them every night — one per region, each idempotent, resumable after a crash, and provably complete — is an orchestration problem. The failure modes are different from a single render: a scheduler that fires twice, a dataset that arrives late, one corrupt input that aborts the whole run, or a silent gap where last Tuesday’s reports were simply never produced. This guide builds a batch layer that turns a directory of report jobs into a scheduled, idempotent, retry-safe run with an auditable manifest, driven interchangeably by cron, a GitHub Actions scheduled workflow, or a Celery beat task queue.
Prerequisites
- Python 3.10+ with
jinja2>=3.1and a render backend (weasyprint>=60.0orreportlab>=4.0), pluscelery>=5.3if you use a task queue - A single-report render command you can invoke per job — ideally the containerised entrypoint from Headless PDF Rendering in Docker Containers, so every scheduler runs the identical image
- A job source: a database query, a directory of GeoJSON inputs, or a config file enumerating regions and templates
- Somewhere to write outputs and manifests — a mounted volume or object storage, versioned per the conventions in Report Artifact Storage & Versioning
- A scheduler: system cron, GitHub Actions
schedule, or a Celery beat + broker (Redis/RabbitMQ) deployment
pip install "jinja2>=3.1" "weasyprint>=60.0" "celery[redis]>=5.3"
Pipeline Architecture
A scheduled trigger fans a job list out to a bounded pool of render workers; each worker checks idempotency before rendering, retries on transient failure, and reports its outcome into a single run manifest that records successes, skips, and failures.
Step-by-Step Implementation
Step 1: Enumerate the Batch as a Deterministic Job List
Turn “generate tonight’s reports” into an explicit list of job descriptors. Each descriptor carries everything a render needs — region, template, data source — plus the logical run date, which must be a parameter, never datetime.now() read inside the job. Reading the wall clock inside a render makes backfills impossible and output non-reproducible.
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class ReportJob:
region: str
template: str
run_date: date
data_uri: str
def enumerate_jobs(regions: list[str], run_date: date) -> list[ReportJob]:
"""Expand a batch into one immutable job per region for a given run date."""
return [
ReportJob(
region=r,
template="regional_spatial_report.html",
run_date=run_date,
data_uri=f"s3://gis-inputs/{run_date:%Y/%m/%d}/{r}.geojson",
)
for r in regions
]
Freezing the dataclass makes each job hashable, which the next step relies on for a stable key.
Step 2: Make Each Render Idempotent with a Stable Job Key
Derive a deterministic key from the job’s identity and check for an existing, current artifact before rendering. If it exists and no --force was requested, skip. This single property makes re-runs, overlapping schedules, and partial retries safe — they converge on the same output set rather than duplicating work.
import hashlib
from pathlib import Path
def job_key(job: ReportJob, template_version: str) -> str:
"""Stable identity: same inputs + template version => same key."""
raw = f"{job.region}|{job.run_date.isoformat()}|{template_version}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
def already_current(key: str, out_dir: Path) -> bool:
return (out_dir / f"{key}.pdf").exists()
Because the key folds in template_version, changing the template naturally invalidates prior outputs — the next run regenerates them, while an unchanged template leaves them untouched. Feeding a deterministic render (see the SOURCE_DATE_EPOCH note in Headless PDF Rendering in Docker Containers) makes the key line up with a content hash on the artifact side.
Step 3: Chunk Large Batches and Bound Concurrency
Do not dispatch four hundred renders at once — each WeasyPrint or ReportLab render is CPU- and memory-heavy, and unbounded fan-out will exhaust the host. Chunk the job list and cap concurrency. For CPU-bound rendering the right pool is processes, not threads; the deep-dive on sizing and sharing work across cores lives in Parallel Batch Rendering with ProcessPoolExecutor.
from itertools import islice
from typing import Iterator
def chunked(items: list[ReportJob], size: int) -> Iterator[list[ReportJob]]:
it = iter(items)
while batch := list(islice(it, size)):
yield batch
Chunking also gives you natural checkpoints: write a partial manifest after each chunk so a crash mid-batch leaves a record of what already completed.
Step 4: Add Retries and Failure Isolation Per Job
Transient failures — a data source briefly unavailable, an object-store timeout — should retry with backoff; a deterministic failure like a malformed template should be recorded and skipped without aborting the batch. Isolate every job in its own try/except so one bad input never takes down the run.
import logging
import time
from typing import Callable
logger = logging.getLogger("batch")
def render_with_retry(
job: ReportJob,
render_fn: Callable[[ReportJob], Path],
max_attempts: int = 3,
base_delay: float = 2.0,
) -> tuple[str, str | None]:
"""Return (status, error). status in {'ok', 'failed'}."""
for attempt in range(1, max_attempts + 1):
try:
render_fn(job)
return "ok", None
except Exception as exc: # isolate: never propagate into the batch loop
logger.warning(
"render %s attempt %d/%d failed: %s",
job.region, attempt, max_attempts, exc,
)
if attempt == max_attempts:
return "failed", repr(exc)
time.sleep(base_delay * 2 ** (attempt - 1)) # exponential backoff
return "failed", "exhausted"
Step 5: Write a Run Manifest and Schedule the Trigger
The manifest is the audit record: a run identifier, the parameters, and per-job outcomes (ok, skipped, failed). It answers “did last night’s batch actually complete?” without opening every PDF, and it drives alerting when the failure count crosses a threshold.
# .github/workflows/nightly-reports.yml
name: nightly-reports
on:
schedule:
- cron: "15 2 * * *" # 02:15 UTC nightly
workflow_dispatch: # allow manual + backfill runs
inputs:
run_date:
description: "Logical run date (YYYY-MM-DD); blank = yesterday"
required: false
jobs:
render:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python -m batch.run --run-date "${{ inputs.run_date }}"
- uses: actions/upload-artifact@v4
with:
name: run-manifest
path: output/manifest-*.json
The workflow_dispatch input is what makes backfills a one-liner; the cron entry handles the nightly path. Both call the same batch.run module, so the scheduler stays interchangeable. The specifics of tuning cron expressions and Actions schedules — including timezone and missed-run behaviour — are covered in Scheduling Nightly PDF Runs with Cron and GitHub Actions.
Production-Ready Script
This entry point ties the pieces together: enumerate jobs for a run date, skip already-current outputs, render the rest with bounded retries, and emit a JSON manifest. It defaults the run date to yesterday and accepts an explicit date for backfills.
#!/usr/bin/env python3
"""
batch/run.py — scheduled, idempotent batch renderer with a run manifest.
Usage:
python -m batch.run --run-date 2026-07-11 --regions regions.txt --chunk 8
"""
from __future__ import annotations
import argparse
import json
import logging
import uuid
from datetime import date, datetime, timedelta
from pathlib import Path
logging.basicConfig(
level="INFO", format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S"
)
logger = logging.getLogger("batch")
TEMPLATE_VERSION = "2026.07"
OUT_DIR = Path("output")
def render_job(job: "ReportJob") -> Path:
"""Placeholder: call the containerised single-report renderer here."""
key = job_key(job, TEMPLATE_VERSION)
out = OUT_DIR / f"{key}.pdf"
out.parent.mkdir(parents=True, exist_ok=True)
# In production: subprocess/celery task invoking render.py on the job.
out.write_bytes(b"%PDF-1.7\n% rendered\n")
return out
def run_batch(regions: list[str], run_date: date, chunk: int, force: bool) -> dict:
run_id = f"{run_date:%Y%m%d}-{uuid.uuid4().hex[:8]}"
jobs = enumerate_jobs(regions, run_date)
results: list[dict] = []
for group in chunked(jobs, chunk):
for job in group:
key = job_key(job, TEMPLATE_VERSION)
if not force and already_current(key, OUT_DIR):
results.append({"region": job.region, "key": key, "status": "skipped"})
continue
status, error = render_with_retry(job, render_job)
results.append(
{"region": job.region, "key": key, "status": status, "error": error}
)
manifest = {
"run_id": run_id,
"run_date": run_date.isoformat(),
"template_version": TEMPLATE_VERSION,
"generated_at": datetime.utcnow().isoformat() + "Z",
"counts": {
s: sum(1 for r in results if r["status"] == s)
for s in ("ok", "skipped", "failed")
},
"jobs": results,
}
manifest_path = OUT_DIR / f"manifest-{run_id}.json"
manifest_path.write_text(json.dumps(manifest, indent=2))
logger.info("Manifest %s — %s", manifest_path, manifest["counts"])
return manifest
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Scheduled batch report generation")
parser.add_argument("--run-date", default="", help="YYYY-MM-DD; blank = yesterday")
parser.add_argument("--regions", type=Path, default=Path("regions.txt"))
parser.add_argument("--chunk", type=int, default=8)
parser.add_argument("--force", action="store_true", help="re-render current outputs")
args = parser.parse_args(argv)
run_date = (
date.fromisoformat(args.run_date)
if args.run_date
else date.today() - timedelta(days=1)
)
regions = [r.strip() for r in args.regions.read_text().splitlines() if r.strip()]
manifest = run_batch(regions, run_date, args.chunk, args.force)
# Non-zero exit if anything failed, so the scheduler surfaces it.
return 1 if manifest["counts"]["failed"] else 0
if __name__ == "__main__":
raise SystemExit(main())
Edge Cases & Advanced Configuration
Late or Missing Input Data
Automated batches routinely fire before upstream GIS extracts land. Rather than rendering stale or empty inputs, have the dispatcher verify each data_uri exists and is fresh; treat a missing input as a skipped outcome with a reason, not a hard failure, and let a later backfill pick it up once the data arrives. Reports whose layers come up empty still need to render coherently — apply the guard patterns from Conditional Rendering for Missing Spatial Data so an empty extent produces a valid page rather than a broken template.
Distributed Scale with Celery Beat
When one host cannot finish the batch in its window, move to a Celery beat schedule that enqueues one task per job onto a broker, with a worker pool draining the queue. Idempotency becomes essential here because the broker may deliver a task more than once — the already_current check makes at-least-once delivery safe.
from celery import Celery
from celery.schedules import crontab
app = Celery("reports", broker="redis://redis:6379/0")
app.conf.beat_schedule = {
"nightly-batch": {
"task": "reports.enqueue_batch",
"schedule": crontab(hour=2, minute=15),
}
}
Multi-Format Batches
A batch often produces both a WeasyPrint HTML-to-PDF report and a programmatic ReportLab variant for a different audience. Key each format separately (fold the format into job_key) so they version independently, and route ReportLab jobs through the Jinja2 Templating & Theme Logic for Automated Spatial Reporting context builders shared with the WeasyPrint path. Large attribute tables in either format should paginate with the techniques in Table Pagination Strategies for Large Attribute Tables.
Headless Execution Environment
Every worker in the batch must run in the same headless image, or output drifts between machines. Standardise on the container from Headless PDF Rendering in Docker Containers and pass the job descriptor as arguments, so cron on a VM, a GitHub Actions runner, and a Celery worker all render byte-identical PDFs.
Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
| Duplicate PDFs for the same report in one run | Overlapping schedules with no idempotency check | Derive a stable job_key and skip when the artifact already exists |
| Nightly batch silently produced nothing | Scheduler misfired or job list was empty | Emit a manifest even for empty runs; alert when counts.ok == 0 |
| Whole batch aborts on one bad dataset | Exception propagates out of the job loop | Wrap each render in per-job try/except with retries; record and continue |
| Host runs out of memory partway through | Unbounded concurrent renders | Chunk the job list and cap the worker pool size |
| Backfill regenerates reports that were already correct | Job reads datetime.now() instead of the run date |
Parameterise run_date; fold it into the job key |
| Celery task rendered twice | At-least-once broker delivery | Rely on the idempotency check; make render_job safe to repeat |
Frequently Asked Questions
How do I make a scheduled report batch idempotent so re-runs are safe?
Derive a stable job key from the report parameters plus the logical run date — for example a hash of region, template version, and the run’s date partition. Before rendering, check whether an artifact with that key already exists and skip it unless a force flag is set. Idempotency lets a retried or overlapping run converge on the same set of outputs instead of duplicating or corrupting them.
Should I schedule report batches with cron, GitHub Actions, or Celery beat?
Use cron on a single host for small, self-contained batches; use a GitHub Actions scheduled workflow when the batch fits in a runner’s time budget and you want the run history and artifacts in CI; use Celery beat with a worker pool when the batch is large, needs distributed concurrency, or must survive individual worker failures. All three should invoke the same idempotent render command so the scheduler is interchangeable.
How do I backfill reports for dates that were missed or need regenerating?
Parameterise the batch by a logical run date rather than reading the wall clock inside the job. A backfill is then just the same command invoked for a range of past dates. Because each job is idempotent and keyed by its run date, backfilling a range regenerates exactly the missing or changed outputs without touching correct ones.
How should a batch handle a single report that fails to render?
Isolate failures per job: catch the exception, retry with backoff a bounded number of times, and if it still fails record it in the run manifest and continue the rest of the batch. A single malformed dataset should never abort hundreds of healthy renders. The manifest then lists which reports succeeded, which were skipped as already-current, and which failed for follow-up.
Detailed Guides in This Section
- Scheduling Nightly PDF Runs with Cron and GitHub Actions — write robust cron expressions and Actions
scheduletriggers, handle timezones, and recover from missed windows. - Parallel Batch Rendering with ProcessPoolExecutor — size a process pool for CPU-bound renders, share job state across workers, and avoid the memory blow-ups of naive fan-out.
Related
- Headless PDF Rendering in Docker Containers — the standard image every batch worker should run for byte-identical output
- Report Artifact Storage & Versioning — where a batch’s outputs and manifests are stored, hashed, and retained
- Conditional Rendering for Missing Spatial Data — keep reports valid when a batch hits empty or late inputs
- Table Pagination Strategies for Large Attribute Tables — control pagination for the heavy tables batches often generate
- Jinja2 Templating & Theme Logic for Automated Spatial Reporting — the shared context layer that feeds both WeasyPrint and ReportLab jobs in a batch
Parent: CI/CD Scheduling & Automation for Spatial Report Pipelines
Conclusion
A dependable report batch is defined less by the scheduler that fires it than by the properties around it: deterministic job enumeration, an idempotency key that makes re-runs and backfills safe, bounded concurrency with per-job retries, and a manifest that proves the run completed. Build those in once and cron, GitHub Actions, and Celery beat become interchangeable triggers over the same reliable engine that anchors your CI/CD Scheduling & Automation for Spatial Report Pipelines workflow.