mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[F108] atomic replace_chunks: single-txn delete+insert closes reindex race
This commit is contained in:
@@ -465,8 +465,16 @@ class BaseIndexPlugin(ABC):
|
||||
)
|
||||
|
||||
if self.replace_on_reingest:
|
||||
await store.delete_by_source(doc.source)
|
||||
await store.add_chunks(chunks_with_embeddings)
|
||||
# Atomic delete + insert in one transaction (F108): the prior
|
||||
# separate delete_by_source + add_chunks awaited on two pool
|
||||
# connections, so concurrent re-indexes of the same source
|
||||
# interleaved and produced duplicate chunk rows. replace_chunks
|
||||
# does both on a single connection inside a transaction, so the
|
||||
# whole replace is atomic (last committer wins, no duplicates, and
|
||||
# a failed insert reverts the delete — no data loss).
|
||||
await store.replace_chunks(doc.source, chunks_with_embeddings)
|
||||
else:
|
||||
await store.add_chunks(chunks_with_embeddings)
|
||||
return len(chunks)
|
||||
|
||||
def _prepare_docs_for_batch(
|
||||
|
||||
@@ -236,6 +236,55 @@ class VectorStore:
|
||||
source,
|
||||
)
|
||||
|
||||
async def replace_chunks(self, source: str, chunks: list[Chunk]) -> None:
|
||||
"""Atomically replace every chunk row for *source* with *chunks*.
|
||||
|
||||
Deletes the source's existing rows and inserts the new embedded chunks
|
||||
on a SINGLE connection inside a SINGLE transaction, so the whole
|
||||
replace is atomic. ``delete_by_source`` + ``add_chunks`` were two
|
||||
separate awaits on two separate pool connections; two concurrent
|
||||
re-indexes of the same source interleaved across those connections and
|
||||
produced duplicate chunk rows, and an insert failure after a
|
||||
successful delete lost the source's index rows. Wrapping both in one
|
||||
transaction closes the race (concurrent replacers serialize on the
|
||||
row locks; the last committer wins with no duplicates) and reverts the
|
||||
delete if the insert fails (no data loss).
|
||||
|
||||
Chunks without an ``embedding`` are silently skipped (matches
|
||||
``add_chunks``); an empty ``chunks`` list still clears the source
|
||||
(matches the prior delete-then-no-op-add behavior).
|
||||
"""
|
||||
records = [
|
||||
(
|
||||
chunk.text,
|
||||
chunk.source,
|
||||
_vec_to_str(chunk.embedding),
|
||||
json.dumps(chunk.metadata or {}),
|
||||
)
|
||||
for chunk in chunks
|
||||
if chunk.embedding is not None
|
||||
]
|
||||
pool = self._require_pool()
|
||||
# One acquire, one transaction: the DELETE and INSERT share a single
|
||||
# connection and commit together (or roll back together on failure).
|
||||
async with pool.acquire() as conn, conn.transaction():
|
||||
await conn.execute(
|
||||
self._q("DELETE FROM {table} WHERE source = $1"),
|
||||
source,
|
||||
)
|
||||
if records:
|
||||
await conn.executemany(
|
||||
self._q(
|
||||
"""
|
||||
INSERT INTO {table}
|
||||
(content, source, embedding, metadata)
|
||||
VALUES
|
||||
($1, $2, $3::vector, $4::jsonb)
|
||||
"""
|
||||
),
|
||||
records,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Read
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user