Part of the Scheduled Map Rebuild Workflows guide.
Operative rule: exactly one object in the deployment is mutable — the pointer. Everything else is written once, under a versioned path, and never touched again.
Why In-Place Publishing Produces Half-States
Publishing a rebuild means writing many objects: tiles or an archive, a style document, data files, a legend definition. Object storage gives you atomicity per object and nothing beyond that. While the upload runs, readers are served a mixture of the previous build and the current one — and the two are not interchangeable, because a tile pyramid and its style are a matched pair.
The visible symptoms are familiar: a legend that describes classes the tiles no longer use, a seam where new tiles meet old ones at a zoom boundary, features that appear twice. All of them come from the same cause, and a failure halfway through leaves the site in a state that is neither version and cannot be described.
Production-Ready Implementation
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Pointer:
"""The only mutable object in the deployment."""
build_id: str
tiles: str
style: str
source_timestamp: str
run_id: str
def to_json(self) -> str:
return json.dumps(self.__dict__, indent=2, sort_keys=True)
def publish_build(storage, local_dir: Path, build_id: str) -> str:
"""Upload every artifact under an immutable prefix. Slow, safe, invisible."""
prefix = f"builds/{build_id}/"
for file in sorted(local_dir.rglob("*")):
if not file.is_file():
continue
storage.put(
key=prefix + str(file.relative_to(local_dir)),
body=file.read_bytes(),
cache_control="public, max-age=31536000, immutable",
)
return prefix
def switch_pointer(storage, pointer: Pointer) -> None:
"""One small write. This is the moment readers change build."""
storage.put(
key="current.json",
body=pointer.to_json().encode("utf-8"),
cache_control="public, max-age=0, must-revalidate",
content_type="application/json",
)
def rollback(storage, previous_build_id: str) -> None:
"""Rollback is re-publishing an older pointer — no rebuild involved."""
previous = json.loads(storage.get(f"builds/{previous_build_id}/pointer.json"))
switch_pointer(storage, Pointer(**previous))
Writing a copy of the pointer inside each build directory is the small extra step that makes rollback trivial. The build knows what its own pointer should look like; storing it means rolling back never requires reconstructing that information from a log.
What the Client Must Do
Retention and Cleanup
Rolling Back Under Pressure
The value of this arrangement is realised on the day something goes wrong, and that day is a poor time to discover the procedure has never been run. Rolling back should be a single documented command that reads a previous build’s stored pointer and republishes it — no rebuild, no manual editing of paths, no reasoning about which artifacts belong together.
Practise it. A rollback rehearsed once in normal working hours takes two minutes and confirms three things at once: that old builds are retained, that their pointers were stored, and that the person on call knows where the command lives. The same rehearsal in an incident takes twenty minutes and produces the questions that make an outage longer than it needed to be.
It is also worth deciding in advance what a rollback does not fix. Reverting the pointer restores the previous data and the previous style, but it does not undo a schema change in a downstream consumer, and it does not recall a notification that has already been sent. Naming those boundaries in the runbook keeps the rollback from being attempted as a general-purpose remedy for problems it cannot address.
Verification Steps
- Publish a build without switching the pointer and confirm readers still see the previous one.
- Switch the pointer and confirm a reload picks up the new build within one revalidation.
- Roll back and confirm the map returns to the previous data with no rebuild.
- Confirm every artifact URL under a build prefix carries an immutable cache lifetime.
- Confirm the lifecycle rule does not touch builds referenced by saved views.
Common Errors & Fixes
Readers still see the old build long after the switch
The pointer is being cached. It is the one object that must revalidate on every load.
Rollback needs a rebuild
Old builds were deleted, or the pointer contents were never stored alongside them. Keep both.
Storage costs grow steadily
No lifecycle rule on the builds prefix. Add one, and exclude the referenced prefix explicitly.
A partially uploaded build was switched to
The switch ran before the upload completed. Make the pointer write the last statement after an upload verification step, in the same order the rebuild transaction prescribes.
Choosing the Build Identifier
The build id appears in every path, every log line and every rollback conversation, so it is worth choosing deliberately rather than reaching for a random string.
Three properties matter. It should sort chronologically, so a directory listing reads as a history rather than as a jumble. It should be unique even when two runs start in the same second, which a timestamp alone is not. And it should be traceable back to what produced it, so that an artifact found six months later can be connected to a pipeline version.
A compound identifier satisfies all three: a UTC timestamp for ordering, a short random suffix for uniqueness, and the pipeline’s commit hash recorded inside the build rather than in the name. That keeps paths readable while preserving the link to the code — and it avoids the trap of putting the commit in the path, which produces two identical-looking builds when the same commit runs twice against different data.
It is also worth deciding what the id means when a run is retried. A retry that rebuilds from the same source should generally get a new id, because its output may differ; a retry that merely re-uploads an already-built artifact should reuse the original, because the bytes are the same. Conflating the two produces a directory full of near-duplicates that nobody can tell apart later.
What the Pointer Should Carry
Beyond naming the current build, the pointer is the natural place for the small facts every reader and every operator needs. The data’s source timestamp goes here, because the freshness badge reads it. The run id goes here, so any artifact can be traced back to the record that describes how it was produced. And a short schema version goes here, so that a page from an older deploy can detect a pointer it does not understand and fail visibly rather than misreading it.
Keep it small and keep it flat. The pointer is fetched on every page load with revalidation, so every field costs something on every visit, and a nested structure invites the page to depend on parts of it that were never meant to be an interface.
Gotchas & Edge Cases
- Object storage is eventually consistent for listings on some providers even when it is strongly consistent for reads; verify the build by fetching known keys rather than by listing the prefix.
- A pointer written before the last artifact finishes uploading produces exactly the mixed state this pattern exists to prevent — verify, then switch.
- Readers who loaded the page before a switch keep the old build for the life of their session, which is correct; a change that must reach them immediately needs a page-level reload prompt, not a faster pointer.
- Deleting an old build that a saved view still references breaks that link silently. Cleanup must consult the references, not only the age.
- Do not reuse a build id after a failed run. The partial artifacts may still be present, and a second run under the same id produces a directory that is a mixture of two attempts.
- Keep the pointer small enough to be fetched on every page load without thought; if it is growing, that is a sign something belongs in the build directory instead.
Related
- Scheduled Map Rebuild Workflows — the transaction this swap concludes
- Deploying Generated Map Bundles with GitHub Actions CI/CD — running the publish and swap from CI
- Setting Cache-Control Headers for Tiles and GeoJSON — the header policy this layout depends on
- Publishing a Data Freshness Badge on a Geo-Dashboard — reading the vintage the pointer carries