You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Tracking: move multitenancy from sharded (one process per collection, per-tenant tables and shard keys) to non-sharded (shared structures served by any process) #1574
The issues linked below were filed one at a time, most of them as bugs. Read individually they look like an accumulation of unrelated defects in the storage layer. Read together they are one change of model: the codebase was built for sharded horizontal scaling (each logical collection or partition owned by exactly one process, and separated from other tenants by a physical structure of its own), and the requirement is now non-sharded scaling (any process serves any tenant, and tenants are rows or values inside structures they share). This issue states that change once, records why the previous model was there, classifies each linked issue against it, and tracks what remains.
The model the codebase was built on, and why
Two decisions, both explicit and documented when they were made.
1. Ownership by process sharding.#1201 (merged 2026-04-02) set the VectorStore contract that is still in common/vector_store/vector_store.py today:
A given logical collection identified by a (namespace, name) pair must be managed by at most one process at a time. The consumer is responsible for sharding names across processes.
Its description states the guarantees plainly: "safe concurrent usage on a single process" and "safe concurrent sharded usage (one node per logical collection) on multiple processes". With ownership fixed per process, lifecycle operations (create, open-or-create, delete) could be read-check-write sequences guarded by per-process asyncio locks, and backends without transactions or unique constraints (Qdrant, Milvus) could keep their catalog of logical collections inside the backend itself. The multi-process guarantee was correct under its contract and was never broken: strict sharding always worked. The restriction itself is what became the limitation (#1524, #1525).
2. Per-tenant physical structures.#1205 (merged 2026-05-08) scoped the event memory "by partition key: should allow sharding and horizontal scaling", and on PostgreSQL gave every segment-store partition its own pair of child tables under LIST-partitioned parents, created and dropped by per-tenant DDL. #1292 (merged 2026-05-02) gave every SQLite vector collection its own tables, for a stated reason: "Partition keys are avoided in favor of per-collection tables, since sqlite-vec ANN indexes may not support them." Qdrant's is_distributed flag gives every logical collection its own shard key. The payoff was real: deleting a tenant was one DROP rather than a row-scanning DELETE, one tenant's rows never interleaved with another's in a heap or index, and backend index limitations were sidestepped. Note that #1201 already rejected one native collection per tenant ("most vector databases recommend against one collection per tenant, and empirical tests show that many collections induces significant overhead"); the per-tenant structure was the shard key or child table beneath a shared native collection, not the collection itself.
Both decisions fit the deployments they served: a single server process, embedded SQLite as a first-class backend, an opt-in pre-GA event backend, and tenant counts at which per-tenant DDL was cheap. Nothing below should be read as those decisions having been mistakes at the time.
Measured, in the linked issues and in the design doc:
Cost of the model
Evidence
Per-tenant DDL on shared PostgreSQL parents
#1544's second half, and the design doc in #1545: create and delete DDL deadlocked with writers and with each other, on upstream and on every intermediate fix, in two cycle shapes; the class has nowhere to live once there is no lifecycle DDL. (#1544's first half, a CASCADE that removed the shared foreign key, is a plain bug; see the classification below.)
Per-tenant partitions and prepared statements
#1546: after five executions PostgreSQL switches to a generic plan that locks every child partition per read: 9 locks per read with a custom plan, 319 with the generic plan at 316 partitions, HTTP 500 out of shared memory at 128-way concurrency. Each per-tenant table also costs about 5 catalog relations and disk files, which does not survive the required tenant counts.
Per-tenant shard keys on Qdrant
#1564: 450 ms per tenant admission, 45 s for 100 tenants against 0.3 s with payload partitioning, 505 segments against 5, and cluster mode required.
Per-process ownership
#1524, #1525: the moment two processes manage the same name, lock-guarded read-check-write lifecycle has no arbiter. #1566 (folded into #1564): the in-process lock table was a leftover of that serialisation.
The comparison that settled the segment store (raw asyncpg, 40 tenants x 2000 row-pairs, pgvector:pg16, 2 CPUs; full table in the design doc):
layout
ingest
seed read
tenant create
catalog cost
PARTITION OF children
8.7-9.2k pairs/s
0.43-0.46 ms
~12 ms DDL
~5 relations/tenant
shared tables
15k pairs/s
0.21 ms
0.006 ms
none
Per-tenant tables won only small-tenant removal latency (9 ms drop against 58 ms row delete), which does not justify their catalog cost at the required tenant counts. This is the industry's pool model: O(1) logical deletion via a registry plus asynchronous batched reclamation; O(1) physical drops exist only in per-tenant-resource (silo) architectures, which the tenant-count requirement excludes.
The target model
The layer above the stores that ties these together (a tenant record with a step table and a reconciler, UUID-only store keys minted per tenant lifetime, a single tenant handle, and prompt purge as best-effort) is proposed in design/tenant_lifecycle.md under #1579, with the resource-level contracts the stores expose to it. The bullets below are the store-level targets that proposal builds on.
A tenant is a row, not a relation. One registry row per live tenant, carrying a random incarnation minted at creation; data rows and records are keyed by the incarnation. Creating a tenant is an insert.
One physical structure per backend, shared by every tenant: shared tables on SQL; payload-partitioned native collections on vector backends, with native collections shared by config as Improve vector store API #1201 already did.
Lifecycle arbitrated by the database, so any process may perform it: unique constraints decide create races, row locks serialise delete against in-flight writers, FOR UPDATE SKIP LOCKED lets concurrent purgers split a backlog.
Deletion is O(1) logical (registry delete plus a purge-queue entry in one transaction); physical reclamation is asynchronous, bounded per call, safe to run from any process, and driven by a background purger.
native container created inside create_collection (a dual write across registry and backend, safe only because names are content-addressed); per-collection VectorStoreCollectionConfig promising per-tenant schemas
containers provisioned before serving; create_collection is a registry insert; collection shape configured per deployment
Found along the way, not sharding-related, but blocking correctness on SQLite: #1568 (per-connection state registered per store on caller-supplied engines) and #1542 (engines built without WAL, busy timeout, or explicit write transactions). Contract issues that matter more once native collections are shared across tenants: #1534 (unbounded property-key cardinality, undeclared-property mandate) and #1535 (indexed_properties_schema is part of collection identity); #1573 narrows that to whether per-collection configuration belongs in the interface at all.
The "at most one process" clause is gone from vector_store.py, replaced by a scope every store declares and honours.
No backend creates a per-tenant physical structure by default; tenant creation is a row write everywhere, and the per-tenant tier is an explicit opt-in.
Every lifecycle operation is safe from any process on the same backend: create races resolve by constraint, delete is idempotent and fences in-flight writers, purge is concurrent-safe and bounded.
A stale handle cannot reach a successor tenant on any backend (raising where the backend can pin a registry row, landing in a dead incarnation where it cannot), and every backend has reclamation for dead tenants.
Native containers are provisioned before serving and creating a logical collection is a single registry insert; collection shape is configured per deployment, one schema per container.
Why this issue exists
The issues linked below were filed one at a time, most of them as bugs. Read individually they look like an accumulation of unrelated defects in the storage layer. Read together they are one change of model: the codebase was built for sharded horizontal scaling (each logical collection or partition owned by exactly one process, and separated from other tenants by a physical structure of its own), and the requirement is now non-sharded scaling (any process serves any tenant, and tenants are rows or values inside structures they share). This issue states that change once, records why the previous model was there, classifies each linked issue against it, and tracks what remains.
The model the codebase was built on, and why
Two decisions, both explicit and documented when they were made.
1. Ownership by process sharding. #1201 (merged 2026-04-02) set the
VectorStorecontract that is still incommon/vector_store/vector_store.pytoday:Its description states the guarantees plainly: "safe concurrent usage on a single process" and "safe concurrent sharded usage (one node per logical collection) on multiple processes". With ownership fixed per process, lifecycle operations (create, open-or-create, delete) could be read-check-write sequences guarded by per-process
asynciolocks, and backends without transactions or unique constraints (Qdrant, Milvus) could keep their catalog of logical collections inside the backend itself. The multi-process guarantee was correct under its contract and was never broken: strict sharding always worked. The restriction itself is what became the limitation (#1524, #1525).2. Per-tenant physical structures. #1205 (merged 2026-05-08) scoped the event memory "by partition key: should allow sharding and horizontal scaling", and on PostgreSQL gave every segment-store partition its own pair of child tables under LIST-partitioned parents, created and dropped by per-tenant DDL. #1292 (merged 2026-05-02) gave every SQLite vector collection its own tables, for a stated reason: "Partition keys are avoided in favor of per-collection tables, since sqlite-vec ANN indexes may not support them." Qdrant's
is_distributedflag gives every logical collection its own shard key. The payoff was real: deleting a tenant was oneDROPrather than a row-scanningDELETE, one tenant's rows never interleaved with another's in a heap or index, and backend index limitations were sidestepped. Note that #1201 already rejected one native collection per tenant ("most vector databases recommend against one collection per tenant, and empirical tests show that many collections induces significant overhead"); the per-tenant structure was the shard key or child table beneath a shared native collection, not the collection itself.Both decisions fit the deployments they served: a single server process, embedded SQLite as a first-class backend, an opt-in pre-GA event backend, and tenant counts at which per-tenant DDL was cheap. Nothing below should be read as those decisions having been mistakes at the time.
What changed
Two requirements the sharded model does not meet:
design/segment_store_shared_tables.mdin Overhaul segment store: shared tables with incarnation-scoped tenant keys (fixes #1544, #1546, #1549) #1545).Why the sharded model fails them
Measured, in the linked issues and in the design doc:
CASCADEthat removed the shared foreign key, is a plain bug; see the classification below.)out of shared memoryat 128-way concurrency. Each per-tenant table also costs about 5 catalog relations and disk files, which does not survive the required tenant counts.The comparison that settled the segment store (raw asyncpg, 40 tenants x 2000 row-pairs, pgvector:pg16, 2 CPUs; full table in the design doc):
PARTITION OFchildrenPer-tenant tables won only small-tenant removal latency (9 ms drop against 58 ms row delete), which does not justify their catalog cost at the required tenant counts. This is the industry's pool model: O(1) logical deletion via a registry plus asynchronous batched reclamation; O(1) physical drops exist only in per-tenant-resource (silo) architectures, which the tenant-count requirement excludes.
The target model
The layer above the stores that ties these together (a tenant record with a step table and a reconciler, UUID-only store keys minted per tenant lifetime, a single tenant handle, and prompt purge as best-effort) is proposed in
design/tenant_lifecycle.mdunder #1579, with the resource-level contracts the stores expose to it. The bullets below are the store-level targets that proposal builds on.incarnationminted at creation; data rows and records are keyed by the incarnation. Creating a tenant is an insert.FOR UPDATE SKIP LOCKEDlets concurrent purgers split a backlog.FOR SHARE), a stale handle raises; where it cannot (the vector backends), a stale handle's writes land in a dead incarnation that nothing reads and reclamation removes, with lease validation against the registry as the remaining option (Handles held across delete and recreate resurrect records in QdrantVectorStore #1563, Commit Qdrant to payload-partitioned multitenancy: drop per-collection shard keys, retire the in-process lock table, recover fencing and reclamation #1564). Either way a tenant recreated under the same key is unreachable from the old handle, on any process, without coordination.Status by component
Each row names the issues that carry the work; fixes and their state are tracked on those issues, not here.
create_collection(a dual write across registry and backend, safe only because names are content-addressed); per-collectionVectorStoreCollectionConfigpromising per-tenant schemascreate_collectionis a registry insert; collection shape configured per deploymentis_distributedshard key per logical collection; native name re-derived fromsha256(config)on every open; in-process lock tablepartition_keyfield filtered on queries (already value-based); catalog in a dummy-vector__registrycollectioncreate_allat boot; no migration path for a layout changeFound along the way, not sharding-related, but blocking correctness on SQLite: #1568 (per-connection state registered per store on caller-supplied engines) and #1542 (engines built without WAL, busy timeout, or explicit write transactions). Contract issues that matter more once native collections are shared across tenants: #1534 (unbounded property-key cardinality, undeclared-property mandate) and #1535 (
indexed_properties_schemais part of collection identity); #1573 narrows that to whether per-collection configuration belongs in the interface at all.How to read the linked issues
CASCADEthat removes a shared constraint), QdrantVectorStore re-derives the native collection name on every open, so any config-serialization change silently orphans existing data #1562 (config serialisation repoints data), SQLite per-connection state (foreign keys, sqlite-vec extension) is registered per-store on caller-supplied engines #1568 (per-connection SQLite state registered late).Done when
vector_store.py, replaced by a scope every store declares and honours.Out of scope
EpisodicMemoryManagercache beyond handle eviction ([Feat]: Server tech debt resolution wishlist #1297 rates it the main scaling bottleneck).Investigated and written by Claude (Claude Code), filed from the account of the user who commissioned the investigation.