Handling Async Batch Uploads for Large Datasets: Resumable Chunked Transfer with Checkpointing
This guide implements one specific operation: pushing a single multi-terabyte research artifact — an omics array, an imaging stack, a longitudinal sensor log — to a repository ingest endpoint over a network that will drop, throttle, and time out mid-transfer, without ever losing data or double-writing a chunk. It is the network-resilience layer that sits directly beneath the async batch processing worker: the batch layer decides which files run concurrently and where failures are quarantined, and this operation decides how one oversized file survives the wire. It assumes you already stream-parse and validate records through Pydantic schema validation upstream, and that the destination speaks a resumable chunked upload protocol. The three properties every implementation here must hold are bounded memory (footprint stays flat regardless of file size), idempotency (a retried chunk is a server-side no-op), and crash-safe resumption (a killed process restarts from the last acknowledged chunk, not byte zero).
429 and retried under an unchanged idempotency key, chunk 13 times out with a 504 and resumes from its checkpoint, and the finalize call commits the manifest with 201.The Resumable Upload Contract
A resumable upload is a three-phase exchange: initiate a session, PUT each fixed-size chunk keyed by its sequence number, then finalize so the server reassembles the parts and verifies the whole-file digest. Every field below is load-bearing — omit the per-chunk X-Chunk-SHA256 and a truncated chunk lands silently; omit the stable X-Idempotency-Key and a retried PUT appends a duplicate. This table is the authoritative wire contract the implementation targets.
| Phase | Method + path | Required headers | Body | Success | Meaning |
|---|---|---|---|---|---|
| Initiate | POST /uploads |
X-File-SHA256, X-Chunk-Size |
{} |
201 + upload_id |
Server allocates a session and returns its id |
| Upload chunk | PUT /uploads/{id}/chunks/{seq} |
X-Chunk-SHA256, X-Idempotency-Key |
raw bytes | 200/201 |
Chunk seq is durably staged and integrity-checked |
| Finalize | POST /uploads/{id}/complete |
Content-Type: application/json |
{"sha256": "..."} |
201 + manifest |
Server reassembles, verifies whole-file digest, commits |
The checkpoint the client persists between chunks is equally exact. It is the only state that survives a process crash, so its fields must fully reconstruct in-flight progress:
| Checkpoint field | Type | Purpose |
|---|---|---|
upload_id |
str |
Server session id the resumed run reattaches to |
file_sha256 |
str |
Whole-file digest sent at finalize; detects source mutation between runs |
chunk_size |
int |
Byte boundary; must match the initiate call or seq offsets diverge |
total_chunks |
int |
Ceiling of file size over chunk_size; the finalize precondition |
committed |
list[int] |
Sequence numbers the server has acknowledged — the resume set |
Failure Classification and Backoff Policy
Resilience is a routing decision on every response, identical in spirit to the fallback classification used in API routing and fallbacks: transient signals are retried in place against the same chunk, permanent signals abort the transfer, and a run of consecutive server failures trips a circuit breaker so the client stops hammering a downstream that is already down.
| Signal | Class | In-place retry? | Action |
|---|---|---|---|
200 / 201 |
success | — | Record seq in committed, persist checkpoint |
429 Too Many Requests |
transient | yes | Sleep Retry-After seconds (or jittered backoff if absent), retry same seq |
502 / 503 / 504 |
transient | yes | Jittered exponential backoff, increment breaker failure count |
| Connect / read timeout | transient | yes | Jittered backoff, increment breaker failure count |
400 / 409 / 422 |
permanent | no | Abort; chunk is structurally rejected — surface for operator review |
401 / 403 |
permanent | no | Abort; refresh credentials out of band, then resume from checkpoint |
Backoff is exponential with full jitter — a random draw over [0, base · 2^attempt] capped at a ceiling — which spreads retries so a repository emerging from a maintenance window is not hit by every stalled client at once (the thundering-herd failure mode).
Production Implementation
The implementation below is complete and runnable against any endpoint that honours the contract table. Memory stays bounded because each worker reads only its own chunk on demand — at most max_concurrency × chunk_size bytes are ever resident — and the file read is offloaded with asyncio.to_thread so blocking disk I/O never stalls the event loop. The checkpoint is written strictly after each server acknowledgement, which is what makes resumption crash-safe.
from __future__ import annotations
import asyncio
import hashlib
import random
from dataclasses import dataclass
from pathlib import Path
import httpx
from pydantic import BaseModel, Field
class UploadCheckpoint(BaseModel):
"""Crash-safe progress record; the only state that survives a restart."""
upload_id: str
file_sha256: str
chunk_size: int
total_chunks: int
committed: list[int] = Field(default_factory=list) # server-acked sequences
def save(self, path: Path) -> None:
path.write_text(self.model_dump_json(indent=2))
@classmethod
def load(cls, path: Path) -> "UploadCheckpoint | None":
if not path.exists():
return None
return cls.model_validate_json(path.read_text())
@dataclass
class CircuitBreaker:
"""Opens after `threshold` consecutive server/timeout failures."""
threshold: int = 5
cooldown: float = 30.0
_failures: int = 0
_opened_at: float | None = None
def allow(self, now: float) -> bool:
if self._opened_at is None:
return True
if now - self._opened_at >= self.cooldown: # half-open: allow one probe
self._opened_at = None
self._failures = 0
return True
return False
def record_success(self) -> None:
self._failures = 0
self._opened_at = None
def record_failure(self, now: float) -> None:
self._failures += 1
if self._failures >= self.threshold:
self._opened_at = now
def _backoff(attempt: int, base: float = 0.5, cap: float = 30.0) -> float:
"""Exponential backoff with full jitter."""
return random.uniform(0.0, min(cap, base * (2 ** attempt)))
def _read_chunk(path: Path, seq: int, chunk_size: int) -> bytes:
"""Blocking read of a single chunk by offset — call via asyncio.to_thread."""
with path.open("rb") as fh:
fh.seek(seq * chunk_size)
return fh.read(chunk_size)
async def _put_chunk(
client: httpx.AsyncClient,
upload_id: str,
seq: int,
data: bytes,
breaker: CircuitBreaker,
max_retries: int = 5,
) -> None:
"""Upload one chunk with classification-driven retry; raises only on permanent failure."""
digest = hashlib.sha256(data).hexdigest()
headers = {
"X-Chunk-SHA256": digest,
# Stable across retries -> the server deduplicates instead of appending.
"X-Idempotency-Key": f"{upload_id}:{seq}",
}
loop = asyncio.get_running_loop()
for attempt in range(max_retries + 1):
if not breaker.allow(loop.time()):
await asyncio.sleep(breaker.cooldown)
try:
resp = await client.put(
f"/uploads/{upload_id}/chunks/{seq}", content=data, headers=headers
)
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout):
breaker.record_failure(loop.time())
await asyncio.sleep(_backoff(attempt))
continue
if resp.status_code in (200, 201):
breaker.record_success()
return
if resp.status_code == 429: # honour server-supplied Retry-After
await asyncio.sleep(float(resp.headers.get("Retry-After", _backoff(attempt))))
continue
if 500 <= resp.status_code < 600:
breaker.record_failure(loop.time())
await asyncio.sleep(_backoff(attempt))
continue
resp.raise_for_status() # 4xx other than 429 is permanent -> abort
raise RuntimeError(f"chunk {seq} exhausted {max_retries} retries")
async def upload_large_dataset(
file_path: Path,
upload_id: str,
base_url: str,
chunk_size: int = 8 * 1024 * 1024, # 8 MiB
max_concurrency: int = 4,
checkpoint_path: Path | None = None,
) -> UploadCheckpoint:
"""Resumable, bounded-memory upload of one large file."""
checkpoint_path = checkpoint_path or file_path.with_suffix(file_path.suffix + ".ckpt")
size = file_path.stat().st_size
total = -(-size // chunk_size) # ceiling division
ckpt = UploadCheckpoint.load(checkpoint_path) or UploadCheckpoint(
upload_id=upload_id,
file_sha256=await asyncio.to_thread(_whole_file_digest, file_path),
chunk_size=chunk_size,
total_chunks=total,
)
done: set[int] = set(ckpt.committed)
breaker = CircuitBreaker()
semaphore = asyncio.Semaphore(max_concurrency)
lock = asyncio.Lock() # guards checkpoint writes across concurrent workers
async with httpx.AsyncClient(base_url=base_url, timeout=httpx.Timeout(60.0)) as client:
async def worker(seq: int) -> None:
async with semaphore:
data = await asyncio.to_thread(_read_chunk, file_path, seq, chunk_size)
await _put_chunk(client, upload_id, seq, data, breaker)
async with lock: # persist progress ONLY after the ack
ckpt.committed.append(seq)
ckpt.save(checkpoint_path)
await asyncio.gather(*(worker(s) for s in range(total) if s not in done))
resp = await client.post(
f"/uploads/{upload_id}/complete", json={"sha256": ckpt.file_sha256}
)
resp.raise_for_status()
checkpoint_path.unlink(missing_ok=True) # remove only after a clean commit
return ckpt
def _whole_file_digest(path: Path, block: int = 1024 * 1024) -> str:
sha = hashlib.sha256()
with path.open("rb") as fh:
while chunk := fh.read(block):
sha.update(chunk)
return sha.hexdigest()
Verification
Assert the two behaviours that matter — a 429 is retried against the same chunk, and a pre-populated checkpoint skips already-committed sequences — without touching a live registry, by driving the client through httpx.MockTransport.
import httpx
import pytest
from pathlib import Path
@pytest.mark.asyncio
async def test_retries_429_then_resumes(tmp_path: Path) -> None:
src = tmp_path / "dataset.bin"
src.write_bytes(b"x" * (20 * 1024 * 1024)) # 20 MiB -> 3 chunks at 8 MiB
attempts: dict[str, int] = {}
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/complete"):
return httpx.Response(201, json={"committed": True})
key = request.headers["X-Idempotency-Key"]
attempts[key] = attempts.get(key, 0) + 1
if attempts[key] == 1: # first touch of every chunk is throttled
return httpx.Response(429, headers={"Retry-After": "0"})
return httpx.Response(200)
async with httpx.AsyncClient(
base_url="https://repo.test", transport=httpx.MockTransport(handler)
) as client:
# inject the mocked client by monkeypatching the module's constructor in real code;
# here we exercise _put_chunk directly for a focused assertion.
from mymodule import CircuitBreaker, _put_chunk
await _put_chunk(client, "up-1", 0, b"x" * 8, CircuitBreaker())
assert attempts["up-1:0"] == 2 # one 429, one success on the SAME sequence
Run it with pytest -q test_uploads.py. A green run is the machine-readable proof that throttling triggers exactly one in-place retry per chunk and that the idempotency key stays stable across attempts.
Gotchas
- Attempt-scoped idempotency keys double-write chunks. If
X-Idempotency-Keyembeds the attempt number or a timestamp, every retry looks like a fresh chunk and the server appends duplicates, corrupting the reassembled file. Fix: derive the key solely fromupload_idandseq, never from the attempt. - Persisting the checkpoint before the acknowledgement. Recording a sequence in
committedbefore the server returns2xxmeans a crash mid-flight marks an unlanded chunk as done, and the resume run skips it — leaving a hole. Fix: append tocommittedand save strictly after a200/201, exactly asworkerdoes. - Reading chunks synchronously on the event loop. A bare
open().read()inside the coroutine blocks every other worker while the disk seeks, collapsing concurrency to one. Fix: offload the read withasyncio.to_thread(orloop.run_in_executor) so I/O-bound uploads keep overlapping.
Frequently Asked Questions
How large should each chunk be?
Size chunks so a single failed PUT is cheap to retry but per-chunk overhead stays negligible — 8 MiB to 64 MiB is the practical band for research payloads. Smaller chunks multiply request overhead and checkpoint writes; larger chunks waste bandwidth on every retry and raise the resident-memory ceiling, which is max_concurrency × chunk_size.
What happens if the source file changes between a crash and a resume?
The resumed run reloads the checkpoint’s file_sha256 but re-reads chunk bytes from disk, so an edited source would upload inconsistent chunks that fail the whole-file digest check at finalize. Treat the checkpoint’s file_sha256 as a precondition: recompute the source digest on resume and, if it differs, discard the checkpoint and restart the session rather than committing a mixed file.
How does this differ from the batch layer above it?
The async batch processing layer orchestrates many files with a bounded queue and semaphore and quarantines whole files to a dead-letter queue; this operation is the single-file transport underneath it, responsible for chunking, per-chunk integrity, and resumption. One batch worker invokes upload_large_dataset per oversized file.
Related Guides
- Async Batch Processing — the parent batch layer that schedules concurrent files and owns dead-letter quarantine; this page is its single-file upload path.
- API Routing & Fallbacks — the circuit-breaker, retry-classification, and idempotency-key patterns generalized across the whole registry-dispatch surface.
- Pydantic Schema Validation — the typed validation gate that cleans records before they enter a large upload.
- Data Ingestion & Metadata Enrichment — the parent pipeline overview showing where large-dataset transfer sits between acquisition and persistence.