Repository navigation
fix(store): keep duplicate-ID rows intact in backfill and recall metadata writes - #1032
Open
TriDefender wants to merge 1 commit into
Open
TriDefender wants to merge 1 commit into
TriDefender wants to merge 1 commit into
Conversation
…data writes
Two write paths mishandled physical rows that share one memory id:
- backfillSearchTextRows fetched whole duplicate-ID groups per stale id
and passed multiple source rows with the same key to
mergeInsert("id").whenMatchedUpdateAll(), so LanceDB rejected the write
with "Ambiguous merge insert"; even without the rejection a whole-row
update would have overwritten sibling rows' distinct vectors.
- applyManualRecallMetadataBatch deduped its read by id keeping the last
physical row only, then merge-inserted one source row per candidate id
with whenMatchedUpdateAll — LanceDB overwrote every same-id target row
with that single source row, silently collapsing the duplicate group's
distinct vectors to one while reporting success.
Fix:
- Backfill: batches are cut from DISTINCT stale ids, fetched rows are
regrouped by id per batch (doubling as the post-scan duplicate
recheck), ids occurring once keep the existing batched mergeInsert
unchanged, and duplicate ids are rewritten per (text, metadata)
subgroup via table.update({where, values}) touching ONLY search_text,
so vectors and all other fields survive. Undercounts are warned, not
inflated.
- Recall metadata: the read keeps ALL rows per id; candidates are
partitioned per chunk into the unchanged single-row mergeInsert path
(with classifyUncertainMerge) and a new duplicate update path. The
duplicate path merges each row's own metadata snapshot
(parseSmartMetadata derives valid_from/last_accessed_at and
memory_category/fact_key from the row's own timestamp/category), so
subgroups are keyed by (text, old metadata, timestamp, category) and
each table.update carries null-aware id/text/metadata/timestamp/
category matchers plus the scope guard, writing only metadata and the
re-derived search_text — never vectors. rowsUpdated is accounted per
subgroup; thrown or mismatched statements classify per row by re-read
(succeeded/pending/unknown), mirroring classifyUncertainMerge, and
noteDataModification fires once per chunk when data may have changed.
Fixes CortexReach#1031
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1031
What was broken
Two write paths mishandled databases where several physical rows share one memory
id(the reporter had 3,404 rows in 332 duplicate-ID groups, 331 of them same-text/metadata with different vectors):backfillSearchTextRows(used by bothbackfill-search-text --applyand thesearch_textschema migration) fetched the whole duplicate-ID group per stale id and passed multiple source rows with the same key tomergeInsert("id").whenMatchedUpdateAll()— LanceDB rejects that withAmbiguous merge insert: multiple source rows match the same target row. Because backfill commits per batch, a failed run could leave earlier batches already committed.applyManualRecallMetadataBatch(silent data corruption) deduped its read by id keeping the last physical row only, then merge-inserted one source row per candidate id withwhenMatchedUpdateAll— LanceDB overwrote every same-id target row with that single source row, collapsing the duplicate group's distinct vectors to one while returning success (verified by the reporter in a disposable database).Per the issue triage, the duplicate-ID provenance (
importEntryappend /upsertdelete-suppression /reembeddefaults) is intentionally not addressed here — this change only makes both write paths safe when duplicate ids already exist.How it is fixed
backfillSearchTextRows(src/store.ts)updated === staleaccounting on clean runs).mergeInsert("id").whenMatchedUpdateAll()— semantics unchanged for the common case.(text, metadata)subgroup viatable.update({ where, values })touching onlysearch_text(null-awareIS NULLmatchers,escapeSqlLiteralon every literal, asearch_text IS NULL OR != expectedguard). Vectors and all other fields are untouched by construction;updatedcounts actualrowsUpdated, and an undercount emits aconsole.warninstead of inflating the counter.dryRuncontract are unchanged (dry-run stays fully read-only; counters still count stale rows).applyManualRecallMetadataBatch(src/store.ts)row.scope ?? "global"); a candidate with no matching row gets the existing non-retryable scope-changed error.mergeInsertpath includingclassifyUncertainMerge; everything else goes to a new duplicate update path.parseSmartMetadataderivesvalid_from/last_accessed_atfrom the row's own timestamp andmemory_category/fact_keyfrom the row's own category, so subgroups are keyed by(text, old metadata, timestamp, category)and eachtable.updatecarries null-aware id/text/metadata/timestamp/category matchers plus the same scope guard as the merge path.valuescontain onlymetadataand the re-derivedsearch_text(whensearchTextColumnReady) — vectors are never written, so they cannot collapse.rowsUpdatedis accounted per subgroup; a thrown or mismatched statement classifies each row by re-read (succeeded / pending / unknown), mirroringclassifyUncertainMerge's recovery semantics, andnoteDataModification()fires once per chunk whenever data may have changed (keeps FTS index folding fresh).Tests
Two new files following the repo's
node --test+ jiti conventions, synthetic fixtures, float32-exact vectors so preserved vectorsdeepEqualbit-for-bit:test/backfill-search-text-duplicate-ids.test.mjs(5 cases): dry-run read-only, duplicate/mixed groups keep per-row distinct vectors and per-row expectedsearch_text, apostrophe/newline escaping round-trip, and the schema migration path on a pre-upgrade duplicate-ID table.test/manual-recall-duplicate-ids.test.mjs(7 cases): the first tests to drive the realapplyManualRecallMetadataBatchagainst a real LanceDB table — per-row snapshot increments on duplicate groups, mixed-scope groups (only matching rows updated, sibling byte-identical), scope-mismatch errors,mergeInsert/updatefailure injection with recovery classification, two candidates with the same id under different scopes, and a regression case for per-row derived metadata (valid_from) when siblings share text+metadata but differ in timestamp (this one was red against an intermediate implementation and is green now).Regression suites re-run green:
fts-search-text-column7/7,manual-recall-metadata-queue4/4,migrate-legacy-schema2/2,cli-subcommand-attachment22/22,update-consistency-lancedb6/6;tsc --noEmitandnpm run buildclean (dist/src/store.jsrebuilt —dist/is committed in this repo).The issue reporter's own repro script, run against the rebuilt build, prints exactly the reporter's post-fix expectation (the unpatched build prints
Ambiguous merge insert):{"result":{"scanned":2,"stale":2,"updated":2},"rows":2,"distinctVectors":2,"stale":0}Notes
accessCountDeltato rows that were already updated (the queue re-enqueues the original update on retryable errors). Worth an idempotent-delta follow-up.update({ where: "_rowid = …" })is not usable on@lancedb/lancedb0.26.2 (schema planner rejects_rowidin update statements), which is why rows are disambiguated by column matchers rather than rowids.