Skip to content

fix: keep in-memory BM25 statistics coherent during concurrent mutations - #13674

Merged
bogdankostic merged 5 commits into
deepset-ai:mainfrom
remote-controlled-man:codex/prflow-40e3c7790953
Nov 11, 2026
Merged

bogdankostic merged 5 commits into
deepset-ai:mainfrom
remote-controlled-man:codex/prflow-40e3c7790953

Conversation

@remote-controlled-man

@remote-controlled-man remote-controlled-man commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Related Issues

No existing issue found for this race.

Proposed Changes:

Concurrent operations on an InMemoryDocumentStore can publish a document before its BM25 statistics are ready. An overlapping overwrite can leave the final content as beta while its statistics describe alpha, so searching for the stored content returns nothing. Deleting or clearing during a write can also leave orphan statistics and change later scores. Concurrent filter-based mutations can raise RuntimeError: dictionary keys changed during iteration or operate on a replacement document that no longer matches the filter.

Serialize write_documents, delete_documents, delete_all_documents, delete_by_filter, update_by_filter and bm25_retrieval with a reentrant lock owned by the index. The filter-based operations hold the lock from selection through mutation. Shared store instances use the same lock; non-shared stores keep their own. Reentrancy preserves the existing overwrite-to-delete and filter-to-delete call paths. Their async methods delegate to these protected methods, and independent indexes continue to run concurrently.

This incorporates Aman Agnihotri's supplied fix, preserving its authorship, and extends its regression to cover both filter mutation methods.

How did you test it?

  • The regression tests cover overwrite/delete/clear, reads during writes, shared instances, custom async executors, and independent indexes.
  • Against the previous PR head 087b6fe, all four new filter-mutation cases failed with the dictionary-iteration error. With the follow-up fix, all four passed, covering deletion and metadata updates with both shared and non-shared stores.
  • hatch run test:unit test/document_stores/test_in_memory.py: 416 passed, 8 skipped. The skips are existing optional index-recreation conformance cases; this backend has no recreate_index / recreate_collection parameter.
  • hatch run test:unit test/components/retrievers/test_in_memory_bm25_retriever.py: 19 passed, 5 integration cases deselected.
  • hatch run test:integration test/components/retrievers/test_in_memory_bm25_retriever.py: 5 passed, 19 unit cases deselected.
  • Scoped hatch run test:types and hatch run fmt-check on the two Python files passed. All applicable pre-commit hooks passed on all three changed files.

All final checks ran on Python 3.12.14 and commit 1202fc9. Full CI matrices, whole-tree checks, API-reference generation and Reno lint remain for CI; no remote CI result is claimed here.

Notes for the reviewer

The original code and tests were generated with OpenAI Codex. This follow-up incorporates Aman's supplied patch and a Codex-authored test extension. Codex ran the reported checks; a separate Codex agent reviewed the frozen change.

The lock serializes the six methods listed above for the same index. Other read APIs and direct mutation of returned Document objects are outside this synchronization contract.

Checklist

  • Read the contribution guidelines and code of conduct.
  • Added regression tests, updated the class docstring, and used a conventional commit title.
  • Added a release note.
  • Completed final checks and pre-commit hooks.

@remote-controlled-man
remote-controlled-man requested a review from a team as a code owner November 10, 2026 17:54
@remote-controlled-man
remote-controlled-man requested review from a team and bogdankostic and a balanced review from Copilot and removed request for a team November 10, 2026 17:54
@vercel

vercel Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

@remote-controlled-man is attempting to deploy a commit to the deepset Team on Vercel.

A member of the Team first needs to authorize it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@github-actions github-actions Bot added topic:tests type:documentation Improvements on the docs labels Nov 10, 2026
@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  haystack/document_stores/in_memory
  document_store.py 684
Project Total  

This report was generated by python-coverage-comment-action

"Invalid filter syntax. See https://docs.haystack.deepset.ai/docs/metadata-filtering for details."
)

@_synchronized

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete_by_filter and update_by_filter still scan self.storage outside the lock, so an overwrite landing mid scan blows up with RuntimeError: dictionary keys changed during iteration, and if it lands between the scan and delete_documents the new version gets deleted even though it no longer matches the filter. lock is reentrant so just wrapping both is enough

+    @_synchronized
     def update_by_filter(self, filters: dict[str, Any], meta: dict[str, Any]) -> int:
@@
+    @_synchronized
     def delete_by_filter(self, filters: dict[str, Any]) -> int:

fixed this plus a test in this commit, you can just cherry pick it instead of redoing it: its-amann@203399a

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this and providing the patch! I've cherry-picked your commit, preserving its authorship, and extended the regression to cover update_by_filter as well as delete_by_filter, with both shared and non-shared stores. Both methods now hold the index's reentrant lock throughout selection and mutation, including the nested delete_documents call.

All four new regression cases failed against the previous PR head and pass with the fix. On 1202fc9, the complete in-memory store module passed (416 passed, 8 existing optional skips), as did the BM25 retriever unit tests (19) and pipeline integration tests (5), scoped type/format checks, and pre-commit hooks. The class documentation, release note and PR description now include both filter methods in the synchronization contract. Remote CI remains to run on the updated head.

@bogdankostic bogdankostic self-assigned this Nov 11, 2026

@bogdankostic bogdankostic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this @remote-controlled-man!

I pushed a commit that also puts the remaining read methods (filter_documents, embedding_retrieval, save_to_disk and the count/metadata methods) under the same lock, since they could still fail when a write landed mid-scan.

@bogdankostic
bogdankostic merged commit 9f9c022 into deepset-ai:main Nov 11, 2026
23 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

topic:tests type:documentation Improvements on the docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants