swadhinbiswas Push the serving-swap fix and add a fallback for name collisions · 9/16/2026
0668493
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
"""Publish the Gold/serving tables to Turso (libSQL) for the dashboard.
Turso is the site's read layer: it supports **read-only tokens**, which the
browser can safely hold, so analytics and the SQL workbench query it directly
with no API in between. MotherDuck remains the warehouse — this is a derived,
one-way copy.
Write budget is the constraint: the free tier allows a limited number of row
writes per month, so small tables are fully replaced while the two growing
fact tables are synced **incrementally** against a watermark kept in Turso's
``_sync_state`` table.
python -m scripts.publish_turso
python -m scripts.publish_turso --url file:/tmp/x.db # local test
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import math
import time
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
import duckdb
import requests
from config.logging import logger
from config.settings import settings
# Small tables: replaced wholesale every run (cheap, always consistent).
STATIC_TABLES = (
"dim_airport",
"dim_airline",
"dim_aircraft",
"dim_route",
"dim_date",
"dim_fuel",
"fact_emissions",
"fact_notams",
"fact_positions",
"gold_airport_metrics",
"gold_airline_rankings",
"gold_delay_analysis",
"gold_weather_impact",
"gold_seasonal_trends",
"gold_fuel_price_series",
"gold_aircraft_class_mix",
"gold_airport_official_traffic",
"gold_emissions_analysis",
"gold_route_performance",
"gold_sector_analysis",
"gold_notam_summary",
"gold_aircraft_utilization",
"site_stories",
"site_ops",
"site_lineage",
)
# Growing tables: upserted incrementally. table → (primary key, watermark column)
GROWING_TABLES: dict[str, tuple[tuple[str, ...], str]] = {
# collected_at, not actual_arrival: en-route and cancelled flights have no
# arrival, and arrivals are backfilled with older timestamps.
"fact_flights": (("flight_id",), "collected_at"),
"weather": (("station_icao", "timestamp"), "timestamp"),
}
BATCH_SIZE = 400
# Rows per INSERT statement (400 separate statements were the bottleneck).
MULTI_ROW_STATEMENT_ROWS = 100
MAX_ROWS_PER_SYNC = 20_000
# Static tables upload only when their content hash changes. Positions move
# every cycle, so their refresh is capped to protect the free write budget.
STATIC_REFRESH_SECONDS: dict[str, int] = {"fact_positions": 900}
# When a growing table is recreated after a schema change it must be re-seeded;
# the old watermark points past rows that no longer exist.
GROWING_RESET_LOOKBACK_MS: dict[str, int] = {
# Keep re-seeds bounded: Turso's HTTP write path is slow (hundreds of rows
# per second), so a week of flights is a multi-minute step.
"fact_flights": 24 * 3_600_000,
"weather": 12 * 3_600_000,
}
def _sqlite_type(duck_type: str) -> str:
t = (duck_type or "").upper()
if t.startswith(("BOOL",)):
return "INTEGER"
if any(x in t for x in ("INT",)):
return "INTEGER"
if any(x in t for x in ("DOUBLE", "FLOAT", "DECIMAL", "NUMERIC", "REAL")):
return "REAL"
return "TEXT"
def _value(value: Any) -> Any:
"""Convert a DuckDB value into something libSQL accepts."""
if value is None or isinstance(value, (int, float, str)):
return value
if isinstance(value, bool):
return int(value)
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
return json.dumps(value, default=str)
def _epoch_ms(value: Any) -> int:
"""Milliseconds since epoch for a datetime, date or ISO-8601 string.
``collected_at`` reaches DuckDB as a VARCHAR (Silver keeps the ISO text),
while ``timestamp`` is a real TIMESTAMPTZ; the watermark must accept both.
"""
if isinstance(value, datetime):
moment = value if value.tzinfo else value.replace(tzinfo=UTC)
return int(moment.timestamp() * 1000)
if isinstance(value, date):
return int(datetime(value.year, value.month, value.day, tzinfo=UTC).timestamp() * 1000)
text = str(value).strip().replace("Z", "+00:00")
try:
moment = datetime.fromisoformat(text)
except ValueError:
return 0
if moment.tzinfo is None:
moment = moment.replace(tzinfo=UTC)
return int(moment.timestamp() * 1000)
class _Rows:
"""Result wrapper exposing the ``.rows`` attribute the publisher uses."""
def __init__(self, rows: list[list[Any]]) -> None:
self.rows = rows
class _TursoHttpClient:
"""Minimal client for Turso's HTTP pipeline API (``POST /v2/pipeline``).
The ``libsql-client`` Python package is unmaintained and speaks the retired
Hrana v1 protocol (``wss://`` + ``/v1/execute``), which Turso rejects with
HTTP 400. The v2 pipeline is the same protocol ``@libsql/client/web`` uses
in the browser, so the write path and the read path agree.
"""
def __init__(self, url: str, token: str, timeout: float = 60.0) -> None:
base = url
for scheme in ("libsql://", "wss://"):
if base.startswith(scheme):
base = "https://" + base[len(scheme) :]
self._endpoint = base.rstrip("/") + "/v2/pipeline"
self._timeout = timeout
self._session = requests.Session()
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
self._session.headers.update(headers)
@staticmethod
def _arg(value: Any) -> dict[str, Any]:
if value is None:
return {"type": "null"}
if isinstance(value, bool):
return {"type": "integer", "value": "1" if value else "0"}
if isinstance(value, int):
return {"type": "integer", "value": str(value)}
if isinstance(value, float):
# Hrana encodes integers as strings (to dodge JSON precision loss)
# but floats must be JSON numbers, and JSON cannot hold NaN/Inf.
if math.isnan(value) or math.isinf(value):
return {"type": "null"}
return {"type": "float", "value": value}
if isinstance(value, (bytes, bytearray)):
return {"type": "blob", "base64": base64.b64encode(bytes(value)).decode("ascii")}
return {"type": "text", "value": str(value)}
@staticmethod
def _cell(cell: dict[str, Any]) -> Any:
kind = cell.get("type")
if kind == "null":
return None
if kind == "integer":
return int(cell["value"])
if kind == "float":
return float(cell["value"])
if kind == "blob":
return base64.b64decode(cell.get("base64") or "")
return cell.get("value")
@staticmethod
def _statement(sql: str, args: Any) -> dict[str, Any]:
return {
"type": "execute",
"stmt": {
"sql": sql,
"args": [_TursoHttpClient._arg(value) for value in (args or [])],
},
}
def _pipeline(self, payload: list[dict[str, Any]]) -> list[list[Any]]:
response = self._session.post(
self._endpoint,
json={"requests": payload},
timeout=self._timeout,
)
if response.status_code != 200:
raise RuntimeError(f"Turso HTTP {response.status_code}: {response.text[:300]}")
results: list[list[Any]] = []
for entry in response.json().get("results", []):
if entry.get("type") != "ok":
message = (entry.get("error") or {}).get("message", "unknown error")
raise RuntimeError(f"Turso error: {message}")
body = entry.get("response") or {}
if body.get("type") != "execute":
results.append([])
continue
result = body.get("result") or {}
results.append([[self._cell(cell) for cell in row] for row in result.get("rows", [])])
return results
def execute(self, sql: str, args: Any = None) -> _Rows:
rows = self._pipeline([self._statement(sql, args)])
return _Rows(rows[0] if rows else [])
def batch(self, statements: list[tuple[str, Any]]) -> None:
payload = [self._statement(sql, args) for sql, args in statements]
if payload:
self._pipeline(payload)
def close(self) -> None:
self._session.close()
class TursoPublisher:
def __init__(
self, url: str | None = None, token: str | None = None, db_path: Path | None = None
):
self.url = url or settings.turso_database_url
self.token = token or settings.turso_auth_token
self.db_path = Path(db_path or settings.duckdb_path)
self.local: duckdb.DuckDBPyConnection | None = None
self.remote: Any = None
self.counts: dict[str, int] = {}
# ── connections ────────────────────────────────────────────────────────
def _connect(self) -> bool:
if not self.url:
logger.warning("[turso] TURSO_DATABASE_URL not set — skipping")
return False
if not self.db_path.exists():
logger.error("[turso] local warehouse missing: %s", self.db_path)
return False
self.local = duckdb.connect(str(self.db_path), read_only=True)
if self.url.startswith(("file:", "sqlite:")):
# Local libSQL file, used by the tests.
from libsql_client import create_client_sync
kwargs: dict[str, Any] = {"url": self.url}
if self.token:
kwargs["auth_token"] = self.token
self.remote = create_client_sync(**kwargs)
else:
self.remote = _TursoHttpClient(self.url, self.token or "")
return True
def _close(self) -> None:
if self.local is not None:
self.local.close()
self.local = None
if self.remote is not None:
try:
self.remote.close()
except Exception: # noqa: BLE001
pass
self.remote = None
# ── schema ─────────────────────────────────────────────────────────────
def _columns(self, table: str) -> list[tuple[str, str]]:
assert self.local is not None
try:
rows = self.local.execute(f'DESCRIBE "{table}"').fetchall()
except duckdb.Error:
return []
return [(str(r[0]), str(r[1])) for r in rows]
def _table_exists(self, table: str) -> bool:
"""Authoritative existence check, used by every before/after decision.
The swap used to infer existence from ``pragma_table_info`` with a
broad ``except`` that read any transport hiccup as "does not exist". The
outgoing table was then never moved aside and the final rename hit the
name that was still occupied (Turso: "already another table or index
with this name"). sqlite_master cannot fail that way, and a transport
error here raises instead of silently answering no.
"""
result = self.remote.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", [table]
)
return bool(result.rows)
def _sqlite_columns(self, table: str) -> list[str]:
"""Column names for an existing table, or [] when it has no columns yet."""
result = self.remote.execute("SELECT name FROM pragma_table_info(?) ORDER BY cid", [table])
return [str(r[0]) for r in result.rows]
def _ensure_table(self, table: str, columns: list[tuple[str, str]], replace: bool) -> bool:
"""Returns True only when an existing table was recreated (schema change).
A brand-new table must keep the caller's initial watermark; recreating
one with data in it invalidates the stored watermark.
"""
wanted = [c for c, _ in columns]
if not self._table_exists(table):
self._create_table(table, columns)
return False
existing = self._sqlite_columns(table)
if replace or existing != wanted:
self.remote.execute(f'DROP TABLE IF EXISTS "{table}"')
self._create_table(table, columns)
return True
return False
def _create_table(self, table: str, columns: list[tuple[str, str]]) -> None:
defs = ", ".join(f'"{c}" {_sqlite_type(t)}' for c, t in columns)
primary = GROWING_TABLES.get(table, ((), ""))[0]
if primary:
defs += ", PRIMARY KEY (" + ", ".join(f'"{c}"' for c in primary) + ")"
self.remote.execute(f'CREATE TABLE "{table}" ({defs})')
# ── state ──────────────────────────────────────────────────────────────
def _state_get(self, table: str) -> str:
try:
rows = self.remote.execute(
"SELECT watermark FROM _sync_state WHERE table_name = ?", [table]
).rows
return str(rows[0][0]) if rows else ""
except Exception: # noqa: BLE001 - created lazily by the caller
return ""
def _state_set(self, table: str, value: str) -> None:
self.remote.execute(
"INSERT OR REPLACE INTO _sync_state (table_name, watermark) VALUES (?, ?)",
[table, value],
)
def _watermark(self, table: str) -> str:
value = self._state_get(table)
if value.startswith("wm:"):
return value[3:]
# Legacy rows stored a bare epoch; anything else is unusable.
return value if value.isdigit() else "0"
def _set_watermark(self, table: str, watermark: str) -> None:
self._state_set(table, f"wm:{watermark}")
def _static_state(self, table: str) -> tuple[str, int]:
"""Return (content hash, last upload epoch ms) for a static table."""
value = self._state_get(table)
if not value.startswith("static:"):
return "", 0
parts = value.split(":", 2)
if len(parts) != 3:
return "", 0
try:
return parts[2], int(parts[1])
except ValueError:
return "", 0
# ── writes ─────────────────────────────────────────────────────────────
def _write_rows(self, table: str, columns: list[str], rows: list[tuple[Any, ...]]) -> int:
if not rows:
return 0
column_list = ", ".join(f'"{c}"' for c in columns)
row_placeholder = "(" + ", ".join("?" for _ in columns) + ")"
written = 0
for start in range(0, len(rows), BATCH_SIZE):
chunk = rows[start : start + BATCH_SIZE]
# One multi-row INSERT per statement instead of one per row: Turso
# parses far fewer statements, which is what made bulk syncs slow.
# Statements stay modest (request-size safety) and still travel as
# one pipeline request per chunk.
statements = []
for index in range(0, len(chunk), MULTI_ROW_STATEMENT_ROWS):
group = chunk[index : index + MULTI_ROW_STATEMENT_ROWS]
values = ", ".join(row_placeholder for _ in group)
statements.append(
(
f'INSERT OR REPLACE INTO "{table}" ({column_list}) VALUES {values}',
[value for row in group for value in row],
)
)
self.remote.batch(statements) # type: ignore[union-attr]
written += len(chunk)
return written
def _replace_table(
self,
table: str,
columns: list[tuple[str, str]],
names: list[str],
rows: list[tuple[Any, ...]],
) -> None:
"""Load a shadow table and swap it in, so readers never see it empty.
The swap is sent as one pipeline request, which Turso executes as a
single transaction. That closes the window where a crash between the two
renames would leave the table missing, and it means a failure leaves the
outgoing table exactly where it was.
"""
shadow, previous = f"{table}__new", f"{table}__old"
defs = ", ".join(f'"{c}" {_sqlite_type(t)}' for c, t in columns)
self.remote.execute(f'DROP TABLE IF EXISTS "{shadow}"')
self.remote.execute(f'CREATE TABLE "{shadow}" ({defs})')
self._write_rows(shadow, names, rows)
swap: list[tuple[str, Any]] = [(f'DROP TABLE IF EXISTS "{previous}"', None)]
if self._table_exists(table):
swap.append((f'ALTER TABLE "{table}" RENAME TO "{previous}"', None))
swap.append((f'ALTER TABLE "{shadow}" RENAME TO "{table}"', None))
swap.append((f'DROP TABLE IF EXISTS "{previous}"', None))
try:
self.remote.batch(swap) # type: ignore[union-attr]
except Exception as exc: # noqa: BLE001
# sqlite names are global, so anything at all sitting under this
# name blocks the rename. The shadow holds a complete copy of the
# rows, so clearing the name first cannot lose data, and the object
# types are logged in case something unexpected turns up.
logger.warning(
"[turso] swap of %s hit %s; clearing the name and retrying",
table,
exc,
)
logger.warning("[turso] objects named %r: %s", table, self._objects_named(table))
self._clear_name(table)
self._clear_name(previous)
self.remote.batch( # type: ignore[union-attr]
[(f'ALTER TABLE "{shadow}" RENAME TO "{table}"', None)]
)
def _objects_named(self, name: str) -> list[tuple[str, str]]:
"""Everything in sqlite_master under this exact name (table or index)."""
rows = self.remote.execute(
"SELECT type, name FROM sqlite_master WHERE name = ?", [name]
).rows
return [(str(row[0]), str(row[1])) for row in rows]
def _clear_name(self, name: str) -> None:
"""Drop whatever occupies ``name``, whether it is a table or an index."""
for kind, object_name in self._objects_named(name):
if kind == "index":
self.remote.execute(f'DROP INDEX IF EXISTS "{object_name}"')
else:
self.remote.execute(f'DROP TABLE IF EXISTS "{object_name}"')
def _sync_static(self, table: str) -> int:
columns = self._columns(table)
if not columns:
logger.warning("[turso] local table %s missing — skipped", table)
return 0
names = [c for c, _ in columns]
select = ", ".join(f'"{c}"' for c in names)
raw = self.local.execute(f'SELECT {select} FROM "{table}"').fetchall() # type: ignore[union-attr]
rows = [tuple(_value(v) for v in record) for record in raw]
digest = hashlib.sha256(
json.dumps(rows, separators=(",", ":"), default=str).encode("utf-8")
).hexdigest()
previous_digest, last_sync_ms = self._static_state(table)
if previous_digest == digest:
# Identical content: no delete, no insert, no write cost.
return 0
now_ms = int(time.time() * 1000)
refresh_ms = STATIC_REFRESH_SECONDS.get(table, 0) * 1000
if refresh_ms and now_ms - last_sync_ms < refresh_ms:
# Changed, but this table has a refresh floor (positions move every
# cycle; re-publishing them every run wastes the write budget).
return 0
self._replace_table(table, columns, names, rows)
self._state_set(table, f"static:{now_ms}:{digest}")
return len(rows)
def _sync_growing(self, table: str) -> int:
primary, watermark_column = GROWING_TABLES[table]
_ = primary
columns = self._columns(table)
if not columns:
logger.warning("[turso] local table %s missing — skipped", table)
return 0
recreated = self._ensure_table(table, columns, replace=False)
if recreated:
# The old watermark points past rows that no longer exist; re-seed
# a recent window instead of leaving the serving copy empty.
lookback = GROWING_RESET_LOOKBACK_MS.get(table, 24 * 3_600_000)
self._set_watermark(table, str(int(time.time() * 1000) - lookback))
logger.warning("[turso] %s was recreated — re-seeding recent rows", table)
names = [c for c, _ in columns]
select = ", ".join(f'"{c}"' for c in names)
# Watermarks are epoch milliseconds: absolute, so no timezone or
# string-format comparison pitfalls. Strict ">" — a correction to an
# existing flight is re-sent under a newer collected_at, so the same
# flight_id simply upserts.
watermark = self._watermark(table) or "0"
raw = self.local.execute( # type: ignore[union-attr]
f'SELECT {select} FROM "{table}" '
f'WHERE "{watermark_column}" IS NOT NULL '
f'AND epoch_ms(CAST("{watermark_column}" AS TIMESTAMPTZ)) > CAST(? AS BIGINT) '
f'ORDER BY "{watermark_column}" LIMIT {MAX_ROWS_PER_SYNC}',
[watermark],
).fetchall()
if not raw:
return 0
rows = [tuple(_value(v) for v in record) for record in raw]
written = self._write_rows(table, names, rows)
peak = max(
record[names.index(watermark_column)]
for record in raw
if record[names.index(watermark_column)] is not None
)
self._set_watermark(table, str(_epoch_ms(peak)))
logger.info("[turso] %s: +%s rows (watermark %s)", table, written, peak)
return written
def run(self) -> dict[str, int]:
from scripts.publish_site_tables import build_site_payloads, write_site_tables_local
if not self.url:
import os
if os.environ.get("TURSO_DATABASE_URL", "unset") == "":
raise RuntimeError("[turso] TURSO_DATABASE_URL is set but empty — fix the secret")
logger.warning("[turso] TURSO_DATABASE_URL not set — skipping")
return {}
if not self.db_path.exists():
logger.error("[turso] local warehouse missing: %s", self.db_path)
return {}
# Site payloads are computed, not stored: materialise them locally first
# (this needs a write connection, before the read-only one opens).
try:
write_site_tables_local(build_site_payloads(), db_path=self.db_path)
except Exception as exc: # noqa: BLE001
logger.warning("[turso] site payloads unavailable: %s", exc)
if not self._connect():
return {}
try:
self.remote.execute(
"CREATE TABLE IF NOT EXISTS _sync_state "
"(table_name TEXT PRIMARY KEY, watermark TEXT)"
)
for table in STATIC_TABLES:
written = self._sync_static(table)
if written:
self.counts[table] = written
for table in GROWING_TABLES:
written = self._sync_growing(table)
if written:
self.counts[table] = written
logger.info(
"[turso] published %s tables → %s (rows: %s)",
len(self.counts),
(self.url or "").split("?")[0],
sum(self.counts.values()),
)
return self.counts
finally:
self._close()
def publish(
app_settings: Any = None, url: str | None = None, token: str | None = None
) -> dict[str, int]:
_ = app_settings
return TursoPublisher(url=url, token=token).run()
def main() -> int:
from config.logging import setup_logging
setup_logging()
parser = argparse.ArgumentParser(description="Publish serving tables to Turso")
parser.add_argument("--url", default=None, help="override TURSO_DATABASE_URL (file: works)")
parser.add_argument("--token", default=None, help="override TURSO_AUTH_TOKEN")
args = parser.parse_args()
try:
counts = publish(url=args.url, token=args.token)
except Exception as exc: # noqa: BLE001 - never fail the whole workflow
logger.error("[turso] publish failed: %s", exc)
return 1
if not counts:
logger.warning("[turso] nothing published")
return 0
if __name__ == "__main__":
raise SystemExit(main())