fix flaky crime download
This commit is contained in:
parent
d78a301028
commit
56b5d9f7df
2 changed files with 116 additions and 13 deletions
|
|
@ -15,6 +15,7 @@ import json
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
@ -35,6 +36,11 @@ ARCHIVE_LINK_RE = re.compile(
|
||||||
)
|
)
|
||||||
VALID_MD5_RE = re.compile(r"^[0-9a-fA-F]{32}$")
|
VALID_MD5_RE = re.compile(r"^[0-9a-fA-F]{32}$")
|
||||||
MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
||||||
|
# Each archive is up to ~1.6 GB, so a transient failure (network blip, or the
|
||||||
|
# work dir being removed underneath us by a concurrent run) must not abort the
|
||||||
|
# whole multi-archive download. Retry a few times with a short backoff.
|
||||||
|
DOWNLOAD_ATTEMPTS = 4
|
||||||
|
RETRY_BACKOFF_SECONDS = 5
|
||||||
STREET_CRIME_CSV_RE = re.compile(r"^\d{4}-\d{2}-.+-street\.csv$")
|
STREET_CRIME_CSV_RE = re.compile(r"^\d{4}-\d{2}-.+-street\.csv$")
|
||||||
CONTAINED_RANGE_RE = re.compile(
|
CONTAINED_RANGE_RE = re.compile(
|
||||||
r"Contains data from (?P<start_month>[A-Za-z]+) (?P<start_year>\d{4}) "
|
r"Contains data from (?P<start_month>[A-Za-z]+) (?P<start_year>\d{4}) "
|
||||||
|
|
@ -265,6 +271,10 @@ def file_md5(path: Path) -> str:
|
||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class _RetryableDownloadError(Exception):
|
||||||
|
"""A download attempt failed in a way that is worth retrying."""
|
||||||
|
|
||||||
|
|
||||||
def download_archive(
|
def download_archive(
|
||||||
archive: CrimeArchive,
|
archive: CrimeArchive,
|
||||||
archive_dir: Path,
|
archive_dir: Path,
|
||||||
|
|
@ -273,7 +283,12 @@ def download_archive(
|
||||||
force: bool,
|
force: bool,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Download one archive ZIP, resuming an existing .part file when possible."""
|
"""Download one archive ZIP, resuming an existing .part file when possible.
|
||||||
|
|
||||||
|
Transient failures (network errors, or the work dir being removed
|
||||||
|
underneath us by a concurrent run) are retried with a short backoff so a
|
||||||
|
single hiccup does not abort a whole multi-archive download.
|
||||||
|
"""
|
||||||
dest = archive_dir / archive.filename
|
dest = archive_dir / archive.filename
|
||||||
partial = dest.with_suffix(dest.suffix + ".part")
|
partial = dest.with_suffix(dest.suffix + ".part")
|
||||||
|
|
||||||
|
|
@ -297,6 +312,43 @@ def download_archive(
|
||||||
print(f"{archive.filename}: already downloaded")
|
print(f"{archive.filename}: already downloaded")
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
last_error: Exception | None = None
|
||||||
|
for attempt in range(1, DOWNLOAD_ATTEMPTS + 1):
|
||||||
|
# The work dir can be removed underneath us (e.g. a concurrent run's
|
||||||
|
# startup cleanup), which is exactly what makes the final rename fail
|
||||||
|
# with ENOENT even after the download reached 100%. Recreate it so the
|
||||||
|
# (re)download has somewhere to land; a surviving .part is resumed.
|
||||||
|
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
_stream_to_partial(archive, partial, timeout=timeout)
|
||||||
|
partial.replace(dest)
|
||||||
|
if verify and archive.md5 is not None:
|
||||||
|
actual_md5 = file_md5(dest)
|
||||||
|
if actual_md5 != archive.md5:
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
raise _RetryableDownloadError(
|
||||||
|
f"{archive.filename}: MD5 mismatch: "
|
||||||
|
f"expected {archive.md5}, got {actual_md5}"
|
||||||
|
)
|
||||||
|
return dest
|
||||||
|
except (httpx.HTTPError, OSError, _RetryableDownloadError) as error:
|
||||||
|
last_error = error
|
||||||
|
if attempt < DOWNLOAD_ATTEMPTS:
|
||||||
|
delay = RETRY_BACKOFF_SECONDS * attempt
|
||||||
|
print(
|
||||||
|
f"{archive.filename}: download attempt {attempt} failed "
|
||||||
|
f"({error}); retrying in {delay}s",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{archive.filename}: download failed after {DOWNLOAD_ATTEMPTS} attempts"
|
||||||
|
) from last_error
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_to_partial(archive: CrimeArchive, partial: Path, *, timeout: float) -> None:
|
||||||
|
"""Stream one archive into its .part file, resuming a valid prefix."""
|
||||||
resume_from = partial.stat().st_size if partial.exists() else 0
|
resume_from = partial.stat().st_size if partial.exists() else 0
|
||||||
headers = {"Range": f"bytes={resume_from}-"} if resume_from else {}
|
headers = {"Range": f"bytes={resume_from}-"} if resume_from else {}
|
||||||
|
|
||||||
|
|
@ -332,18 +384,6 @@ def download_archive(
|
||||||
output.write(chunk)
|
output.write(chunk)
|
||||||
progress.update(len(chunk))
|
progress.update(len(chunk))
|
||||||
|
|
||||||
partial.replace(dest)
|
|
||||||
|
|
||||||
if verify and archive.md5 is not None:
|
|
||||||
actual_md5 = file_md5(dest)
|
|
||||||
if actual_md5 != archive.md5:
|
|
||||||
dest.unlink(missing_ok=True)
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{archive.filename}: MD5 mismatch: expected {archive.md5}, got {actual_md5}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return dest
|
|
||||||
|
|
||||||
|
|
||||||
def _is_street_crime_csv(path: PurePosixPath | Path) -> bool:
|
def _is_street_crime_csv(path: PurePosixPath | Path) -> bool:
|
||||||
return STREET_CRIME_CSV_RE.fullmatch(path.name) is not None
|
return STREET_CRIME_CSV_RE.fullmatch(path.name) is not None
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
|
import hashlib
|
||||||
|
import shutil
|
||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pipeline.download import crime
|
||||||
from pipeline.download.crime import (
|
from pipeline.download.crime import (
|
||||||
CrimeArchive,
|
CrimeArchive,
|
||||||
|
download_archive,
|
||||||
extract_csvs,
|
extract_csvs,
|
||||||
prepare_archive_dir,
|
prepare_archive_dir,
|
||||||
prune_unused_csvs,
|
prune_unused_csvs,
|
||||||
|
|
@ -10,6 +16,19 @@ from pipeline.download.crime import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_archive(md5: str | None = None) -> CrimeArchive:
|
||||||
|
return CrimeArchive(
|
||||||
|
month="2023-05",
|
||||||
|
label="May 2023",
|
||||||
|
url="https://data.police.uk/data/archive/2023-05.zip",
|
||||||
|
filename="2023-05.zip",
|
||||||
|
size="1.6 GB",
|
||||||
|
contained_range="",
|
||||||
|
md5=md5,
|
||||||
|
raw_md5=md5 or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_parse_archives_reads_monthly_zip_links_only():
|
def test_parse_archives_reads_monthly_zip_links_only():
|
||||||
html = """
|
html = """
|
||||||
<p><a href="/data/archive/latest.zip">latest.zip</a></p>
|
<p><a href="/data/archive/latest.zip">latest.zip</a></p>
|
||||||
|
|
@ -194,3 +213,47 @@ def test_prune_unused_csvs_removes_non_street_csvs(tmp_path):
|
||||||
assert street.exists()
|
assert street.exists()
|
||||||
assert not outcomes.exists()
|
assert not outcomes.exists()
|
||||||
assert not stop_search.exists()
|
assert not stop_search.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_archive_recovers_when_workdir_wiped_mid_download(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
# Reproduces the crash where a completed .part vanished before the rename
|
||||||
|
# (a concurrent run wiped _download_tmp), so partial.replace raised ENOENT
|
||||||
|
# even though the download had reached 100%.
|
||||||
|
monkeypatch.setattr(crime.time, "sleep", lambda *_: None)
|
||||||
|
archive_dir = tmp_path / "_download_tmp"
|
||||||
|
archive_dir.mkdir()
|
||||||
|
payload = b"real-zip-bytes" * 4096
|
||||||
|
archive = _make_archive(md5=hashlib.md5(payload).hexdigest())
|
||||||
|
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def fake_stream(_archive, partial, *, timeout):
|
||||||
|
calls["n"] += 1
|
||||||
|
partial.write_bytes(payload)
|
||||||
|
if calls["n"] == 1:
|
||||||
|
# Simulate the concurrent cleanup: the rename target disappears.
|
||||||
|
shutil.rmtree(partial.parent)
|
||||||
|
|
||||||
|
monkeypatch.setattr(crime, "_stream_to_partial", fake_stream)
|
||||||
|
|
||||||
|
dest = download_archive(archive, archive_dir, verify=True, force=False, timeout=1.0)
|
||||||
|
|
||||||
|
assert calls["n"] == 2
|
||||||
|
assert dest.read_bytes() == payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_archive_gives_up_after_repeated_failures(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(crime.time, "sleep", lambda *_: None)
|
||||||
|
archive_dir = tmp_path / "_download_tmp"
|
||||||
|
|
||||||
|
def always_fail(_archive, _partial, *, timeout):
|
||||||
|
raise OSError("connection reset")
|
||||||
|
|
||||||
|
monkeypatch.setattr(crime, "_stream_to_partial", always_fail)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match=f"after {crime.DOWNLOAD_ATTEMPTS} attempts"):
|
||||||
|
download_archive(
|
||||||
|
_make_archive(), archive_dir, verify=False, force=False, timeout=1.0
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue