{{ message }}
Overhaul segment store: shared tables with incarnation-scoped tenant keys (fixes #1544, #1546, #1549) - #1545
Closed
edwinyyyu wants to merge 114 commits into
Closed
Conversation
…artition metadata on delete Deleting a partition on PostgreSQL dropped its child tables with CASCADE. The foreign key from segment_store_dv_ln to segment_store_sg is declared on the partitioned parents, so the CASCADE dropped the parent-level constraint rather than only the part belonging to the deleted partition. After the first partition deletion the store stopped enforcing the link for every remaining partition, and ON DELETE CASCADE stopped removing derivative links with it, so delete_segments left orphaned rows that get_derivative_uuids_by_segment_uuids still returned. Detaching each child before dropping it keeps the constraint and the cascade intact. delete_partition also took only a row lock on the partition row. ROW SHARE does not conflict with the SHARE ROW EXCLUSIVE table lock the create paths take, so a concurrent create and delete could reach segment_store_sg and segment_store_dv_ln in opposite order and deadlock; that reproduced in 4 of 7 sampled interleavings against PostgreSQL 16, and in 0 of 7 once delete takes the same table lock first. The row lock stays, because it is what makes delete wait for in-flight writers holding FOR SHARE on that row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MpFwBnZ6SxdMe3pSuMTCHD
Fixes MemMachine#1546. Parent-table queries carry the partition key as a bind parameter; once asyncpg's prepared statement flips to a cached generic plan (after five executions) PostgreSQL locks every child partition on every execution before runtime pruning. With hundreds of partitions and concurrent sessions this exhausts the lock table (searches fail 500 'out of shared memory') and saturates the database CPU with lock churn and generic-plan startup. The partition handle now maps the ORM entities onto its own child tables (orm.aliased with adapt_on_names) and targets them for insert/delete, so every plan references exactly one partition. SQLite keeps the parent tables (it has no children). Measured on a store with 316 partitions: max relation locks held by a backend during a read loop drops 1276 -> 4; the 239 HTTP 500s in a 4-worker load test disappear; throughput at 128 concurrent requests rises ~20-30% with PostgreSQL no longer pinned at its CPU cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The foreign-key half of this branch has a regression test; the lock ordering did not. A deadlock test would be timing-dependent, so assert the invariant the deadlock analysis rests on instead: delete_partition issues LOCK TABLE segment_store_pt IN SHARE ROW EXCLUSIVE MODE, and issues it before any DETACH or DROP of a child table. Without the lock the test fails and reports the statement sequence it saw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MpFwBnZ6SxdMe3pSuMTCHD
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MpFwBnZ6SxdMe3pSuMTCHD
Regression test for MemMachine#1546. Captures the SQL the partition handle emits across add / seed read / windowed read / filtered read / uuid maps / delete and asserts no statement references the partitioned parents -- the deterministic observable of the generic-plan lock explosion (lock counts would need timing-dependent pg_locks sampling). Fails against the parent-table implementation, passes with per-partition DML. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 tasks
Contributor
Author
This SQLAlchemy version exports no public AliasedClass name (only the aliased() factory), so the attribute annotations forced an import from sqlalchemy.orm.util. The annotations were documentation only; the branch comment already records that the attributes hold either the ORM class or its child-table alias. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The parent names come from the models' __tablename__ and the child naming pattern lives in _pg_child_table_name, used by child-table creation, teardown, and the per-partition DML targets, so the three sites cannot drift apart. Physical names are unchanged; the regression tests keep literal names to pin the on-disk naming contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…le waiting for writers Three follow-ups from review of this branch. The child-table probe asked whether the table exists, but the statement it guards is DETACH PARTITION, which requires that the table be attached. A child left detached by manual maintenance or an interrupted DETACH PARTITION CONCURRENTLY (that form is not transactional) passed the probe and made DETACH raise "is not a partition of", rolling back the transaction, so the partition became neither deletable nor recreatable -- a state the CASCADE drop this branch replaced used to clean up. Probe pg_inherits for attachment instead, in one round trip for both children, and drop an unattached child directly. delete_partition took the partitions-table lock before the row lock that waits for in-flight writers, so a slow writer on one partition stalled open_or_create_partition for every partition, which runs on the request path. Take the row lock first; the table lock only has to be held across the child DDL for the deadlock argument to hold. Re-measured: 4/7 sampled interleavings deadlock with no table lock, 0/7 with either ordering. Tests: the foreign key is now asserted to be enforced after a partition delete, not only that the cascade fires -- the PR's measurements list those as separate things the CASCADE drop broke. A second test leaves a child detached and requires deletion to succeed and the key to be reusable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MpFwBnZ6SxdMe3pSuMTCHD
…helper Review follow-ups on the child-table change: - The child Table objects and aliases are now built once per partition key (functools.cache) instead of per handle. SQLAlchemy's compiled- statement cache keys on the Table objects a statement references, so per-handle tables made every handle's statements recompile and polluted the cache for everything else (verified: cache keys differed across handles for the same partition; now identical). - The tables carry columns only. The to_metadata copies dragged along foreign keys with unresolvable targets and duplicate index names -- latent hazards for anything walking that MetaData. - _pg_child_table_name validates the partition key itself, so every SQL string built from a child table name (including the DDL literals) is safe by construction rather than by call-site convention; the store's validator moved to module level beside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was
linked to
issues
Aug 31, 2026
…tract Partition keys are embedded in native storage identifiers by any implementation, so the alphabet/length rule is interface-level, not a SQLAlchemy detail: validate_partition_key now lives in the package's data_types (exported from the package), the SegmentStore.create_partition docstring states the contract, and the SQLAlchemy store imports it. Deliberately NOT unified with the vector store's identical identifier rule: the repo-wide naming contract is not wired through yet, so the convergence is treated as incidental for now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirrors the vector store's layout (validate_identifier in vector_store/utils.py); the interface docstring states the key rule plainly instead of referencing a code path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same ABC-level 'Naming constraints:' block the vector store uses, no method-level restatement, and the length limit is enforced and documented in bytes, matching validate_identifier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mirrors the vector store's validate_identifier: the predicate returns bool so callers can compose it, and each entry point raises its own error in the vector store's message style. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This reverts commit 88be866.
functools.cache grew ~29 KiB per distinct partition key (measured) for the life of the process, including deleted partitions. The LRU cap bounds it at ~115 MiB per worker; eviction is harmless since a rebuilt entry is identical and only costs recompiling that partition's statements once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Type the partition entities: _pg_partition_entities returns a NamedTuple with real field types instead of a positional tuple of object, which was adding 55 ty diagnostics (the CI static check would have failed) and forcing blind unpacking; callers and the memoization test use named fields, and the private _generate_cache_key assertion (redundant given object identity) is dropped. - DROP TABLE gains IF EXISTS back, so an out-of-band drop landing between the state probe and the drop cannot leave a partition half-deleted. - open_or_create_partition opens existing partitions without the store-wide management lock (double-checked: unlocked read, then lock and re-check only when creating), so request-path opens no longer serialize behind a concurrent deletion's DDL window; pinned by test_open_existing_partition_takes_no_management_lock. - The engine's compiled-statement cache is raised from the default 500 (per-partition statements would thrash it once enough partitions are live concurrently). - Comments and the DML test docstring scope the lock claim honestly: PostgreSQL's FK integrity triggers still address the parents internally, costing a one-shot per-backend lock spike on writes/deletes when a trigger plan first goes generic (verified: ~10 locks steady, one spike at execution six, then back) -- tracked on MemMachine#1546. - The detach test cleans up its detached child unconditionally so a failure cannot poison the session-scoped container for later tests; the statement recorder is a shared fixture instead of copy-paste; the byte-length check encodes once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
inspect(Model).columns yields real Column objects (the stubs type __table__ as FromClause, which forced the cast), and SQLAlchemy's typing convention represents an aliased entity as the mapped class type, so the NamedTuple fields are type[SegmentRow] / type[DerivativeLinkRow] and aliased() assigns without coercion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
edwinyyyu
marked this pull request as draft
August 31, 2026 18:41
Outside the segment store, only regressions this PR introduced that are worse than what they replaced: - The in-memory short-term filter evaluator compared filter datetimes (now UTC-aware by the filter language's contract) raw against stored metadata, so a naive stored value raised TypeError on ordering and compared silently unequal. Both sides are tagged as UTC before the comparison, the codebase-wide naive-means-UTC rule, rather than rewriting stored user data. - drop_session_partition raised a drain failure after the destructive work had committed, reporting a completed erasure as failed, and a retry died on the nulled handles. The drain is opportunistic (the background purger finishes reclamation), so the failure is logged. - The purge loop was a bound method, so its pending task pinned the whole ResourceManagerImpl for the loop's lifetime unless close() ran. It is a module-level coroutine over the store now; the pin was reproduced with a weakref probe on the bound-method version. - The TZ-pinned Neo4j test skips where time.tzset is missing; Windows is in the pytest matrix. In the store, on the ordinary standard: - Only unique and primary-key violations enter the registry insert's collision-retry path (SQLSTATE 23505 on PostgreSQL drivers, sqlite3's extended result names); any other integrity error surfaces as itself instead of ten logged re-mint attempts ending in SegmentStoreAttemptsExhaustedError. - The startup foreign-key probe drew one arbitrary connection, so a caller's pool mixed before and after pragma registration passed or failed by which connection it got. It draws every checked-in connection at once and probes each. Each fix's test fails with the fix ablated (evaluator, drain, and both store tests); the resource-manager pin is the probe's finding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…possible collision Two round-6 changes reverted on the author's direction. The startup PRAGMA foreign_keys check is removed entirely, not widened to every pooled connection. The reference practice for SQLite foreign keys (SQLAlchemy's dialect docs, Django's and Rails' adapters) is for the engine's owner to register the pragma at connection creation and trust it; DatabaseManager already does, and the requirement now sits on the store's engine parameter. A one-connection check was a coin flip on a mixed pool, probing the whole pool was non-standard and could stall on pool_timeout, and requiring an empty pool is false by construction (boot validation pools a connection first). The purge's integrity-escape guard remains the backstop for a misconfigured engine. The registry insert no longer classifies driver error codes (SQLSTATE, sqlite_errorname) to decide whether to retry: that is stringly typed. Any integrity rejection with no row under the key is retried as a possible incarnation collision, boundedly, as before; the private error is renamed _RegistryInsertRejectedError so it admits only that an insert was rejected, and its docstring and warning list the possible causes without claiming one. A persistent cause surfaces through SegmentStoreAttemptsExhaustedError with the driver error chained (test_persistent_integrity_error_surfaces_with_cause, both dialects). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…uirement Drops a startup comment describing a check the code no longer makes, shortens the retry error's docstring and the comments around it, and states the consequence of an engine without the pragma accurately: link rows outlive their segments until the partition is dropped, when the purge reclaims them with a warning. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Same facts and measurements, shorter sentences: the purge entry becomes sub-bullets, run-on passages are split, and the fencing section now states that windowed reads end with a registry read. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Contributor
Author
|
Self-reviewed at |
…s it Draining the global purge queue from drop_session_partition used a scheduler primitive for a targeted goal. The queue is FIFO, so a deletion's own entry was the newest at enqueue time: the loop cleared every older tenant's garbage first, then kept going through entries that arrived after it started, work that delayed the request without serving it, with completion time inflating by the usual queueing factor under sustained deletion traffic. The SegmentStore ABC gains purge_partition(partition_key) -> bool next to purge_deleted_partitions(). Same implementation, same bounds, same "call until False" protocol; only the claim differs. The sweeper keeps FOR UPDATE SKIP LOCKED across all keys. The targeted purge claims this key's oldest entry with a plain FOR UPDATE and waits, since its set is bounded and known, so a sweeper holding the key's entry is a short wait rather than a skip that returns with nothing reclaimed. Deadlock-free: a cycle needs both parties waiting, and the sweeper never waits. Keyed by the partition key the caller already holds, not by incarnation, so no identity token enters the ABC; every dead generation under the key is reclaimed oldest first, and a recreated key is safe because its live incarnation is never in the queue. delete_partition stays -> None. LongTermMemory.drop_session_partition loops purge_partition on its own key. Contract change stated on the ABC and in the design doc: the delete path no longer sweeps the global backlog, so a deployment must run purge_deleted_partitions somewhere; purge_partition is an erasure-promptness optimization, not a substitute. Tests: key scoping and no global fallback (both dialects), dead generations reclaimed oldest first with the live recreation spared, and on PostgreSQL the targeted purge blocking on a sweeper's claim and then continuing with the key's next generation after the sweeper commits. The wiring test asserts the delete path calls purge_partition and not the sweeper. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
The targeted claim used a plain FOR UPDATE to wait for a sweeper's claim. A holder that never finishes (a stuck transaction, or a backend whose client vanished and holds its lock until keepalive gives up) would hang the delete request, and PostgreSQL's default lock timeout is unbounded. The claim is now skip-locked within the key, like the sweeper's, so no call ever waits on another purger. Return semantics stay a bool and get sharper. When nothing is claimable, one unlocked read of the key decides: no entry means False, so False means exactly "this key has no garbage left"; a held entry means True. Before returning that True the call pauses briefly, outside any transaction, so a caller looping on True polls rather than spins while the sweeper finishes its one bounded call. On SQLite the locking clause is dropped anyway: the plain read sees a held entry and the purgers serialize at the DELETE, so the wait happens there. The caller's loop is deliberately unbounded: a purger that holds one of the key's entries and never finishes stalls the deletion, which is an operational incident rather than a case to design for. The PostgreSQL test now asserts the targeted purge returns True promptly while a sweeper holds the key's oldest entry, having reclaimed the key's other generation, and False once the sweeper commits; it fails with the blocking claim restored and fails without the existence read. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…-check test PARTITION_KEY_MAX_BYTES keeps its byte measure but now says why: the charset is ASCII, so bytes equal characters today, and bytes is the unit on purpose because the budgets a key must fit are byte-denominated and stay honest if the charset ever widens. test_old_sqlite_runtime_is_rejected patched sqlite_version_info on the stdlib sqlite3 module, process-global state SQLAlchemy's dialect also reads. The store's minimum is now a module constant the validator reads and the test raises past the runtime, keeping the blast radius inside the unit under test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
The delete path's purge loop is unbounded by design, and since the targeted purge polls rather than waits, a claim stalled in another purger shows up in the database as short healthy queries, not as a lock wait. One warning after _PURGE_SLOW_WARNING_SECONDS names the partition key so the incident is findable; the loop continues unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Contributor
Author
|
Self-reviewed at |
- Index the purge queue on (partition_key, enqueued_at): the targeted claim, its existence read and the within-key order are one index range, where before the call that concludes a key is clear, which every drop makes, scanned the whole backlog. - Say what the enqueue stamp can order. func.now() is CURRENT_TIMESTAMP on SQLite, whole seconds, so same-tick deletions of one key are unordered among themselves; the queue docstring and both ABC purge docstrings now admit that, and the generations test sets explicit stamps against insertion order instead of trusting the clock. - The queue row's docstring said the key was carried for forensics only; purge_partition made it load-bearing, and the design doc says so. The docstring now agrees. - The purge_partition comment claimed the call never waits, then said SQLite waits at the DELETE. It now says which dialect does what: PostgreSQL never waits on a claim; SQLite drops the locking clause and the DELETE waits on the write lock for the driver's busy timeout, raising if it expires (MemMachine#1542). The design doc carries the same qualification. - The slow-purge warning logs the measured elapsed time, not the threshold, and fires again each time the wait doubles, so an operator can tell thirty seconds from forty minutes. The test drives it with a fake clock. - The held-entry test takes a positive done signal (wait_for with a timeout) instead of inferring "not blocked" from pg_stat_activity, which any unrelated lock wait could have falsified. - The design doc's reason for rejecting a blocking claim was the wedged holder, which the accepted design shares. The operative reasons are recorded instead: skip-locked keeps reclaiming the key's other generations while one is held and never stalls behind a sweeper's short claim, at the cost of polling. Ablations: the index test fails without the index; the warning test fails when the constant is logged instead of elapsed time; the generations test fails only with both the ORDER BY and the composite index removed, because the index alone already yields stamp order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
The composite queue index yields stamp order within a key on its own, so no data arrangement can tell the ORDER BY from the access path; the clause is asserted on the statement, where a plan change would otherwise drop the order silently. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Contributor
Author
|
I think we need more proper tenant records. We can make the lifecycle of each resource appear atomic but there is nothing to make tenant lifecycle appear atomic. |
edwinyyyu
marked this pull request as draft
September 2, 2026 17:28
…est; empty-key message The ABC promised the PostgreSQL mechanism (the call paces itself); it now promises what every dialect delivers: bounded work, an exact False, no spinning loop on a held entry, and that a call may fail on backend contention and is always safe to repeat, with the remainder the sweeper's. The design doc and PR body say a deletion normally returns with its rows gone, naming the SQLite busy-timeout case. A SQLite test pins the counterpart of the held-entry test: with another connection holding the write lock briefly, the call waits and then completes. An empty partition key is reported as empty rather than as invalid characters. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…e to repeat Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…repeat Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…laims purge_partition existed to serve a promise the delete path can no longer make: prompt physical erasure is not something the store can keep on every dialect (on SQLite any writer past the busy timeout fails), and the inline drain that used it, first of the global queue and then scoped to the deleted key, added a second purger, a polling loop while an entry was held, and a slow-purge warning, all to shorten a window that no contract requires to be short. drop_session_partition now deletes the collection and the partition, nulls its handles, and returns; the partition is unreachable at once and its rows are reclaimed by the resource manager's sweeper within its interval. The ABC keeps one purge method, the sweeper, whose contract already says a deployment must run it. The composite queue index, the held-entry pause, the existence read, the slow-purge warning and their tests go with the method; the sweeper's same-tick ordering wording and the queue-row docstring's forensic key stay. A per-tenant reclaim step returns with the tenant lifecycle layer (MemMachine#1579), where single-use keys make it job-like: progress, retry and failure per tenant, which the global sweeper cannot attribute. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
edwinyyyu
added a commit
to edwinyyyu/MemMachine
that referenced
this pull request
Sep 2, 2026
…enant purge Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
…tition Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5
Contributor
Author
|
Self-reviewed at |
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Purpose of the change
Fixes #1544, #1546, and #1549 by construction: the segment store's per-tenant partitioned layout is replaced with shared tables and an incarnation-carrying tenant registry, on every dialect. Design record:
design/segment_store_shared_tables.md.Also fixes #1557, #1558, and #1559: #1462's UTC-normalization fixes are folded in for every SQL store (segment, episode, cluster), so no store ships with the SQLite timestamp corruption.
Why an overhaul instead of the earlier targeted fixes
This PR began as three targeted repairs to the partitioned layout (detach-before-drop for #1544, a management lock for the create/delete deadlock, child-table DML for #1546), each verified on its own. Further investigation showed the layout itself was the root cause and could not be fully repaired:
Description
segment_store_ptis the tenant registry: logical key (primary key) plus anincarnation, a random unique-constrained UUID. Data rows carry the incarnation alone, so a data query cannot be built without resolving the registry, and referencing the wrong tenant is structurally impossible. A deleted-and-recreated tenant never sees its predecessor's rows, even mid-purge. A colliding mint is rejected rather than left to probability: the unique constraint rejects a collision with a live incarnation, and the mint transaction re-checks the purge queue after its insert so an incarnation whose garbage is still awaiting purge is re-minted (race-free with the existing tables: the check runs after the registry insert, and no new queue entry for the minted value can appear before commit, since the only registry row carrying it is uncommitted). Any integrity rejection with no committed row under the key is likewise retried with a fresh incarnation, up to_MAX_MINT_ATTEMPTS; a persistent cause surfaces asSegmentStoreAttemptsExhaustedErrorwith the database error chained. Rejections are warning-logged at the detection site, since a genuine collision is astronomically unlikely and the log is the signal for broken randomness or a persistent database error even when the re-mint heals it.create_partitionis a row insert (microseconds, no DDL).delete_partitiontakesFOR UPDATEon the registry row (waiting out writers'FOR SHAREpins), enqueues the incarnation onsegment_store_gc(logical key kept for forensics), and deletes the row: O(1) at any tenant size, the pool-model contract of "unreachable immediately, erased asynchronously".purge_deleted_partitions() -> boolis part of theSegmentStoreinterface. The caller's whole protocol is "call until False"; sizing is not a call argument, because callers cannot know engine-appropriate bounds. Deployments set them once at construction:purge_max_segments(default 10000) andpurge_max_partitions(default 100, its own bound because retiring an empty entry costs ~0.95 ms, ~200x a segment row, and empty partitions are cheap to mass-create-and-delete). Each call is one transaction (measured ~147k-312k rows/s), committing its progress or nothing. Links follow byON DELETE CASCADE, 50-68% faster than manual link deletion at one and four links per segment; cascade deletion saturates ~3M link rows/s (380k segments/s at 1 link/segment, 46k at 64), so heavily linked partitions purge in sub-second calls. Link fan-out is set by the deriver, which the store cannot reject after derivation. Before retiring an entry the call also reclaims any link rows that escaped referential integrity, in batches drawn from the same budget and logged as a warning; a link row deletes ~3x cheaper than a segment row (1.0 vs 3.3 us/row, batched), so the shared budget bounds the call without assuming a ratio. Entries are claimed one at a time, oldest first (enqueue stamped by the database clock, indexed), withFOR UPDATE SKIP LOCKED, so a call neither materializes nor locks the rest of the backlog and concurrent purgers from any process share the queue; only the claiming call touches a dead incarnation's rows, so reclamation is deadlock-free by construction. On SQLite, which drops locking clauses, purgers serialize on the write lock at the DELETE: two may claim the same entry, and the second finds no rows and re-retires it. The store never schedules purging; implementations whose deletes reclaim physically returnFalse. The delete path does not purge: an inline drain was tried, first of the global queue and then scoped to the deleted key, and removed, since prompt physical erasure is not a promise the store can keep on every dialect (on SQLite any writer past the busy timeout fails) and the sweeper reclaims within its interval; a deployment must run the sweeper, and the ABC says so.FOR SHAREwith an incarnation predicate. Reads add the same predicate to their data statement as anEXISTS, so one statement checks liveness and reads; a read that finds no rows issues the registry check on its own, to tell an empty partition from a stale handle, and a windowed read ends with one registry read because its statements take separate snapshots. On SQLite the driver defers BEGIN until the first write, so a SELECT-only fence checks nothing; BEGIN IMMEDIATE would mean taking over transaction management of the caller's shared engine, so the fence is a self-checking registry-row UPDATE (same write lock, scoped to the transaction, match count as the staleness check), and deletion opens its transaction the same way. A handle that outlives its partition or its incarnation raisesSegmentStorePartitionHandleStaleErroron every dialect; SQLite previously had no stale-handle detection at all. Measured cost: none on reads that find rows, one registry round trip on reads that find none.enable_sqlite_foreign_keys, applied by the DatabaseManager to its engines): a listener added later misses connections the shared engine already pooled, a pre-existing bug surfaced in review round 4. The store states the requirement on itsengineparameter and does not verify it, which is the reference practice for SQLite foreign keys; an engine without the pragma leaves link rows that outlive their segments until the partition is dropped, when the purge reclaims them with a warning. The SQLite vector stores carry the same latent pattern; filed as SQLite per-connection state (foreign keys, sqlite-vec extension) is registered per-store on caller-supplied engines #1568.created_atand itsstart_time/end_timebounds, clusterlast_tsand pendingcreated_at. Without this, SQLite (whoseDateTime(timezone=True)discards tzinfo) read non-UTC values back shifted by their offset. Datetime filter values are normalized to UTC-aware instants atComparison/Inconstruction, the filter language's contract stated onFilterExpr(a value denotes an instant; naive means UTC), so every consumer receives instants and compilers only choose a representation; the SQL column leaf binds values as-is, and remaining per-backend normalizations are idempotent defenses, removable separately. Two consumers change by design: the Neo4j compiler'sdatetime.timestamp()read a naive value in the server's local zone and now receives instants, with its ISO-string branch parsing under the same rule (pinned under a non-UTC process zone); the in-memory short-term evaluator tags a naive stored metadata datetime as UTC at comparison time, since stored user data is not rewritten. The episode store's bounds spell the convention inline as two explicit steps,ensure_tz_aware(...).astimezone(UTC), kept unwrapped so the naive-means-UTC decision stays visible at each site. PostgreSQL is unaffected. On SQLite, rows the episode and cluster stores previously wrote from non-UTC-offset datetimes hold the local wall clock as if it were UTC and read back shifted before and after this change alike; the offset was never stored, so only new writes are correct, and a range query spanning the upgrade mixes the two conventions.Measurements
Layout comparison and scaled runs are in the design doc. Headlines (pgvector:pg16, one harness for all arms): tenant creation 0.006 ms vs 6-12 ms of per-tenant DDL; ingest and read parity or better at 40x2k and 1M-pair scales including a 500k-row tenant; O(1) delete at any size; churn 0 deadlocks vs 41-83.
Store-API ABAB against upstream main (3 interleaved rounds, one PG instance, medians, at the revision before the read fence was folded into the data statement): ingest +42% (11.1k vs 7.8k pairs/s),
delete_segments+20%, event/derivative lookups 10-17% faster, windowed context expansion 8% faster (5.9 vs 6.4 ms), lifecycle create+open+delete 4.4x faster (2.9 vs 12.8 ms/cycle); seed context reads +0.19 ms (1.30 vs 1.11 ms) from the fence's separate registry round trip, since removed. Read-path ABAB of that fold (5 interleaved rounds, medians): seed context reads 1.17 vs 1.42 ms, event lookups 1.04 vs 1.61 ms, derivative lookups 1.05 vs 1.33 ms, windowed context expansion 8.74 vs 9.67 ms (8 rounds x 600 reps, paired median -0.91 ms); reads that find nothing unchanged. Server-side (EXPLAIN ANALYZE, best of 30) theEXISTSconjunct plans as a one-time InitPlan costing ~3 us per statement, so the client-side gain is the round trip. Purge drains ~216k rows/s; the batched-delete pattern was benchmarked on a 100k-row incarnation with the cascade in place:uuid IN (SELECT ... LIMIT n)252k rows/s vs a PostgreSQL-only ctid batch at 238k and an unbatched single-DELETE ceiling at 295k, so the portable pattern is also the fastest batched one. Claim and mint-check statements measure 215/156 us.Testing
-m "not integration"lane, 86 in the-m integrationlane; full server suite 1926 passed, 3 skipped. Concurrency coverage runs on both dialects wherever the property exists on both: lifecycle churn, racing deletions (single-enqueue assertion), overlapping segment deletes, the mint-vs-deletion collision race, and O(1) deletion via recorded SQL.test_purge_reclaims_oldest_garbage_first(FIFO, explicit stamps),test_purge_queue_stamps_enqueue_time_from_database_clock,test_purge_bounds_entries_processed_per_call,test_purge_batches_links_that_escaped_integrity(orphans staged through a second SQLite engine without the pragma; fails against the unbounded form),test_purge_skips_entries_claimed_by_concurrent_purger(fails withSKIP LOCKEDablated),test_concurrent_purges_reclaim_everything(both dialects),test_purge_claims_queue_entries_incrementally(fails against the claim-all form),test_purge_reclaims_only_dead_incarnations,test_purge_bound_comes_from_params,test_default_purge_bound_comes_from_params.test_incarnation_with_garbage_left_is_never_reused(queue re-check) andtest_incarnation_colliding_with_live_partition_is_never_reused(unique constraint) force a collision on both creation paths and both dialects, each failing with its guard ablated;test_mint_detects_collision_with_concurrent_deletionpins the insert-then-check ordering with a staged concurrent deletion (swapping the statements fails only this test);test_persistent_integrity_error_surfaces_with_causepins the bounded re-mint with the driver error chained, both dialects;test_persistent_mint_failure_raises_instead_of_loopingpins the attempt cap on both creation paths.test_stale_handle_raises_after_delete/..._after_recreate(both dialects),test_reads_check_liveness_inside_the_data_statement,test_windowed_read_raises_when_partition_dies_between_statements(both dialects; fails without the closing registry read),test_recreated_partition_is_isolated_from_old_rows,test_delete_partition_touches_only_registry_and_queue(recorded SQL),test_write_pin_blocks_partition_delete, the SQLite fence test (a write racing delete-plus-purge can no longer orphan rows; the two SQLite race tests carry started-events so a loaded box cannot pass them vacuously).pg_stat_activity, no grace sleeps). Verified by per-lock ablation on both generations: removing a lock from the new store fails exactly its targeted tests, and the pre-overhaul store fails the churn and concurrent-delete tests with all its locks intact (reproducibleDeadlockDetectedErrorin the two cycle shapes on Episodic search reads lock every segment-store partition once prepared statements go generic (Postgres lock-table exhaustion, 500s under load) #1546). The churn test also caught the_open_or_create_partitionrace, now a retry loop bounded by_MAX_MINT_ATTEMPTS.test_timestamp_roundtrips_with_timezoneandtest_timestamp_filter_compares_instants_not_wall_clocks([Bug]: Segment store corrupts non-UTC timezone-aware timestamps on SQLite (write path + filter bounds) #1557) are parametrized over non-UTC zones on both dialects;test_older_than_compares_instants_not_wall_clockscovers the semantic history bound; the filter-parser tests pin construction-time normalization;test_iso_string_coerces_as_utc_instantpins the Neo4j string branch under a non-UTC process zone (skipped wheretime.tzsetis unavailable);test_datetime_metadata_filters_tag_naive_stored_valuespins the in-memory evaluator. Each fails without its fix.LongTermMemorydeletes the collection and the partition and returns; the partition is unreachable at once and its rows are reclaimed by the sweeper within its interval. Its handles are nulled so later use raises (test_event_backend_unusable_after_drop_session_partition), and the wiring test asserts the delete path never purges. The resource manager runs one background purge task per store (bounded calls a short pause apart while the store reports a backlog, one idle call per interval otherwise; failures logged and retried; cancelled on close); the loop is a module-level coroutine so a pending task does not pin the manager (test_purge_task_does_not_pin_the_manager), andget_segment_storeafterclose()raisesResourceManagerClosedError. Both purge bounds are validated at construction; the server uses the defaults, and config plumbing is future work.re.fullmatch), and the session-id-to-partition-key mapping validates with the store's own validator instead of a drifted copy; the StaticPool guard raisesValueError(test_static_pool_engine_is_rejected); pre-3.35 SQLite runtimes are rejected at construction (test_old_sqlite_runtime_is_rejected); empty-inputadd_segmentsshort-circuits likedelete_segments. Stale-handle handling above the store (API status mapping, cross-replica cache eviction) is filed as Stale segment-store handles need cache eviction and an API status mapping #1571.test_delete_partition_keeps_other_partitions_cascadingandtest_delete_partition_keeps_foreign_key_enforcednow hold trivially, since no DDL can damage the constraint.ruff format --check,ruff check, andty checkclean (zero added diagnostics).Compatibility
No migration from the partitioned layout is provided: the event backend is opt-in and pre-GA, and existing databases recreate their schema (see the design doc). The partition-key contract (
[a-z0-9_], max 32 bytes) is unchanged.Type of change
🤖 Generated with Claude Code
https://claude.ai/code/session_01MpFwBnZ6SxdMe3pSuMTCHD
https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5