Overhaul segment store: shared tables with incarnation-scoped tenant keys (fixes #1544, #1546, #1549) by edwinyyyu · Pull Request #1545 · MemMachine/MemMachine · GitHub
Skip to content

Overhaul segment store: shared tables with incarnation-scoped tenant keys (fixes #1544, #1546, #1549) - #1545

Closed
edwinyyyu wants to merge 114 commits into
MemMachine:mainfrom
edwinyyyu:fix/segment-store-partition-delete
Closed

Overhaul segment store: shared tables with incarnation-scoped tenant keys (fixes #1544, #1546, #1549)#1545
edwinyyyu wants to merge 114 commits into
MemMachine:mainfrom
edwinyyyu:fix/segment-store-partition-delete

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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:

  • PostgreSQL's FK integrity triggers still addressed the partitioned parents internally, so writes and deletes kept a per-backend lock spike proportional to partition count even after every client statement was fixed (10 -> 166 -> 10 relation locks across the trigger's generic-plan attempt).
  • Writer-vs-lifecycle-DDL deadlocks through the shared parents survived every lock discipline: 41-83 deadlocks per 20-second churn on upstream and on every intermediate fix, in two cycle shapes captured from the server log, one of them a lock-upgrade cycle inherent to dropping a child that carries a cloned FK.
  • Scaling requirements ruled out every per-tenant-table variant: tenant creation must stay cheap at 1e5-1e6 tenants (a per-tenant table costs ~5 catalog relations plus disk files), with 1e4-1e7 rows per tenant.

Description

  • Shared tables everywhere. The ORM models are the physical schema on every dialect. PostgreSQL partitioning, per-tenant DDL, detach machinery, the store-wide management lock, and the per-partition entity cache are removed. With no partitions there is no generic-plan lock fan-out, and with no lifecycle DDL there is nothing for the deadlock class to live in: the churn smoke that measured 41-83 deadlocks/20s on every partitioned build measures zero, with ~60x more write throughput and ~200x more lifecycle throughput.
  • Incarnation-scoped tenant identity. segment_store_pt is the tenant registry: logical key (primary key) plus an incarnation, 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 as SegmentStoreAttemptsExhaustedError with 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.
  • O(1) deletion plus a purge queue. create_partition is a row insert (microseconds, no DDL). delete_partition takes FOR UPDATE on the registry row (waiting out writers' FOR SHARE pins), enqueues the incarnation on segment_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 as an ABC capability. purge_deleted_partitions() -> bool is part of the SegmentStore interface. 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) and purge_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 by ON 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), with FOR 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 return False. 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.
  • Fencing (Segment store partition handles are not fenced to a partition incarnation: stale handles silently operate on a recreated partition #1549). Writes pin the registry row under FOR SHARE with an incarnation predicate. Reads add the same predicate to their data statement as an EXISTS, 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 raises SegmentStorePartitionHandleStaleError on 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.
  • Foreign keys. The segment table's FK to the registry is removed on purpose (registry and data rows decouple so deletion is O(1)); the link table's FK to the segment table and its cascade remain, enforced by a single-row indexed check with no partition fan-out. On SQLite, enforcement is per-connection state registered at ENGINE creation (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 its engine parameter 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.
  • Folded in: Fix timezone-aware datetime roundtrip on SQLite #1462's SQL-store fixes (fixes [Bug]: Segment store corrupts non-UTC timezone-aware timestamps on SQLite (write path + filter bounds) #1557, [Bug]: Episode store corrupts non-UTC timezone-aware created_at on SQLite (write path + time-range bounds) #1558, [Bug]: Cluster store corrupts non-UTC timezone-aware timestamps on SQLite (last_ts, pending created_at) #1559). App-supplied timezone-aware datetimes are normalized to UTC before persisting in every SQL store: segment timestamps, episode created_at and its start_time/end_time bounds, cluster last_ts and pending created_at. Without this, SQLite (whose DateTime(timezone=True) discards tzinfo) read non-UTC values back shifted by their offset. Datetime filter values are normalized to UTC-aware instants at Comparison/In construction, the filter language's contract stated on FilterExpr (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's datetime.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) the EXISTS conjunct 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

  • The behavioral suite runs unchanged against the new layout on both dialects: segment store file 82 in the -m "not integration" lane, 86 in the -m integration lane; 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.
  • Purge: 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 with SKIP LOCKED ablated), 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.
  • Incarnations: test_incarnation_with_garbage_left_is_never_reused (queue re-check) and test_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_deletion pins the insert-then-check ordering with a staged concurrent deletion (swapping the statements fails only this test); test_persistent_integrity_error_surfaces_with_cause pins the bounded re-mint with the driver error chained, both dialects; test_persistent_mint_failure_raises_instead_of_looping pins the attempt cap on both creation paths.
  • Fencing: 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).
  • Lock-necessity tests (integration lane, under a second): each locking property is pinned by staging the interleaving it serializes (blocked-ness observed via 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 (reproducible DeadlockDetectedError in 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_partition race, now a retry loop bounded by _MAX_MINT_ATTEMPTS.
  • Timestamps: Fix timezone-aware datetime roundtrip on SQLite #1462's regression tests for the episode and cluster stores are ported; test_timestamp_roundtrips_with_timezone and test_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_clocks covers the semantic history bound; the filter-parser tests pin construction-time normalization; test_iso_string_coerces_as_utc_instant pins the Neo4j string branch under a non-UTC process zone (skipped where time.tzset is unavailable); test_datetime_metadata_filters_tag_naive_stored_values pins the in-memory evaluator. Each fails without its fix.
  • Erasure and scheduling: LongTermMemory deletes 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), and get_segment_store after close() raises ResourceManagerClosedError. Both purge bounds are validated at construction; the server uses the defaults, and config plumbing is future work.
  • Other review-round fixes, each with a test that fails pre-fix: unloadable codec config commits no registry row (both creation paths); trailing-newline partition keys rejected (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 raises ValueError (test_static_pool_engine_is_rejected); pre-3.35 SQLite runtimes are rejected at construction (test_old_sqlite_runtime_is_rejected); empty-input add_segments short-circuits like delete_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.
  • Retained: test_delete_partition_keeps_other_partitions_cascading and test_delete_partition_keeps_foreign_key_enforced now hold trivially, since no DDL can damage the constraint.
  • ruff format --check, ruff check, and ty check clean (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

  • Bug fix (non-breaking change which fixes an issue)
  • Breaking change (schema layout of the opt-in event backend)

🤖 Generated with Claude Code

https://claude.ai/code/session_01MpFwBnZ6SxdMe3pSuMTCHD

https://claude.ai/code/session_01MbYdqGZsuws6Z2WHYfCCR5

…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>
@edwinyyyu edwinyyyu changed the title Fix: Detach segment store partitions before dropping them, and lock partition metadata on delete Fix segment store PostgreSQL partition defects: deletion destroying the store-wide foreign key, create/delete deadlock, and reads locking every partition Aug 28, 2026
edwinyyyu and others added 3 commits August 28, 2026 15:45
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>
@edwinyyyu

Copy link
Copy Markdown
Contributor Author

edwinyyyu and others added 2 commits August 28, 2026 16:38
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>
@edwinyyyu
edwinyyyu requested a review from malatewang August 29, 2026 00:06
…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>
edwinyyyu and others added 9 commits August 31, 2026 10:06
…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>
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>
edwinyyyu and others added 2 commits August 31, 2026 11:18
- 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
edwinyyyu marked this pull request as draft August 31, 2026 18:41
edwinyyyu and others added 5 commits September 1, 2026 21:42
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
…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
@edwinyyyu

Copy link
Copy Markdown
Contributor Author

Self-reviewed at 2e58e1fc828ed83db9945446e2dd12b69f8c6b40 (skimmed). More changes incoming.

edwinyyyu and others added 5 commits September 1, 2026 23:05
…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
@edwinyyyu

Copy link
Copy Markdown
Contributor Author

Self-reviewed at 10d1f6acd845de4ea8eb5da1401753a0de49c7ee (skimmed).

edwinyyyu and others added 2 commits September 2, 2026 01:21
- 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
@edwinyyyu

Copy link
Copy Markdown
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 and others added 6 commits September 2, 2026 11:44
…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
…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
@edwinyyyu

edwinyyyu commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Self-reviewed at c38d54335532b1d715c28e4d4266f69f85e2acc7 (skimmed).
Essentially converged, very minor changes since 2e58e1fc828ed83db9945446e2dd12b69f8c6b40.

@edwinyyyu

edwinyyyu commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

@edwinyyyu edwinyyyu closed this Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment