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.

Which sinks allow an interrupted import to resume A grid of five sinks. An append-only file cannot resume because the position is unknown and the file may be half-written. Partitioned files can resume because a partition is either complete or absent. A database with upsert can resume because re-applying a row is a no-op. A database with plain insert cannot, because a second run doubles the rows. An in-place file rewrite cannot, because the source has already been consumed. Resumability is a property of the sink, not of the loop can you resume? what makes it work append-only file no — position unknown nothing; the file may be half-written partitioned files yes a partition is complete or absent database with upsert yes re-applying a row is a no-op database with plain insert no — duplicates nothing; the second run doubles rows in-place file rewrite no — the source is gone nothing; recover from a copy Two of these five resume cleanly, and both do it the same way: make re-processing the same input produce the same result.
Resumability is designed into the write path. No amount of checkpoint bookkeeping rescues a sink that cannot absorb the same input twice.

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.

The commit ordering that makes a resume safe A four-stage chain. Process one unit, a partition or a block range, which must be atomic. Write it under a temporary name invisible to readers. Rename it atomically, which is the commit point at which it exists and is complete. Only then record the marker saying the unit is done, never before. Commit the work before the marker, always process a unit one partition, one block range the unit must be atomic write to a temp name part-00042.parquet.tmp invisible to readers atomic rename now it exists, complete the commit point record the marker unit 42 done after, never before A crash between the rename and the marker replays one unit — free, because the unit is idempotent. A crash the other way round loses it forever.
The asymmetry is deliberate: a crash must be able to duplicate work, never to skip it.

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.

Remaining wall-clock after a crash, by resume unit size A bar chart for a 412 million object import that crashed at 71 percent after four hours 20 minutes. Restarting from scratch takes six hours six minutes with four hours 20 wasted. Resuming with the whole file as one unit is identical, because there are no units to skip. Resuming with one partition as the unit, 84 in total, takes one hour 52 minutes and skips 60 partitions. Resuming with a block range as the unit takes one hour 46 minutes at the finest granularity. What resuming is worth 412 M-object import, crash at 71% after 4 h 20 m restart from scratch 6 h 06 m total · 4 h 20 m wasted resume, unit = whole file 6 h 06 m · no units to skip resume, unit = 1 partition (84) 1 h 52 m · 60 partitions skipped resume, unit = block range 1 h 46 m · finest granularity Unit size sets the ceiling on what a resume can recover. One unit for the whole job is a checkpoint that never fires.
Most of the benefit arrives at the first sensible unit boundary. Going finer than a partition buys minutes, not hours.

Runnable solution Jump to heading

python
#!/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:

bash
# 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:

bash
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:

bash
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 is fsynced first, and on some filesystems the containing directory must be fsynced for the rename itself to survive a power loss.

Up one level: Error Handling in Large OSM Extracts.