Skip to content

Commit 71c0a82

Browse files
fix(filepath): fix the dowload as you go and delete the temps
1 parent a324cb7 commit 71c0a82

4 files changed

Lines changed: 205 additions & 65 deletions

File tree

osmsg/pipeline.py

Lines changed: 79 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import datetime as dt
88
import hashlib
99
import json
10+
import multiprocessing
1011
import os
1112
import shutil
1213
from dataclasses import dataclass, field
@@ -543,49 +544,81 @@ def _processing_config(cfg: RunConfig, *, parquet_dir: Path, geom_wkt: str | Non
543544
_DOWNLOAD_WORKERS = 4
544545

545546

546-
def _download_all(
547+
def _stream_window(url: str) -> int:
548+
"""Cap on raw files held on disk at once, sized by file weight: planet day diffs are ~1 GB so
549+
only a few fit; hour diffs are moderate; minute and changeset files are tiny so many can."""
550+
lowered = url.lower()
551+
if "day" in lowered:
552+
return 4
553+
if "hour" in lowered:
554+
return 24
555+
return 100
556+
557+
558+
def _stream_download_process(
547559
urls: list[str],
560+
*,
548561
mode: str,
549-
workers: int,
550562
cookie: str | None,
551563
cache_dir: Path,
552-
label: str,
553-
description: str = "downloading",
554-
) -> None:
555-
try:
556-
with (
557-
progress_bar(len(urls), unit=label, description=description) as advance,
558-
concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool,
559-
):
560-
for _ in pool.map(lambda u: download_osm_file(u, mode=mode, cookie=cookie, cache_dir=cache_dir), urls):
561-
advance()
562-
except requests.exceptions.RequestException as exc:
563-
raise OsmsgError(
564-
f"Network error downloading {label} after retries ({type(exc).__name__}). "
565-
"Re-run to resume: finished downloads are cached, so it continues from where it stopped."
566-
) from exc
567-
568-
569-
def _process_all(
570-
items: list,
571-
*,
564+
window: int,
572565
target,
573566
initializer,
574-
init_args,
567+
init_args: tuple,
575568
chunksize: int,
576-
label: str,
577569
workers: int,
578-
extra_iterables: tuple[list, ...] = (),
579-
description: str = "processing",
570+
label: str,
571+
description: str,
572+
extra_iterable: list | None = None,
580573
) -> None:
574+
"""Download and parse the range in overlapping windows so the next window downloads while the
575+
current one is parsed and its raw files are erased. Peak disk stays ~one window, not the range."""
576+
total = len(urls)
577+
578+
def download_window(download_pool: concurrent.futures.ThreadPoolExecutor, start: int, end: int) -> None:
579+
try:
580+
for _ in download_pool.map(
581+
lambda file_url: download_osm_file(file_url, mode=mode, cookie=cookie, cache_dir=cache_dir),
582+
urls[start:end],
583+
):
584+
pass
585+
except requests.exceptions.RequestException as exc:
586+
raise OsmsgError(
587+
f"Network error downloading {label} after retries ({type(exc).__name__}). "
588+
"Re-run to resume: finished downloads are cached, so it continues from where it stopped."
589+
) from exc
590+
581591
with (
582-
progress_bar(len(items), unit=label, description=description) as advance,
592+
progress_bar(total, unit=label, description=description) as advance,
593+
concurrent.futures.ThreadPoolExecutor(max_workers=_DOWNLOAD_WORKERS) as downloaders,
594+
concurrent.futures.ThreadPoolExecutor(max_workers=1) as prefetcher,
595+
# spawn, not fork: this pool lives alongside the download threads, and forking a
596+
# multi-threaded process risks inheriting a held lock and deadlocking.
583597
concurrent.futures.ProcessPoolExecutor(
584-
max_workers=workers, initializer=initializer, initargs=init_args
585-
) as pool,
598+
max_workers=workers,
599+
mp_context=multiprocessing.get_context("spawn"),
600+
initializer=initializer,
601+
initargs=init_args,
602+
) as processors,
586603
):
587-
for _ in pool.map(target, items, *extra_iterables, chunksize=chunksize):
588-
advance()
604+
download_window(downloaders, 0, min(window, total))
605+
start = 0
606+
while start < total:
607+
end = min(start + window, total)
608+
next_start = end
609+
prefetching = (
610+
prefetcher.submit(download_window, downloaders, next_start, min(next_start + window, total))
611+
if next_start < total
612+
else None
613+
)
614+
process_iterables = (
615+
(urls[start:end],) if extra_iterable is None else (urls[start:end], extra_iterable[start:end])
616+
)
617+
for _ in processors.map(target, *process_iterables, chunksize=chunksize):
618+
advance()
619+
if prefetching is not None:
620+
prefetching.result()
621+
start = end
589622

590623

591624
def run(cfg: RunConfig) -> dict[str, Any]:
@@ -755,24 +788,19 @@ def run(cfg: RunConfig) -> dict[str, Any]:
755788
cs_config = _processing_config(cfg, parquet_dir=cs_dir, geom_wkt=geom_wkt)
756789
cs_config["window_start_utc"] = cfg.start_date.astimezone(UTC)
757790

758-
_download_all(
759-
urls,
760-
"changeset",
761-
_DOWNLOAD_WORKERS,
762-
None,
763-
cfg.cache_dir,
764-
"changesets",
765-
description="Downloading changesets",
766-
)
767-
_process_all(
791+
_stream_download_process(
768792
urls,
793+
mode="changeset",
794+
cookie=None,
795+
cache_dir=cfg.cache_dir,
796+
window=_stream_window(CHANGESETS_REPLICATION),
769797
target=process_changeset,
770798
initializer=init_changeset_worker,
771799
init_args=(cs_config,),
772800
chunksize=10,
773-
label="changesets",
774801
workers=max_workers,
775-
description="Processing changesets",
802+
label="changesets",
803+
description="Changesets",
776804
)
777805
dbmod.merge_parquet_files(conn, cs_dir, cleanup=True)
778806
upsert_state(
@@ -822,27 +850,22 @@ def run(cfg: RunConfig) -> dict[str, Any]:
822850
cf_config = _processing_config(cfg, parquet_dir=cf_dir, geom_wkt=None)
823851
cf_config["start_date_utc"] = url_start_date_utc
824852

825-
_download_all(
826-
urls,
827-
"changefiles",
828-
_DOWNLOAD_WORKERS,
829-
cookie,
830-
cfg.cache_dir,
831-
"changefiles",
832-
description="Downloading changefiles",
833-
)
834853
chunksize = 10 if "minute" in url.lower() else 1
835854
seq_ids = list(range(src_start_seq, src_end_seq + 1))
836-
_process_all(
855+
_stream_download_process(
837856
urls,
857+
mode="changefiles",
858+
cookie=cookie,
859+
cache_dir=cfg.cache_dir,
860+
window=_stream_window(url),
838861
target=process_changefile,
839862
initializer=init_changefile_worker,
840863
init_args=(valid_changesets, cf_config),
841864
chunksize=chunksize,
842-
label="changefiles",
843865
workers=max_workers,
844-
extra_iterables=(seq_ids,),
845-
description="Processing changefiles",
866+
label="changefiles",
867+
description="Changefiles",
868+
extra_iterable=seq_ids,
846869
)
847870
dbmod.merge_parquet_files(conn, cf_dir, cleanup=True)
848871
# state.last_ts is the seq_ts of last_seq so the next tick's lower-bound filter

tests/test_download_resilience.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,23 @@ def test_download_concurrency_is_capped():
1111
assert pipeline._DOWNLOAD_WORKERS <= 4
1212

1313

14-
def test_download_all_wraps_network_error(monkeypatch, tmp_path):
14+
def test_stream_download_wraps_network_error(monkeypatch, tmp_path):
1515
def boom(url, **kwargs):
1616
raise requests.exceptions.ConnectTimeout("planet timed out")
1717

1818
monkeypatch.setattr(pipeline, "download_osm_file", boom)
1919
with pytest.raises(OsmsgError, match="Re-run to resume"):
20-
pipeline._download_all(
20+
pipeline._stream_download_process(
2121
["https://planet.openstreetmap.org/replication/changesets/007/035/882.osm.gz"],
22-
"changeset",
23-
8,
24-
None,
25-
tmp_path,
26-
"changesets",
22+
mode="changeset",
23+
cookie=None,
24+
cache_dir=tmp_path,
25+
window=250,
26+
target=int,
27+
initializer=None,
28+
init_args=(),
29+
chunksize=1,
30+
workers=1,
31+
label="changesets",
32+
description="Changesets",
2733
)

tests/test_pipeline_smoke.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,7 @@ def fake_changefile_download_urls(_start, _end, _base, *, resume_seq=None, cs_ts
102102
return (["https://fake/101.osc.gz"], fake_server_ts, 101, 101, "u1", "u2")
103103

104104
monkeypatch.setattr(pipeline_mod, "changefile_download_urls", fake_changefile_download_urls)
105-
monkeypatch.setattr(pipeline_mod, "_download_all", lambda *a, **kw: None)
106-
monkeypatch.setattr(pipeline_mod, "_process_all", lambda *a, **kw: None)
105+
monkeypatch.setattr(pipeline_mod, "_stream_download_process", lambda *a, **kw: None)
107106
monkeypatch.setattr(pipeline_mod.dbmod, "merge_parquet_files", lambda *a, **kw: None)
108107
monkeypatch.setattr(pipeline_mod, "changefile_seq_timestamp", lambda _base, _seq: seq_ts)
109108
monkeypatch.setattr(

tests/test_pipeline_stream.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""_stream_download_process streams in overlapping windows: full coverage, seq alignment,
2+
download-before-process ordering, and a bounded on-disk footprint (delete-as-you-go)."""
3+
4+
import pathlib
5+
6+
import pytest
7+
import requests
8+
9+
from osmsg import pipeline
10+
from osmsg.exceptions import OsmsgError
11+
12+
_RESULTS = ""
13+
_CACHE = ""
14+
15+
16+
def _init(results_dir: str, cache_dir: str) -> None:
17+
global _RESULTS, _CACHE
18+
_RESULTS, _CACHE = results_dir, cache_dir
19+
20+
21+
def _probe(url: str, seq_id: int | None = None) -> None:
22+
base = url.rsplit("/", 1)[-1]
23+
raw = pathlib.Path(_CACHE) / base
24+
existed = raw.exists()
25+
on_disk = sum(1 for f in pathlib.Path(_CACHE).iterdir() if f.is_file())
26+
key = seq_id if seq_id is not None else base
27+
(pathlib.Path(_RESULTS) / f"r_{key}").write_text(f"{url}|existed={existed}|on_disk={on_disk}")
28+
if existed:
29+
raw.unlink() # mimic the worker's per-file erase
30+
31+
32+
def _fake_download(url: str, mode: str = "", cookie: str | None = None, cache_dir=None) -> pathlib.Path:
33+
path = pathlib.Path(cache_dir) / url.rsplit("/", 1)[-1]
34+
path.write_text("x")
35+
return path
36+
37+
38+
def _run(urls, extra, window, workers, cache, results) -> None:
39+
pipeline._stream_download_process(
40+
urls,
41+
mode="changefiles",
42+
cookie=None,
43+
cache_dir=pathlib.Path(cache),
44+
window=window,
45+
target=_probe,
46+
initializer=_init,
47+
init_args=(str(results), str(cache)),
48+
chunksize=1,
49+
workers=workers,
50+
label="files",
51+
description="files",
52+
extra_iterable=extra,
53+
)
54+
55+
56+
def test_streams_covered_aligned_and_bounded(monkeypatch, tmp_path):
57+
monkeypatch.setattr(pipeline, "download_osm_file", _fake_download)
58+
cache, results = tmp_path / "cache", tmp_path / "results"
59+
cache.mkdir()
60+
results.mkdir()
61+
62+
count, window = 23, 4
63+
urls = [f"https://x/{i:03d}.osc.gz" for i in range(count)]
64+
seqs = list(range(1000, 1000 + count))
65+
_run(urls, seqs, window, 3, cache, results)
66+
67+
peak = 0
68+
for url, seq in zip(urls, seqs):
69+
body = (results / f"r_{seq}").read_text()
70+
assert body.startswith(url + "|"), f"seq {seq} misaligned: {body}"
71+
assert "existed=True" in body, f"processed before download: {body}"
72+
peak = max(peak, int(body.split("on_disk=")[1]))
73+
74+
assert len(list(results.iterdir())) == count, "every file must be processed exactly once"
75+
assert peak <= 2 * window, f"footprint {peak} exceeds one prefetch window ahead ({2 * window})"
76+
assert not list(cache.iterdir()), "raw files must be erased as they are processed"
77+
78+
79+
def test_streams_without_extra_iterable(monkeypatch, tmp_path):
80+
monkeypatch.setattr(pipeline, "download_osm_file", _fake_download)
81+
cache, results = tmp_path / "cache", tmp_path / "results"
82+
cache.mkdir()
83+
results.mkdir()
84+
urls = [f"https://x/{i:03d}.osm.gz" for i in range(15)]
85+
_run(urls, None, 100, 2, cache, results)
86+
assert len(list(results.iterdir())) == 15
87+
88+
89+
def test_single_window(monkeypatch, tmp_path):
90+
monkeypatch.setattr(pipeline, "download_osm_file", _fake_download)
91+
cache, results = tmp_path / "cache", tmp_path / "results"
92+
cache.mkdir()
93+
results.mkdir()
94+
urls = [f"https://x/{i:03d}.osc.gz" for i in range(3)]
95+
_run(urls, [0, 1, 2], 8, 2, cache, results)
96+
assert len(list(results.iterdir())) == 3
97+
98+
99+
def test_network_error_is_wrapped(monkeypatch, tmp_path):
100+
def boom(url, **kwargs):
101+
raise requests.exceptions.ConnectTimeout("down")
102+
103+
monkeypatch.setattr(pipeline, "download_osm_file", boom)
104+
with pytest.raises(OsmsgError, match="Re-run to resume"):
105+
_run(["https://x/1.osc.gz"], [1], 4, 1, tmp_path / "cache", tmp_path)
106+
107+
108+
def test_stream_window_sizes_by_file_weight():
109+
assert pipeline._stream_window("https://planet/replication/day") == 4
110+
assert pipeline._stream_window("https://planet/replication/hour") == 24
111+
assert pipeline._stream_window("https://planet/replication/minute") == 100
112+
assert pipeline._stream_window("https://planet/replication/changesets/") == 100

0 commit comments

Comments
 (0)