Skip to content

Commit 80fa7a7

Browse files
fix(chore): cleanup and adds progress bar in the hf download
1 parent f6b7eb6 commit 80fa7a7

15 files changed

Lines changed: 325 additions & 237 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ Any flag works as a YAML key. See [docs/Manual.md](./docs/Manual.md) for the ful
181181
Every run writes `stats.duckdb` (or `<--name>.duckdb`) plus the formats you ask for via
182182
`-f parquet|csv|json|markdown|psql`. Parquet is the default. Open it with duckdb, polars, pandas, anything.
183183

184+
Rerunning the same query with a different `-f` re-exports from the existing `<name>.duckdb` instead of
185+
refetching, so adding a format is instant. Pass `--overwrite` to force a fresh recompute.
186+
184187
## Configuration
185188

186189
Every meaningful flag has a matching `OSMSG_*` env var so the CLI, a `.env` file, and a
@@ -196,6 +199,7 @@ docker-compose `environment:` block all reach the same setting. CLI flag wins ov
196199
| `--cache-dir` | `OSMSG_CACHE_DIR` | platform cache | Where downloaded OSM files are kept across runs. |
197200
| `--output-dir` | `OSMSG_OUTPUT_DIR` | `.` | Where `<name>.duckdb` and exports are written. |
198201
| `--format` / `-f` | `OSMSG_FORMAT` | `parquet` | Repeat for multiple. Comma-separated when set via env. |
202+
| `--overwrite` | (none) | off | Recompute even if `<name>.duckdb` already holds this exact query. |
199203
| `--psql-dsn` | `OSMSG_PSQL_DSN` | unset | libpq DSN for `-f psql`. |
200204
| `--psql-bulk` | `OSMSG_PSQL_BULK` | off | Faster first full load to Postgres. |
201205
| `--history` / `--no-history` | `OSMSG_HISTORY` | on | Read covered months from the published dataset. |

docs/Manual.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ osmsg --last day -f psql --psql-dsn "host=localhost dbname=osm user=osm"
7777
> breakdown is just a query over the four base tables, so consumers derive it on demand instead of
7878
> duplicating data.
7979
80+
`<name>.duckdb` is stamped with the query that built it. Rerunning the same query with a different `-f`
81+
re-exports from that store instead of refetching, so adding a format is instant. Changing any query
82+
parameter (window, hashtags, tags, boundary) recomputes; `--overwrite` forces a fresh recompute.
83+
8084
## Config file
8185

8286
Long invocations are easier to maintain in YAML. Keys mirror the CLI flag names.

osmsg/cli.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,14 @@ def main(
271271
str | None,
272272
typer.Option("--changeset-file", help="Changeset dump (.osm.bz2) paired with --osh-file."),
273273
] = None,
274+
overwrite: Annotated[
275+
bool,
276+
typer.Option(
277+
"--overwrite",
278+
help="Recompute even if <name>.duckdb already holds this exact query; otherwise a rerun "
279+
"that only changes the output format re-exports from the existing store.",
280+
),
281+
] = False,
274282
) -> None:
275283
"""Run osmsg. With no subcommand this generates stats (or loads history with --insert)."""
276284
if ctx.invoked_subcommand is not None:
@@ -338,6 +346,7 @@ def main(
338346
insert=insert,
339347
osh_file=osh_file,
340348
changeset_file=changeset_file,
349+
overwrite=overwrite,
341350
)
342351

343352
if last is not None:

osmsg/export/psql.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,6 @@
55
from ..exceptions import OsmsgError
66
from ..pg_schema import PG_SCHEMA
77

8-
# Secondary indexes and foreign keys that make a row-by-row insert slow. For a one-time bulk load
9-
# they are dropped before the COPY and rebuilt once after (one index build + one FK validation,
10-
# instead of maintaining them per row). Primary keys stay, because the ON CONFLICT upserts need them.
11-
# Indexes are (name, create-sql); foreign keys are (table, name, add-clause).
128
_BULK_INDEXES = [
139
("idx_changesets_created_at", "CREATE INDEX idx_changesets_created_at ON changesets (created_at)"),
1410
("idx_changesets_geom", "CREATE INDEX idx_changesets_geom ON changesets USING GIST (geom)"),
@@ -25,8 +21,6 @@
2521
]
2622

2723

28-
# Bulk loads push the big tables in this many changeset_id ranges, each its own statement and so its
29-
# own commit, so a failure costs one range instead of rolling back the whole multi-GB load.
3024
_BULK_COMMIT_CHUNKS = 32
3125

3226

@@ -102,9 +96,6 @@ def to_psql(conn: duckdb.DuckDBPyConnection, dsn: str, *, bulk_load: bool = Fals
10296
)
10397

10498
if bulk_load:
105-
# Stream rows instead of buffering them to preserve order; buffering 180M+ JSON-bearing
106-
# rows is what exhausts memory in a single INSERT. Then drop the secondary indexes and
107-
# foreign keys so the load does not maintain them per row.
10899
conn.execute("SET preserve_insertion_order = false")
109100
for table, name, _add in _BULK_FKS:
110101
_pg(conn, f"ALTER TABLE {table} DROP CONSTRAINT IF EXISTS {name}")
@@ -114,8 +105,6 @@ def to_psql(conn: duckdb.DuckDBPyConnection, dsn: str, *, bulk_load: bool = Fals
114105
_push_chunked(conn, "changesets", _push_changesets)
115106
_push_chunked(conn, "changeset_stats", _push_changeset_stats)
116107
elif _pg_has_history(conn):
117-
# The history layer (seq_id=0) is already in PG from the bulk load and never changes, so an
118-
# incremental --update pushes only the live layer and its parents, not the 180M history rows.
119108
live_ids = "changeset_id IN (SELECT changeset_id FROM changeset_stats WHERE seq_id <> 0)"
120109
conn.execute(
121110
"INSERT INTO pg_target.users SELECT * FROM users "
@@ -124,7 +113,6 @@ def to_psql(conn: duckdb.DuckDBPyConnection, dsn: str, *, bulk_load: bool = Fals
124113
_push_changesets(conn, f"WHERE {live_ids}")
125114
_push_changeset_stats(conn, "WHERE seq_id <> 0")
126115
else:
127-
# No history in PG (a plain live target): push everything (live rows are all seq_id<>0).
128116
conn.execute("INSERT INTO pg_target.users SELECT * FROM users ON CONFLICT DO NOTHING")
129117
_push_changesets(conn)
130118
_push_changeset_stats(conn)
@@ -141,7 +129,6 @@ def to_psql(conn: duckdb.DuckDBPyConnection, dsn: str, *, bulk_load: bool = Fals
141129
)
142130

143131
if bulk_load:
144-
# Rebuild once, with more memory for the sort-based index builds, then refresh planner stats.
145132
for table, name, add in _BULK_FKS:
146133
_pg(conn, f"ALTER TABLE {table} ADD CONSTRAINT {name} {add}")
147134
for _name, create in _BULK_INDEXES:

osmsg/history.py

Lines changed: 46 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,26 @@
1010
import duckdb
1111
import requests
1212

13-
from .ui import info, warn
13+
from .ui import info, progress_bar, warn
1414

1515
UTC = dt.UTC
1616
SCHEMA_VERSION = 1
1717
DEFAULT_HISTORY_URL = "hf://datasets/kshitijrajsharma/osmsg-history"
18-
HISTORY_SEQ_ID = 0 # sentinel seq_id for rows sourced from the history backfill (no replication seq)
18+
HISTORY_SEQ_ID = 0
1919

2020

2121
@dataclass
2222
class Manifest:
2323
schema_version: int
24-
min_month: dt.datetime # first day of the earliest covered month (UTC)
25-
frontier: dt.datetime # first day of the month AFTER the latest covered month (exclusive bound)
24+
min_month: dt.datetime
25+
frontier: dt.datetime
2626

2727

2828
@dataclass
2929
class WindowSplit:
3030
remote_start: dt.datetime | None
31-
remote_end: dt.datetime | None # exclusive
32-
live_start: dt.datetime # the live diff path handles [live_start, end]
31+
remote_end: dt.datetime | None
32+
live_start: dt.datetime
3333

3434
@property
3535
def has_remote(self) -> bool:
@@ -51,7 +51,6 @@ def has_metadata_filter(self) -> bool:
5151

5252

5353
def _manifest_http_url(history_url: str) -> str:
54-
# hf://datasets/<repo> -> https://huggingface.co/datasets/<repo>/resolve/main/manifest.json
5554
if history_url.startswith("hf://datasets/"):
5655
repo = history_url[len("hf://datasets/") :]
5756
return f"https://huggingface.co/datasets/{repo}/resolve/main/manifest.json"
@@ -78,7 +77,7 @@ def fetch_manifest(history_url: str, timeout: int = 15) -> Manifest | None:
7877
return None
7978
payload = response.json()
8079
else:
81-
with open(url) as handle: # local path (testing / self-hosted mirror)
80+
with open(url) as handle:
8281
payload = json.load(handle)
8382
except (requests.RequestException, OSError, ValueError) as exc:
8483
warn(f"history: manifest unreachable ({type(exc).__name__}); using live path.")
@@ -124,9 +123,8 @@ def _months(start: dt.datetime, end: dt.datetime) -> list[tuple[int, int]]:
124123

125124

126125
def _partition_list(base: str, dataset: str, months: list[tuple[int, int]]) -> str | None:
127-
"""Direct read_parquet() over the dataset's month partitions, or None when none exist. A glob would
128-
make DuckDB list every partition over the HF API. Local bases are filtered to files that exist,
129-
since a converted slice may lack a partition (e.g. a month with metadata but no counted edits)."""
126+
"""Direct read_parquet() over the given month partitions (local bases filtered to existing files),
127+
or None when none exist."""
130128
root = base.rstrip("/")
131129
remote = root.startswith(("hf://", "http://", "https://", "s3://"))
132130
files = [f"{root}/{dataset}/year={year}/month={month}/data.parquet" for (year, month) in months]
@@ -138,9 +136,7 @@ def _partition_list(base: str, dataset: str, months: list[tuple[int, int]]) -> s
138136

139137

140138
def _hashtag_predicate(hashtags: list[str], exact_lookup: bool) -> str:
141-
"""SQL predicate over the changesets `hashtags` list, matching the live ChangesetHandler.
142-
Whole-token (case-insensitive) with exact_lookup, otherwise substring. hashtags are already
143-
canonicalised to a leading '#'."""
139+
"""SQL predicate matching the changesets `hashtags` list: whole-token with exact_lookup, else substring."""
144140
needles = [h.lower() for h in hashtags]
145141
if exact_lookup:
146142
terms = ", ".join(f"'{n}'" for n in needles)
@@ -160,10 +156,6 @@ def ingest_remote(
160156
if split.remote_start is None or split.remote_end is None:
161157
return 0
162158
months = _months(split.remote_start, split.remote_end)
163-
changesets_src = _partition_list(history_url, "changesets", months)
164-
changefiles_src = _partition_list(history_url, "changefiles", months)
165-
if changesets_src is None and changefiles_src is None:
166-
return 0
167159
start_iso = split.remote_start.astimezone(UTC).isoformat()
168160
end_iso = split.remote_end.astimezone(UTC).isoformat()
169161
in_window = f"created_at >= TIMESTAMPTZ '{start_iso}' AND created_at < TIMESTAMPTZ '{end_iso}'"
@@ -172,21 +164,8 @@ def ingest_remote(
172164
conn.execute("INSTALL spatial; LOAD spatial;")
173165
if history_url.startswith(("hf://", "http://", "https://", "s3://")):
174166
conn.execute("INSTALL httpfs; LOAD httpfs;")
175-
# Ride out HF rate-limits on multi-partition reads instead of failing the run.
176167
conn.execute("SET http_retries=10; SET http_retry_wait_ms=2000; SET http_retry_backoff=1.5;")
177168

178-
info(f"history: remote ingest {start_iso} -> {end_iso} ({len(months)} month partitions) from {history_url}")
179-
180-
if changesets_src is not None:
181-
# Names for everyone in the window; every changeset_stats uid has a changeset row here.
182-
conn.execute(
183-
f"""INSERT INTO users
184-
SELECT uid, any_value(username) FROM {changesets_src}
185-
WHERE {in_window} AND username IS NOT NULL
186-
GROUP BY uid
187-
ON CONFLICT (uid) DO NOTHING"""
188-
)
189-
190169
changeset_preds = [in_window]
191170
if filters.hashtags:
192171
changeset_preds.append(_hashtag_predicate(filters.hashtags, filters.exact_lookup))
@@ -199,51 +178,55 @@ def ingest_remote(
199178
changeset_preds.append(f"uid IN (SELECT uid FROM users WHERE username IN ({names}))")
200179
changeset_where = " AND ".join(changeset_preds)
201180

202-
# Always populate changesets: every changeset_stats row needs a parent row (the live path keeps
203-
# this invariant via stubs, and Postgres enforces it as a foreign key). A metadata filter narrows
204-
# which changesets (and thus which stats) are kept; a plain run keeps all in the window.
205-
if changesets_src is not None:
206-
conn.execute(
207-
f"""INSERT INTO changesets
208-
SELECT changeset_id, uid, created_at, hashtags, editor,
209-
CASE WHEN min_lon IS NOT NULL
210-
THEN ST_MakeEnvelope(min_lon, min_lat, max_lon, max_lat) END
211-
FROM {changesets_src} WHERE {changeset_where}
212-
ON CONFLICT (changeset_id) DO NOTHING"""
213-
)
214-
215181
stats_preds = [in_window]
216182
if filters.has_metadata_filter:
217-
# Keep element stats only for changesets that passed the metadata filter above.
218183
stats_preds.append("changeset_id IN (SELECT changeset_id FROM changesets)")
219184
stats_where = " AND ".join(stats_preds)
220185

221-
if changefiles_src is not None:
222-
conn.execute(
223-
f"""INSERT INTO changeset_stats
224-
SELECT changeset_id, {HISTORY_SEQ_ID} AS seq_id, uid,
225-
nodes_created, nodes_modified, nodes_deleted,
226-
ways_created, ways_modified, ways_deleted,
227-
rels_created, rels_modified, rels_deleted,
228-
poi_created, poi_modified, tag_stats
229-
FROM {changefiles_src} WHERE {stats_where}
230-
ON CONFLICT (seq_id, changeset_id) DO NOTHING"""
231-
)
186+
info(f"history: remote ingest {start_iso} -> {end_iso} ({len(months)} month partitions) from {history_url}")
187+
188+
with progress_bar(len(months), unit="months", description="Reading history") as advance:
189+
for month in months:
190+
changesets_src = _partition_list(history_url, "changesets", [month])
191+
changefiles_src = _partition_list(history_url, "changefiles", [month])
192+
if changesets_src is not None:
193+
conn.execute(
194+
f"""INSERT INTO users
195+
SELECT uid, any_value(username) FROM {changesets_src}
196+
WHERE {in_window} AND username IS NOT NULL
197+
GROUP BY uid ON CONFLICT (uid) DO NOTHING"""
198+
)
199+
conn.execute(
200+
f"""INSERT INTO changesets
201+
SELECT changeset_id, uid, created_at, hashtags, editor,
202+
CASE WHEN min_lon IS NOT NULL
203+
THEN ST_MakeEnvelope(min_lon, min_lat, max_lon, max_lat) END
204+
FROM {changesets_src} WHERE {changeset_where}
205+
ON CONFLICT (changeset_id) DO NOTHING"""
206+
)
207+
if changefiles_src is not None:
208+
conn.execute(
209+
f"""INSERT INTO changeset_stats
210+
SELECT changeset_id, {HISTORY_SEQ_ID} AS seq_id, uid,
211+
nodes_created, nodes_modified, nodes_deleted,
212+
ways_created, ways_modified, ways_deleted,
213+
rels_created, rels_modified, rels_deleted,
214+
poi_created, poi_modified, tag_stats
215+
FROM {changefiles_src} WHERE {stats_where}
216+
ON CONFLICT (seq_id, changeset_id) DO NOTHING"""
217+
)
218+
advance()
219+
232220
row = conn.execute(f"SELECT count(*) FROM changeset_stats WHERE seq_id = {HISTORY_SEQ_ID}").fetchone()
233221
return row[0] if row else 0
234222

235223

236-
# Resume one day before the frontier, not at it. A changeset can stay open for up to 24h, so its
237-
# edits can straddle the frontier, and converting a date to a replication sequence is not exact. The
238-
# re-scanned day overlaps the history layer, which the seq_id=0 dedup removes, so this never misses an
239-
# edit and never double counts.
240224
RESUME_SAFETY = dt.timedelta(days=1)
241225

242226

243227
def seed_resume_at(conn: duckdb.DuckDBPyConnection, resume_at: dt.datetime, replication_url: str) -> dt.datetime | None:
244-
"""Seed the `state` table so `osmsg --update` resumes at `resume_at` on `replication_url`. Derives
245-
the replication sequence from the timestamp, so the caller never picks a seq by hand. Returns the
246-
resume timestamp, or None if no sequence resolves at that time."""
228+
"""Seed `state` so `osmsg --update` resumes at `resume_at` on `replication_url`. Returns resume_at,
229+
or None if no sequence resolves."""
247230
from osmium.replication.server import ReplicationServer
248231

249232
from .db.schema import upsert_state

osmsg/maintain/convert.py

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
"""Convert a planet .osh history plus a changeset dump into the changefiles/changesets parquet
2-
datasets, out of core via osmsg's own DuckDB tables. Streams raw per-edit rows to parquet in bounded
3-
batches, then aggregates and joins in DuckDB (a changeset's edits are scattered across the .osh, so an
4-
in-memory pass OOMs at planet scale)."""
2+
datasets, out of core via osmsg's own DuckDB tables."""
53

64
import concurrent.futures as cf
75
import datetime as dt
@@ -20,11 +18,8 @@
2018

2119
BATCH = 1_000_000
2220
CREATE, MODIFY, DELETE = 0, 1, 2
23-
# Out-of-core settings for planet-scale aggregation. Leave headroom below physical RAM; spill to disk.
2421
DUCKDB_MEMORY_LIMIT = "40GB"
2522
DUCKDB_THREADS = 24
26-
# A global GROUP BY over all string-keyed tag rows OOMs even with spill, and json_group_object does
27-
# not spill. Shard raw tags to disk by changeset_id % K, then aggregate each shard independently.
2823
TAG_SHARDS = 64
2924

3025
ELEM_SCHEMA = pa.schema(
@@ -162,9 +157,7 @@ def stream_changesets(dump: str, start: dt.datetime, end: dt.datetime, work: pat
162157

163158

164159
def build_tables(con: duckdb.DuckDBPyConnection, work: pathlib.Path) -> None:
165-
"""Populate osmsg's tables (users, changesets, changeset_stats) from the streamed raw rows. Globs
166-
raw_elements_*/raw_tags_* so single-process and split-parallel runs both work: one global GROUP BY
167-
recombines each changeset's edits across parts."""
160+
"""Populate osmsg's tables (users, changesets, changeset_stats) from the streamed raw rows."""
168161
con.execute("INSTALL json; LOAD json;")
169162
work = pathlib.Path(work)
170163
cs = (work / "raw_changesets.parquet").as_posix()
@@ -209,8 +202,6 @@ def build_tables(con: duckdb.DuckDBPyConnection, work: pathlib.Path) -> None:
209202
a.rels_created, a.rels_modified, a.rels_deleted,
210203
a.poi_created, a.poi_modified"""
211204
for b in range(TAG_SHARDS):
212-
# Insert this shard's agg changesets; attach tag_stats only if the shard has tags (tiny inputs
213-
# and edit-only changesets carry none).
214205
shard_dir = shards / f"shard={b}"
215206
if shard_dir.is_dir():
216207
shard_glob = (shard_dir / "*.parquet").as_posix()
@@ -244,11 +235,8 @@ def build_tables(con: duckdb.DuckDBPyConnection, work: pathlib.Path) -> None:
244235

245236

246237
def export_parquet(con: duckdb.DuckDBPyConnection, out: pathlib.Path) -> None:
247-
"""Materialise the two datasets as persisted tables (a view would re-run the planet-scale joins per
248-
partition; a TEMP table would hold 180M JSON rows in RAM), then write Morton-sorted partitions."""
238+
"""Materialise the two datasets as persisted tables, then write Morton-sorted partitions."""
249239
con.execute(MORTON_MACROS)
250-
# changefiles created_at falls back to the element edit time when the changeset predates the window,
251-
# so in-window edits are never dropped.
252240
con.execute(
253241
f"""CREATE TABLE changefiles_all AS
254242
SELECT s.* EXCLUDE (seq_id),
@@ -292,9 +280,8 @@ def aggregate(work: pathlib.Path, out: pathlib.Path) -> pathlib.Path:
292280
def convert(
293281
osh: str, changesets: str, start: dt.datetime, end: dt.datetime, work_dir: pathlib.Path, parts: int = 1
294282
) -> pathlib.Path:
295-
"""Convert one .osh history + changeset dump to the two parquet datasets under `work_dir/out`.
296-
With parts>1 the history is split at blob boundaries and streamed concurrently. Returns the out
297-
directory holding changefiles/, changesets/, and stats.duckdb."""
283+
"""Convert one .osh history + changeset dump to the two parquet datasets under `work_dir/out`,
284+
returned as a path. With parts>1 the history is split and streamed concurrently."""
298285
work = pathlib.Path(work_dir)
299286
raw = work / "raw"
300287
raw.mkdir(parents=True, exist_ok=True)

osmsg/maintain/month.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@
1212
from .parquet import GEOM_COLS, MORTON_MACROS, write_partitions
1313

1414
UTC = dt.UTC
15-
# Planet-wide edits are continuous, so a complete month reaches within minutes of its end. A larger
16-
# shortfall means the source day diffs did not cover the whole month (a mid-day snapshot or lagging
17-
# replication), so the partition would be published short, the exact gap the read-side backstep masks.
1815
COMPLETENESS_TOLERANCE = dt.timedelta(hours=1)
1916

2017

0 commit comments

Comments
 (0)