Resuming an Interrupted OSM Import Jump to heading
Make a multi-hour import restartable, so a crash at seventy percent costs the remaining thirty rather than the whole job.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Resumability is usually approached as a bookkeeping problem — remember how far we got — and that is the smaller half. The larger half is whether re-processing a unit is safe at all.
If replaying a unit duplicates rows, no marker helps: the resume produces a corrupt result rather than a slow one. So the first design decision is the write path, and the two shapes that work are a partition that is either complete or absent, and an upsert keyed on something stable.
Given a safe write path, the ordering rule is short and absolute.
Commit the work, then record the marker. A crash in between replays one unit, which costs a little time and no correctness because the unit is idempotent. Recording the marker first inverts that: a crash loses the unit permanently and nothing will ever notice — the same asymmetry as the checkpoint discipline in Building a Minutely Update Pipeline.
Runnable solution Jump to heading
#!/usr/bin/env python3
"""A resumable import: atomic units, markers written after the work, safe replay."""
from __future__ import annotations
import json
import logging
import os
import signal
import tempfile
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
class Markers:
"""Durable record of completed units. One line per unit, fsync'd on write."""
def __init__(self, path: Path) -> None:
self.path = path
self.done: set[str] = set()
if path.exists():
for line in path.read_text().splitlines():
if line.strip():
self.done.add(json.loads(line)["unit"])
logger.info("resuming: %d unit(s) already complete", len(self.done))
def record(self, unit: str, rows: int) -> None:
# Append + flush + fsync: a marker that is not on disk is not a marker.
with self.path.open("a") as handle:
handle.write(json.dumps({"unit": unit, "rows": rows}) + "\n")
handle.flush()
os.fsync(handle.fileno())
self.done.add(unit)
def __contains__(self, unit: str) -> bool:
return unit in self.done
def write_atomically(target: Path, write: Callable[[Path], int]) -> int:
"""Write via a temp file in the same directory, then rename.
Same directory matters: rename is only atomic within one filesystem, and a
temp file in /tmp is frequently on a different one.
"""
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(dir=target.parent, suffix=".tmp")
os.close(fd)
tmp = Path(tmp_name)
try:
rows = write(tmp)
# Durability before visibility: the data must be on disk before the rename.
with tmp.open("rb") as handle:
os.fsync(handle.fileno())
tmp.replace(target) # atomic within the filesystem
return rows
except BaseException:
tmp.unlink(missing_ok=True) # never leave a half-written .tmp
raise
@dataclass
class Interrupted(Exception):
"""Raised on SIGTERM so the current unit unwinds cleanly rather than being killed."""
signum: int
def _install_signal_handlers() -> None:
def handler(signum, _frame):
raise Interrupted(signum)
for sig in (signal.SIGINT, signal.SIGTERM):
signal.signal(sig, handler)
def run_import(units: Iterable[str],
process: Callable[[str, Path], int],
out_dir: Path,
marker_path: Path) -> None:
"""Process each unit exactly once across any number of runs."""
_install_signal_handlers()
markers = Markers(marker_path)
total_rows = skipped = 0
for unit in units:
if unit in markers:
skipped += 1
continue
target = out_dir / f"{unit}.parquet"
try:
rows = write_atomically(target, lambda tmp, u=unit: process(u, tmp))
except Interrupted as stop:
logger.warning("interrupted by signal %d during unit %s — "
"that unit will be replayed on the next run", stop.signum, unit)
raise SystemExit(130)
markers.record(unit, rows) # AFTER the rename, never before
total_rows += rows
logger.info("unit %s: %d row(s)", unit, rows)
logger.info("import complete: %d unit(s) skipped, %d row(s) written this run",
skipped, total_rows)
def reconcile(units: Iterable[str], out_dir: Path, marker_path: Path) -> list[str]:
"""Find units whose marker and output disagree — the crash-window casualties."""
markers = Markers(marker_path)
problems: list[str] = []
for unit in units:
exists = (out_dir / f"{unit}.parquet").exists()
recorded = unit in markers
if exists and not recorded:
problems.append(f"{unit}: written but not marked (crash before marker)")
if recorded and not exists:
problems.append(f"{unit}: marked but missing (output deleted?)")
for problem in problems:
logger.warning("%s", problem)
return problems
Step-by-step walkthrough Jump to heading
Markers.record flushes and fsyncs. A marker sitting in the operating system’s page cache when the machine loses power is not a marker, and this is the one place in the loop where the cost of an fsync — a few milliseconds per unit — is obviously worth paying.
write_atomically creates its temporary file in the target directory. rename is atomic only within a single filesystem, so a temp file in /tmp followed by a rename into a data volume is a copy, not a rename, and it is not atomic. It also fsyncs the data before renaming, because a rename that becomes visible before the data it points at has reached the disk gives you a complete-looking file full of nothing after a power loss.
The except BaseException on the temp file is deliberately broad. KeyboardInterrupt and SystemExit do not derive from Exception, and without catching them a Ctrl-C leaves .tmp files scattered through the output directory for the next run to trip over.
The signal handler converts SIGTERM into an exception so the current unit unwinds through the same cleanup path as any other failure. Without it, a container being stopped kills the process mid-write and leaves the temp file behind — harmless here because temp files are ignored on resume, but only because the naming keeps them invisible.
reconcile looks for the two states that disagree. “Written but not marked” is the expected crash-window casualty and is harmless: the unit replays and overwrites itself. “Marked but missing” is not expected and means something removed output behind the marker’s back, which is worth failing on.
Verification Jump to heading
Prove the resume works by causing the crash rather than waiting for one:
# Start the import, kill it partway, then run it again.
timeout 60 python3 import.py; echo "exit $?"
python3 import.py # should log "resuming: N unit(s) already complete"
The second run’s log line is the whole test. If it reports zero complete units, markers are not being persisted; if it reports every unit, the marker is being written before the work.
Then prove idempotence directly, which is the property the resume depends on:
python3 import.py # run to completion
find out -name '*.parquet' -printf '%s %p\n' | sort > /tmp/first
rm -f markers.jsonl # force a full replay
python3 import.py
find out -name '*.parquet' -printf '%s %p\n' | sort > /tmp/second
diff /tmp/first /tmp/second && echo "idempotent"
Byte-identical output from a full replay means a resume can never produce something a clean run would not.
Finally, check for leftovers, since a stray temp file is the visible symptom of an incomplete cleanup path:
find out -name '*.tmp' -print | head # expect nothing
python3 -c "from import_ import reconcile; ..." # expect no problems
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
| Resume skips a unit that was never written | Marker recorded before the work | Record after the atomic rename |
| Duplicate rows after a resume | Sink appends rather than replaces | Use partitions or upserts |
| Half-written files after a crash | Written directly to the target name | Temp file plus atomic rename |
| Rename is not atomic | Temp file on another filesystem | Create it in the target directory |
| Markers lost after a power cut | No fsync | Flush and fsync each marker |
.tmp files accumulate |
Cleanup missed on KeyboardInterrupt |
Catch BaseException around the temp file |
| Resume replays everything | Unit identity not stable between runs | Derive unit ids from the input, not from enumeration order |
Frequently Asked Questions Jump to heading
How large should a unit be?
Large enough that the per-unit overhead — a temp file, an fsync, a marker — is negligible, and small enough that losing one is cheap. For file sinks a partition is almost always the right unit; for database sinks a batch of tens of thousands of rows in one transaction works well. The measurement above shows most of the benefit arriving at the first sensible boundary, so there is little reason to go finer than a partition.
Can I resume a database import the same way?
Yes, with one simplification: put the marker insert inside the same transaction as the data. Then the marker and the data commit or roll back together and the crash window disappears entirely — the same property that makes a PostGIS sink attractive in Applying Minutely Diffs to a PostGIS Database. This is only available when the marker and the data live in the same database.
What if the input file changes between runs?
Then the resume is invalid, because unit 42 of the new file is not unit 42 of the old one. Record a hash of the input alongside the markers and refuse to resume when it differs — a fresh extract downloaded overnight is exactly the situation where a silent mismatch produces a dataset that is half one week’s data and half the next.
Is a marker file good enough, or do I need a database?
A file is fine for a single-process import, which is the common case. It stops being fine the moment two workers process units concurrently, because appending from several processes without coordination interleaves lines. At that point either give each worker its own marker file or move the markers into something with atomic writes.
Specification reference Jump to heading
POSIX
rename(2)is atomic when source and destination are on the same filesystem: the destination always names either the old file or the new one, never a partial state. It does not imply durability — the rename may be reordered ahead of the data writes unless the file isfsynced first, and on some filesystems the containing directory must befsynced for the rename itself to survive a power loss.
Related Jump to heading
- Error Handling in Large OSM Extracts — the topic this recovery belongs to.
- Building a Minutely Update Pipeline — the same commit ordering, for diffs.
- Partitioning a GeoParquet OSM Lake by H3 Cell — a layout whose units are naturally atomic.
- Sizing PBF Chunk Batches to a Memory Budget — how big a unit can be before memory decides.
- Splitting a Planet File into Regional Extracts — a batch job with the same partial-failure problem.
Up one level: Error Handling in Large OSM Extracts.