1010import duckdb
1111import requests
1212
13- from .ui import info , warn
13+ from .ui import info , progress_bar , warn
1414
1515UTC = dt .UTC
1616SCHEMA_VERSION = 1
1717DEFAULT_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
2222class 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
2929class 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
5353def _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
126125def _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
140138def _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.
240224RESUME_SAFETY = dt .timedelta (days = 1 )
241225
242226
243227def 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
0 commit comments