Handling Spot Interruptions in PDAL Batch Jobs
TL;DR: Trap SIGTERM, flush whatever is complete, write the marker only after the output object is durable, and make the job’s first action a check for that marker — then a spot reclaim costs one sub-tile instead of the whole job.
# Context and Motivation
This guide is part of AWS Batch Processing for PDAL. Spot capacity is roughly a third of the on-demand price, and at a typical interruption rate of a few percent the arithmetic is not close — provided an interruption is cheap. Making it cheap is a design property, not a setting.
The mechanism is straightforward. When capacity is reclaimed, the instance receives a termination notice and the container gets SIGTERM, followed about two minutes later by SIGKILL. Two minutes is ample to finish writing a sub-tile and record that it is done. It is nowhere near enough to finish a forty-minute pipeline, so the design has to be built from units that fit inside the warning.
# Prerequisites and Assumptions
| Requirement | Detail |
|---|---|
| AWS Batch | on a spot compute environment |
| A decomposable job | sub-tiles, or a natural checkpoint boundary |
| Durable output | S3, with the marker written after the object |
| Signal handling | in the entrypoint, not in a wrapper script that ignores it |
| Retries enabled | attempts above 1 in the job definition |
# Step-by-Step Implementation
# Step 1 — Decompose the tile into sub-tiles
Choose a size that completes well inside two minutes. On a typical pipeline that is a few hundred metres square.
# Step 2 — Write the marker after the object
write_output(sub_tile) # object is durable first
put_marker(sub_tile) # only then is it recorded as doneReversing these turns an interruption into permanent missing data — the point made in scaling PDAL tile processing with AWS Batch.
# Step 3 — Skip completed sub-tiles on entry
The first action of every attempt is to list the markers and remove those sub-tiles from the work list.
# Step 4 — Trap SIGTERM and stop cleanly
signal.signal(signal.SIGTERM, lambda *_: stop.set())Check the flag between sub-tiles rather than trying to abort mid-write.
# Step 5 — Exit with a retryable status
Exit non-zero on interruption so Batch retries the job; the retry will skip everything already marked.
# Complete Working Example
"""A Batch entrypoint that survives spot reclamation."""
from __future__ import annotations
import json
import logging
import os
import signal
import sys
import threading
from pathlib import Path
import boto3
LOG = logging.getLogger("worker")
S3 = boto3.client("s3")
BUCKET = os.environ["OUTPUT_BUCKET"]
PREFIX = os.environ["OUTPUT_PREFIX"]
stop = threading.Event()
def _on_term(signum, frame): # noqa: ARG001
LOG.warning("received signal %s — finishing the current sub-tile and stopping", signum)
stop.set()
def marker_key(sub_tile: str) -> str:
return f"{PREFIX}/_markers/{sub_tile}.done"
def already_done(sub_tile: str) -> bool:
try:
S3.head_object(Bucket=BUCKET, Key=marker_key(sub_tile))
return True
except S3.exceptions.ClientError:
return False
def process_sub_tile(sub_tile: str) -> None:
"""Run the pipeline for one sub-tile and upload the result."""
out = Path(f"/scratch/{sub_tile}.laz")
# ... pdal.Pipeline(...).execute() writes `out` ...
S3.upload_file(str(out), BUCKET, f"{PREFIX}/{sub_tile}.laz")
# Marker last: the object is durable before anything claims it is.
S3.put_object(Bucket=BUCKET, Key=marker_key(sub_tile), Body=b"")
out.unlink(missing_ok=True)
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
signal.signal(signal.SIGTERM, _on_term)
signal.signal(signal.SIGINT, _on_term)
sub_tiles = json.loads(Path("/work/sub_tiles.json").read_text())
pending = [s for s in sub_tiles if not already_done(s)]
LOG.info("%d of %d sub-tiles remain", len(pending), len(sub_tiles))
for sub_tile in pending:
if stop.is_set():
LOG.warning("stopping early; %d sub-tiles left for the retry", len(pending))
return 75 # non-zero: Batch retries, and the retry skips what is done
process_sub_tile(sub_tile)
LOG.info("completed %s", sub_tile)
return 0
if __name__ == "__main__":
sys.exit(main())# Key Parameter Table
| Setting | Where | Guidance |
|---|---|---|
| sub-tile size | your decomposition | Completes well inside the two-minute warning |
| marker order | entrypoint | Object first, marker second, always |
attempts |
job definition | 3 is typical on spot |
| exit code | entrypoint | Non-zero on interruption so Batch retries |
SIGTERM handler |
entrypoint | Sets a flag; never aborts a write in progress |
# Verification
A killed job resumes. Send SIGTERM to a running container and confirm the retry starts from the first unmarked sub-tile.
Markers never precede objects. Delete an output object but leave its marker, re-run, and confirm the gap is visible — because it will not be filled.
Retries are cheap. A re-run of a completed job should finish in seconds, having found every marker.
# Gotchas and Edge Cases
A wrapper script that swallows the signal. sh -c "python worker.py" may not forward SIGTERM. Use exec, or make the Python process PID 1.
Sub-tiles that are too large. If one takes five minutes, the warning cannot save it and checkpointing buys nothing.
Markers in the same prefix as outputs. A later listing then treats markers as data. Keep them under their own prefix.
Assuming the warning always arrives. It usually does; occasionally an instance simply disappears. The design must be correct without it, which it is — the marker is the only source of truth.
# Frequently Asked Questions
How long does a spot instance give me?
About two minutes between the termination notice and the kill. That is ample to finish writing one sub-tile and record it, and nowhere near enough to finish a forty-minute pipeline — which is why the unit of work has to be smaller than the warning for the warning to be useful at all.
Why must the marker be written after the output?
Because a marker written first turns a crash into permanent missing data. A later run finds the marker, concludes the tile is done, and skips it forever. Writing the object first means the worst case is redundant work, which costs money rather than correctness.
Is spot actually worth it?
At roughly a third of the on-demand price and a few percent interruption rate, yes, provided an interruption is cheap. With sub-tile checkpoints an interruption costs a few minutes; without them it costs the whole job, and at that point the arithmetic can reverse.
What if the termination notice never arrives?
It occasionally does not, and the design has to be correct without it. Because the marker is written only after the object is durable, an instance that simply disappears leaves an unmarked sub-tile that the retry picks up — the same path as an orderly shutdown.
# Related
- AWS Batch Processing — the parent guide to the compute environment and job model
- Scaling PDAL Tile Processing with AWS Batch — the array-job fan-out this makes safe to retry
- Running PDAL Pipelines in Docker — the entrypoint that has to forward the signal
- Batch Automation and Cloud Integration for PDAL — the section overview
- S3 and Cloud Storage I/O — the durability guarantees the marker design rests on