MAINT: Batch the seed dedupe lookup when adding seeds to memory - #2523
Open
varunj-msft wants to merge 1 commit into
Open
MAINT: Batch the seed dedupe lookup when adding seeds to memory#2523varunj-msft wants to merge 1 commit into
varunj-msft wants to merge 1 commit into
Conversation
varunj-msft
force-pushed
the
varunj-msft/v1.1.0-Release-Seed-Dedupe-Batching
branch
2 times, most recently
from
September 1, 2026 19:30
7da86bb to
5940b16
Compare
hannahwestra25
approved these changes
Sep 1, 2026
Comment on lines
+3029
to
+3042
| hashes = sorted({prompt.value_sha256 for prompt in seeds if prompt.value_sha256}) | ||
|
|
||
| existing_pairs: set[tuple[str, str]] = set() | ||
| existing_hashes: set[str] = set() | ||
| for index in range(0, len(hashes), _SEED_DEDUPE_CHUNK_SIZE): | ||
| chunk = hashes[index : index + _SEED_DEDUPE_CHUNK_SIZE] | ||
| for existing in self.get_seeds(value_sha256=chunk): | ||
| if not existing.value_sha256: | ||
| continue | ||
| existing_hashes.add(existing.value_sha256) | ||
| if existing.dataset_name: | ||
| existing_pairs.add((existing.value_sha256, existing.dataset_name)) | ||
|
|
||
| return existing_pairs, existing_hashes |
Contributor
There was a problem hiding this comment.
Suggested change
| hashes = sorted({prompt.value_sha256 for prompt in seeds if prompt.value_sha256}) | |
| existing_pairs: set[tuple[str, str]] = set() | |
| existing_hashes: set[str] = set() | |
| for index in range(0, len(hashes), _SEED_DEDUPE_CHUNK_SIZE): | |
| chunk = hashes[index : index + _SEED_DEDUPE_CHUNK_SIZE] | |
| for existing in self.get_seeds(value_sha256=chunk): | |
| if not existing.value_sha256: | |
| continue | |
| existing_hashes.add(existing.value_sha256) | |
| if existing.dataset_name: | |
| existing_pairs.add((existing.value_sha256, existing.dataset_name)) | |
| return existing_pairs, existing_hashes | |
| hashes_by_dataset: dict[str | None, set[str]] = {} | |
| for seed in seeds: | |
| if seed.value_sha256: | |
| # Empty names were previously treated as an unfiltered lookup. | |
| dataset_name = seed.dataset_name or None | |
| hashes_by_dataset.setdefault(dataset_name, set()).add(seed.value_sha256) | |
| existing_pairs: set[tuple[str, str]] = set() | |
| existing_hashes: set[str] = set() | |
| for dataset_name, hashes in hashes_by_dataset.items(): | |
| ordered_hashes = sorted(hashes) | |
| for index in range(0, len(ordered_hashes), _SEED_DEDUPE_CHUNK_SIZE): | |
| chunk = ordered_hashes[index : index + _SEED_DEDUPE_CHUNK_SIZE] | |
| matches = self.get_seeds( | |
| value_sha256=chunk, | |
| dataset_name=dataset_name, | |
| ) | |
| for match in matches: | |
| if not match.value_sha256: | |
| continue | |
| if dataset_name is None: | |
| existing_hashes.add(match.value_sha256) | |
| else: | |
| # SQL has already applied the backend's collation rules. | |
| existing_pairs.add((match.value_sha256, dataset_name)) | |
| return existing_pairs, existing_hashes |
Contributor
There was a problem hiding this comment.
otherwise we're switching from equality in sql to python tuple equality (ie dataset == Dataset in sql)
Comment on lines
+3049
to
+3050
| for index in range(0, len(hashes), self._MAX_BIND_VARS): | ||
| chunk = hashes[index : index + self._MAX_BIND_VARS] |
Contributor
There was a problem hiding this comment.
dataset_binds = 1 if dataset_name is not None else 0
chunk_size = self._MAX_BIND_VARS - dataset_binds
for index in range(0, len(hashes), chunk_size):
chunk = hashes[index : index + chunk_size]
bc we can exceed the backend limit by one since we bind _MAX_BIND_VARS hashes with dataset_name,
add_seeds_to_memory_async ran one SELECT per seed to decide whether that seed
was already stored. value_sha256 is not indexed, so each of those scans the
table, and the cost grows with the number of rows already present. Loading the
default datasets adds roughly 158,000 seeds, where this dominated the whole
load, and it was worse still on every later load because the table was already
full.
Prepare the seeds first, then resolve which hashes are already stored in chunked
lookups instead of per seed. get_seeds issues its statement directly rather than
through the batching helpers, so the lookup has to bound the IN clause itself. It
chunks on _MAX_BIND_VARS, the per-statement bind ceiling each backend already
tunes for itself, so SQL Server gets the 2000 AzureSQLMemory sets rather than a
separate hardcoded limit.
The lookups are grouped by dataset name so the name is still compared by the
database. Equality there is a property of the column's collation: Azure SQL's
default is case-insensitive and also ignores trailing blanks, so an existing
("Dataset") row matched an incoming ("dataset") one before this change.
Comparing the names in Python would impose one fixed rule on every backend and
insert a duplicate wherever the stored spelling differed, which no test would
catch because CI runs on SQLite, whose default collation is case-sensitive.
Since the database decides the match, the pair is keyed by the requested name
rather than the stored one. An empty name filters nothing, exactly like None, so
the two are normalized together to keep the lookup and the comparison agreeing.
The stored keys are snapshotted once, before any comparison, which preserves the
existing behaviour that duplicates within a single call are all inserted; only
what storage held when the call started counts as already present. Seeds without
a dataset name still match their hash in any dataset, matching the unfiltered
lookup they previously performed.
The dataset name takes one of those binds, so the hashes get the ceiling minus one
rather than the whole budget. Binding a full chunk plus the name would have gone
one over the limit each backend declares.
varunj-msft
force-pushed
the
varunj-msft/v1.1.0-Release-Seed-Dedupe-Batching
branch
from
September 1, 2026 23:55
5940b16 to
345829a
Compare
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.
Description
add_seeds_to_memory_asyncran oneSELECTper seed to decide whether that seed was already stored.value_sha256is not indexed, so each of those scans the table, and the cost grows with the number of rows already present — which makes the second load of a dataset far more expensive than the first.Loading the default datasets adds roughly 158,000 seeds, and this lookup dominated the entire load. Measured on
mainagainst a real database:LoadDefaultDatasets.initialize_asynchas no already-loaded short-circuit, so it re-resolves the whole selection every time it runs. A session-scoped backend serving many scenarios therefore pays that second, much worse, cost repeatedly.This prepares the seeds first, then resolves which hashes are already stored using chunked lookups rather than one query per seed, so a full dataset resolves in a handful of queries instead of roughly 158,000. The chunk size of 500 is deliberately well under SQL Server's 2100-parameter ceiling.
Existing behaviour is preserved deliberately, not incidentally:
There is no schema change and no migration here. Adding a bounded type and an index on
value_sha256would be the more complete fix, but that needs an Alembic revision, andalembic checkruns on every memory init, so it is intentionally left as a follow-up rather than landing during a release window. This change is purely query shape and requires no database changes at all.Part of the v1.1.0 release wave with #2510, #2511 and #2512.
Tests and Documentation
Four new tests in
tests/unit/memory/memory_interface/test_interface_seed_prompts.py:test_add_seed_prompts_duplicates_within_one_call_are_all_storedpins the snapshot semantics, which is the subtle way a change here could silently alter behaviour.test_add_seed_prompts_without_dataset_name_matches_any_datasetcovers the unfiltered lookup path.test_add_seed_prompts_dedupes_across_chunk_boundariesuses 1025 seeds, two full chunks plus a partial, then re-adds them with one new seed to confirm only the new one is stored.test_add_seed_prompts_queries_are_batchedis a regression guard: it assertsget_seedsis called exactly once for 50 seeds, so a revert to per-seed lookups fails loudly instead of quietly getting slow again.The existing duplicate-handling tests, same dataset and different datasets, are unchanged and still pass, which is the main evidence that this is a pure performance change.
I also diffed old versus new results across seven edge cases, including 2500 hashes in a single call to confirm the chunking respects the parameter ceiling. The retained and skipped sets were identical in every case.
Ran
pytest tests/unit/memory/memory_interface/test_interface_seed_prompts.py: 70 passed.No documentation changes: the public API and its behaviour are unchanged. The
add_seeds_to_memory_asyncdocstring now states the within-call duplicate rule explicitly, which was previously only implied. JupyText was not run and is not applicable: no notebooks or code samples are affected.