Skip to content

[vector store scale-out 5/6] Move the Milvus store onto the collection registry, against a Milvus server - #1736

Merged
edwinyyyu merged 37 commits into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:feat/vector-store-milvus-registry-speedkick
Oct 9, 2026
Merged

edwinyyyu merged 37 commits into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:feat/vector-store-milvus-registry-speedkick

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Purpose of the change

Summary

The Milvus store moves onto the collection registry, against a Milvus server, so any number of server processes sharing its registry may serve its collections. The store was rewritten rather than adapted, so the move is one commit; its message lists each change.

  • Catalog: the collection registry in the relational database MilvusConf.collection_registry names (required). The registry collections, whose insert let two creators of one name both succeed, and the process-local locks go.
  • Schema:
    • The incarnation is the partition key, with partitionkey.isolation, and part of the primary key, "{incarnation}:{record_uuid}".
    • Each declared property is a nullable typed field (_p_<name>; a datetime is a TIMESTAMPTZ holding its UTC instant, which is all a filter compares; the segment store keeps the offset). Undeclared properties go in one JSON field, where a filter compares one only with values of a comparable type, by its type tag, as it does a declared field: an int compares with an int or a float, a float only with a float.
    • Negation is the complement, as on Qdrant.
  • Index:
    • HNSW_SQ with AUTOINDEX's own parameters (M=18, efConstruction=240, 4-bit codes rescored against FP16), named so that every server builds it.
    • A search sets only refine_k = 8. On the 100k-row tenant of a 180k-vector test set (Milvus 2.6.24), recall@10 is 0.988-0.990, against 0.73 with no search parameters, at 2.2-2.3 ms per search.
    • Scores are the server's.
    • Cosine, inner product, and Euclidean are the metrics; another is refused before the registry reserves the name.
  • Client: AsyncMilvusClient, with every request bounded by MilvusConf.request_timeout_seconds. Under the synchronous client on asyncio.to_thread, with 32 Strong gets in flight beside 8 searchers, slow reads exhausted the shared executor: 12-21 searches per second, against 670-752 on the async client.
  • Consistency: reads run at Bounded, the level the store names when it creates a native collection, at most the server's common.gracefulTime behind. Session had stalled every search behind the same process's writes (p99 5.3-5.8 s, against 39-58 ms at Bounded). MilvusConf.consistency_level goes.
  • Upsert: a batch Milvus refuses as over its request size limit (proxy.grpc.serverMaxRecvSize, gRPC's RESOURCE_EXHAUSTED status) is halved until its halves fit or a single entity is refused, as the Qdrant store does; any other error raises at once. The milvus extra declares grpcio for the status.
  • Purge: a round lists and deletes up to MilvusConf.purge_batch_size (10,000) primary keys. A delete raises unless Milvus accepted every key sent. A round first indexes and loads a native collection that a failed creation left part-made, since Milvus answers the listing query only on a loaded collection. That step is a deliberate stopgap: it adds about 2 ms to a round (see Verification), and [vector store scale-out 6] Give each vector store a fixed schema and string-keyed partitions, built from the deployment's configuration #1627 removes it. There the store creates, indexes, and loads its one native collection in startup, so no purge round meets a part-made one.
  • Configuration:
    • MilvusConf.tombstone_retention_seconds has the same floor as Qdrant's.
    • MilvusConf.max_varchar_length is a declared string's VARCHAR length; a native collection keeps the length it was created with.
    • Limits the server configures stay the server's, among them common.JSONMaxLength on a record's undeclared properties. A declared property's field name, its key of at most 32 bytes under _p_, fits proxy.maxNameLength (255 unless configured).
    • MilvusConf.metrics_factory_id names the metrics factory the store reports its operation latencies to, as Qdrant's does.
  • Milvus Lite is dropped. It is a separate embedded engine that scores, indexes and enforces collection properties differently.
    • MilvusConf.uri defaults to http://localhost:19530, and a URI ending in .db, which pymilvus serves with Milvus Lite, is refused.
    • The milvus extra no longer installs milvus-lite.
    • The supported servers are Milvus 2.6.8 and later.
  • Tests: the store's tests, with the collection lifecycle contract, run against a Milvus 2.6.24 server container as integration tests. They pin query and delete isolation between collections sharing a native collection; ranking, scores and the threshold for every metric; reads at Bounded or stronger; random filters against the in-memory evaluator and a seeded operation sequence against a model; purge rounds on a dropped collection and with two purgers; concurrent preparation of one native collection; non-default settings reaching the client; a match scoring exactly the threshold kept; filters on characters outside the Basic Multilingual Plane and on undeclared properties of another type; a purge round completing a part-made native collection; a declared datetime whose offset has a seconds component; an upsert over the request size limit; and churn across two stores sharing one registry, with purgers running. The test containers need testcontainers 4.15.0.

Breaking, with no migration:

  • Existing Milvus data is not carried over: drop the Milvus collections before upgrading. The native collection keeps its name, and the store cannot prepare an existing collection of the earlier schema, so the first request of every new session would fail.
  • Every Milvus configuration must name a collection_registry.
  • Milvus Lite URIs are refused.

Commits

  1. Move the Milvus store onto the collection registry, against a Milvus server.
  2. Document how the Milvus store meets the shared contracts.
  3. Build Milvus handles from live registrations.
  4. Take a Registration in the Milvus handle.
  5. Say to drop the Milvus collections before upgrading.
  6. Test that Milvus queries and deletes stay within their collection.
  7. Test the Milvus score threshold and ranking for every metric.
  8. Test that the Milvus store reads at Bounded or stronger.
  9. Check random Milvus filters against the in-memory evaluator.
  10. Check a random sequence of Milvus operations against a model.
  11. Test Milvus purge rounds on a dropped collection and on two purgers.
  12. Test concurrent preparations of one Milvus native collection.
  13. Test Milvus settings with values other than their defaults.
  14. Test concurrent churn across two Milvus stores with purgers running.
  15. Give the Milvus wiring tests' collection registry a file database.
  16. Test that the Milvus store keeps a match scoring exactly the threshold.
  17. Use serial commas in the Milvus document and the store's comments.
  18. Name the Milvus filter helpers for what they produce.
  19. Send Milvus filter strings as UTF-8, not ASCII escapes.
  20. Compare an undeclared Milvus property only with values of a comparable type.
  21. Index and load a part-made Milvus collection before a purge round lists it.
  22. Drop the Milvus store's already-exists guard, which no supported server reaches.
  23. Refuse a metric Milvus does not support before reserving the collection.
  24. Bound the Milvus connectivity check by the request timeout.
  25. Require testcontainers 4.15.0, the first with the community modules.
  26. Dispose a mocked-client purge test's registry engine when it fails.
  27. Drop the Milvus store's re-sort of search results.
  28. Say that a Milvus native collection keeps its VARCHAR length.
  29. Match no declared Milvus int property with a float filter value.
  30. Create Milvus native collections at Bounded by name.
  31. Report Milvus store operation latencies to a metrics factory.
  32. Name Milvus's cap on a record's undeclared properties.
  33. Refuse a Milvus Lite URI by its .db suffix, as pymilvus selects Lite.
  34. Write a declared Milvus datetime as its UTC instant.
  35. Halve a Milvus upsert refused as too large, as the Qdrant store does.
  36. Name Milvus's datetime offset fields for their unit.
  37. Keep a declared Milvus datetime as its UTC instant alone.

Stack

21 open PRs: three independent PRs, and the vector store tree of short parallel branches. Every PR in the tree has feat/horizontal-scaling as its GitHub base, and the independent PRs have main. The branches are in a fork, and a pull request can target only this repository's branches, so the on column gives the order the PRs build on each other. A stacked PR's diff on GitHub includes the PRs under it until they merge.

Independent of the vector store tree, directly on main:

# PR change on
— #1624 Make no memory request create a project main
— #1786 Refuse a filter value of the wrong type for a datetime column, and answer an invalid list filter with 422 (port of #1620) main
— #1792 Refuse property values that some store refuses or alters where an episode enters main

The vector store tree. Each PR builds on the one in its on column; PRs on the same parent are parallel branches and do not depend on each other. #1631 is closed, superseded by #1733–#1736, which hold its changes split in four, with review changes since. #1670 and #1702 sit beneath #1627, whose code depends on them. Until the PRs under it merge, their changes show in a stacked PR's diff.

# PR change on
[vector store scale-out 1/6] #1671 (merged) Remove custom sharding from the Qdrant store (port of #1654) main
[vector store scale-out 2/6] #1733 (merged into feat/horizontal-scaling) Answer vector store queries with record UUIDs and scores, and refuse invalid inputs feat/horizontal-scaling
[vector store scale-out 3/6] #1734 (merged into feat/horizontal-scaling) Arbitrate vector store collections in a SQL registry, with an incarnation per collection life and a purge feat/horizontal-scaling
[vector store scale-out 4/6] #1735 (merged into feat/horizontal-scaling) Move the Qdrant store onto the collection registry feat/horizontal-scaling
[vector store scale-out 5/6] #1736 (this PR) Move the Milvus store onto the collection registry, against a Milvus server feat/horizontal-scaling
— #1775 (merged into feat/horizontal-scaling) Accept attempts exhausted in the lifecycle churn contract, and say which Qdrant operations filter on the incarnation feat/horizontal-scaling
— #1779 (merged into feat/horizontal-scaling) Classify Qdrant errors by status code alone feat/horizontal-scaling
— #1788 Refuse a repeated record UUID or a non-finite property value at upsert, and state the datetime property contract #1736
— #1631 (closed) Superseded by #1733–#1736, which hold its changes split in four, with review changes since —
[user properties 1/2] #1670 Remove per-project filterable properties (port of #1606) #1736
[user properties 2/2] #1702 Keep user properties out of the vector store #1670
[vector store scale-out 6/6] #1627 Make a vector store one collection, with string-keyed partitions #1702
[session storage 1/2] #1622 Create a session's storage with the session, never on a request #1627
[session storage 2/2] #1625 Remove open-or-create from both stores, and close from the segment store #1622
[search results] #1663 Score every vector search by cosine similarity, and name scores for it (port of #1598's cosine half) #1627
[sqlite store fixes 1/7] #1460 Publish vector index files atomically (but not durably) #1663
[sqlite store fixes 2/7] #1469 Never reuse a row id in SQLiteVectorStore #1460
[sqlite store fixes 3/7] #1672 Own the search engine's concurrency in the store, not in each engine (port of #1612) #1469
[sqlite store fixes 4/7] #1673 Serialize a partition's writes so the engine sees them in order (port of #1607) #1672
[sqlite store fixes 5/7] #1674 Refuse a pending row replay cannot honor, instead of dropping it (port of #1608) #1673
[sqlite store fixes 6/7] #1675 Take SQLite's write lock at BEGIN, not at the first write (port of #1609) #1674
[sqlite store fixes 7/7] #1676 Give every write a fresh row id, so a key names one version (port of #1610) #1675
[qdrant options] #1618 Let a deployment tune a Qdrant collection's HNSW, optimizers and quantization #1663
[declared schema 1/2] #1628 Make a vector store filter only on the properties it declares #1663
[declared schema 2/2] #1616 Close the filter union, and make negation the complement on every backend #1628

This PR is its 37 commits, aa809bd91, 955f84a89, 5582cf4d6, 8e6539180, caefa7b73, 509c2359d, 7f1eb3b42, 6e312e078, 6d1ed1e92, 25e79487d, 2575af03c, 6f368777f, d43e9e611, 6dfbf595a, 1b94f7f9f, cc29d2765, 3681d092e, bc6d4f9cd, 1a7fffa42, d6fc2199b, 28e8c507f, 9fd7f76d5, b3ef4f1ef, 76062f81d, 6dd2f9c87, 6fcd8c5c2, c264b3d94, 9696e6e2c, 31fc7c465, baabf4899, 07e6ce899, dac193a56, 25fab8f2d, ca890c8b1, f56686f28, b01522967, 49b9ec2dd, directly on feat/horizontal-scaling. #1788 and #1670 are stacked on it, in parallel. Split from #1631 (closed).

Verification

Commit 37: at the head 49b9ec2dd: ruff check, ruff format --check, and ty check are clean, and uv lock --check passes; the full server suite without integration tests passes (2169 passed, 8 skipped); in test containers, the integration tests pass (1676 passed, 266 skipped). The schema test asserts the native collection's exact field set, with no offset field, and the datetime tests read back the written instant in UTC.

Commit 36: at the head b01522967: ruff check, ruff format --check, and ty check are clean, and uv lock --check passes; the full server suite without integration tests passes (2169 passed, 8 skipped); in test containers, the integration tests pass (1676 passed, 266 skipped). The new test declares a datetime under a 32-byte key, the longest a property may have, so its fields are named _p_ and _tz_offset_seconds_ plus 32 characters, and Milvus 2.6.24 creates, writes, reads back, and filters on both.

Commit 35: at the head f56686f28: ruff check, ruff format --check, and ty check are clean, and uv lock --check passes; the full server suite without integration tests passes (2169 passed, 8 skipped); in test containers, the integration tests pass (1675 passed, 266 skipped). Against Milvus 2.6.24, a 72 MB upsert is refused in 0.2 s with gRPC's RESOURCE_EXHAUSTED ("received message larger than max (72055171 vs. 67108864)"), raised by pymilvus as it is, with nothing stored; a 32 MB one is accepted. The new integration test fails with RESOURCE_EXHAUSTED with the halving removed, and so does the mocked halving test.

Commits 33-34, after another review of this PR: at the head ca890c8b1: ruff check, ruff format --check, and ty check are clean, and uv lock --check passes; the full server suite without integration tests passes (2165 passed, 8 skipped); in test containers, the integration tests pass (1674 passed, 266 skipped). Commit 33's tests of a file:// URI ending in .db and of a unix: URI fail under the earlier check, which refused a URI without ://. Commit 34's test fails with the datetime written at its own offset: Milvus 2.6.24 refuses +00:19:32 with "invalid timezone name" (code 1100).

Cost of the purge round's index-and-load, measured on this PR's purge and collection-preparation code: against Milvus 2.6.24 in a test container on one machine, with 768-dimension vectors, a collection of 20,000 records purged in rounds of 5,000 beside another collection of 20,000 in the same native collection, three passes with the step and three with it replaced by a no-op. The step alone takes 2.4 ms median (p90 4.5 ms) on a loaded collection. A round that deletes a full batch takes 32 ms median both with and without it, and whole purges took 228-383 ms in either mode.

Commits 30-32, after two more reviews of this PR: at the head dac193a56: ruff check, ruff format --check, and ty check are clean, and uv lock --check passes; the full server suite without integration tests passes (2160 passed, 8 skipped); in test containers, the integration tests pass (1673 passed, 266 skipped), both measured at a head whose tree differs only in how one configuration test builds MilvusConf, which passes at this head. Commit 30's test fails with the level left out of the create request; commit 31's fails with the factory not passed. Commit 32 cites Milvus v2.6.24's common.JSONMaxLength (pkg/util/paramtable/component_param.go, default 64 KiB) and the upsert check that enforces it (internal/proxy/validate_util.go).

Commit 29: at the head 31fc7c465: ruff check, ruff format --check, and ty check are clean, and uv lock --check passes; the full server suite without integration tests passes (2160 passed, 8 skipped); in test containers, the integration tests pass (1673 passed, 266 skipped). Its test fails with a float counted as comparable with an int property: Milvus 2.6.24 refuses the query ("cannot cast value to Int64", code 1100).

Rebased onto feat/horizontal-scaling after it was rebased onto main (2d471c50a): every commit is patch-identical to the one before the rebase; commit 28 changes documentation only. At the head 9696e6e2c: ruff check, ruff format --check, and ty check are clean, and uv lock --check passes; the full server suite without integration tests passes (2160 passed, 8 skipped); in test containers, the integration tests pass (1672 passed, 266 skipped).

Commits 19-27, after a review of this PR: at the head, ruff check, ruff format --check, and ty check are clean, and uv lock --check passes; the full server suite without integration tests passes (2055); in test containers, the integration tests pass (1533 passed, 266 skipped). Each new test failed under a named mutation of the code, listed in its commit message. Against Milvus 2.6.24: a filter string escaped to a surrogate pair fails to parse; a native collection left unloaded refuses the purge's listing with "collection not loaded"; a create of an existing collection with another schema is refused as "create duplicate collection with different parameters", which the removed guard's text did not match; and 450 of 450 result lists came back best first, across the three metrics, filtered and not.

Rebased onto feat/horizontal-scaling after #1734 and #1735 merged there as 5a846570a and f4cb7bd84: every commit's tree is unchanged, so the results below hold.

Commits 6-18, rebased with the stack onto feat/horizontal-scaling: at the head, ruff check, ruff format --check and ty check are clean; the full server suite without integration tests passes (2055); in test containers, the integration tests pass against PostgreSQL, Qdrant 1.19.1 and Milvus 2.6.24 (662 passed, 6 skipped). Each new or changed test failed under a named mutation of the code, listed in its commit message. Milvus 2.6.24 answered identical concurrent creates of one collection with success every time, so the store's already-exists branch is tested with a mocked client.

After the fourth review round of #1733 and #1734: the commits are rebased onto #1735's head a0891bec2, patch-identical to the ones verified at 0022fed7d, with commit 1's conflict in milvus_vector_store.py resolving to the commit's own file. At the head: ruff check, ruff format --check and ty check are clean; the full server suite without integration tests passes (2107); in test containers, the integration tests pass against PostgreSQL, Qdrant 1.19.1 and Milvus 2.6.24 (597 passed, 6 skipped).

After the third review round of #1734: commits 1-4 are patch-identical to the ones verified below, rebased onto #1735's head 0c4f87037, except commit 1, whose conflict in milvus_vector_store.py resolves to the commit's own rewritten file. Commit 5 adds documentation only. At commit 5, as verified before #1734's last test-only change: ruff check, ruff format --check and ty check are clean; the full server suite without integration tests passes (2100); in test containers, the integration tests pass against PostgreSQL, Qdrant 1.19.1 and Milvus 2.6.24 (597 passed, 6 skipped).

After the second review round of #1733-#1735: commits 1-4 are patch-identical to the ones verified below, rebased onto #1735's head d823255de. At commit 4: ruff check, ruff format --check and ty check are clean; the full server suite without integration tests passes (2097); in test containers, the integration tests pass against PostgreSQL, Qdrant 1.19.1 and Milvus 2.6.24 (597 passed, 6 skipped).

Rebased onto #1735. Commits 1 and 2 are patch-identical to the ones verified below, and do not type-check on this base until commit 3, which adapts the Milvus handle and its test to the registration API. At commit 3: ruff check, ruff format --check and ty check are clean; the full server suite without integration tests passes (2085); in test containers, the integration tests pass against PostgreSQL, Qdrant 1.19.1 and Milvus 2.6.24 (590 passed, 6 skipped).

After #1734's rename: commits 1-3 are patch-identical to the ones above, rebased onto #1735's head, and do not type-check there until commit 4, which renames the Milvus handle's uses. At commit 4: ruff check, ruff format --check and ty check are clean; the full server suite without integration tests passes (2085); in test containers, the integration tests pass against PostgreSQL, Qdrant 1.19.1 and Milvus 2.6.24 (590 passed, 6 skipped).

Before the rebase:

At each commit:

  • ruff check, ruff format --check and ty check are clean, run as CI runs them.
  • The full server suite without integration tests passes at commit 1 (2083 tests); the Milvus store's tests are integration tests from here on.
  • Commit 2 adds the design document only.

At the head, in test containers: the integration tests pass against PostgreSQL, Qdrant 1.19.1 and Milvus 2.6.24 (589 passed, 6 skipped). They cover the vector store (the Milvus store with the collection lifecycle contract), the vector-store semantic storage, event memory, long-term memory and the resource manager. The 6 skipped are three Neo4j-specific semantic storage cases, which skip on the other backends, and three long-term memory tests, for want of a NebulaGraph server.

Before the review changes of #1733-#1736, the head's tree was byte-identical to #1631's head 89ba59636 (tree 8f3ad14bc); it now differs from it by those changes.

🤖 Generated with Claude Code

This was referenced Oct 1, 2026
@edwinyyyu
edwinyyyu force-pushed the feat/vector-store-milvus-registry-speedkick branch from 973ea51 to 30c1d79 Compare October 1, 2026 22:30
@edwinyyyu edwinyyyu added the horizontal scaling Wrong or unsafe when more than one server process serves the same backends (replicas or workers) label Oct 1, 2026
@edwinyyyu
edwinyyyu force-pushed the feat/vector-store-milvus-registry-speedkick branch from 30c1d79 to 2f72832 Compare October 1, 2026 23:51
@edwinyyyu
edwinyyyu force-pushed the feat/vector-store-milvus-registry-speedkick branch from 2f72832 to 196d45e Compare October 2, 2026 03:19
@edwinyyyu
edwinyyyu marked this pull request as ready for review October 2, 2026 17:08
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 15, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
xiongzubiao pushed a commit that referenced this pull request Oct 15, 2026
…n registry, against a Milvus server (#1736)

* Move the Milvus store onto the collection registry, against a Milvus server

The Milvus store kept its catalog in a `memmachine_<namespace>__registry`
collection per namespace, whose `insert` does not enforce primary-key
uniqueness, so two creators of one name both succeeded, and it serialized
creating and deleting a collection with locks in one process. A
collection's name was the partition key on its entities, so a handle kept
writing into a collection deleted and created again under its name, and a
write in flight during a deletion outlived it. Each call ran the sync
client on a thread of the event loop's default executor, so calls waiting
on Milvus queued every other `to_thread` call of the process behind them.

`MilvusVectorStore` is now a `RegistryBackedVectorStore`, and any number
of processes sharing its registry may serve its collections. The store
was rewritten rather than adapted; what changes:
- The catalog is the collection registry in the relational database
  `MilvusConf.collection_registry` names, built and started by the
  database manager before it opens the client. The registry collections
  and the process-local locks go.
- Every entity carries its collection's incarnation as the partition key,
  with partition-key isolation, and in its primary key,
  `"{incarnation}:{record_uuid}"`.
- Declared properties are typed, indexed fields (`_p_<name>`, a datetime
  as TIMESTAMPTZ with its UTC offset beside it in `_tz_<name>`), and
  undeclared ones live in the JSON field, still filterable. Negation is
  the complement, as on Qdrant: a negated condition holds where the
  property has no value.
- The vector index is HNSW_SQ with 4-bit codes and FP16 refinement, what
  AUTOINDEX builds on CPU from Milvus 2.6.10, named so every server builds
  the same; a search rescores `limit x 8` candidates. Scores are the
  server's.
- Reads run at Milvus's default consistency level, Bounded, which the
  store states: a query reflects every write made at least the server's
  `common.gracefulTime` before it. `MilvusConf.consistency_level` goes.
- The store calls Milvus through pymilvus's `AsyncMilvusClient`, and
  bounds every request by `MilvusConf.request_timeout_seconds`.
- A purge round lists a batch of the incarnation's primary keys, at most
  `MilvusConf.purge_batch_size`, and deletes them.
  `MilvusConf.tombstone_retention_seconds` is the retention, refused below
  10 x `request_timeout_seconds` + 300 seconds as for Qdrant. A declared
  string's VARCHAR length is `MilvusConf.max_varchar_length`. Limits the
  server configures stay the server's.
- A delete raises unless Milvus accepted every key sent.

Milvus Lite is dropped. It is a separate embedded engine that scores,
indexes and enforces collection properties differently, so a store tested
against it is not tested against what production runs; MemMachine's
local, single-node backend is the SQLite vector store. `MilvusConf.uri`
defaults to `http://localhost:19530`, a URI with no scheme, which pymilvus
reads as a Lite file, is refused, and the milvus extra no longer installs
milvus-lite (the lock drops it with the packages only it required).

The store's tests, including the collection lifecycle contract, run
against a Milvus 2.6.24 server container as integration tests, and read
past the store at Strong to check what Milvus holds.

Breaking: existing Milvus data is orphaned, and an existing native Milvus
collection has to be dropped; no migration is included. Every Milvus
store needs a relational database for its registry, which the samples,
the configuration wizard and the configuration docs name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Document how the Milvus store meets the shared contracts

`design/milvus_vector_store.md` records the Milvus store's layout: the
shared native collection and partition-key tenancy, the composite key,
the index and why it was chosen, declared properties as typed fields, the
purge in batches, consistency levels, and the async client, with the
measurements behind each choice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Build Milvus handles from live registrations

As for Qdrant: the Milvus handle takes its live registration in place of
the namespace, name, incarnation, configuration and registry lookup, and
_build_collection_handle builds it from one. The test that builds a
handle on a mocked client gives it a live registration that stays
current.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Take a Registration in the Milvus handle

#1734 renames the registry's LiveRegistration to Registration. Names only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say to drop the Milvus collections before upgrading

The Milvus store keeps the native collection's name from main, so on
an upgraded server it finds main's collection, whose schema it cannot
prepare, and the first request of every new session fails. The user
docs' upgrade note now names Milvus beside Qdrant.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test that Milvus queries and deletes stay within their collection

Two collections of one namespace and configuration share a native
collection. test_a_query_returns_only_its_own_collections_records gives
both records that match the same filters and checks that each answers
with its own records alone, unfiltered, on a declared property, on an
undeclared one, and under a negation. It fails when _query lets the
property filter replace the incarnation filter, ORs the two, or drops
the incarnation filter: Milvus 2.6.24 refuses each of those searches
under partition key isolation, and with isolation off as well the query
returns the other collection's records.

test_same_uuid_can_exist_in_different_logical_collections now also
deletes the shared UUID through one collection and checks that the
other still holds and returns its record. It fails when _delete
deletes by a record_uuid filter in place of the composite key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test the Milvus score threshold and ranking for every metric

test_a_threshold_keeps_the_matches_within_it_best_first upserts three
records at increasing distance from the query, out of order, and
queries with a threshold between the middle and the farthest: the two
nearest match, best first, with the metric's scores. For Euclidean the
distances are 1, 2 and 5 and the threshold 3, between the middle
distance and its square; dot product and cosine take their own
thresholds.

It fails when the threshold is applied to Milvus's squared Euclidean
distance before the square root, when every metric keeps scores at or
above the threshold, when every metric keeps scores at or below it,
when the matches are sorted in reverse, and when every metric's score
is the square root of Milvus's distance.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test that the Milvus store reads at Bounded or stronger

test_the_store_reads_at_bounded_or_stronger creates a native collection,
writes, queries, deletes the collection and drains the purge, then
checks that describe_collection reports Bounded and that no search,
query, get or hybrid search the store made names a level other than
Bounded or Strong. The purge's listing and the tombstone retention rely
on reads lagging writes by at most common.gracefulTime.

It fails when the native collection is created at Session or
Eventually, when the store's search names Eventually, and when the
purge round's listing names Session.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Check random Milvus filters against the in-memory evaluator

test_random_filters_agree_with_the_model writes 48 records whose
properties cover every type, declared and undeclared, with datetimes at
offsets from -12:00 to +14:00, instants a microsecond and a second
apart, strings with quotes, backslashes and non-ASCII letters, and each
property missing a quarter of the time. 200 seeded filter trees of
Comparison, In, IsNull, And, Or and Not, with values of their
properties' types, must select the UUIDs evaluate_filter selects, after
one settle. The trees compare with != only through Not(=): on a missing
property the store's != holds, as the complement of =, while
evaluate_filter's does not.

It fails when a declared datetime is written without its offset, when
a negated condition no longer holds on a missing property, when
negation is not pushed through And and Or by De Morgan's laws, and when
undeclared datetimes are encoded at their own offset in place of UTC.

Dropping the store's own UTC normalization of datetime filter literals
is an equivalent mutant: Comparison and In normalize datetime values to
UTC when they are built, so the store's call never changes a value.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Check a random sequence of Milvus operations against a model

test_a_random_sequence_of_operations_agrees_with_a_model runs 60 seeded
steps on two collections sharing a native collection: upserts of new
and replacing records, deletes of present and absent records, deletion
and recreation of a collection, which then reuses its UUIDs, and purge
drains. Twelve UUIDs serve both collections and every life of each.
After every step each collection holds exactly the model's records,
once each, read at Strong; its settled queries, unfiltered and under
random filters, select what evaluate_filter selects; a replaced
record's old property values no longer select it; and after a drain no
deleted life keeps a record. It fails when _upsert inserts in place of
upserting, when the primary key leaves out the incarnation, and, by
timing out its bounded drain, when the purge round never deletes.

test_upsert_removes_stale_filter_fields now also checks that a filter
on the replaced value finds nothing, which inserting in place of
upserting fails. test_upsert_calls_native_upsert compared the entity
the store built with itself; the model test checks the state an upsert
leaves instead, so it is removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test Milvus purge rounds on a dropped collection and on two purgers

The store fixture now builds its store with _started_store over a
per-test registry database, and other_store builds one as another
process would beside it: its own Milvus client and registry
connections, over the same Milvus and registry database.

test_a_purge_round_on_a_dropped_native_collection_retires_the_tombstone
deletes a collection, drops its native collection, and drains the
purge: the round raises nothing, and the next call finds nothing due.
It fails when _purge_round no longer checks that the native collection
exists, since Milvus refuses the listing.

test_purgers_on_two_stores_reclaim_the_deleted_collections_alone
deletes four collections of seven records each beside a live one in the
same native collection, and drains both stores' purges at once in
batches of 3. Every deleted collection ends empty, the live one keeps
its seven records, and neither store finds anything due. It fails when
a round reports finding nothing after deleting its batch, and when the
listing leaves out the incarnation filter.

test_a_purge_round_milvus_does_not_accept_in_full_raises gives a store
a mocked client whose delete accepts one of the two keys the round
listed: the purge raises. It fails when _purge_round no longer checks
the count. test_a_delete_milvus_does_not_accept_in_full_raises now has
Milvus accept one of two keys, so a check for zero alone fails it, and
matches the exception's type rather than its wording.

The drain in these tests is bounded, so a purge that never ends fails
the test instead of hanging it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test concurrent preparations of one Milvus native collection

Milvus 2.6.24 answers two or more identical create requests for one
collection, sent at once from separate clients, with success every
time (four clients, five trials), and identical concurrent index
requests likewise; a create whose schema differs gets code 1100,
"create duplicate collection with different parameters". So a real
server cannot make a racing creation take the store's already-exists
branch.

test_a_creation_that_loses_the_native_collection_to_another_completes_it
covers that branch with a mocked client: the native collection is
missing when checked and its create is refused as already existing.
The creation indexes and loads the native collection, and the
collection opens. It fails when the already-exists tolerance is
dropped, and when a creation that loses the create returns without
indexing and loading.

test_a_creation_finding_the_native_collection_mid_creation_completes_it
runs two stores on separate clients and registry connections. The
first creates the native collection and is held before its indexes
until the second's creation of another collection, which finds the
native collection present, has returned and been written to and
queried. Both collections end usable with every index. It fails when a
creation that finds the native collection present skips its index and
load steps.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test Milvus settings with values other than their defaults

The configuration, database manager and store tests set the request
timeout, the declared-string length and the purge batch size to values
other than their defaults, so a default passed in place of the
configured value fails them.

- test_every_request_carries_the_timeout runs at a 17-second timeout.
  It fails when the store or the handles it builds use 30 in place of
  the configured timeout.
- test_milvus_client_kwargs_forwarded expects the configured 7-second
  timeout on the client, and test_milvus_creates_vector_store expects
  7, 2048 and 500 in the store's parameters. Each fails when
  async_get_milvus_client passes the default for that setting.
- test_parse_valid_storage_dict parses max_varchar_length 2048 and
  purge_batch_size 500 from the configuration. It fails when either
  field reads another key, so the configured value is ignored.

test_milvus_conf_rejects_a_length_or_batch_size_that_is_not_positive and
test_a_setting_that_is_not_positive_is_refused check that MilvusConf
and MilvusVectorStoreParams refuse 0 for each of these settings. Each
fails when that field's positivity constraint is dropped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test concurrent churn across two Milvus stores with purgers running

test_churn_across_two_stores_with_purgers_keeps_every_collection_exact
runs six workers, three on each of two stores with their own clients
and registry connections over one registry database, for 40 seeded
steps each on three collections sharing a native collection: upserts
and deletes of records the worker alone owns, owner-filtered queries,
and deletion and recreation of the shared collections. Each store's
purger drains, in batches of 2, after every collection deletion, and
each purge round begins at a settled read, as the tombstone retention
would give it. A ledger records, by the incarnation each operation
reached, the records upserts and deletes that returned leave, the
records sent, and the upserts that raced their collection's deletion.

Only the documented outcomes of a lost race may be raised, and the
whole churn is bounded, so a deadlock fails it. During the churn, a
query returns only records its owner sent to its incarnation. Once the
churn is quiet, settled and drained, each live collection holds
exactly its ledger's records, once each, with their latest values, and
its settled owner and tag filters select them; the deleted
collections keep only records of upserts that raced their deletion.
In three runs the churn made 16 to 18 collection lives, 7 to 10
upserts raced a deletion, and queries, deletes and recreations met
stale handles, existing names and deleted reservations.

It fails when _upsert inserts in place of upserting, when the primary
key leaves out the incarnation, when the purge listing leaves out the
incarnation filter, when a round reports finding nothing after its
batch, and, by timing out, when the purge round never deletes.

The two-purgers test now builds its batched stores with the same
_with_purge_batch_size helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Give the Milvus wiring tests' collection registry a file database

The Milvus wiring tests built their collection registry on the module's
in-memory SQLite configuration, which the Qdrant wiring tests' change below
replaced with a file per test, since the registry refuses an in-memory or
single-connection engine. Each Milvus wiring test now puts the registry in a
SQLite file under its own tmp_path, as the Qdrant ones do. What each test
asserts is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test that the Milvus store keeps a match scoring exactly the threshold

The contract says a match scoring exactly the score threshold is returned,
and the Milvus store already keeps it. It now has the test the Qdrant and
SQLite stores have, on cosine, dot and Euclidean: a threshold equal to a
match's reported score keeps the match, and one a step better drops it. It
fails with the store's threshold check made strict.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Use serial commas in the Milvus document and the store's comments

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Name the Milvus filter helpers for what they produce

The module's expression helpers had names that did not say what they make:
`_expr_string`, `_literal`, `_declared_literal`, `_fits`, `_absent`,
`_condition`, and `_milvus_filter` are now `_expression_string_literal`,
`_property_value_literal`, `_declared_property_value_literal`,
`_is_comparable_with_declared_type`, `_property_absent_expression`,
`_condition_expression`, and `_filter_expression`; the handle's `_score` is
`_score_from_distance`. No behavior changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Send Milvus filter strings as UTF-8, not ASCII escapes

json.dumps escapes a character outside the Basic Multilingual Plane as a
UTF-16 surrogate pair, which Milvus's expression parser refuses, so a
filter on a string such as "café 😀" failed to parse on a declared and an
undeclared property alike. The literal now keeps its characters as UTF-8.

The new test filters on such a string on both kinds of property; it fails
with the ASCII escaping.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Compare an undeclared Milvus property only with values of a comparable type

An undeclared property's condition compared only its stored value, so a
string equal to an undeclared datetime's stored text matched it, where the
declared path, the other stores' JSON properties, and the in-memory
evaluator match nothing. The condition now also requires the stored type
tag to be one the value compares with, by the rule the declared path
already uses: a bool with a bool, an int or float with either, and any other
value with its own type.

The new test filters an undeclared datetime by its stored text, and an
undeclared float by an int; it fails without the type check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Index and load a part-made Milvus collection before a purge round lists it

A creation that failed after creating the native collection, before its
indexes or its load, leaves it unindexed and unloaded, and its cancelled
collection's tombstone is due for purge. The round's listing query then
raised "collection not loaded" on every claim, so the tombstone was
dead-lettered unless another creation of the same namespace and
configuration completed the collection first. A round now creates the
missing indexes and loads the collection, the steps preparation already
takes, before it lists.

The new test fails a creation at its index step, then drains the purge: it
fails with "collection not loaded" without the change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Drop the Milvus store's already-exists guard, which no supported server reaches

Preparation caught a create refused as "already exist" by matching the
message text. Milvus answers a create of an existing collection with the
same schema with success, and refuses one with another schema as "create
duplicate collection with different parameters" (code 1100, Milvus
2.6.24), which the text did not match, so the branch ran only under the
mocked client of its own test. The guard, its message match, and that test
go; the concurrent preparation tests cover racing creators against a real
server.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Refuse a metric Milvus does not support before reserving the collection

The store checked the metric while preparing storage, after the registry
had reserved the name, so a refused configuration cost a reservation and
its cancellation, and stayed pending until deleted if the cancellation
failed. create_collection and open_or_create_collection now check it first.

The test, for both operations, finds nothing to purge after the refusal; it
fails with the check back in preparation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Bound the Milvus connectivity check by the request timeout

validate_milvus_client listed collections with no timeout, and the
client's own timeout bounds only connecting, so a Milvus that accepts the
connection and never answers hung validation, the one request
request_timeout_seconds did not bound. The check now passes the configured
timeout, on a store's first build and in build_all's validation.

The new test checks both paths pass it; it fails with the timeout dropped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Require testcontainers 4.15.0, the first with the community modules

The shared conftest imports MilvusContainer from testcontainers.community,
which 4.14.2, the declared floor, does not have, so an install at the floor
could not import the conftest or collect any server test. The lock already
pins 4.15.0; only the floor moves.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Dispose a mocked-client purge test's registry engine when it fails

The test disposed the registry engine it opened only after its last
assertion, so a failure left the engine and its aiosqlite thread open for
the rest of the session.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Drop the Milvus store's re-sort of search results

The store sorted each query's matches by score, though Milvus returns hits
best first and the one transform the store applies, the square root of a
Euclidean distance, keeps their order. On Milvus 2.6.24, 3,000 random
vectors in each metric gave 450 of 450 result lists in order, filtered and
not, at limit 100.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Say that a Milvus native collection keeps its VARCHAR length

The store sizes a declared string's VARCHAR field when it creates the
native collection, and a later preparation that finds the collection there
leaves it as it is, so a changed max_varchar_length applies only to native
collections created afterward. The setting's description, the
configuration docs, and the design document now say so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Match no declared Milvus int property with a float filter value

The declared path counted an int and a float as comparable either way, so
a float filter value on a declared int property reached Milvus as a float
literal against an INT64 field, which Milvus refuses to parse ("cannot
cast value to Int64", code 1100). A float now compares only with a float
property, and matches no int one, as a value of another type matches
nothing; an int still compares with a float property by value.

The new test filters a declared float property with an int and a declared
int property with a float; it fails with the float counted as comparable
with an int property.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Create Milvus native collections at Bounded by name

The store's reads run at the collection's consistency level, and the
purge's listing and the tombstone retention rely on that level being
Bounded or stronger. The store named no level when it created a native
collection, so the level was whatever pymilvus defaults to, which a
pymilvus release could change without the store noticing. The store now
creates each native collection at Bounded.

The test checks the create request names Bounded; it fails with the
keyword removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Report Milvus store operation latencies to a metrics factory

The database manager built the Milvus store's parameters without a
metrics factory, and MilvusConf had no metrics_factory_id, so a
deployment could not configure one and every timed Milvus operation
recorded nothing, where the Qdrant and Neo4j stores report theirs.
MilvusConf takes metrics_factory_id, and the manager passes the factory
it names, as it does for Qdrant.

The wiring test checks the factory reaches the store's parameters; it
fails with the factory not passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Name Milvus's cap on a record's undeclared properties

A record's undeclared properties are stored in one JSON field, and Milvus
refuses an upsert whose JSON exceeds its common.JSONMaxLength setting,
65,536 bytes by default (internal/proxy/validate_util.go and
pkg/util/paramtable/component_param.go at v2.6.24). The design document's
list of server-configured limits and the database docs now name it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Refuse a Milvus Lite URI by its .db suffix, as pymilvus selects Lite

The configuration refused a URI with no "://" as a Milvus Lite file. pymilvus
serves a URI ending in .db with Lite and passes a unix: URI to the server as
a socket address, so a file:// URI ending in .db passed the check and reached
Lite, and a unix: socket URI was refused as a Lite file. The check now refuses
a URI ending in .db, and its message names the Lite file.

The tests refuse ./milvus.db, milvus.db, and a file:// URI ending in .db, and
accept http, https, and unix: URIs; the file:// and unix: cases fail with the
"://" check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Write a declared Milvus datetime as its UTC instant

The entity builder wrote a declared datetime's text with its own offset,
while both filter literals convert to UTC first. Milvus refuses a
TIMESTAMPTZ string whose offset has a seconds component ("invalid timezone
name", code 1100 on 2.6.24), as local mean time and other historical
offsets have, so such a record could not be upserted. The builder now
writes the UTC instant; the offset field already keeps the offset written.

The new test upserts a datetime at +00:19:32, reads back its instant and
offset, and matches it by its instant; it fails with the original offset
written, at Milvus's refusal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Halve a Milvus upsert refused as too large, as the Qdrant store does

Milvus's proxy refuses a request over proxy.grpc.serverMaxRecvSize (64 MiB
unless configured) with gRPC's RESOURCE_EXHAUSTED status, before writing any
of it, and pymilvus raises that status as it is: on 2.6.24 a 72 MB upsert was
refused in 0.2 s with nothing stored. So a batch the Qdrant store accepts by
halving it failed outright on Milvus. The store now halves an upsert refused
with RESOURCE_EXHAUSTED until its halves fit or a single entity is refused.
Any other error raises at once, since a timed-out request may still be
applied.

The store imports grpc for the status, so the milvus extra declares grpcio:
1.59.0 is the first release with Python 3.12 wheels, and grpc.aio.AioRpcError
and grpc.StatusCode are older.

The integration test upserts 1,200 records with a 60,000-character property,
about 72 MB, and fails with RESOURCE_EXHAUSTED without the halving. The
mocked-client tests pin the halving, which its test fails without, a single
refused entity raising, and a timeout or another refusal sent once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Name Milvus's datetime offset fields for their unit

A declared datetime's UTC offset was stored in an INT32 field named
_tz_<key>, which left its unit to the code. The field is now
_tz_offset_seconds_<key>, under the prefix _TZ_OFFSET_SECONDS_FIELD_PREFIX;
its value is unchanged, the offset in seconds. The schema test and the two
datetime round-trip tests read the new names.

A property key is at most 32 bytes, so a declared property's field names are
at most 51 characters, within Milvus's proxy.maxNameLength (255 unless
configured). A new test declares a datetime under a 32-byte key, writes it,
reads both fields back, and matches it by its instant; the design document
states the bound.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Keep a declared Milvus datetime as its UTC instant alone

A declared datetime was stored as its UTC instant in a TIMESTAMPTZ field and
its UTC offset in an INT32 field beside it. A filter compares only instants,
and a search answers with record UUIDs and scores, so nothing reads the
offset back; the store the vector records are derived from keeps it. The
offset fields go: every declared property, a datetime included, is one
field, `_p_<key>`, of those proxy.maxFieldNum allows. Undeclared properties
keep their type-tagged JSON, offset included.

The schema test asserts the native collection's exact field set, the
datetime round-trip tests check that the stored instant is the written one,
in UTC, and the longest-key test checks the one field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 15, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 15, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 15, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 15, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 16, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 16, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 16, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 16, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 19, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 19, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 19, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 19, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 20, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 20, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 26, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit that referenced this pull request Oct 27, 2026
…string-keyed partitions, built from the deployment's configuration (#1627)

* Make a vector store one collection, its partitions keyed by string, built by the composition root

A vector store was a factory of logical collections, each identified by
a (namespace, name) pair and created with its own dimensions, metric and
declared schema; Qdrant and Milvus shared one native collection among
logical collections of equal configuration, under a name derived from a
hash of that configuration, and the collection registry mapped
(namespace, name) to it.

A store is now one collection, and what it held are its partitions.
`VectorStore` is built with its vector store name, dimensions, similarity
metric and declared properties, and every partition shares them; a
partition's handle answers the metric and the declared properties.
`create_partition(key)`,
`open_or_create_partition(key)`, `open_partition(key)` and
`delete_partition(key)` take one string key, and
`purge_deleted_partitions()` replaces `purge_deleted_collections()`. A
partition is the records carrying its incarnation in the store's native
collection (Qdrant, Milvus) or a pair of tables (the SQLite stores).
"Collection" names only the native Qdrant or Milvus collection, whose
create and delete calls keep their names. `startup()` prepares the
storage a store's partitions share: the native collection and its
indexes, or the SQLite tables. Vector store names match `[a-z0-9_]+` and
are at most 32 bytes, the rule property keys follow, so every name works
on every backend: the Qdrant store names its collection by the vector
store name, and the Milvus store by `sys_` followed by it, since Milvus
requires a leading letter or underscore and names its own internals with
a leading underscore. The hash-derived native names go. A partition key
is any non-empty string of at most 255 bytes free of NUL characters,
since no store names a native resource after one. In the vector store's
utilities, a check that returns a bool is named as a predicate,
`is_valid_identifier` and `is_valid_filter`, and a check that raises
starts with `validate_`: `require_declared_types`, `require_dimensions`,
`require_valid_limit`, `require_valid_query_vector` and
`require_valid_score_threshold` become `validate_declared_types`,
`validate_dimensions`, `validate_limit`, `validate_query_vector` and
`validate_score_threshold`, the Milvus store's
`_require_every_key_accepted` becomes `_validate_every_key_accepted`,
and the configuration's `_require_retention_floor`, which checks a
store's tombstone retention, becomes `_validate_tombstone_retention`.

The collection registry becomes the partition registry, keyed by
partition key within one store. Its tables, `partition_registry_pt` and
`partition_registry_gc`, are shared by every store on a database and
keyed by vector store name, so the registries of several stores share
one database and nothing else. Its reservations, registrations,
unregistration and purge rounds are the collection registry's, with the
partition key in place of (namespace, name): the primary key arbitrates
a reservation, a confirmation or cancellation acts on one incarnation,
a registration fences on `require_current()`, and a purge round claims a
tombstone under a lease with a claim generation and counts its attempts.
The registry records no configuration per partition: every partition
shares its store's dimensions, metric and declared properties. On the
registry-backed base, the storage a partition keeps of its own is
prepared by `_prepare_partition_storage(key, incarnation)`, between its
reservation and its confirmation; the Qdrant and Milvus stores keep
nothing per partition, and a purge round works on one incarnation in the
store's own collection. The errors follow the vocabulary:
`VectorStorePartitionAlreadyExistsError`,
`VectorStorePartitionDeletedError`,
`VectorStorePartitionHandleStaleError` and
`VectorStorePartitionPendingError`.

`DatabaseManager.get_vector_store(backend, vector_store_name=,
vector_dimensions=, similarity_metric=, indexed_properties=)` builds and
caches one store per (backend, vector store name), starting its registry
and then the store. The resource manager starts a purge sweeper for each
store the first time it hands the store out. The Qdrant and Milvus
backends name their registry database with `partition_registry`, in
place of `collection_registry`. The event backend's store is named by a
UUIDv5 of its embedder id in the UUIDv5 of its backend's key in a
namespace fixed for the event backend, written as 32 hexadecimal digits,
so neither the key nor the id is constrained, and stores on different
backends never share a name. The event backend opens a session's
partition with `open_or_create_partition`, which retries the races of a
partition another worker is creating or deleting. Semantic memory's one
store is named `semantic_memory` whatever its embedder and holds every
org's features in one partition, `semantic_memory`.

The SQLite stores change shape only: their registry tables become
`vector_store_sqlite_pt` and `vector_store_sqlite_vec_pt`, keyed by
vector store name and partition key, the pending-operation log is keyed
the same way, a partition's tables and index file are named by the vector
store name and a SHA-256 digest of the partition key, and the stores of
one backend share its engine. No migration is provided:
existing SQLite vector data, and the Qdrant and Milvus collections and
registry tables of earlier releases, are left unread.

The lifecycle contract the registry-backed stores satisfy becomes the
partition lifecycle contract, and the in-memory test store's collection
becomes a partition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restate the vector store design documents for one-collection stores and their partitions

The design documents came from #1733-#1736, where a store holds logical
collections addressed by namespace and name, each with its own
configuration. A store is now one collection, with its dimensions, metric
and declared schema fixed at construction, and a partition is one
tenant's records in it, addressed by key. The documents say so:

- the collection registry document becomes the partition registry
  document: a registry belongs to one store and is addressed by partition
  key; its tables are `partition_registry_pt` and `partition_registry_gc`,
  and a tombstone needs no location, since the incarnation alone finds a
  dead partition's records in the store's native collection; a decision
  records that a store is one collection;
- the Qdrant and Milvus documents lay out one native collection per
  store, named by the vector store name (`sys_` and the name on Milvus),
  created at startup, where a partition's creation makes nothing in the
  backend;
- the overview, isolation, consistency and purge documents speak of
  partitions and `purge_deleted_partitions`, and the overview describes
  the store and its partitions.

The measurements and their conditions are unchanged; the purge document
names the #1734 commits they were taken at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Describe a vector store by what it is built with, not as a collection

The general description called a store "one collection: a body of records
searched together", which borrows the Qdrant and Milvus term for the
abstraction and says something false: every search runs within one
partition. A store is constructed with a name, a dimensionality, a
similarity metric, and a declared schema, and every record it holds has
that dimensionality and schema; the interface, the SQLite stores, the
database manager, the user documentation, and the design documents now
say so. "Collection" names only a Qdrant or Milvus native collection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name each SQLite partition's resources by an incarnation, as the registry-backed stores do

The SQLite stores named a partition's tables and index file by a SHA-256
digest of its key, so a partition created again under a key took the
deleted one's names: a handle held across the deletion wrote into the new
partition (#1536), and a creation that lost a race to create a key evicted
the winner's search engine, whose records a restart then lost (#1834).

A partition row now carries an incarnation, a UUID minted when the
partition is created, as a Qdrant or Milvus registration does. A
partition's records table, vec0 table, index file, and cached search
engine are named or keyed by it, and so are SQLiteVectorStore's pending
operations, which deleting the partition deletes by a foreign key on the
incarnation. A partition created again gets resources of its own, and a
store's startup replays only its own partitions' operations.

Every operation through a handle checks its incarnation first and raises
VectorStorePartitionHandleStaleError once the partition is deleted, an
operation with nothing to do included, as the registry-backed stores do.
A write's check is a self-checking UPDATE of the partition row: the
driver defers BEGIN until the first data-modifying statement, so the
UPDATE opens the write transaction and holds a deletion off until it
commits. For the same reason, creation writes the partition row before
its tables and deletion deletes the row before dropping them, so the DDL
rolls back with a failed transaction instead of leaving tables no
partition names. A new partition's search engine is cached once its row
commits, so a failed creation leaves none behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* State that a handle of a deleted partition raises, on every store

With the SQLite stores checking each handle's incarnation, no store acts
on a partition created again under a deleted one's key, so the partition
contract states the stale-handle error outright instead of allowing an
implementation that cannot tell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the SQLite stores' creation and deletion to the partition contract

A creation that finds its key taken raises
VectorStorePartitionAlreadyExistsError, and open-or-create returns a
handle on the partition, whoever created it. In the SQLite stores, two
callers creating one key at once both found no row, and the one whose
INSERT lost at the partition row's primary key raised SQLAlchemy's
IntegrityError from either method.

Creation now inserts the partition row without reading first and raises
VectorStorePartitionAlreadyExistsError when the primary key refuses it.
Open-or-create reads, then creates, retried as the registry-backed
stores' is, so a caller that loses the creation opens the winner's
partition; the attempt bound is now one constant for every store.

An index save that the partition's deletion overtook could write its file
after the deletion unlinked it, leaving the deleted records' vectors on
disk with nothing to reclaim them, since no later partition is named by
that incarnation. The save's transaction now opens with a self-checking
UPDATE of the partition row and unlinks the file when the row is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Serve every session's event memory from the deployment's configuration, in one store

A vector store has one dimensionality and one metric, so the event backend
named its store by a UUIDv5 of the backend and the embedder, keeping apart
the stores of sessions configured with different embedders. A deployment
instead keeps every session's event memory in the vector store, segment
store, and embedder its episodic memory configures: a session naming
others is refused with a ConfigurationError when it is opened, since its
data would live elsewhere. The event store then takes one fixed name,
`event_memory`, as semantic memory's takes `semantic_memory`, and the
resource manager protocol exposes the configuration the check reads.

The configuration documentation states the rule, and that changing a
deployment's embedder needs the event memory's vectors rebuilt first; the
database documentation and the partition registry document name the two
stores instead of describing per-embedder names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Take SQLite's write lock at BEGIN in the SQLite vector stores' write transactions

The SQLite vector stores read before they write in one transaction: the
stale-handle check, creation's existence check, deletion's lookup. The
driver's deferred BEGIN ran those reads outside the transaction, and the
stores worked around it statement by statement: a self-checking UPDATE
first, the partition row before any DDL, and creation decided by the
primary key, with retries.

The database manager now sets up a SQLite vector store backend's engine
when it creates it, before any connection is pooled, so the setup covers
every connection: every transaction begins explicitly, with a deferred
BEGIN for reads and BEGIN IMMEDIATE for a transaction opened with
write_transaction. A write transaction's reads, writes, and DDL are then
one atomic unit, and reads still never take the write lock. The hooks and
write_transaction live in memmachine_server.common.sqlite_transactions,
and the stores require an engine set up so. This is #1609's mechanism,
installed where the engine is created instead of by the store.

Every write transaction of both stores opens with write_transaction. The
stale-handle check is a read inside it; creation and open-or-create check
and add the partition in one transaction, and deletion looks it up and
drops it in one. The open-or-create retry goes, and with it the attempt
bound the SQLite stores shared with the registry-backed stores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the shared SQL engine setup and transactions in common/sql.py

The module holding enable_sqlite_explicit_begin and write_transaction is
named for what the server's SQLAlchemy-backed stores share, engine setup
and transactions, instead of for one of its functions; write_transaction
serves stores on any dialect, and only the SQLite-specific functions say
so in their names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the naming and sweeping rules the redesign states but did not test

A partition key's limit is in bytes: every store now refuses 128 two-byte
characters, 256 bytes in fewer than 256 characters. Each store's params
refuse a vector store name outside the rule, as the registry's already
did, so a name one backend would refuse never reaches any. The partition
registry keeps a key of the longest length with characters no identifier
allows on every dialect it runs on, PostgreSQL included. Stores of two
names on one backend get a purge sweeper each, since each store purges
its own registry's deleted partitions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Roll back a write transaction whose commit fails

SQLite keeps a transaction whose COMMIT failed open, and with it the
write lock: a COMMIT that waits out the busy timeout behind a reader,
for one, reports the database locked and leaves the transaction to be
retried or rolled back. write_transaction committed after its block and
left the rest to the session's close, which after a failed commit
returns the connection to the pool without a rollback. The connection
then held the write lock for good, and the next transaction to begin on
it failed, since SQLite cannot begin a transaction inside another.

write_transaction now runs its block in the session's transaction
context, which commits when the block exits and rolls back when the
block raises or the commit fails, as engine.begin() and session.begin()
do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Open the SQLite vector stores' startup DDL as a write transaction too

On an engine set up for explicit BEGIN, every transaction that writes
opens as a write transaction, and the SQLite vector stores' startup
create_all still ran in engine.begin(): its table checks read before its
DDL writes, which a deferred BEGIN cannot upgrade while another
connection holds the write lock. write_connection, for work on a
connection rather than a session, takes the write lock at BEGIN and
commits or rolls back as write_transaction does, and both stores'
startup uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Annotate the write transaction context managers as async generators

asynccontextmanager deprecates annotating the decorated function as
returning an AsyncIterator; write_transaction and write_connection are
annotated as the AsyncGenerator they are, as the codebase's other
context managers are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Give every vector store's parameters one base, VectorStoreParams

Every store is constructed with a name, a dimensionality, a similarity
metric and a declared schema, and the SQLite, sqlite-vec, and
registry-backed stores' parameters each declared the four, with the
vector store name rule's validator, anew. They are now VectorStoreParams
in vector_store.py, beside the store they describe, and each store's
parameters inherit them and add what its backend needs; a store keeps
what its name names, and the metrics its backend accepts, in its own
parameters' description. Each store is constructed as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name the session write transaction write_session, as write_connection is named

Both context managers open a write transaction, and write_connection is
named for what its block receives; the session one is now write_session
for the same reason, and its tests are named to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Describe only what a caller chooses in the registry-backed stores' parameters

The Milvus and Qdrant parameters described how each store lays its
native collection out: the collection's name, a declared property's
field or payload index. A caller chooses none of it, and the constants
that name the native collection and fields, the design documents, and the
user documentation say it already. The parameters keep what bears on the
values a caller passes: Milvus's similarity metric is cosine, dot, or
euclidean, and stores of different names may share a client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the redesign's docstrings and comments to what each place needs

An audit of the docstrings and comments the redesign added or changed
against the codebase's writing rules. Removed or cut down: text that
named consumers or callers (the composition root, services, sessions, the
registry beside the partition key rule), implementation layout in the
vector store and registry ABCs and in parameters (native collections,
payload indexes, tables, rows), restatements of a contract or of the
code at a second place (the module docstring, partition and store class
docstrings, the cascade, the hook's mechanism), examples, negatives, a
figure that copies a constant, and comments that ordered steps without a
reason. A docstring that said every read of the SQLite partition table
names the vector store, which incarnation reads no longer do, goes. That
stores of different names may share a client moves to the Qdrant and
Milvus client fields, and the registry's name follows the vector store
name rule instead of restating it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pass a vector store's dimensions, metric, and declared properties as one schema

A store's dimensions, similarity metric, and declared properties travelled
as three values through `get_vector_store` on the resource manager protocol,
its implementation, and the database manager, through each backend's
builder, into each store's parameters, and from each store into each
partition handle. `VectorStoreSchema` holds the three, with the dimension
bound and the property rules, and travels as one value; it is frozen, since
a store and its handles share it. `VectorStoreParams` holds the vector
store name and the schema.

The name stays beside the schema rather than in it: with the backend, it
is the key the database manager and the purge sweepers find a store by,
and it names the store's native collection, registry, and rows, while the
schema is what the store is built with.

"Schema" now names that whole shape, so text that used it for the declared
properties alone says "declared properties", and
`EventMemory.expected_vector_store_collection_schema()` becomes
`expected_vector_store_indexed_properties()`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name what the redesign's code and tests still call a collection

The redesign leaves "collection" to Qdrant's and Milvus's native
collections; these places still used it for a store or a partition.

- The filter event memory hands its vector store is `vector_store_filter`.
- Two `EventMemory` docstrings no longer say a method raises when the
  collection schema lacks a field: neither method checks that.
- A long-term memory comment names the vector store's metric, not the
  collection's.
- The store tests' handles are `partition`, `partition_a`, `partition_b`,
  and `partition_2`, and their test names, docstrings, partition keys, and
  section headers say store or partition; the semantic storage and event
  memory tests' partitions are `vector_store_partition`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name the semantic storage sync test for its partition

Its fixture is the vector store partition that was a collection before
the redesign.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name a configured vector store's identifier `vector_store_id`

`get_vector_store`'s first argument, and the database manager's and
resource manager's keys, called the configured vector store a backend, a
word the semantic manager's `storage_backend` and the event backend
already use for other things. The value is the id of a vector store
configured under `resources.databases`, the value
`episodic_memory.long_term_memory.vector_store` holds, so it is
`vector_store_id`, beside the `vector_store_name` of a store on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Set up the SQLite vector stores' engines where they are created

The SQLite vector store registered its foreign keys pragma, and the
sqlite-vec store its extension loader, on the engine it is handed, which
other stores on the same configured vector store share. A connect listener
reaches only connections opened after it, so this held only while no
connection was pooled before a store was built. The database manager now
registers both where it creates a SQLite vector store's engine, beside
`BEGIN IMMEDIATE`, and the stores state the requirement on their engine
parameter. The SQLite vector store still registers the pragma on the
synchronous engine it creates itself.

`enable_sqlite_foreign_keys` moves to `common/sql.py`, beside
`enable_sqlite_explicit_begin`, and the sqlite-vec loader is
`load_sqlite_vec_extension`, beside its store. The database manager's
tests check that its engines enforce foreign keys and load sqlite-vec on
a new connection; the store tests set up their engines the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Close the SQLite vector store's own engine at shutdown

The store creates a synchronous engine for the filter it evaluates during
a search, and nothing closed it: a filtered query left a connection in its
pool after `shutdown()`. Shutdown now disposes it; a store started again
opens new connections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a request for a store built with another schema

The database manager builds a store on the first request for its name and
returned it to every later request, whatever schema that request asked
for, so a caller asking for other dimensions, metric, or declared
properties got a store built for the first caller and failed later on a
record or a filter. A later request naming the store with another schema
now raises `VectorStoreConfigurationError`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Start the SQLite vector stores' DDL as every other store does

`916cb57e8` opened both SQLite vector stores' startup `create_all` with
`write_connection`, so that concurrent first starts on one file
serialize. Every store that creates its tables at startup has that race,
PostgreSQL included, and taking schema creation out of startup removes it
for all of them (#1570). These two stores start in `engine.begin()` like
the others, and `write_connection`, with no other use here, leaves with
its tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Skip the sqlite-vec engine test where sqlite3 cannot load extensions

The database manager's test that a sqlite-vec store's engine loads
sqlite-vec opens a real connection, and sqlite3 on the macOS runners is
built without `enable_load_extension`. It takes the condition the
sqlite-vec store's tests already skip on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse a project whose event memory is not the deployment's when it is created

A session whose event memory names a vector store, segment store, or
embedder other than the deployment's was refused only when it was opened.
Creating it succeeded, and deleting it opens it, so it could never be
deleted. Creation now refuses it with `InvalidArgumentError`, a 422 from
the project API, through the same check opening uses
(`is_deployment_event_memory`). A deployment that configures no event
memory refuses event projects the same way. The configuration docs say so,
and that changing the deployment's vector store, segment store, or
embedder, or its backend, leaves existing sessions' event memory
unopenable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say deployments on different backends need different registry databases

The registries are keyed by vector store name, and event memory's and
semantic memory's names are fixed, so two deployments on different
backends that keep their registries in one database would share
registrations and tombstones but not records: one's purge could retire
the other's tombstone without reclaiming its records. The docs said such
deployments share stores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer a partition's schema as one value

A partition handle answered its store's similarity metric and declared
properties as two properties, though it is built with one
`VectorStoreSchema`. `VectorStorePartition.vector_store_schema` answers
that schema, and each handle returns the one it holds. Event memory and
long-term memory read the metric and properties from it, and the
in-memory test partition is built with a schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Share one search engine among a partition's concurrent first opens

`_get_or_create_vector_search_engine` checked the engine cache, awaited
the read of `index_saved`, and then stored its engine, so two opens of
one partition that both missed the cache each built an engine, and the
second replaced the first. The first handle's writes then went to an
engine no later handle used, and they dropped out of the index once the
cached engine saved it. The cache now keeps the engine stored first; a
test opens a restarted store's partition twice at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Create the SQLite vector stores' partitions as the other stores do

Both SQLite stores created a partition through
`_add_partition_if_absent`, a get-or-create that returned the
incarnation and whether it added the partition, each caller discarding
one; `SQLiteVectorStore`'s also built the new partition's search engine
and cached it after commit. They now follow the segment store and the
partition registry: `_insert_partition` inserts the registry row under a
minted incarnation, so the unique constraints decide the key and the
incarnation, and creates the tables in the same transaction.
`create_partition` mints again when an insert is rejected for a reason
other than the key being taken, up to `_MAX_MINT_ATTEMPTS`, and
`open_or_create_partition` reads, then inserts, retried the same way.

A handle's search engine is built when the handle is opened, as
`open_partition` already did, so `SQLiteVectorStore` now builds one at
construction to refuse a metric or dimensionality its engine cannot
serve before any partition exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Raise the stale-handle error from a query its partition's deletion overtakes

`SQLiteVectorStorePartition.query` checks the partition is live, then
searches, reading the records table per candidate under a filter, and
then reads the matches' rows. A deletion committed in between drops the
table, so the query failed with SQLAlchemy's `OperationalError` ("no such
table") rather than `VectorStorePartitionHandleStaleError`. A query that
fails with `OperationalError` now reads the registry again: if the
partition is gone it raises the stale-handle error, and otherwise the
original error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name a store's schema `store_schema`

`vector_store_schema` spelled out its type's whole name at every use.
`store_schema` names the same value everywhere: `get_vector_store`'s
argument, the stores' parameters' field, the handles' constructor
argument and attribute, and `VectorStorePartition.store_schema`. A field
named `schema` would shadow Pydantic's `BaseModel.schema`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave a store built with another schema to the startup check

`62c7a511a` refused a request naming a store already built in this process
with another schema. It compared against the process's own cache, so it
never saw another process or a restart, where the startup check against
storage owns the comparison. Within one process, only a runtime change of
the configured embedder could reach it, and refusing such changes is left
to a later change. A store built with other dimensions still refuses a
vector of the wrong length on every write and query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* State the Milvus and sqlite-vec metric limits as rules on the schema

The metric is no longer a field of these parameters but the schema's, and
the sentences read as a description of it. They now say which metrics the
schema may give.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the SQLite partition rows' incarnation comments

The partition registry and the segment store declare the same column
without one; the partition contract and the design documents say what an
incarnation is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Spell a property's type PropertyType where the redesign wrote it out

validate_declared_types and
EventMemory.expected_vector_store_indexed_properties, both rewritten by
the redesign, take or answer the alias the redesign added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Delete a session's event memory from the deployment's stores

A session records the vector store, segment store, and embedder it was
created with. Opening refuses one whose recorded stores are not the
deployment's, and deletion built the session the same way, so it raised
too: the queued deletion failed before the session's row went, and
every later scan failed it again.

Deletion now moves the session's event memory onto the deployment's
vector store, segment store, and embedder before building it, or drops
it when the deployment configures no event memory, so it always
completes. What the session kept in other stores stays there, as the
configuration documentation says. Building the deployment's event store
with the session's embedder instead could build it, and on a fresh
backend create it, with another schema.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Take every session's event memory stores from the deployment's configuration

A session saved the vector store, segment store, and embedder it was
created with, and review commits compared those copies with the
deployment's configuration: opening refused a session whose copies
differed, creation refused a project that named others, and deletion
substituted the deployment's. Each covered one path, and a renamed
configuration id still made every session unopenable.

The session's event configuration no longer has those fields; event
memory takes them from the deployment's configuration whenever a session
is created, opened, or deleted, and rows saved earlier load with their
copies ignored. The three checks and the substitution go. Creation
refuses an event project only when the deployment configures no event
memory, which the merge used to refuse before the fields went. Project
responses no longer name the deployment's stores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove the event memory stores from the project API

A project could name the vector store and segment store of its event
memory, and the response named them back, though every event session now
uses the deployment's. ProjectConfig no longer has `vector_store` or
`segment_store`, so a request that still sends them has them ignored and
a response names no store; the Python client's create_project and
get_or_create_project drop the parameters. A project's `embedder`
applies to the declarative backend only, as its description now says;
an event project's is ignored, as its `vector_graph_store` already was.
The OpenAPI document is regenerated for these fields only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Say a deployment without event memory can neither open nor delete event sessions

Earlier releases let a project name its own event memory stores on a
deployment whose long-term memory is declarative. Every session now
takes its stores from the deployment's configuration, so on such a
deployment those sessions can be neither opened nor deleted until it
configures event memory. The configuration documentation says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 27, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 27, 2026
A declared datetime on the SQLite stores had a `tz_<key>` column beside its
microseconds column, holding the UTC offset in seconds that no filter
reads. The vector stores keep a datetime property's instant only, as
MemMachine#1736 now does for Milvus and MemMachine#1788 for Qdrant, and the segment store
keeps the offset, so the column, `offset_column_name`, and the value
written to it go: each declared property is one column, and a datetime
stays microseconds since the epoch.

The tests read a stored datetime back as its instant in UTC, and the
roundtrip test checks that the stored instant equals the written one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Oct 27, 2026
The declared-schema contract gains a test that every store matches
strings holding accented letters, an emoji, quotes, a backslash, and
control characters, by equality and by membership. It fails on Milvus
with `ensure_ascii=False` removed from `_expression_string_literal`, the
UTF-8 literal [vector store scale-out 6/6] ports from MemMachine#1736's 1a7fffa.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESpWYTmCR7X3bJEpoA8SAn
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

horizontal scaling Wrong or unsafe when more than one server process serves the same backends (replicas or workers)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants