Skip to content

Commit 9e5fe40

Browse files
fix(fix): convert parquet history length km
1 parent 9dddb6e commit 9e5fe40

30 files changed

Lines changed: 566 additions & 198 deletions

api/duck.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
_pool: queue.Queue | None = None
3232
_pool_lock = threading.Lock()
3333

34+
_QUERY_CACHE_DIR = os.getenv("OSMSG_QUERY_CACHE_DIR") # memoize all-time mega aggregates here; unset -> off
3435
_HISTORY_URL = os.getenv("OSMSG_HISTORY_URL", "hf://datasets/kshitijrajsharma/osmsg-history")
3536
_ROLLUP = os.getenv("OSMSG_ROLLUP_BASE", f"{_HISTORY_URL}/rollup")
3637
_HASHTAG_CHANGESET = os.getenv("OSMSG_HASHTAG_CHANGESET", f"{_ROLLUP}/hashtag_changeset/data.parquet")
@@ -83,6 +84,7 @@ def _sources() -> Sources:
8384
frontier=_frontier(),
8485
users_rel=f"read_parquet('{_USERS}')",
8586
pg_attach=_PG_ATTACH,
87+
cache_dir=_QUERY_CACHE_DIR,
8688
)
8789

8890

api/pg_schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# depends on. Created before the schema, only when absent (PG_TAG_TYPE_EXISTS_SQL): a plain CREATE TYPE
33
# is a recognized write both under asyncpg and DuckDB's postgres_execute, unlike a DO/plpgsql block.
44
PG_TAG_TYPE_EXISTS_SQL = "SELECT 1 FROM pg_type WHERE typname = 'osmsg_tag'"
5-
PG_TAG_TYPE_SQL = "CREATE TYPE osmsg_tag AS (k text, v text, c bigint, m bigint, len_m double precision)"
5+
PG_TAG_TYPE_SQL = "CREATE TYPE osmsg_tag AS (k text, v text, c bigint, m bigint, l double precision)"
66

77
PG_SCHEMA = """
88
CREATE TABLE IF NOT EXISTS users (

docs/length_backfill.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Way length (`l`) and the history backfill
2+
3+
osmsg records the length of open ways per tag. Each tag in the `changeset_stats.tags` struct carries
4+
`l`, the way length in metres (`STRUCT(k, v, c, m, l)`). The API `/tags` endpoint returns `length_m`
5+
per tag and the leaderboard UI shows it as a kilometre badge.
6+
7+
## What gets a length
8+
9+
A way gets a length when it is **open** (its first and last node references differ) and on **create**.
10+
The length is the haversine distance along the way's nodes, attached to every tag of that way. Closed
11+
ways (buildings, areas, roundabouts) get no length. A computed length above `MAX_WAY_LENGTH_M`
12+
(2,000 km) is dropped as a geometry error (for example an early node first uploaded at (0,0) and later
13+
corrected).
14+
15+
Geometry source: osmium's node index keeps each node's first-seen coordinates, so a way is measured
16+
with near-creation-time geometry. On a sample of real ways this sits within about 2% of the exact
17+
creation-time length in aggregate.
18+
19+
## Live path
20+
21+
The worker computes length on every tick: it applies changefiles with node locations on, and measures
22+
open-way creates whose nodes are present in the same diff. A way whose nodes come from an earlier diff
23+
has no location in the current diff and is skipped; the history backfill measures those.
24+
25+
## History backfill
26+
27+
The published history has no length until the datasets are regenerated, because the length needs node
28+
coordinates that the earlier conversion did not read.
29+
30+
`osmsg maintain convert` (and `convert()` in `osmsg/maintain/convert.py`) now streams the history in a
31+
**single pass** with a file-backed node index, so open-way lengths can be measured. The parallel
32+
blob-split path is not used: a way's nodes can live in another split part, and osmium
33+
`add-locations-to-ways` rejects history files, so a global node index in one pass is required.
34+
35+
Run on a large machine:
36+
37+
```
38+
osmsg maintain convert --osh history-latest.osh.pbf --changesets changesets-latest.osm.bz2 \
39+
--start <min> --end <max> --work-dir /data/convert
40+
```
41+
42+
Then republish the two datasets and the rollup to HuggingFace, and bump the manifest.
43+
44+
### Cost
45+
46+
- Disk: the node index is roughly 100-150 GB for the full planet (one entry per node id). Keep it on
47+
the work volume, not the OS disk.
48+
- Time: the single streaming pass is single-threaded. The earlier parallel (24-part) streaming ran in
49+
about 8.4 h; without that parallelism the streaming is substantially longer (plan for the order of a
50+
day), plus aggregation and export. Size the run accordingly.
51+
- Optimisation (not built): pre-build the node index once, then let parallel part-readers use it
52+
read-only. This restores parallelism and is the way to cut the single-pass time.
53+
54+
## Deployment order (load-bearing)
55+
56+
The field rename `len_m` -> `l` changes the schema, so the renamed code and the republished data must
57+
land together. Deploying the renamed reader against data that still has `len_m` fails.
58+
59+
1. Backfill and republish the rollup, changefiles, and changesets with `l`.
60+
2. Migrate the production Postgres: run `docs/migrate_pg_len_to_l.sql` once (renames the `osmsg_tag`
61+
composite attribute). A fresh store self-creates the type with `l`.
62+
3. Deploy the renamed worker and API images.
63+
64+
Until all three are done, the current `len_m` data and the old images stay in place.

docs/migrate_pg_len_to_l.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Rename the tag composite field len_m -> l on an existing Postgres store.
2+
-- Run ONCE before deploying the renamed image (a fresh deploy self-creates the type with `l`).
3+
-- The osmsg_tag composite backs changeset_stats.tags; renaming the attribute keeps existing rows.
4+
ALTER TYPE osmsg_tag RENAME ATTRIBUTE len_m TO l;

docs/rollups.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ Under `hf://datasets/kshitijrajsharma/osmsg-history`:
2121
| `changefiles` | per-changeset counts + native `tags` + `created_at` + centroid | the source of truth for counts |
2222
| `changesets` | per-changeset uid/username/editor/hashtags + centroid | the source of truth for metadata |
2323
| `rollup/hashtag_changeset` | one row per (hashtag, changeset), full breakdown + native `tags` | any hashtag query, exact |
24-
| `rollup/alltime_user` | one row per uid | the all-time leaderboard with no hashtag |
2524
| `rollup/users` | uid to username | display names, joined at read time |
2625

2726
`manifest.json` records `min_month` and `max_month`. The frontier is the first instant after
@@ -31,7 +30,7 @@ Under `hf://datasets/kshitijrajsharma/osmsg-history`:
3130

3231
One row per `(hashtag, changeset_id)`, sorted by lowercased hashtag so a prefix range prunes row
3332
groups. Columns: `hashtag`, `changeset_id`, `uid`, `editor`, `created_at`, the eleven count columns
34-
(`nodes_created``poi_modified`), and `tags`. `tags` is a native `LIST<STRUCT(k, v, c, m, len_m)>`
33+
(`nodes_created``poi_modified`), and `tags`. `tags` is a native `LIST<STRUCT(k, v, c, m, l)>` (`l` is way length in metres)
3534
(pre-exploded tag stats), so the tag breakdown reads a column instead of parsing JSON per query.
3635

3736
Keeping `changeset_id` makes any prefix exact: a query dedups by `changeset_id`, so a changeset that
@@ -99,8 +98,8 @@ and refines from day to hour to minute as it catches up.
9998
## Building and publishing
10099

101100
`osmsg maintain month <YYYY-MM>` builds a finished month: it runs osmsg over the month from day diffs,
102-
exports the `changefiles` and `changesets` partitions, refreshes `hashtag_changeset`, `alltime_user`,
103-
and `users`, and uploads them. It refuses a month that stopped short of its boundary, so published
101+
exports the `changefiles` and `changesets` partitions, refreshes `hashtag_changeset` and `users`,
102+
and uploads them. It refuses a month that stopped short of its boundary, so published
104103
months are complete. Re-running a month rebuilds and overwrites it.
105104

106105
## Consistency

osmsg/catalog.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def recent_tag_agg(attach, *, prefixes, frontier, start=None, end=None) -> str:
9393
inner = (
9494
f"WITH m AS ({m}) "
9595
f"SELECT (t).k AS k, (t).v AS v, sum((t).c) AS creates, sum((t).m) AS modifies, "
96-
f"sum((t).len_m) AS length_m FROM m JOIN changeset_stats s USING (changeset_id), unnest(s.tags) AS t "
96+
f"sum((t).l) AS length_m FROM m JOIN changeset_stats s USING (changeset_id), unnest(s.tags) AS t "
9797
f"GROUP BY (t).k, (t).v"
9898
)
9999
return _pg_query(attach, inner)
@@ -170,7 +170,7 @@ def recent_user_tags(attach, uids: list[int], *, prefixes, frontier, start=None,
170170
uid_list = ", ".join(str(int(u)) for u in uids)
171171
ranges = " OR ".join(f"(lower(h) >= {_pg_str(lo)} AND lower(h) < {_pg_str(hi)})" for lo, hi in prefixes)
172172
inner = (
173-
f"SELECT s.uid AS uid, (t).k AS k, (t).v AS v, sum((t).c) AS c, sum((t).m) AS m, sum((t).len_m) AS len_m "
173+
f"SELECT s.uid AS uid, (t).k AS k, (t).v AS v, sum((t).c) AS c, sum((t).m) AS m, sum((t).l) AS l "
174174
f"FROM changeset_stats s JOIN changesets c USING (changeset_id), unnest(s.tags) AS t "
175175
f"WHERE s.uid IN ({uid_list}) AND {_pg_user_window(frontier, start, end)} "
176176
f"AND EXISTS (SELECT 1 FROM unnest(c.hashtags) AS h WHERE {ranges}) GROUP BY s.uid, (t).k, (t).v"
@@ -223,7 +223,7 @@ def _recent_from_base(stats_rel: str, changesets_rel: str, window_sql: str, pref
223223
),
224224
tag_rows AS (
225225
SELECT changeset_id, t.k AS k, t.v AS v,
226-
SUM(t.c) AS c, SUM(t.m) AS m, SUM(t.len_m) AS len_m
226+
SUM(t.c) AS c, SUM(t.m) AS m, SUM(t.l) AS l
227227
FROM (
228228
SELECT s.changeset_id, UNNEST(s.tags) AS t
229229
FROM {stats_rel} s JOIN matched USING (changeset_id)
@@ -232,7 +232,7 @@ def _recent_from_base(stats_rel: str, changesets_rel: str, window_sql: str, pref
232232
GROUP BY changeset_id, t.k, t.v
233233
),
234234
tags AS (
235-
SELECT changeset_id, list(struct_pack(k := k, v := v, c := c, m := m, len_m := len_m)) AS tags
235+
SELECT changeset_id, list(struct_pack(k := k, v := v, c := c, m := m, l := l)) AS tags
236236
FROM tag_rows GROUP BY changeset_id
237237
)
238238
SELECT m.changeset_id, m.uid, m.editor, m.created_at, {payload}, COALESCE(t.tags, []) AS tags

osmsg/db/duckdb_schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
rels_deleted INTEGER DEFAULT 0,
3030
poi_created INTEGER DEFAULT 0,
3131
poi_modified INTEGER DEFAULT 0,
32-
tags STRUCT(k VARCHAR, v VARCHAR, c BIGINT, m BIGINT, len_m DOUBLE)[],
32+
tags STRUCT(k VARCHAR, v VARCHAR, c BIGINT, m BIGINT, l DOUBLE)[],
3333
PRIMARY KEY (seq_id, changeset_id)
3434
);
3535
CREATE INDEX IF NOT EXISTS idx_changeset_stats_uid ON changeset_stats(uid);

osmsg/db/ingest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@
1111
import pyarrow.parquet as pq
1212

1313
# Native shard column for the per-changeset tag breakdown, matching the store's
14-
# STRUCT(k VARCHAR, v VARCHAR, c BIGINT, m BIGINT, len_m DOUBLE)[] so ingest is a direct copy.
14+
# STRUCT(k VARCHAR, v VARCHAR, c BIGINT, m BIGINT, l DOUBLE)[] so ingest is a direct copy.
1515
_TAG_PA_TYPE = pa.list_(
1616
pa.struct(
1717
[
1818
pa.field("k", pa.string()),
1919
pa.field("v", pa.string()),
2020
pa.field("c", pa.int64()),
2121
pa.field("m", pa.int64()),
22-
pa.field("len_m", pa.float64()),
22+
pa.field("l", pa.float64()),
2323
]
2424
)
2525
)

osmsg/db/queries.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ def _rows(result) -> list[dict[str, Any]]:
1515

1616

1717
def _tags_to_nested(tags: list[dict[str, Any]] | None) -> dict[str, dict[str, dict[str, Any]]]:
18-
"""The native `tags` list (list of {k, v, c, m, len_m}) as the nested {key: {value: {c, m, len}}}
18+
"""The native `tags` list (list of {k, v, c, m, l}) as the nested {key: {value: {c, m, len}}}
1919
shape `_accumulate_tags` sums over. len is omitted when absent."""
2020
out: dict[str, dict[str, dict[str, Any]]] = {}
2121
for t in tags or []:
2222
entry: dict[str, Any] = {"c": t["c"], "m": t["m"]}
23-
if t["len_m"] is not None:
24-
entry["len"] = t["len_m"]
23+
if t["l"] is not None:
24+
entry["len"] = t["l"]
2525
out.setdefault(t["k"], {})[t["v"]] = entry
2626
return out
2727

osmsg/export/psql.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -122,16 +122,20 @@ def _push_changeset_hashtags(conn: duckdb.DuckDBPyConnection, where: str = "") -
122122
)
123123

124124

125-
def _push_chunked(conn: duckdb.DuckDBPyConnection, source: str, push) -> None:
126-
"""Call push() once per changeset_id range so each range commits on its own."""
127-
bounds = conn.execute(f"SELECT min(changeset_id), max(changeset_id) FROM {source}").fetchone()
125+
def _push_chunked(conn: duckdb.DuckDBPyConnection, source: str, push, extra: str = "") -> None:
126+
"""Call push() once per changeset_id range so each range commits on its own, keeping peak memory to
127+
one chunk. `extra` is an extra predicate (no WHERE) ANDed into both the bounds probe and every chunk,
128+
so an incremental push chunks only the rows it will actually push."""
129+
where_extra = f" WHERE {extra}" if extra else ""
130+
bounds = conn.execute(f"SELECT min(changeset_id), max(changeset_id) FROM {source}{where_extra}").fetchone()
128131
if not bounds or bounds[0] is None:
129132
return
130133
lo, hi = bounds
131134
step = (hi - lo) // _BULK_COMMIT_CHUNKS + 1
132135
cursor = lo
133136
while cursor <= hi:
134-
push(conn, f"WHERE changeset_id >= {cursor} AND changeset_id < {cursor + step}")
137+
rng = f"changeset_id >= {cursor} AND changeset_id < {cursor + step}"
138+
push(conn, f"WHERE {rng} AND {extra}" if extra else f"WHERE {rng}")
135139
cursor += step
136140

137141

@@ -215,14 +219,16 @@ def to_psql(conn: duckdb.DuckDBPyConnection, dsn: str, *, bulk_load: bool = Fals
215219
"INSERT INTO pg_target.users SELECT * FROM users "
216220
"WHERE uid IN (SELECT uid FROM changeset_stats WHERE seq_id <> 0) ON CONFLICT DO NOTHING"
217221
)
218-
_push_changesets(conn, f"WHERE {live_ids}")
219-
_push_changeset_stats(conn, "WHERE seq_id <> 0")
220-
_push_changeset_hashtags(conn, f"WHERE {live_ids}")
222+
# Chunked like the bulk path: one INSERT of the whole live tail exceeds the worker's memory
223+
# once the tail grows to a month; per-range commits keep peak memory to a single chunk.
224+
_push_chunked(conn, "changesets", _push_changesets, live_ids)
225+
_push_chunked(conn, "changeset_stats", _push_changeset_stats, "seq_id <> 0")
226+
_push_chunked(conn, "changesets", _push_changeset_hashtags, live_ids)
221227
else:
222228
conn.execute("INSERT INTO pg_target.users SELECT * FROM users ON CONFLICT DO NOTHING")
223-
_push_changesets(conn)
224-
_push_changeset_stats(conn)
225-
_push_changeset_hashtags(conn)
229+
_push_chunked(conn, "changesets", _push_changesets)
230+
_push_chunked(conn, "changeset_stats", _push_changeset_stats)
231+
_push_chunked(conn, "changesets", _push_changeset_hashtags)
226232

227233
conn.execute(
228234
"""

0 commit comments

Comments
 (0)