Skip to content

[event memory recovery 2] Keep event memory's two stores consistent through failed ingests and forgets - #1840

Draft
edwinyyyu wants to merge 13 commits into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:feat/event-memory-recovery
Draft

edwinyyyu wants to merge 13 commits into
MemMachine:feat/horizontal-scalingfrom
edwinyyyu:feat/event-memory-recovery

Conversation

@edwinyyyu

@edwinyyyu edwinyyyu commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Purpose of the change

Event memory keeps events, segments, and derivative links in the SQL segment store, and the derivatives' vectors in the vector store. The two share no transaction, so:

  • An ingest that stops after storing its segments (a crash, a restart, a vector store outage) leaves its events unsearchable unless the client retries.
  • A forget looks up an event's derivatives and deletes them in separate steps. An ingest committing in between, or a vector write the vector store applies after the delete, leaves vectors of a forgotten event in the index.

This PR makes ingests complete and forgets complete, with no lock held across a vector store call. design/event_memory_ingest_and_deletion.md gives the protocol, the alternatives, and why it is safe.

  • Ingest. EventMemory starts a write budget, then stores the events, their segments, their derivative links, and one pending derivatives record per event in one transaction. It upserts the vectors, cancelled when the budget runs out, and deletes the record. A batch naming an event already stored is refused whole.
  • Forget. One transaction locks the events, deletes their pending records, writes one tombstone naming the derivatives it unlinked, and deletes the events, which cascades to segments and links. The vectors are deleted at once, and again after the tombstone retention.
  • Background work. Any process claims pending records older than the budget and writes them within a budget of its own, and claims tombstones due for their first or second delete. A claim holds a lease, backs off doubling on failure, and stops after repeated failures, which is logged.
  • Search. A hit with no link is dropped, and its vector deleted once, best effort.
  • Partition deletion. The segment store's partition goes first, so a recovery that opened the vector store partition by key can check that it still belongs to the same life.
  • One EventMemory per deployment. The records name no vector store or embedder, so the resource manager builds the deployment's EventMemory from the episodic memory's long-term memory configuration (its vector store, segment store, and embedder, and its segmenter, deriver, and reranker), and the server starts it at startup. Its background work runs whether or not a session is opened.

Behavior that changes:

  • The deployment's segmenter, deriver, and reranker serve every event-backend session. A session's event configuration no longer saves its own, and sessions saved with them load with them ignored.
  • A project's reranker applies to the declarative backend only, as embedder does, and an event project's response names none. The OpenAPI document and the Python client's docstrings say so.
  • An event is readable as soon as its ingest commits, and searchable once its vectors are written.
  • The segment store gains an events table, pending derivatives, and tombstones, and each purge call's bound covers segments, events, and the derivatives tombstones name.
  • The segment store refuses SQLite older than 3.35, since adding events and claiming pending derivatives and tombstones use RETURNING.

The safety argument relies on what cancelling an upsert guarantees, which this PR's first commit states in VectorStorePartition.upsert's contract: cancelling the call stops it from sending further requests, and a request already sent may still be applied. Every store already behaves that way. Each store's tests cancel an upsert mid-call and check that nothing more goes out: Qdrant and Milvus with a fake client that refuses the batch as too large and holds its first half, and against the server; both SQLite stores at the upsert's first statement, and the engine-backed one also while its search engine applies the records.

The protocol was model-checked on a small instance (two encodes of one event, an evicting encode, two recovery workers, crashes and timeouts at every step, forgets at any moment). Removing any one piece breaks deletion, findability, completion, durability, or erasure.

Stack

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

Independent of the vector store tree, directly on main:

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

The vector store tree. Each PR builds on the one in its on column; PRs on the same parent are parallel branches and do not depend on each other. #1631 is closed, superseded by #1733–#1736, which hold its changes split in four, with review changes since. #1675 is closed, superseded by #1627. #1841 is closed: its request packing is dropped, and its cancellation contract is in #1840. #1828 and #1839 sit side by side on the branch; #1839 and #1840 are the event memory recovery stack, and #1684 heads the event memory stack, which its own PRs index, listed because it builds on #1840, its branch also carrying #1828's commits until one of the two merges; #1871 keeps its own Stack section. Until the PRs under it merge, their changes show in a stacked PR's diff.

# PR change on
[vector store scale-out 1/6] #1671 (merged) Remove custom sharding from the Qdrant store (port of #1654) main
[vector store scale-out 2/6] #1733 (merged into feat/horizontal-scaling) Answer vector store queries with record UUIDs and scores, and refuse invalid inputs feat/horizontal-scaling
[vector store scale-out 3/6] #1734 (merged into feat/horizontal-scaling) Arbitrate vector store collections in a SQL registry, with an incarnation per collection life and a purge feat/horizontal-scaling
[vector store scale-out 4/6] #1735 (merged into feat/horizontal-scaling) Move the Qdrant store onto the collection registry feat/horizontal-scaling
[vector store scale-out 5/6] #1736 (merged into feat/horizontal-scaling) Move the Milvus store onto the collection registry, against a Milvus server feat/horizontal-scaling
— #1775 (merged into feat/horizontal-scaling) Accept attempts exhausted in the lifecycle churn contract, and say which Qdrant operations filter on the incarnation feat/horizontal-scaling
— #1779 (merged into feat/horizontal-scaling) Classify Qdrant errors by status code alone feat/horizontal-scaling
— #1813 (merged into feat/horizontal-scaling) Create Qdrant collections with strict mode off feat/horizontal-scaling
— #1820 (merged into feat/horizontal-scaling) List only active projects in projects/list feat/horizontal-scaling
[user properties 1/2] #1702 (merged into feat/horizontal-scaling) Keep undeclared properties out of the vector store feat/horizontal-scaling
[user properties 2/2] #1670 (merged into feat/horizontal-scaling) Remove per-project filterable properties (port of #1606) feat/horizontal-scaling
— #1824 (merged into feat/horizontal-scaling) Drop the unread logical key from the purge queues feat/horizontal-scaling
— #1788 Refuse a repeated record UUID or a non-finite property value at upsert, and state the datetime property contract feat/horizontal-scaling
— #1631 (closed) Superseded by #1733–#1736, which hold its changes split in four, with review changes since —
[vector store scale-out 6] #1627 (merged into feat/horizontal-scaling) Give each vector store a fixed schema and string-keyed partitions, built from the deployment's configuration feat/horizontal-scaling
[declared schema 1/2] #1628 (merged into feat/horizontal-scaling) Make a vector store filter only on the properties it declares feat/horizontal-scaling
[sqlite write transactions 1] #1844 (merged into feat/horizontal-scaling) Take SQLite's write lock at BEGIN in every store on a relational SQLite engine feat/horizontal-scaling
[session storage 1/2] #1622 Create a session's storage with the session, never on a request feat/horizontal-scaling
[session storage 2/2] #1625 Look a partition up with get_partition, and remove open-or-create from both stores and close from the segment store #1622
[selective filter 1] #1871 Route filtered queries by selectivity, and search allowlists exhaustively feat/horizontal-scaling
[search results, deferred] #1663 Score every vector search by cosine similarity, and name scores for it (port of #1598's cosine half) #1628
[declared schema 2/2] #1616 Close the filter union, and make negation the complement on every backend feat/horizontal-scaling
[sqlite store fixes 1/7] #1460 Publish vector index files atomically (but not durably) feat/horizontal-scaling
[sqlite store fixes 2/7] #1469 Never reuse a row id in SQLiteVectorStore #1460
[sqlite store fixes 3/7] #1672 Own the search engine's concurrency in the store, not in each engine (port of #1612) #1469
[sqlite store fixes 4/7] #1673 Serialize a partition's writes so the engine sees them in order (port of #1607) #1672
[sqlite store fixes 5/7] #1674 Refuse a pending row replay cannot honor, instead of dropping it (port of #1608) #1673
[sqlite store fixes 6/7] #1675 (closed) Superseded by #1627, which takes SQLite's write lock at BEGIN where the engine is created —
[sqlite store fixes 7/7] #1676 Give every write a fresh row id, so a key names one version (port of #1610) #1674
[sqlite store fixes 8] #1843 Hold the engine lock until a cancelled engine call's thread finishes #1676
[qdrant options] #1618 Let a deployment tune a Qdrant collection's HNSW, optimizers and quantization feat/horizontal-scaling
[milvus options] #1741 Let a deployment tune a Milvus collection's vector index and its searches #1618
— #1841 (closed) Its request packing is dropped, and its cancellation contract is in #1840 —
[property keys] #1828 Refuse an illegal metadata key before any store writes, and reserve one key prefix feat/horizontal-scaling
[event memory recovery 1/2] #1839 Give EventMemory a store's shape, with a handle per partition feat/horizontal-scaling
[event memory recovery 2] #1840 (this PR) Keep event memory's two stores consistent through failed ingests and forgets #1839
[event memory 1/6] #1684 Add time bounds, sources and expansion to EventMemory (port of #1597, without session) #1840
[schema migration 1/2] #1825 Add memmachine-server migrate, which creates every configured store's schema before servers start feat/horizontal-scaling
[schema migration 2/2] #1826 Make startup check what migrate created, and create nothing at startup #1825

This PR is its 5 commits, 739ac7ec1, 90c9761c2, 943ddfacb, 552ed2d77, a82fc6e65, stacked on #1839. #1684 is stacked on it.

Verification

At this PR's head a82fc6e65:

  • ruff check and ruff format --check are clean, ty check is clean as CI runs it (uv run --frozen --all-extras ty check --project packages/server), and uv lock --check passes.
  • Server unit tests (packages/server/server_tests -m 'not integration'): 2415 passed, 8 skipped.
  • Client unit tests (packages/client/client_tests): 259 passed.
  • Integration tests through testcontainers: the event memory and segment store tests on PostgreSQL, 140 passed; the declarative LongTermMemory tests against Neo4j, 3 passed; the vector store tests against Qdrant, Milvus, and the registry's PostgreSQL, 387 passed.

🤖 Written by Claude Code (Claude Opus 5.5) on behalf of @edwinyyyu.

This was referenced Oct 20, 2026
@edwinyyyu

Copy link
Copy Markdown
Contributor Author

Need performance measurement.

@edwinyyyu
edwinyyyu force-pushed the feat/event-memory-recovery branch 3 times, most recently from 0154c69 to 96097c2 Compare November 2, 2026 17:57
edwinyyyu and others added 2 commits November 2, 2026 11:30
EventMemory is built from the segment store, the vector store and the
models. It opens or creates a partition by key in both stores, the vector
store's first, and deletes a partition from both. EventMemoryPartition is
the handle that encodes, queries and forgets; it holds its two store
partitions privately, so no caller can pair one key's partition in one
store with another key's partition in the other.

The service locator builds the EventMemory and opens the session's
partition through it. EventBackendParams carries the EventMemory and the
handle, and LongTermMemory deletes the session's partition through
EventMemory. The reserved timestamp field and text extraction, shared by
both classes, move to module level. No behavior changes.

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

A vector store's schema is fixed when the store is built, so EventMemory
takes it as a parameter, the same schema the composition root built the
vector store with, rather than reading it from each partition it opens.

- EventMemoryParams gains `store_schema`. EventMemory checks once, at
  construction, that the schema declares the fields EventMemory requires,
  and hands each partition handle the declared fields and the similarity
  metric. A handle's `similarity_metric` and its score direction come
  from them.
- The service locator builds the vector store and EventMemory from one
  schema, `event_backend_store_schema`.
- `VectorStorePartition` no longer has a `store_schema` property; each
  store's partitions keep the schema privately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@edwinyyyu
edwinyyyu force-pushed the feat/event-memory-recovery branch from 96097c2 to cda5fac Compare November 2, 2026 19:34
edwinyyyu and others added 2 commits November 2, 2026 14:27
Deleting a session built its EpisodicMemory, which opened, and so could
create, the session's event-backend partitions, and then deleted them
through the session's own LongTermMemory, which held the EventMemory only
for that.

The long-term service locator now builds each backend's memory and
deletes a session's storage, both as functions that match on the
configuration's backend: the event backend deletes the session's
partitions by key through EventMemory, opening neither, and the
declarative backend deletes the episodes its DeclarativeMemory of the
session matches. The episodic service locator deletes the storage
whenever the configuration builds long-term memory. EpisodicMemoryManager
closes a cached idle instance, then deletes the stored configuration's
storage, and builds no EpisodicMemory.

LongTermMemory is the one class that routes requests on the backend: it
takes each backend's memory built, holds no EventMemory, partition key,
or deleted state, and has neither drop_session_partition nor close, whose
body did nothing. EpisodicMemory.delete_session_episodes has no caller
and goes; its short-term half only reset in-memory state. The evaluation
scripts build the DeclarativeMemory they pass in, and delete a session
through delete_long_term_memory_storage.

A session saved with `enabled: false` now has its long-term storage
deleted too, where the old path returned before deleting anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LongTermMemory has had a single search since its unscored `search` went,
so `search_scored` no longer tells it apart from anything. It is `query`,
as the calls above and below it are: EpisodicMemory.query_memory and
EventMemoryPartition.query. Its two backend branches are
`_query_declarative` and `_query_event`. DeclarativeMemory keeps
`search_scored`, which it tells apart from its unscored `search`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@edwinyyyu
edwinyyyu force-pushed the feat/event-memory-recovery branch from cda5fac to 6a90736 Compare November 2, 2026 23:55
edwinyyyu and others added 3 commits November 2, 2026 16:04
`_declarative_params` was one statement and `_event_params` a block with
one caller, each standing between `long_term_memory_params_from_config`'s
match and the work it routes to. The work now sits in the match arms, as
it does in `delete_long_term_memory_storage` beside it. `_declarative_memory`
and `_event_memory` stay, since building and deleting both use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Once the service locator built each backend's memory, the two backend
params differed only in that memory: a DeclarativeMemory, or an
EventMemoryPartition with the episode storage that turns its results into
episodes and the reranker that sets its score direction. LongTermMemory
now takes the memory itself, with those two as keywords, and each request
matches on the memory's type. An EventMemoryPartition without episode
storage is refused when the LongTermMemory is built.

DeclarativeBackendParams, EventBackendParams, their union, the backend
literal, and the session id, which nothing read, go. The service locator's
`long_term_memory_from_config` builds the LongTermMemory in each match arm,
and the episodic service locator takes it from there. The evaluation
helper passes its DeclarativeMemory directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The episodic service locator's `delete_episodic_memory_storage` only
checked whether the stored configuration builds long-term memory and
passed it on. EpisodicMemoryManager.delete_episodic_session now makes that
check and calls `delete_long_term_memory_storage` itself, so deleting a
session goes from the manager straight to the long-term layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@edwinyyyu
edwinyyyu force-pushed the feat/event-memory-recovery branch from 6a90736 to ea69c3a Compare November 3, 2026 01:40
edwinyyyu and others added 6 commits November 3, 2026 08:50
EventMemoryParams carried the schema its vector store was built with,
beside the store itself, so the schema had two sources that the
composition root had to keep in step. A vector store now answers its
own `store_schema`, the schema it was built with, and EventMemory reads
its required fields, declared fields, and similarity metric from it.
EventMemoryParams no longer takes a schema, and the service locator no
longer passes one. Each store returns the schema it already held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Event memory bounds when its vector writes are sent by cancelling an
upsert at a deadline, which needs a written guarantee of what cancelling
does. VectorStorePartition.upsert now states that cancelling the call
stops it from sending further requests to the backend, and that a
request already sent may still be applied, so a cancelled upsert may
have written some of the records. RegistryBackedVectorStorePartition's
`_upsert` hook states the same obligation for its implementations.

Every implementation honors it:

- RegistryBackedVectorStorePartition awaits the registration check,
  `_upsert`, and the check again in sequence, so a cancellation stops at
  the await it lands on.
- Qdrant and Milvus await each request, and send the halves of a batch
  the server refuses as too large one after another, so a cancellation
  raised in a request ends the call. Neither client catches
  CancelledError: qdrant-client's REST path catches Exception only, and
  pymilvus's retry decorators catch grpc.RpcError, MilvusException, and
  Exception, so a cancelled RPC surfaces as CancelledError and is not
  retried.
- sqlite-vec writes in one transaction. A statement already queued to
  aiosqlite's thread finishes there; SQLAlchemy treats the cancellation
  as a disconnect and invalidates the connection, so the transaction
  never commits and nothing more is executed on it.
- The engine-backed SQLite store commits its records and pending
  operations, then applies them to the search engine, then marks them
  applied. A cancellation in the engine step leaves the operations
  pending, and startup replays them.
- The in-memory test partition has no await in its upsert.

Tests cancel an upsert mid-call and check that nothing more goes out:
for Qdrant and Milvus with a fake client that refuses the batch as too
large and holds its first half (the second half is never sent, and the
registration is not checked after it), and against the server (the
request in flight; for Milvus counted at the gRPC stub, so a pymilvus
retry would show); for both SQLite stores, cancelling at the upsert's
first statement, and for the engine-backed one also while its search
engine applies the records, no further statement executes.

Verification: ruff, ruff format, and ty clean; the vector store unit
tests, 401 passed, 5 skipped. The fake-client tests fail when the
halving swallows the cancellation and sends the second half. The
integration tests need Docker and were not run.

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

The segment store and the vector store share no transaction, so an ingest
that stopped after storing its events left them unsearchable, and a forget
racing an ingest or a late vector write could leave vectors of forgotten
events behind.

The segment store now holds the events themselves, and two queues of work
the vector store still owes:

- Pending derivatives: an ingest stores its events, segments, derivative
  links, and one pending record per event in one transaction, upserts the
  vectors within a write budget, and deletes the record. A record left
  behind is claimed by any process once the budget has passed, and
  written again within a budget of its own.
- Tombstones: a forget locks its events, deletes their pending records,
  records the derivatives it unlinked in one tombstone, and deletes the
  events in one transaction. The vectors are deleted at once and again
  after the tombstone retention, which outlasts any write sent within
  its budget.

Claims hold a lease, back off doubling on failure, and stop after
repeated failures, which is logged. A search drops a hit with no link
and deletes its vector once, best effort. Partitions are deleted from
the segment store first. Each purge call stays within its bound across
segments, events, and the derivatives tombstones name.

design/event_memory_ingest_and_deletion.md gives the protocol and why
it is safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EventMemory's background work claims pending derivatives and tombstones
from the whole segment store database, and its records name no vector
store or embedder: it writes and deletes with its own. That is right only
if one EventMemory serves every partition in the database, and only if it
runs whether or not a session is opened.

The resource manager now builds the deployment's EventMemory from the
episodic memory's long-term memory configuration, starts its background
work, and shuts it down on close. The server builds it at startup when
that configuration names a segment store. A session opens its partition
on it, and the deployment's segmenter, deriver, and reranker serve every
session.

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

The deployment's one EventMemory serves every event session with its own
segmenter, deriver, and reranker, so the copies a session saved no longer
apply. The session's event configuration no longer has those fields, and
rows saved earlier load with them ignored; creating a project checks a
reranker only on the declarative backend.

A project's `reranker` applies to the declarative backend only, as its
description now says, and an event project's response names none, as for
`embedder`. The OpenAPI document is regenerated for these fields only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adding events and claiming pending derivatives and tombstones depend on
RETURNING, which SQLite added in 3.35: the event insert returns the
events it stored, and each claim returns the row it took. The store's
params refuse an older SQLite runtime, after the check that each session
gets its own connection to one database, and a test raises the minimum
past the runtime to see the refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@edwinyyyu
edwinyyyu force-pushed the feat/event-memory-recovery branch from ea69c3a to a82fc6e Compare November 3, 2026 17:22
@edwinyyyu

Copy link
Copy Markdown
Contributor Author

Should simplify this.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant