Scheduling & Orchestration for Municipal Feed Runs

A county publishes its parcel layer “weekly.” In practice it publishes on Tuesday most weeks, on Wednesday when Monday is a holiday, twice on the Thursday after a planning-commission meeting, and not at all during the last week of the fiscal year. Your scheduler, meanwhile, runs at 02:00 daily and does not care. The result is the failure mode this page addresses: a pipeline that is technically running on schedule and is nevertheless ingesting nothing new for nine days, then ingesting the same restated history three times in one morning. Scheduling municipal feeds is not a cron problem — it is a problem of tracking what you have already correctly consumed from sources that have no obligation to tell you when they changed. This topic sits at the front of the Automated Feed Ingestion & GIS Data Parsing area, upstream of everything else in it: the order and repeatability of runs is what makes async batch processing worth optimising at all.

Prerequisites and operational context jump to heading

Before orchestration is worth designing, three things have to exist. Each of them is a precondition rather than a nicety, and skipping any one of them produces a scheduler that appears to work and quietly loses data.

The first is a durable watermark per source. Not a global “last run” timestamp, but one record per jurisdiction naming what that jurisdiction had published the last time you successfully consumed it. A single global watermark is the most common design error in municipal ingestion, because sources move independently: advancing one watermark after a run in which three of eleven counties failed marks the failed counties as consumed.

The second is idempotent task bodies. Municipal orchestration involves reruns constantly — a portal times out, a schema check fails, a deploy lands mid-run — and a task that cannot be re-executed safely turns every one of those ordinary events into a data-repair job. Idempotency here means the task computes the same target state from the same inputs, not merely that it “does not crash twice.”

The third is a content digest for every fetched artifact. Municipal portals frequently republish identical content with a new timestamp, and just as frequently change content while leaving the timestamp alone. Neither an HTTP Last-Modified header nor a file name is reliable evidence that something changed; only a digest over the bytes is. This is the same digest that data lineage & provenance tracking needs downstream, so it is not extra work — it is work you were going to do anyway, done earlier.

With those in place, a run becomes describable as a pure function of (source, watermark, digest) rather than of the wall clock, and everything else on this page follows from that.

Architecture: a dependency graph over jurisdictions, not over stages jump to heading

The intuitive orchestration design puts pipeline stages in the graph: fetch, then normalise, then validate, then publish. It is intuitive and it is wrong for this domain, because it couples jurisdictions that have nothing to do with each other. If fetch is one task for all eleven counties, then one county’s outage blocks normalisation for the ten that succeeded.

The design that survives production inverts the graph. Each jurisdiction gets its own independent chain, and the only shared node is the publication gate at the end.

Stage-major against jurisdiction-major orchestration graphs Two dependency graphs compared. In the stage-major graph on the left, one fetch task collects all four counties, so when County B fails the single fetch task fails and the downstream normalise, validate and publish tasks never run for any county. In the jurisdiction-major graph on the right, each county has its own fetch, normalise and validate chain, and only the final publication gate is shared. County B's chain fails on its own, the other three chains complete, and the publication gate publishes three counties while recording the fourth as held. The jurisdiction-major shape costs more task instances and is the only one where a single bad portal does not stop the run. Stage-major: one bad portal stops the run County A County B County C County D fetch (all) fails on B normalise never runs validate never runs 4 counties fetched, 0 published Three healthy portals are punished for the fourth. Jurisdiction-major: only the failing chain stops A · fetch → val B · fetch fails here C · fetch → val D · fetch → val publication gate 3 published 1 held, with a reason Each chain owns its own watermark, so B is simply not advanced — and A, C and D are. Cost: 3 tasks per jurisdiction instead of 4 for the whole run.
The graph should be shaped like the failure boundary, and for municipal data that boundary is the jurisdiction. A stage-major graph makes eleven independent portals into one shared fate; a jurisdiction-major graph costs more task instances and lets a dead county fail alone.

The publication gate at the end is deliberately shared, because publishing is the one operation where consistency across jurisdictions matters: a consumer reading a regional dataset should not see county A’s Tuesday data joined to county C’s data from three weeks ago without being told. The gate publishes what passed and records what was held, which is exactly the tiering that fallback routing logic then serves from.

Choosing a trigger, not just an interval jump to heading

A schedule answers “when do we look?” — it does not answer “is there anything new?” Those need separate mechanisms, and conflating them is what produces the nine-quiet-days-then-three-runs pattern.

Trigger What it detects Cost per check Fails when
Fixed interval nothing — it only wakes you one request the source publishes irregularly
HEAD + ETag / Last-Modified server-declared change one cheap request headers are wrong or absent
Content digest of the payload actual change one full fetch fetching is expensive or rate-limited
Feed metadata endpoint publisher-declared change one cheap request the publisher does not maintain it
Planning-calendar scrape an upcoming likely change one page fetch the calendar is stale

The workable combination for most portals is a frequent cheap check with a conditional full fetch behind it: poll HEAD hourly, and only run the expensive chain when the validator says something moved. Where headers cannot be trusted — which is common — the cheap check has to be a small ranged request or a metadata endpoint, and the digest becomes the authority. Whatever the mechanism, the rate at which you check is governed by municipal API rate limit management, and an hourly HEAD across eleven counties is well inside any reasonable budget.

Production implementation jump to heading

The core of a municipal scheduler is smaller than most teams expect. What follows is the watermark-and-digest logic that decides whether a chain should run at all; it is deliberately framework-agnostic, because it is the part that has to be correct regardless of whether Airflow, Prefect, or a cron script is calling it.

import hashlib
import logging
from dataclasses import dataclass, replace
from datetime import datetime, timezone

log = logging.getLogger(__name__)


@dataclass(frozen=True)
class Watermark:
    """What we have already correctly consumed from one source."""
    source_id: str
    published_at: datetime | None   # the publisher's own timestamp, if it has one
    content_digest: str | None      # sha256 over the fetched bytes
    consumed_at: datetime | None    # when OUR run committed it
    run_id: str | None


class SkipRun(Exception):
    """Raised to mean 'nothing new' — not an error, and not retried."""


def decide(source, wm: Watermark, probe) -> str:
    """Return 'run', 'skip', or 'restated'.

    `probe` is a cheap HEAD-style result: (published_at, etag, size).
    The digest is only computed after a fetch, so this function decides
    whether the fetch is worth doing at all.
    """
    published_at, etag, size = probe

    if wm.content_digest is None:
        return "run"                       # never consumed: always run

    # A publisher timestamp that moved forward is the strongest cheap signal.
    if published_at and wm.published_at and published_at > wm.published_at:
        return "run"

    # Some portals never move the timestamp but do change the payload size.
    if size is not None and wm.size_hint is not None and size != wm.size_hint:
        log.info("%s: size changed with a static timestamp — fetching", source.id)
        return "run"

    if etag and etag == wm.etag:
        return "skip"

    # No usable signal at all. Fetch on a floor interval so a portal that
    # lies about everything is still picked up within one day.
    if wm.consumed_at and (datetime.now(timezone.utc) - wm.consumed_at).days >= 1:
        return "run"

    return "skip"


def consume(source, store, fetch) -> Watermark:
    """Fetch, compare digests, and advance the watermark only on success."""
    wm = store.load(source.id) or Watermark(source.id, None, None, None, None)

    verdict = decide(source, wm, fetch.probe(source))
    if verdict == "skip":
        raise SkipRun(f"{source.id}: no change since {wm.consumed_at}")

    payload = fetch.get(source)                       # the expensive call
    digest = hashlib.sha256(payload.body).hexdigest()

    if digest == wm.content_digest:
        # The publisher moved a timestamp without changing content. Record that
        # we looked, so the floor interval does not re-fetch in an hour, but do
        # NOT emit a change downstream.
        log.info("%s: republished identical content", source.id)
        return store.save(replace(wm, consumed_at=datetime.now(timezone.utc)))

    if wm.published_at and payload.published_at and payload.published_at < wm.published_at:
        # The publisher went BACKWARDS: a restatement of history.
        log.warning("%s: restated history (%s < %s)", source.id,
                    payload.published_at, wm.published_at)
        raise Restatement(source.id, payload)         # handled out of band

    # Only now is it safe to hand the payload downstream.
    return store.save(Watermark(
        source_id=source.id,
        published_at=payload.published_at,
        content_digest=digest,
        consumed_at=datetime.now(timezone.utc),
        run_id=payload.run_id,
    ))

Two properties of that code matter more than the details. First, the watermark is written after the downstream commit, never before — a watermark advanced ahead of the data it describes is indistinguishable from successful consumption, and the records it skipped are gone silently. Second, SkipRun is not an error. A scheduler that reports “nothing new” as a failure trains its operators to ignore failures, and a scheduler that reports it as success without distinguishing it from real work makes “did anything happen last night?” unanswerable.

What the run should record jump to heading

Every chain execution should leave a row that makes the decision auditable later, whether or not it did any work:

Field Why it is needed
source_id, run_id joins the run to the records it produced
verdict run / skip / restated — the reason the chain did or did not proceed
probe_signal which signal fired (timestamp, size, etag, floor interval)
content_digest proves what was consumed, and detects republished identical content
watermark_before, watermark_after makes an accidental watermark jump visible
duration_ms, records_out the operational series that shows a portal degrading

Edge cases and gotchas jump to heading

A portal restates history. A county reloads its parcel layer from an older backup, so the newest published file describes an older state. Handled naively, the next run treats months of stale records as fresh changes and emits thousands of spurious rezones. This is why decide() treats a backwards-moving publisher timestamp as a distinct verdict rather than as ordinary change — the run must stop and be looked at, because the correct response depends on why it happened.

A restatement seen through a watermark A timeline of six publisher releases. Releases one to four advance normally and the watermark follows each one. Release five is a restatement whose publisher timestamp is earlier than release four, drawn below the line in red. A pipeline that only checks whether the content digest differs treats release five as new data and emits every difference between release four and the older state as a change, producing thousands of false rezones. A pipeline that compares the publisher timestamp against the stored watermark detects that time went backwards and holds the run instead. Release six resumes normal forward publication. Publisher releases, in the order they appear on the portal r1 · 03 Mar r2 · 10 Mar r3 · 17 Mar r4 · 24 Mar r5 · 06 Feb restated from backup r6 · 31 Mar watermark → 03 Mar → 10 Mar → 17 Mar → 24 Mar → 31 Mar Digest-only check: r5 differs from r4, so every difference is emitted as change Six weeks of superseded values arrive as thousands of fresh rezones, all of them wrong. Watermark check: 06 Feb < 24 Mar, so the run is held for review The correct response depends on why the portal did it — which is a decision for a human, once.
A content digest alone cannot tell a restatement from a change: the bytes genuinely differ. Only comparing the publisher's own timestamp against the stored watermark reveals that the source moved backwards, which is why the watermark stores both.

Two runs overlap. A slow run is still going when the next fires. Without a per-source lock, both fetch, both write, and the watermark ends up describing whichever finished last — which may be the older one. The fix is a lease keyed on source_id, not a global lock: a global lock reintroduces exactly the cross-jurisdiction coupling the graph shape was designed to remove.

A schema change arrives mid-run. Half the counties are ingested against the old contract and half against the new one. Because the publication gate is shared, this is recoverable: hold the whole batch rather than publishing a mixed one, and let the schema validation & data quality checks tier decide which contract is authoritative.

The floor interval hides a dead portal. A source whose signals are all unusable gets fetched once a day by the floor interval. If that fetch always returns identical content, the pipeline is stable and the data is frozen. Track time since content last changed per source, not just time since last run — a parcel layer that has not changed in six weeks is either a very quiet county or a portal that has stopped updating, and the distinction is worth an alert.

Daylight-saving transitions. A schedule expressed in local time either runs twice or not at all on the transition day. Municipal effective dates are inherently local, so store and compare instants in UTC and convert only for display; the one place local time genuinely matters is interpreting an ordinance’s effective date, which is a data question rather than a scheduling one.

Integration points jump to heading

Orchestration is the layer everything else in the ingestion area hangs from, and it has two hard interfaces.

Downstream, each chain hands a validated batch plus a watermark candidate to the publication gate. The gate is where GIS export sync workflows take over: it is the point at which a delta becomes publishable, and the point at which a held jurisdiction becomes a fallback-tier decision rather than an ingestion problem.

Upstream, the scheduler is the only component that should know about wall-clock time at all. Fetch code takes a source and returns bytes; parsers take bytes and return records; none of them should ask what day it is. Keeping time in one layer is what makes the whole pipeline testable, which is the argument developed in testing spatial data pipelines — a task whose behaviour depends on datetime.now() inside its body cannot be exercised deterministically.

The retry relationship also runs through here. Orchestrator-level retries and in-task retries solve different problems and must not be layered without thought: an orchestrator retrying a task that already contains a five-attempt backoff loop produces twenty-five attempts against a struggling portal. Set one of them to retry and the other to fail fast, and let the error handling & retry logic design decide which.

Compliance and audit artifacts jump to heading

Because the scheduler decides what was consumed and when, its records are part of the audit trail whether or not they were designed to be.

Three artifacts should survive every run. The run row described above, which answers “why did this chain run, or not.” The watermark history — the watermark table should be append-only or at minimum versioned, because a watermark that was advanced incorrectly is the root cause of a whole class of missing-data incidents and the only way to prove that is to see its previous value. And the held-jurisdiction record: when the publication gate holds a county, that decision, its reason, and the tier consumers were served instead need to be recorded, because a consumer who made a decision during the hold will eventually ask what they were looking at.

Those three, joined on run_id, are what let an auditor ask “was this parcel’s zoning current on the day we priced this deal?” and receive an answer that includes “no — that county was held for eleven hours and you were served a snapshot from the previous evening, flagged.” That is a defensible answer. “Our pipeline runs nightly” is not.

Choosing a scheduler jump to heading

The framework matters far less than the two properties above, and the honest comparison is short. A cron script with a watermark table and a lease is entirely adequate for a dozen sources, and it has the enormous advantage that it can be read in one sitting. Airflow earns its operational weight when backfills over date ranges become routine and when the dependency graph genuinely branches — its scheduler is built around exactly the “run this logical date again” question that a municipal restatement forces. Prefect sits between them, with the practical benefit that tasks are ordinary Python functions, which keeps the watermark logic above testable without a scheduler running.

Three schedulers against what municipal ingestion actually needs A six-row by three-column matrix comparing a cron script with a watermark table, Airflow, and Prefect. Rows are: running a command on a timer, re-running one logical date, a branching dependency graph, per-source concurrency limits, testing a task body without the scheduler, and the operational weight of the tool itself. All three run commands on a timer. Only Airflow and Prefect model logical dates and branching graphs natively. Prefect keeps task bodies as ordinary Python functions, which makes them testable in isolation. The cron script is by far the lightest to operate. Three schedulers against what municipal ingestion actually needs cron + watermark table Airflow Prefect Run a command on a timer yes yes yes Re-run one logical date hand-rolled native native Branching dependency graph hand-rolled native native Per-source concurrency limits a lease table pools concurrency limits Test a task body standalone plain functions needs a context plain functions Operational weight a cron line scheduler + workers + db agent + api good fit workable you build it yourself
None of the three supplies the part that matters: all of them will happily advance a watermark before the data commits. The columns that separate them are logical-date re-runs and graph branching, which is why the honest advice is to start with cron plus a watermark table and move when backfills become routine.

What none of them supply is the domain logic. Every one of these tools will happily run a task that advances a watermark before the data commits, and none of them knows that a publisher timestamp moving backwards is a restatement rather than an update. Choosing a scheduler is therefore a much smaller decision than it appears; the design on this page is the part that determines whether the pipeline loses data, and it is identical under all three.

A practical consequence is worth stating plainly: do not begin a municipal ingestion project by installing an orchestrator. Begin with the watermark table, the digest, and the lease, driven by whatever runs a command on a timer. Move to a heavier scheduler when a specific pain appears — usually the first time somebody has to backfill eleven counties across a fortnight by hand — and migrate the same task bodies into it unchanged.

FAQ jump to heading

Why not just run everything hourly and let the pipeline sort it out?

Because a fetch is not free and a portal that is polled hourly by an automated client is a portal that starts rate-limiting or blocking. More importantly, running the full chain hourly without a change check means the downstream diff runs against identical data twenty-three times a day, which floods the change table with no-op comparisons and makes genuine change harder to see, not easier.

Should the watermark be advanced before or after the downstream write?

Always after, and inside the same transaction as the write where the storage allows it. A watermark advanced first describes data that may never have committed, and the records it skips are lost without any error. If the write and the watermark cannot share a transaction, write the watermark second and accept that a crash between them causes a re-fetch — reprocessing the same batch is harmless when tasks are idempotent, which is why idempotency is a prerequisite rather than a nicety.

How do I tell a quiet county from a broken feed?

Track time since the content digest last changed, separately from time since the last successful run. A source whose runs all succeed while its digest has been static for six weeks is either genuinely quiet or silently frozen, and the two are indistinguishable without asking the publisher. Set a per-source expectation — this county publishes weekly, that one quarterly — and alert when the static period exceeds it by a comfortable margin.

Do orchestrator retries and in-task retries conflict?

They multiply. An orchestrator configured for three attempts wrapping a task that itself retries five times produces fifteen requests against a failing endpoint, which is a retry storm generated by configuration rather than by code. Pick one layer to own retries: in-task, where the backoff can honour a Retry-After header and a circuit breaker, is usually the better place, with the orchestrator set to fail fast and alert.