|
7 | 7 | import datetime as dt |
8 | 8 | import hashlib |
9 | 9 | import json |
| 10 | +import multiprocessing |
10 | 11 | import os |
11 | 12 | import shutil |
12 | 13 | from dataclasses import dataclass, field |
@@ -543,49 +544,81 @@ def _processing_config(cfg: RunConfig, *, parquet_dir: Path, geom_wkt: str | Non |
543 | 544 | _DOWNLOAD_WORKERS = 4 |
544 | 545 |
|
545 | 546 |
|
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( |
547 | 559 | urls: list[str], |
| 560 | + *, |
548 | 561 | mode: str, |
549 | | - workers: int, |
550 | 562 | cookie: str | None, |
551 | 563 | 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, |
572 | 565 | target, |
573 | 566 | initializer, |
574 | | - init_args, |
| 567 | + init_args: tuple, |
575 | 568 | chunksize: int, |
576 | | - label: str, |
577 | 569 | workers: int, |
578 | | - extra_iterables: tuple[list, ...] = (), |
579 | | - description: str = "processing", |
| 570 | + label: str, |
| 571 | + description: str, |
| 572 | + extra_iterable: list | None = None, |
580 | 573 | ) -> 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 | + |
581 | 591 | 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. |
583 | 597 | 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, |
586 | 603 | ): |
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 |
589 | 622 |
|
590 | 623 |
|
591 | 624 | def run(cfg: RunConfig) -> dict[str, Any]: |
@@ -755,24 +788,19 @@ def run(cfg: RunConfig) -> dict[str, Any]: |
755 | 788 | cs_config = _processing_config(cfg, parquet_dir=cs_dir, geom_wkt=geom_wkt) |
756 | 789 | cs_config["window_start_utc"] = cfg.start_date.astimezone(UTC) |
757 | 790 |
|
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( |
768 | 792 | urls, |
| 793 | + mode="changeset", |
| 794 | + cookie=None, |
| 795 | + cache_dir=cfg.cache_dir, |
| 796 | + window=_stream_window(CHANGESETS_REPLICATION), |
769 | 797 | target=process_changeset, |
770 | 798 | initializer=init_changeset_worker, |
771 | 799 | init_args=(cs_config,), |
772 | 800 | chunksize=10, |
773 | | - label="changesets", |
774 | 801 | workers=max_workers, |
775 | | - description="Processing changesets", |
| 802 | + label="changesets", |
| 803 | + description="Changesets", |
776 | 804 | ) |
777 | 805 | dbmod.merge_parquet_files(conn, cs_dir, cleanup=True) |
778 | 806 | upsert_state( |
@@ -822,27 +850,22 @@ def run(cfg: RunConfig) -> dict[str, Any]: |
822 | 850 | cf_config = _processing_config(cfg, parquet_dir=cf_dir, geom_wkt=None) |
823 | 851 | cf_config["start_date_utc"] = url_start_date_utc |
824 | 852 |
|
825 | | - _download_all( |
826 | | - urls, |
827 | | - "changefiles", |
828 | | - _DOWNLOAD_WORKERS, |
829 | | - cookie, |
830 | | - cfg.cache_dir, |
831 | | - "changefiles", |
832 | | - description="Downloading changefiles", |
833 | | - ) |
834 | 853 | chunksize = 10 if "minute" in url.lower() else 1 |
835 | 854 | seq_ids = list(range(src_start_seq, src_end_seq + 1)) |
836 | | - _process_all( |
| 855 | + _stream_download_process( |
837 | 856 | urls, |
| 857 | + mode="changefiles", |
| 858 | + cookie=cookie, |
| 859 | + cache_dir=cfg.cache_dir, |
| 860 | + window=_stream_window(url), |
838 | 861 | target=process_changefile, |
839 | 862 | initializer=init_changefile_worker, |
840 | 863 | init_args=(valid_changesets, cf_config), |
841 | 864 | chunksize=chunksize, |
842 | | - label="changefiles", |
843 | 865 | workers=max_workers, |
844 | | - extra_iterables=(seq_ids,), |
845 | | - description="Processing changefiles", |
| 866 | + label="changefiles", |
| 867 | + description="Changefiles", |
| 868 | + extra_iterable=seq_ids, |
846 | 869 | ) |
847 | 870 | dbmod.merge_parquet_files(conn, cf_dir, cleanup=True) |
848 | 871 | # state.last_ts is the seq_ts of last_seq so the next tick's lower-bound filter |
|
0 commit comments