0668493
commit 0668493
2 files changed
+90 -1
Showing 2 changed files+90-1
▼scripts/publish_turso.py
+33-1
| @@ -420,7 +420,39 @@ class TursoPublisher: | ||
420 | 420 | swap.append((f'ALTER TABLE "{table}" RENAME TO "{previous}"', None)) |
421 | 421 | swap.append((f'ALTER TABLE "{shadow}" RENAME TO "{table}"', None)) |
422 | 422 | swap.append((f'DROP TABLE IF EXISTS "{previous}"', None)) |
423 | - self.remote.batch(swap) # type: ignore[union-attr] | |
423 | + try: | |
424 | + self.remote.batch(swap) # type: ignore[union-attr] | |
425 | + except Exception as exc: # noqa: BLE001 | |
426 | + # sqlite names are global, so anything at all sitting under this | |
427 | + # name blocks the rename. The shadow holds a complete copy of the | |
428 | + # rows, so clearing the name first cannot lose data, and the object | |
429 | + # types are logged in case something unexpected turns up. | |
430 | + logger.warning( | |
431 | + "[turso] swap of %s hit %s; clearing the name and retrying", | |
432 | + table, | |
433 | + exc, | |
434 | + ) | |
435 | + logger.warning("[turso] objects named %r: %s", table, self._objects_named(table)) | |
436 | + self._clear_name(table) | |
437 | + self._clear_name(previous) | |
438 | + self.remote.batch( # type: ignore[union-attr] | |
439 | + [(f'ALTER TABLE "{shadow}" RENAME TO "{table}"', None)] | |
440 | + ) | |
441 | + | |
442 | + def _objects_named(self, name: str) -> list[tuple[str, str]]: | |
443 | + """Everything in sqlite_master under this exact name (table or index).""" | |
444 | + rows = self.remote.execute( | |
445 | + "SELECT type, name FROM sqlite_master WHERE name = ?", [name] | |
446 | + ).rows | |
447 | + return [(str(row[0]), str(row[1])) for row in rows] | |
448 | + | |
449 | + def _clear_name(self, name: str) -> None: | |
450 | + """Drop whatever occupies ``name``, whether it is a table or an index.""" | |
451 | + for kind, object_name in self._objects_named(name): | |
452 | + if kind == "index": | |
453 | + self.remote.execute(f'DROP INDEX IF EXISTS "{object_name}"') | |
454 | + else: | |
455 | + self.remote.execute(f'DROP TABLE IF EXISTS "{object_name}"') | |
424 | 456 | |
425 | 457 | def _sync_static(self, table: str) -> int: |
426 | 458 | columns = self._columns(table) |
427 | 459 | |
▼tests/unit/test_turso_sync.py
+57-0
| @@ -269,3 +269,60 @@ def test_table_exists_is_authoritative(tmp_path): | ||
269 | 269 | assert publisher._table_exists("not_a_table") is False |
270 | 270 | finally: |
271 | 271 | publisher._close() |
272 | + | |
273 | + | |
274 | +def test_clear_name_handles_tables_and_stray_indexes(tmp_path): | |
275 | + """A name can be occupied by an index; the swap fallback has to cope.""" | |
276 | + _, target, source = _run(tmp_path) | |
277 | + publisher = TursoPublisher(url=f"file:{target}", token=None, db_path=source) | |
278 | + assert publisher._connect() | |
279 | + try: | |
280 | + publisher.remote.execute("CREATE INDEX stranded_index ON weather (station_icao)") | |
281 | + assert publisher._objects_named("stranded_index") == [("index", "stranded_index")] | |
282 | + publisher._clear_name("stranded_index") | |
283 | + assert publisher._objects_named("stranded_index") == [] | |
284 | + | |
285 | + assert [kind for kind, _ in publisher._objects_named("dim_airport")] == ["table"] | |
286 | + publisher._clear_name("dim_airport") | |
287 | + assert publisher._objects_named("dim_airport") == [] | |
288 | + finally: | |
289 | + publisher._close() | |
290 | + | |
291 | + | |
292 | +def test_swap_recovers_when_the_name_is_already_taken(tmp_path, monkeypatch): | |
293 | + """If the batched swap is rejected, the loaded shadow still lands.""" | |
294 | + _, target, source = _run(tmp_path) | |
295 | + | |
296 | + src = duckdb.connect(str(source)) | |
297 | + src.execute("UPDATE dim_airport SET name = 'Frankfurt Sud'") | |
298 | + src.close() | |
299 | + | |
300 | + publisher = TursoPublisher(url=f"file:{target}", token=None, db_path=source) | |
301 | + assert publisher._connect() | |
302 | + original_batch = publisher.remote.batch | |
303 | + raised = {"done": False} | |
304 | + | |
305 | + def flaky_batch(statements): | |
306 | + # Only the swap (the batch holding ALTER TABLE) is rejected, to mimic a | |
307 | + # name collision; the row writes must keep working. | |
308 | + if not raised["done"] and any("ALTER TABLE" in sql for sql, _ in statements): | |
309 | + raised["done"] = True | |
310 | + raise RuntimeError("Turso error: SQLite error: there is already another table") | |
311 | + return original_batch(statements) | |
312 | + | |
313 | + monkeypatch.setattr(publisher.remote, "batch", flaky_batch) | |
314 | + try: | |
315 | + assert publisher._sync_static("dim_airport") == 1 | |
316 | + finally: | |
317 | + publisher._close() | |
318 | + assert raised["done"], "the swap batch was never rejected; test did not exercise the fallback" | |
319 | + | |
320 | + con = libsql_client.create_client_sync(url=f"file:{target}") | |
321 | + try: | |
322 | + assert con.execute("SELECT name FROM dim_airport").rows[0][0] == "Frankfurt Sud" | |
323 | + leftovers = con.execute( | |
324 | + "SELECT name FROM sqlite_master WHERE name LIKE '%__new' OR name LIKE '%__old'" | |
325 | + ).rows | |
326 | + assert leftovers == [] | |
327 | + finally: | |
328 | + con.close() | |
272 | 329 | |