swadhinbiswas

[email protected]
2026-09-16 14:08:32 +0600 committed NaN years ago
0668493
commit 0668493
2 files changed
+90 -1
▼scripts/publish_turso.py
+33-1
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
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