Skip to content

Commit 2755c27

Browse files
fix(tick): self heal duckdb range
1 parent 67e502c commit 2755c27

2 files changed

Lines changed: 105 additions & 2 deletions

File tree

osmsg/_tick.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
import sys
88
from pathlib import Path
99

10-
from .db import connect, create_tables, get_state
10+
import duckdb
11+
12+
from .db import connect, create_tables, get_state, upsert_state
1113
from .geofabrik import country_update_url
1214
from .replication import resolve_url
1315

@@ -53,6 +55,60 @@ def _reset_store_buffer(db_path: Path) -> None:
5355
conn.close()
5456

5557

58+
def _read_pg_state(dsn: str) -> list[tuple]:
59+
"""Read the resume state rows from the Postgres permanent copy (read-only), used to re-seed a rebuilt
60+
store. The DSN is interpolated into ATTACH, so it must be trusted (same contract as export.psql)."""
61+
conn = duckdb.connect()
62+
try:
63+
conn.execute("INSTALL postgres")
64+
conn.execute("LOAD postgres")
65+
safe_dsn = dsn.replace("'", "''")
66+
conn.execute(f"ATTACH '{safe_dsn}' AS pg (TYPE postgres, READ_ONLY)")
67+
rows = conn.execute("SELECT source_url, last_seq, last_ts, updated_at FROM pg.state").fetchall()
68+
conn.execute("DETACH pg")
69+
finally:
70+
conn.close()
71+
return rows
72+
73+
74+
def _store_is_dirty(db_path: Path) -> bool:
75+
"""A psql tick must start from an empty delta buffer (the last successful push reset it). Leftover data
76+
means the previous push was interrupted, and the abrupt stop can also leave the store's index corrupt.
77+
An unreadable store is treated as dirty so it gets rebuilt rather than crashing the run."""
78+
if not db_path.exists():
79+
return False
80+
try:
81+
conn = connect(str(db_path))
82+
except duckdb.Error:
83+
return True
84+
try:
85+
create_tables(conn)
86+
row = conn.execute("SELECT count(*) FROM changeset_stats").fetchone()
87+
return bool(row) and row[0] > 0
88+
except duckdb.Error:
89+
return True
90+
finally:
91+
conn.close()
92+
93+
94+
def _rebuild_store_from_pg(db_path: Path, dsn: str) -> None:
95+
"""Discard a dirty or corrupt delta buffer and rebuild it fresh, re-seeding the resume state from the
96+
Postgres permanent copy so `--update` continues from the last durably pushed position: no gap, no
97+
double-count (the push is ON CONFLICT DO NOTHING), and a clean index. This is the automatic recovery
98+
that replaces manual store surgery after an interrupted push."""
99+
pg_state = _read_pg_state(dsn)
100+
for path in (db_path, db_path.with_name(db_path.name + ".wal")):
101+
if path.exists():
102+
path.unlink()
103+
conn = connect(str(db_path))
104+
try:
105+
create_tables(conn)
106+
for source_url, last_seq, last_ts, updated_at in pg_state:
107+
upsert_state(conn, source_url=source_url, last_seq=last_seq, last_ts=last_ts, updated_at=updated_at)
108+
finally:
109+
conn.close()
110+
111+
56112
def main() -> int:
57113
extra_args = shlex.split(os.environ.get("OSMSG_EXTRA_ARGS", ""))
58114
bootstrap_days = os.environ.get("OSMSG_BOOTSTRAP_DAYS", "1")
@@ -78,6 +134,12 @@ def main() -> int:
78134
# otherwise --update can't find the state row and the DuckDB gets wiped every tick.
79135
source_url = country_update_url(country) if country and explicit_url is None else resolve_url(url)
80136
db_path = out / f"{name}.duckdb"
137+
psql_dsn = _parse_arg(extra_args, "--psql-dsn")
138+
139+
# Self-heal
140+
if psql_dsn and _store_is_dirty(db_path):
141+
print("[osmsg-tick] store dirty from an interrupted push; rebuilding from Postgres state", flush=True)
142+
_rebuild_store_from_pg(db_path, psql_dsn)
81143

82144
extra_set = set(extra_args)
83145
cmd = ["osmsg"] + extra_args
@@ -99,7 +161,7 @@ def main() -> int:
99161
# With a psql push, Postgres is the permanent copy and the DuckDB store is only a per-tick
100162
# delta buffer. Clear its data (keeping the resume `state`) after a successful push so the store
101163
# stays small and the next push stays fast; otherwise it re-pushes the whole growing store each tick.
102-
if rc == 0 and _parse_arg(extra_args, "--psql-dsn"):
164+
if rc == 0 and psql_dsn:
103165
_reset_store_buffer(db_path)
104166
return rc
105167
finally:

tests/test_tick.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,3 +193,44 @@ def test_reset_store_buffer_clears_data_keeps_state(tmp_path):
193193
row = conn.execute("SELECT source_url, last_seq FROM state").fetchone()
194194
assert row == (SHORTCUTS["minute"], 123)
195195
conn.close()
196+
197+
198+
def test_store_is_dirty_detects_leftover_data(tmp_path):
199+
db_path = tmp_path / "stats.duckdb"
200+
conn = connect(str(db_path))
201+
create_tables(conn)
202+
assert _tick._store_is_dirty(db_path) is False
203+
conn.execute("INSERT INTO changeset_stats (changeset_id, seq_id, uid) VALUES (1, 5, 9)")
204+
conn.close()
205+
assert _tick._store_is_dirty(db_path) is True
206+
207+
208+
def test_store_is_dirty_false_when_store_missing(tmp_path):
209+
assert _tick._store_is_dirty(tmp_path / "absent.duckdb") is False
210+
211+
212+
def test_rebuild_store_from_pg_empties_data_and_reseeds_state(tmp_path, monkeypatch):
213+
"""A dirty store is discarded and rebuilt: data tables empty, resume state taken from Postgres (not
214+
the store's own stale, ahead-of-PG state), so --update resumes from the last durably pushed position."""
215+
db_path = tmp_path / "stats.duckdb"
216+
conn = connect(str(db_path))
217+
create_tables(conn)
218+
conn.execute("INSERT INTO changeset_stats (changeset_id, seq_id, uid) VALUES (1, 7, 9)")
219+
upsert_state(
220+
conn,
221+
source_url=SHORTCUTS["minute"],
222+
last_seq=999,
223+
last_ts=dt.datetime(2026, 8, 1, 9, 0, tzinfo=dt.UTC),
224+
updated_at=dt.datetime(2026, 8, 1, 9, 0, tzinfo=dt.UTC),
225+
)
226+
conn.close()
227+
228+
ts = dt.datetime(2026, 8, 1, 8, 0, tzinfo=dt.UTC)
229+
pg_state = [(SHORTCUTS["minute"], 42, ts, ts)]
230+
monkeypatch.setattr(_tick, "_read_pg_state", lambda dsn: pg_state)
231+
_tick._rebuild_store_from_pg(db_path, "postgresql://ignored")
232+
233+
conn = connect(str(db_path))
234+
assert conn.execute("SELECT count(*) FROM changeset_stats").fetchone()[0] == 0
235+
assert conn.execute("SELECT source_url, last_seq FROM state").fetchone() == (SHORTCUTS["minute"], 42)
236+
conn.close()

0 commit comments

Comments
 (0)