Skip to content

Commit 3a4777f

Browse files
fix(mem): fix roll up to allow runing on small devices
1 parent 05178ea commit 3a4777f

7 files changed

Lines changed: 230 additions & 50 deletions

File tree

frontend/app.js

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -456,17 +456,37 @@ $$(".preset button").forEach(
456456
// Server sort names differ from the table's column keys in one spot.
457457
const SERVER_SORT = { username: "name", map_changes: "map_changes", created: "created", modified: "modified", deleted: "deleted", changesets: "changesets" };
458458
const LEADERBOARD_TIMEOUT_MS = 130_000;
459+
const BUSY_RETRIES = 1;
460+
const BUSY_BACKOFF_MS = 2500;
459461

460462
function endpoint(name, params) {
461463
const base = `/api/v2/hashtag/${encodeURIComponent(state.hashtags.join(","))}/${name}`;
462464
const u = new URL(base, API_BASE);
463465
params.forEach((v, k) => u.searchParams.set(k, v));
464466
return u;
465467
}
468+
function sleep(ms, signal) {
469+
return new Promise((resolve, reject) => {
470+
if (signal?.aborted) return reject(new DOMException("Aborted", "AbortError"));
471+
const t = setTimeout(resolve, ms);
472+
signal?.addEventListener("abort", () => { clearTimeout(t); reject(new DOMException("Aborted", "AbortError")); }, { once: true });
473+
});
474+
}
466475
async function apiGet(name, params, signal) {
467-
const res = await fetch(endpoint(name, params), { headers: { accept: "application/json" }, mode: "cors", signal });
468-
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText || ""}`.trim());
469-
return res.json();
476+
for (let attempt = 0; ; attempt++) {
477+
const res = await fetch(endpoint(name, params), { headers: { accept: "application/json" }, mode: "cors", signal });
478+
if (res.status === 429) {
479+
if (attempt < BUSY_RETRIES) {
480+
await sleep(BUSY_BACKOFF_MS, signal);
481+
continue;
482+
}
483+
const err = new Error("Server is busy");
484+
err.busy = true;
485+
throw err;
486+
}
487+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText || ""}`.trim());
488+
return res.json();
489+
}
470490
}
471491
// Resolve the window once per query so all sections and the window bar share one [start, end); relative
472492
// ranges like 30d must not each recompute "now".
@@ -605,7 +625,13 @@ async function runQuery() {
605625
state.tagRows = tags;
606626
renderOverviewDetails();
607627
} catch (err) {
608-
if (err?.name !== "AbortError") console.warn("OSMSG query failed:", err);
628+
if (err?.name !== "AbortError") {
629+
console.warn("OSMSG query failed:", err);
630+
if (err?.busy && alive()) {
631+
showError(err);
632+
$("#ov-strip-totals").innerHTML = "";
633+
}
634+
}
609635
} finally {
610636
releasePrimary();
611637
if (alive()) {
@@ -1393,6 +1419,16 @@ function showLoading() {
13931419
}
13941420
function showError(err) {
13951421
const tb = $("#lb-body");
1422+
if (err?.busy) {
1423+
tb.innerHTML = `<tr><td colspan="8"><div class="errbox">
1424+
<i data-lucide="hourglass"></i>
1425+
<h3>Server is busy right now</h3>
1426+
<p style="margin-top:8px;color:#717D78">Too many queries are running at once. Give it a moment, then hit Search again.</p>
1427+
</div></td></tr>`;
1428+
$("#pagination").hidden = true;
1429+
refreshIcons(tb);
1430+
return;
1431+
}
13961432
const msg = err?.message || "Network error";
13971433
const isAbort = err?.name === "AbortError";
13981434
tb.innerHTML = `<tr><td colspan="8"><div class="errbox">

osmsg/db/ingest.py

Lines changed: 78 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import math
56
import shutil
67
import sys
78
from pathlib import Path
@@ -112,6 +113,29 @@ def _sql_escape(value: str) -> str:
112113
return value.replace("'", "''")
113114

114115

116+
# Target changesets per merge chunk. A per-tick delta is one chunk (unchanged behaviour); a month-sized
117+
# merge splits into many so each INSERT/UPDATE stays memory-bounded instead of rewriting all rows at once.
118+
_MERGE_CHUNK_ROWS = 200_000
119+
_MERGE_CHUNK_CAP = 64
120+
121+
122+
def _id_ranges(conn: duckdb.DuckDBPyConnection, shard_glob: str) -> list[tuple[int, int]]:
123+
"""Adaptive [low, high) changeset_id ranges over the shards: one range covering everything for a small
124+
merge, several for a large one. Splitting by changeset_id is exact for DISTINCT ON (changeset_id), a
125+
changeset's rows share one id and land in exactly one range."""
126+
row = conn.execute(
127+
f"SELECT count(*), min(changeset_id), max(changeset_id) FROM read_parquet('{shard_glob}')"
128+
).fetchone()
129+
count, low, high = row if row else (0, 0, 0)
130+
if not count:
131+
return []
132+
chunk_count = max(1, min(_MERGE_CHUNK_CAP, math.ceil(count / _MERGE_CHUNK_ROWS)))
133+
if chunk_count == 1:
134+
return [(low, high + 1)]
135+
width = math.ceil((high - low + 1) / chunk_count)
136+
return [(low + i * width, min(high + 1, low + (i + 1) * width)) for i in range(chunk_count)]
137+
138+
115139
def merge_parquet_files(conn: duckdb.DuckDBPyConnection, parquet_dir: Path, *, cleanup: bool = True) -> None:
116140
parquet_dir = Path(parquet_dir)
117141
if not parquet_dir.exists():
@@ -132,57 +156,65 @@ def pattern(name: str) -> str:
132156
if any(parquet_dir.glob("temp_*_changesets_*.parquet")):
133157
conn.execute("INSTALL spatial")
134158
conn.execute("LOAD spatial")
135-
conn.execute(
136-
f"""
137-
INSERT OR IGNORE INTO changesets
138-
SELECT changeset_id, uid, created_at, hashtags, editor,
139-
CASE WHEN min_lon IS NOT NULL
140-
THEN ST_MakeEnvelope(min_lon, min_lat, max_lon, max_lat)
141-
END
142-
FROM read_parquet('{pattern("changesets")}')
143-
"""
144-
)
145-
# Newer non-NULL wins; dedupe src so multiple emits per window don't trip the PK on UPDATE.
146-
conn.execute(
147-
f"""
148-
UPDATE changesets c
149-
SET created_at = COALESCE(src.created_at, c.created_at),
150-
hashtags = COALESCE(src.hashtags, c.hashtags),
151-
editor = COALESCE(src.editor, c.editor),
152-
geom = COALESCE(src.geom, c.geom)
153-
FROM (
154-
SELECT DISTINCT ON (changeset_id)
155-
changeset_id, created_at, hashtags, editor,
159+
shard_glob = pattern("changesets")
160+
for low, high in _id_ranges(conn, shard_glob):
161+
id_range = f"changeset_id >= {low} AND changeset_id < {high}"
162+
conn.execute(
163+
f"""
164+
INSERT OR IGNORE INTO changesets
165+
SELECT changeset_id, uid, created_at, hashtags, editor,
156166
CASE WHEN min_lon IS NOT NULL
157167
THEN ST_MakeEnvelope(min_lon, min_lat, max_lon, max_lat)
158-
END AS geom
159-
FROM read_parquet('{pattern("changesets")}')
160-
ORDER BY changeset_id,
161-
(min_lon IS NOT NULL) DESC,
162-
(editor IS NOT NULL) DESC,
163-
(hashtags IS NOT NULL) DESC,
164-
created_at DESC NULLS LAST
165-
) src
166-
WHERE c.changeset_id = src.changeset_id
167-
AND (src.created_at IS NOT NULL OR src.hashtags IS NOT NULL
168-
OR src.editor IS NOT NULL OR src.geom IS NOT NULL)
169-
"""
170-
)
168+
END
169+
FROM read_parquet('{shard_glob}')
170+
WHERE {id_range}
171+
"""
172+
)
173+
# Newer non-NULL wins; dedupe src so multiple emits per window don't trip the PK on UPDATE.
174+
conn.execute(
175+
f"""
176+
UPDATE changesets c
177+
SET created_at = COALESCE(src.created_at, c.created_at),
178+
hashtags = COALESCE(src.hashtags, c.hashtags),
179+
editor = COALESCE(src.editor, c.editor),
180+
geom = COALESCE(src.geom, c.geom)
181+
FROM (
182+
SELECT DISTINCT ON (changeset_id)
183+
changeset_id, created_at, hashtags, editor,
184+
CASE WHEN min_lon IS NOT NULL
185+
THEN ST_MakeEnvelope(min_lon, min_lat, max_lon, max_lat)
186+
END AS geom
187+
FROM read_parquet('{shard_glob}')
188+
WHERE {id_range}
189+
ORDER BY changeset_id,
190+
(min_lon IS NOT NULL) DESC,
191+
(editor IS NOT NULL) DESC,
192+
(hashtags IS NOT NULL) DESC,
193+
created_at DESC NULLS LAST
194+
) src
195+
WHERE c.changeset_id = src.changeset_id
196+
AND (src.created_at IS NOT NULL OR src.hashtags IS NOT NULL
197+
OR src.editor IS NOT NULL OR src.geom IS NOT NULL)
198+
"""
199+
)
171200
if any(parquet_dir.glob("temp_*_changeset_stats_*.parquet")):
172201
# The shard stores `tags` as a native LIST<STRUCT> (built in the handler), so ingest is a
173202
# direct column copy.
174-
conn.execute(
175-
f"""
176-
INSERT OR IGNORE INTO changeset_stats
177-
SELECT changeset_id, seq_id, uid,
178-
nodes_created, nodes_modified, nodes_deleted,
179-
ways_created, ways_modified, ways_deleted,
180-
rels_created, rels_modified, rels_deleted,
181-
poi_created, poi_modified,
182-
tags
183-
FROM read_parquet('{pattern("changeset_stats")}')
184-
"""
185-
)
203+
shard_glob = pattern("changeset_stats")
204+
for low, high in _id_ranges(conn, shard_glob):
205+
conn.execute(
206+
f"""
207+
INSERT OR IGNORE INTO changeset_stats
208+
SELECT changeset_id, seq_id, uid,
209+
nodes_created, nodes_modified, nodes_deleted,
210+
ways_created, ways_modified, ways_deleted,
211+
rels_created, rels_modified, rels_deleted,
212+
poi_created, poi_modified,
213+
tags
214+
FROM read_parquet('{shard_glob}')
215+
WHERE changeset_id >= {low} AND changeset_id < {high}
216+
"""
217+
)
186218
finally:
187219
conn.execute("SET preserve_insertion_order = true")
188220

osmsg/db/schema.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ def _apply_runtime_pragmas(conn: duckdb.DuckDBPyConnection) -> None:
2626
if temp_directory:
2727
os.makedirs(temp_directory, exist_ok=True)
2828
conn.execute(f"SET temp_directory='{temp_directory.replace(chr(39), chr(39) * 2)}'")
29+
if os.environ.get("OSMSG_DUCKDB_PRESERVE_ORDER", "").lower() in {"false", "0", "no"}:
30+
conn.execute("SET preserve_insertion_order=false")
2931

3032

3133
def connect(db_path: str) -> duckdb.DuckDBPyConnection:

osmsg/maintain/month.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import duckdb
1010

11+
from ..db.schema import _apply_runtime_pragmas
1112
from ..exceptions import OsmsgError
1213
from .parquet import GEOM_COLS, MORTON_MACROS, write_partitions
1314

@@ -69,6 +70,11 @@ def generate_month(year: int, month: int, work: pathlib.Path) -> pathlib.Path:
6970
history_mode="off",
7071
formats=["parquet"],
7172
output_dir=work,
73+
store_only=True,
74+
delete_temp=True,
75+
# One parse worker so only a single day-diff (its stats + osmium node-location index) is resident
76+
# at a time, keeping a month's build within a small-box memory budget. DuckDB still uses all cores.
77+
workers=1,
7278
)
7379
)
7480
return work / f"{name}.duckdb"
@@ -78,6 +84,7 @@ def export_month(db: pathlib.Path, year: int, month: int, out: pathlib.Path) ->
7884
"""Export the month's changefiles/changesets partitions, Morton-sorted, and return their row counts."""
7985
con = duckdb.connect()
8086
con.execute("INSTALL spatial; LOAD spatial; INSTALL json; LOAD json;")
87+
_apply_runtime_pragmas(con)
8188
con.execute(MORTON_MACROS)
8289
con.execute(f"ATTACH '{db}' AS m (READ_ONLY)")
8390
where = f"year(c.created_at)={year} AND month(c.created_at)={month}"

osmsg/maintain/rollup.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import duckdb
88

9+
from ..db.schema import _apply_runtime_pragmas
910
from ..exceptions import OsmsgError
1011
from ..stats import COUNT_COLS as _COUNT_COLS
1112
from .parquet import ROW_GROUP_SIZE
@@ -71,6 +72,7 @@ def build_month_rollups(year: int, month: int, out: pathlib.Path) -> None:
7172
raise OsmsgError(f"missing raw partition for {year:04d}-{month:02d}; export the month first")
7273

7374
con = duckdb.connect()
75+
_apply_runtime_pragmas(con)
7476
con.execute(f"CREATE VIEW cf AS SELECT * FROM read_parquet('{changefiles}')")
7577
con.execute(f"CREATE VIEW cs AS SELECT * FROM read_parquet('{changesets}')")
7678

osmsg/pipeline.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ class RunConfig:
102102
osh_file: str | None = None
103103
changeset_file: str | None = None
104104
overwrite: bool = False
105+
store_only: bool = False
105106

106107

107108
def _resolve_country_urls(countries: list[str]) -> list[str]:
@@ -690,6 +691,9 @@ def run(cfg: RunConfig) -> dict[str, Any]:
690691
existing = dbmod.connect(str(db_path))
691692
if _read_fingerprint(existing) == fingerprint:
692693
info(f"Reusing {db_path} (same query); re-exporting. Pass --overwrite to recompute.")
694+
if cfg.store_only:
695+
dbmod.close(existing)
696+
return {"db_path": str(db_path)}
693697
start_utc = (cfg.start_date or cfg.end_date).astimezone(UTC)
694698
return _finalize(
695699
cfg,
@@ -945,6 +949,11 @@ def run(cfg: RunConfig) -> dict[str, Any]:
945949
else:
946950
start_date_utc = cfg.start_date.astimezone(UTC)
947951

952+
if cfg.store_only:
953+
_store_fingerprint(conn, fingerprint)
954+
dbmod.close(conn)
955+
return {"db_path": str(db_path)}
956+
948957
return _finalize(
949958
cfg,
950959
conn,

tests/test_merge_chunking.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Adaptive chunking in merge_parquet_files: a large merge splits by changeset_id range, one chunk for a
2+
small delta (unchanged), and the chunked result is identical to a single-chunk merge with the metadata
3+
upsert (newest non-NULL wins) and dedup preserved."""
4+
5+
import datetime as dt
6+
7+
import duckdb
8+
9+
from osmsg.db import ingest
10+
from osmsg.db.ingest import _id_ranges, flush_rows_to_parquet, merge_parquet_files
11+
from osmsg.db.schema import create_tables
12+
from osmsg.models import Changeset
13+
14+
UTC = dt.UTC
15+
16+
17+
def _ids_parquet(path, ids):
18+
con = duckdb.connect()
19+
con.execute("CREATE TABLE t(changeset_id BIGINT)")
20+
con.executemany("INSERT INTO t VALUES (?)", [(i,) for i in ids])
21+
con.execute(f"COPY t TO '{path}' (FORMAT parquet)")
22+
con.close()
23+
24+
25+
def test_id_ranges_single_chunk_covers_all(tmp_path):
26+
p = tmp_path / "ids.parquet"
27+
_ids_parquet(p, [10, 20, 30])
28+
con = duckdb.connect()
29+
assert _id_ranges(con, str(p)) == [(10, 31)] # one range, hi = max + 1
30+
con.close()
31+
32+
33+
def test_id_ranges_splits_and_partitions(tmp_path, monkeypatch):
34+
monkeypatch.setattr(ingest, "_MERGE_CHUNK_ROWS", 2)
35+
p = tmp_path / "ids.parquet"
36+
ids = list(range(100, 110)) # 10 rows -> ceil(10/2) = 5 chunks
37+
_ids_parquet(p, ids)
38+
con = duckdb.connect()
39+
ranges = _id_ranges(con, str(p))
40+
con.close()
41+
assert len(ranges) == 5
42+
assert ranges[0][0] == 100 and ranges[-1][1] == 110 # covers [min, max+1)
43+
for (_, a_hi), (b_lo, _) in zip(ranges, ranges[1:], strict=False):
44+
assert a_hi == b_lo # contiguous, no gap or overlap
45+
covered = {i for lo, hi in ranges for i in range(lo, hi)}
46+
assert set(ids) <= covered # every id lands in exactly one range
47+
48+
49+
def _shard(parquet_dir, changesets, batch):
50+
flush_rows_to_parquet(
51+
parquet_dir=parquet_dir,
52+
pid=1,
53+
batch_index=batch,
54+
users=[(c.uid, f"u{c.uid}") for c in changesets],
55+
changesets=[c.to_row() for c in changesets],
56+
)
57+
58+
59+
def _merge_result(tmp_path, sub, chunk_rows, monkeypatch):
60+
monkeypatch.setattr(ingest, "_MERGE_CHUNK_ROWS", chunk_rows)
61+
ids = (100, 5_000, 90_000)
62+
full = [
63+
Changeset(
64+
changeset_id=cid,
65+
uid=1,
66+
created_at=dt.datetime(2026, 7, 1, tzinfo=UTC),
67+
hashtags=["#x"],
68+
editor="iD",
69+
bbox=(0, 0, 1, 1),
70+
)
71+
for cid in ids
72+
]
73+
bare = [Changeset(changeset_id=cid, uid=1) for cid in ids] # same ids, no metadata
74+
pdir = tmp_path / f"parq_{sub}"
75+
_shard(pdir, bare, batch=1) # metadata-less emit first
76+
_shard(pdir, full, batch=2) # newer non-NULL must win
77+
con = duckdb.connect(str(tmp_path / f"{sub}.duckdb"))
78+
create_tables(con)
79+
merge_parquet_files(con, pdir, cleanup=False)
80+
rows = con.execute(
81+
"SELECT changeset_id, editor, hashtags, created_at, geom IS NOT NULL FROM changesets ORDER BY changeset_id"
82+
).fetchall()
83+
con.close()
84+
return rows
85+
86+
87+
def test_chunked_merge_equals_single_and_upserts_metadata(tmp_path, monkeypatch):
88+
single = _merge_result(tmp_path, "single", 10**9, monkeypatch) # one chunk
89+
chunked = _merge_result(tmp_path, "chunked", 1, monkeypatch) # forced many chunks
90+
assert chunked == single # chunking must not change the result
91+
assert [r[0] for r in chunked] == [100, 5_000, 90_000] # one row per changeset (deduped)
92+
assert all(r[1] == "iD" and r[2] == ["#x"] and r[4] for r in chunked) # metadata upserted, not lost

0 commit comments

Comments
 (0)