@@ -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
108128def 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
151180def 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