77import sys
88from 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
1113from .geofabrik import country_update_url
1214from .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+
56112def 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 :
0 commit comments