Skip to content

Commit bb89ab3

Browse files
committed
feat(turso): upgrade sync engine with keyset pagination and checkpointing
1 parent 3fc32dc commit bb89ab3

1 file changed

Lines changed: 100 additions & 43 deletions

File tree

scripts/turso/sync_platform.py

Lines changed: 100 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,29 @@ def report(self) -> dict:
100100
# Vector serialization
101101
# ---------------------------------------------------------------------------
102102

103-
def embedding_to_blob(embedding: list[float]) -> bytes:
104-
"""Serialize a float32 embedding list into a raw BLOB."""
105-
return struct.pack(f"{len(embedding)}f", *embedding)
103+
def embedding_to_blob(embedding) -> bytes:
104+
"""Serialize a float32 embedding into a raw BLOB.
105+
Handles all formats from Supabase PostgREST:
106+
- flat list: [0.1, 0.2, ...]
107+
- nested list: [[0.1, 0.2, ...]]
108+
- string: "[0.1, 0.2, ...]"
109+
"""
110+
if isinstance(embedding, str):
111+
# PostgreSQL vector returned as "[0.1, 0.2, ...]"
112+
try:
113+
# Strip brackets and split
114+
cleaned = embedding.strip("[]").split(",")
115+
embedding = [float(v.strip()) for v in cleaned if v.strip()]
116+
except Exception:
117+
embedding = json.loads(embedding)
118+
119+
if not embedding:
120+
return b""
121+
122+
if isinstance(embedding[0], list):
123+
embedding = embedding[0]
124+
125+
return struct.pack(f"{len(embedding)}f", *[float(v) for v in embedding])
106126

107127

108128
def blob_to_embedding(blob: bytes) -> list[float]:
@@ -133,44 +153,63 @@ def compute_partition_hash(rows: list[dict]) -> str:
133153
# Sync State persistence
134154
# ---------------------------------------------------------------------------
135155

136-
def load_sync_cursor(turso: libsql.Connection) -> datetime:
137-
"""Load last synced_at cursor from Turso sync_state table."""
156+
def load_sync_cursor(turso: libsql.Connection) -> tuple[datetime, str | None, str | None]:
157+
"""Load last sync state from Turso sync_state table."""
158+
try:
159+
# Ensure columns exist for keyset resumption
160+
turso.execute("ALTER TABLE sync_state ADD COLUMN last_created_at TEXT").fetchall()
161+
turso.execute("ALTER TABLE sync_state ADD COLUMN last_id TEXT").fetchall()
162+
except Exception:
163+
pass # Columns likely exist
164+
138165
try:
139166
rows = turso.execute(
140-
"SELECT last_synced_at FROM sync_state WHERE id = 1"
167+
"SELECT last_synced_at, last_created_at, last_id FROM sync_state WHERE id = 1"
141168
).fetchall()
142169
if rows:
143-
ts = rows[0][0]
144-
return datetime.fromisoformat(ts).replace(tzinfo=timezone.utc)
170+
ts, last_created_at, last_id = rows[0]
171+
dt = datetime.fromisoformat(ts).replace(tzinfo=timezone.utc)
172+
return dt, last_created_at, last_id
145173
except Exception as e:
146174
log.warning(f"Could not read sync_state: {e}")
147-
# Default: sync last 24h on first run
148-
return datetime.now(timezone.utc) - timedelta(hours=24)
175+
176+
# Default: sync last 24h
177+
return datetime.now(timezone.utc) - timedelta(hours=24), None, None
149178

150179

151180
def save_sync_cursor(
152181
turso: libsql.Connection,
153182
cursor: datetime,
154183
metrics: SyncMetrics,
184+
last_created_at: str | None = None,
185+
last_id: str | None = None,
155186
) -> None:
156187
"""Persist the sync cursor and run stats into Turso."""
157188
turso.execute(
158189
"""
159-
INSERT INTO sync_state (id, last_synced_at, last_run_at, rows_processed, rows_failed, checksum_mismatches)
160-
VALUES (1, ?, ?, ?, ?, ?)
190+
INSERT INTO sync_state (
191+
id, last_synced_at, last_run_at,
192+
rows_processed, rows_failed, checksum_mismatches,
193+
last_created_at, last_id
194+
)
195+
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
161196
ON CONFLICT(id) DO UPDATE SET
162197
last_synced_at = excluded.last_synced_at,
163198
last_run_at = excluded.last_run_at,
164199
rows_processed = rows_processed + excluded.rows_processed,
165200
rows_failed = rows_failed + excluded.rows_failed,
166-
checksum_mismatches = checksum_mismatches + excluded.checksum_mismatches
201+
checksum_mismatches = checksum_mismatches + excluded.checksum_mismatches,
202+
last_created_at = excluded.last_created_at,
203+
last_id = excluded.last_id
167204
""",
168205
(
169206
cursor.isoformat(),
170207
datetime.now(timezone.utc).isoformat(),
171208
metrics.rows_processed,
172209
metrics.rows_failed,
173210
metrics.checksum_mismatches,
211+
last_created_at,
212+
last_id,
174213
),
175214
)
176215
turso.commit()
@@ -184,23 +223,31 @@ def fetch_page_from_supabase(
184223
supabase: Client,
185224
since: str,
186225
limit: int,
187-
offset: int,
226+
last_created_at: str | None = None,
227+
last_id: str | None = None,
188228
) -> list[dict]:
189229
"""
190-
Paginated fetch from Supabase with overlap-window cursor.
191-
Includes soft-deleted rows (deleted_at IS NOT NULL) for tombstone propagation.
230+
Paginated fetch from Supabase with Keyset Pagination (created_at, id).
231+
This avoids the performance and offset limits of standard pagination.
192232
"""
193-
result = (
194-
supabase.table("knowledge_chunks")
195-
.select(
196-
"id, document_id, content, metadata, embedding, content_hash, "
197-
"created_at, updated_at, deleted_at, "
198-
"fts_tokens_simple" # used to derive topic hints if needed
233+
query = supabase.table("knowledge_chunks").select(
234+
"id, document_id, content, metadata, embedding, content_hash, "
235+
"created_at, deleted_at"
236+
)
237+
238+
if last_created_at and last_id:
239+
# Keyset logic: (created_at > last) OR (created_at == last AND id > last)
240+
query = query.or_(
241+
f"created_at.gt.{last_created_at},"
242+
f"and(created_at.eq.{last_created_at},id.gt.{last_id})"
199243
)
200-
.gte("updated_at", since)
201-
.order("updated_at", desc=False)
202-
.order("id", desc=False) # stable secondary sort
203-
.range(offset, offset + limit - 1)
244+
else:
245+
query = query.gte("created_at", since)
246+
247+
result = (
248+
query.order("created_at", desc=False)
249+
.order("id", desc=False)
250+
.limit(limit)
204251
.execute()
205252
)
206253
return result.data or []
@@ -298,7 +345,7 @@ def upsert_batch(
298345
row.get("content_hash", ""),
299346
sync_hash,
300347
row.get("created_at"),
301-
row.get("updated_at"),
348+
row.get("created_at"), # no updated_at on source — use created_at
302349
is_deleted,
303350
row.get("deleted_at"),
304351
),
@@ -332,20 +379,20 @@ def validate_partition(
332379
since_s = since.isoformat()
333380
until_s = until.isoformat()
334381

335-
# Fetch source hashes
382+
# Fetch source hashes (use created_at — no updated_at on knowledge_chunks)
336383
src = (
337384
supabase.table("knowledge_chunks")
338385
.select("id, content_hash")
339-
.gte("updated_at", since_s)
340-
.lt("updated_at", until_s)
386+
.gte("created_at", since_s)
387+
.lt("created_at", until_s)
341388
.is_("deleted_at", "null")
342389
.execute()
343390
).data or []
344391

345392
# Fetch replica hashes
346393
replica_rows = turso.execute(
347394
"SELECT id, content_hash FROM knowledge_chunks_platform "
348-
"WHERE updated_at >= ? AND updated_at < ? AND is_deleted = 0",
395+
"WHERE created_at >= ? AND created_at < ? AND is_deleted = 0",
349396
(since_s, until_s),
350397
).fetchall()
351398
replica = [{"id": r[0], "content_hash": r[1]} for r in replica_rows]
@@ -404,10 +451,13 @@ def run_sync(full_sync: bool = False, validate_only: bool = False) -> SyncMetric
404451
if full_sync:
405452
# Sync from the beginning of time
406453
cursor = datetime(2020, 1, 1, tzinfo=timezone.utc)
454+
last_created_at = None
455+
last_id = None
407456
log.info("Full sync requested — fetching all records.")
408457
else:
409-
cursor = load_sync_cursor(turso)
458+
cursor, last_created_at, last_id = load_sync_cursor(turso)
410459

460+
# For full sync, start from a time far in the past
411461
since = cursor - timedelta(minutes=OVERLAP_WINDOW_MINUTES)
412462
now = datetime.now(timezone.utc)
413463
log.info(f"Sync window: {since.isoformat()}{now.isoformat()}")
@@ -418,9 +468,8 @@ def run_sync(full_sync: bool = False, validate_only: bool = False) -> SyncMetric
418468
log.info(f"Validation complete: {metrics.report()}")
419469
return metrics
420470

421-
# Incremental sync with adaptive batching
471+
# Keyset pagination with adaptive batching
422472
batch_size = BATCH_SIZE_INIT
423-
offset = 0
424473
error_streak = 0
425474

426475
while True:
@@ -429,7 +478,9 @@ def run_sync(full_sync: bool = False, validate_only: bool = False) -> SyncMetric
429478
for attempt in range(RETRY_ATTEMPTS):
430479
try:
431480
page = fetch_page_from_supabase(
432-
supabase, since.isoformat(), batch_size, offset
481+
supabase, since.isoformat(), batch_size,
482+
last_created_at=last_created_at,
483+
last_id=last_id
433484
)
434485
error_streak = 0
435486
break
@@ -439,7 +490,7 @@ def run_sync(full_sync: bool = False, validate_only: bool = False) -> SyncMetric
439490
log.warning(f"Fetch failed (attempt {attempt + 1}): {e}. Retrying in {wait}s.")
440491
time.sleep(wait)
441492
else:
442-
log.error(f"Failed to fetch page at offset={offset} after {RETRY_ATTEMPTS} attempts. Stopping.")
493+
log.error(f"Failed to fetch page after {RETRY_ATTEMPTS} attempts. Stopping.")
443494
break
444495

445496
if not page:
@@ -448,20 +499,26 @@ def run_sync(full_sync: bool = False, validate_only: bool = False) -> SyncMetric
448499

449500
try:
450501
upsert_batch(turso, page, metrics)
451-
except Exception:
502+
# Update cursors for keyset pagination
503+
last_created_at = page[-1]["created_at"]
504+
last_id = page[-1]["id"]
505+
506+
# Per-batch checkpointing (allow pausing/resuming)
507+
save_sync_cursor(turso, since, metrics, last_created_at, last_id)
508+
metrics.rows_processed = 0 # reset for cumulative storage in sync_state
509+
510+
except Exception as e:
452511
error_streak += 1
453512
# Adaptive batch size: shrink on errors
454513
batch_size = max(BATCH_SIZE_MIN, batch_size // 2)
455-
log.warning(f"Reduced batch size to {batch_size} after error.")
456-
457-
offset += len(page)
514+
log.warning(f"Reduced batch size to {batch_size} after error: {e}")
458515

459516
# Adaptive batch size: grow on success streak
460517
if error_streak == 0 and batch_size < BATCH_SIZE_MAX:
461518
batch_size = min(BATCH_SIZE_MAX, int(batch_size * 1.5))
462519

463-
# Save new cursor to now
464-
save_sync_cursor(turso, now, metrics)
520+
# Save final cursor
521+
save_sync_cursor(turso, now, metrics, last_created_at, last_id)
465522

466523
# Run partition validation for the synced window
467524
log.info("Running post-sync integrity validation...")
@@ -473,7 +530,7 @@ def run_sync(full_sync: bool = False, validate_only: bool = False) -> SyncMetric
473530
turso.commit()
474531

475532
report = metrics.report()
476-
log.info(f"Sync metrics: {json.dumps(report, indent=2)}")
533+
log.info(f"Sync completed. Metrics: {json.dumps(report, indent=2)}")
477534
return metrics
478535

479536

0 commit comments

Comments
 (0)