Skip to content

(Depends on #1597) Encode an event once, with its records inside the store's transaction (speedkick) - #1659

Closed
edwinyyyu wants to merge 70 commits into
MemMachine:speedkickfrom
edwinyyyu:feat/event-memory-write-transaction-speedkick
Closed

edwinyyyu wants to merge 70 commits into
MemMachine:speedkickfrom
edwinyyyu:feat/event-memory-write-transaction-speedkick

Conversation

@edwinyyyu

Copy link
Copy Markdown
Contributor

Stacked on #1597: its commits come first, and this PR is the commits after 80f0c71. Follows the orphaned-record analysis on #1597's review: with the vector upsert outside the segment store's transaction, a forget interleaving between an encode's commit and its upsert orphans the encode's records, and nothing ever names them again; #1597's forget-first encode had widened that race to concurrent re-encodes before 80f0c71 removed it. This change prevents the race instead of repairing after it, and repairs the residue a crash can leave.

What changes

Segment store contract (segment_store.py)

  • write(*, exclusive=False): a write transaction as an async context manager. Entry checks the handle and pins the partition against deletion; normal exit commits; an exception rolls the block back, so a caller can make a write conditional on work of its own inside the block. exclusive=True takes the registry row FOR UPDATE: it waits for every write in flight and excludes new ones until the block exits, for a reader that must see the partition settled.
  • SegmentStorePartitionWriter, the handle inside the block: add_events(events), keyed by event uuid, each with its segments and their derivative uuids; and get_segment_uuids_by_derivative_uuids, the same read inside the transaction.
  • The partition holds an event at most once. A new event table, segment_store_ev with primary key (incarnation, uuid), is inserted first with ON CONFLICT DO NOTHING RETURNING; any uuid missing from the result rejects the whole batch with SegmentStoreEventAlreadyStoredError, naming them, before anything is stored. Exact under concurrency on both dialects: PostgreSQL makes the second inserter wait on the first transaction and then report the conflict; SQLite serializes on the writer lock. Segments carry a cascading foreign key to the event row.
  • delete_events(event_uuids) replaces the by-event path of forget: event rows locked in order, then their segment rows, then deleted, cascading to segments and links. delete_segments stays for eviction and leaves the event held, so an evicted event cannot come back through a replay; only delete_events frees the uuid.
  • get_derivative_uuids_by_event_uuids replaces get_segment_uuids_by_event_uuids and get_derivative_uuids_by_segment_uuids, whose only caller was forget; a held event with no derivatives answers an empty list.
  • add_segments is gone; the purge reclaims event rows after the segments, on the same budget.

EventMemory (event_memory.py)

  • encode_events: segment, derive, embed, then one write() block: add_events, then the vector upsert inside the transaction, so segments commit only once the vector store has acknowledged their records, and a failed upsert rolls them back. On an upsert error the same ids are deleted before the error propagates, since the upsert may have been applied before it failed; a delete that fails too is added as a note on the upsert's error. Encoding an event twice is rejected whole; forget frees it.
  • forget_events: derivative uuids by event, vector delete, delete_events. Records before events: a failed record delete leaves the event whole for a retry.
  • Read repair in query: a hit whose link is missing is re-checked under write(exclusive=True), which waits for every encode in flight; a link found then is kept, a record still without one is an orphan, deleted after the fence is released. Exact because derivative uuids are never reused. Counter event_memory_orphan_records_deleted_total.

LongTermMemory: tenant deletion removes the segment partition before the vector collection, so the deletion drains and then blocks writers before the collection deletion removes every record that could have landed.

What it guarantees, and what it does not

  • No orphan from any interleaving of encode, forget and eviction: a forget can only see links whose records exist, and its delete is issued after our upsert's acknowledgment. No duplicate copies: an event is held once.
  • The residue is a process dying between the upsert's acknowledgment and the commit, or a request delivered after its compensating delete: crash-rate, an embedding plus reserved ids with no text, reclaimed when retrieved and by tenant deletion. A ledger with row states, a sweeper and retention was designed and rejected as not worth its cost for that residue.
  • A weak-ordered Qdrant replica set can still resurrect a deleted point; that is Qdrant's documented default for any client, and read repair converges it on sight.

Cost

  • PostgreSQL: a pooled connection stays busy for the vector call; writers do not conflict with each other, only partition deletion waits longer. Per-request latency is unchanged: the same statements and the same vector call, reordered.
  • SQLite: the writer lock is held across the vector call, so every writer on the file serializes on segment time plus vector time. A sqlite-vec store on the same file as the segment store cannot be used with this design.
  • Bulk batches hold one transaction for the whole batch's upsert; bounding that is Event memory: bound the vector upsert one encode transaction spans #1658.
  • The check that a PostgreSQL transaction timeout is not shorter than the vector request timeout waits for the vector-store chain's timeout fields.

Decisions

Tests

test_sqlalchemy_segment_store.py, both dialects: rejection whole with the uuids named, a stored segment uuid under another event still rejected, a segment under the wrong event, rollback when the block raises, delete_events cascade and idempotence, delete_segments keeps the event held, stale handle at write() entry, purge budget with event rows; PostgreSQL: the exclusive write blocks on a write in flight and then sees it, a concurrent add of one event blocks on the uncommitted row and is rejected after it commits; SQLite: the exclusive write waits on the writer lock. test_event_memory.py: rejection then forget, an applied-then-failed upsert leaves nothing and the retry succeeds, a failed compensating delete is noted, an orphan is deleted on retrieval and not returned, a record whose encode is in flight is kept.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE

edwinyyyu and others added 30 commits September 14, 2026 16:35
Implements the session/source/expansion/eviction part of
design/event_memory_handoff.md from the tenant-lifecycle branch. The
context-part and block-kind model and the segmenter/deriver tables
follow in a second change; this one keeps the producer/null context
union and the `block_type` discriminator as they are.

Data models. `Event`, `Segment` and `Derivative` carry `session_id`
and `source_id` as nullable fields, copied verbatim down the
pipeline; null is encoded as a missing record key, `None` in a typed
id list selects it, and property values stay `None`-free. `SearchHit(score, seed, segments)` replaces
`ScoredSegmentContext` and `QueryResult`; `Neighborhood(before,
after)` and `EvictionOptions` are added.

Reserved keys. `common/property_keys.py` reserves the `memmachine_`
namespace; every system value a search filters on at the vector stage
sits in the record under a reserved key (`event_timestamp`,
`event_session`, `event_source`, `block_kind`). `utils.py` owns the
translation between the typed filters (`since`, `until`, `session_ids`,
`source_ids`, `block_kinds`) and filter trees; a caller key in the
namespace is rejected before any segment is written.

Segment store. `segment_store_sg` gains `session_id`, `source_id` and
`block_kind` columns, a session-led ordering index and a source index; the total order is
`(timestamp, event_uuid, index, offset)`, windows and neighborhoods
are confined to the seed's session, and a null session is one stream.
`get_segment_windows` takes `before`/`after` and the typed filters;
`get_segment_neighborhoods` returns the neighbors and never the seed, as two
lists; `delete_derivatives` unlinks without touching segments; PG
lateral reads run one statement pair per seed session. No migration:
`startup()` keeps `create_all`, and an existing speedkick database is
recreated; schema migration waits for the lifecycle/DDL changes.

EventMemory. `encode_events` forgets the batch first, so a repeat
leaves one copy, and runs eviction from the agentic_expansion branch,
cosine only: batch predecessors, one bounded neighbor query per
derivative, a cluster over `target_size` trimmed from its temporal
middle, displaced records and their links deleted, skipped ones never
written. `query` is the vector stage and returns hits with the seed's
index in its window; `rerank` is the second stage, static, for a
caller with a reranker; `expand` walks a neighborhoods from a segment
or event anchor; `render` replaces the string formatters. The reranker
and the per-call format options leave the constructor and the call,
respectively; the deriver's format is fixed per memory.

Server. `LongTermMemory` sets `Event.source_id` from the producer id,
leaves the session null and keeps the producer context; it reranks after `query` and reads
hits.

Tests: the branch's neighbor and eviction tests ported to the new
shapes on both dialects, plus session confinement, half-open bounds,
instant comparison of zoned bounds on SQLite, source and kind filters,
and link deletion. Each new store assertion was checked to fail
against a mutated store (no session predicate, an inclusive `until`, a
filtered neighborhoods seed, an unnormalized bound).

Rebased 2026-09-10 onto speedkick after MemMachine#1598 merged, adopting its
post-review names (`segment_by_derivative`, `seed_cosine_similarities`).
`common/property_keys.py` and its test, which MemMachine#1598 did not carry into
its merge, are included here.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MuAu353FiSmCJjLX1LWDQW
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
Every field of Event, Segment and Derivative carries a description;
the byte bound on ids is a validator on the model, so
EventMemory._validate_events checks only property keys; positions are
validated non-negative. SearchHit.seed is seed_index, checked to lie
inside segments, and the window around a seed is a segment window.
EvictionOptions.similarity_threshold is cosine_similarity_threshold,
the threshold at or above which eviction is considered; the other two
options say what is fetched and what is kept.

The store contract's class docstring only contrasts the two reads;
the details live on each method. Typed id lists hold ids only, and a
seed with no session walks every session: events in no session do not
belong together, so "no session" is not a value a list can name and
the only timeline to show around such a seed is everything. The
timestamp ordering index segment_store_sg__in_ts_ev_ix_of is restored
for that walk beside the session-led one.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The memory makes no use of either bound, so the caller discards hits
itself; the vector stage keeps its limit because the store uses it.
Every hit comes back rescored, in descending score.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
cosine_similarity_matrix in eviction, and cosine similarity in every
docstring, comment and test name that named a bare similarity.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
…the store

Properties are set at ingest and never edited; a changed event is
forgotten and encoded again under the same uuid, with new segment and
derivative uuids. Event, EventMemory and SegmentStorePartition each
say so in their own terms, and the store's add is pinned as an insert
that rejects a stored uuid, so the vector record's copy of the
declared properties stays exact by construction and a future update
operation has to argue with the contract.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
get_segment_windows and get_segment_neighborhoods differed only in
whether the seed was filtered, which is a lookup concern, not a
walk's. get_segments(uuids, filters) returns the segments the
partition holds that pass; get_segment_neighborhoods(segments, ...)
walks the order around segments the caller holds and looks nothing
up, so a segment can be walked from even after its row is gone. The
rule for a caller: filters select what a read returns, and only a
segment obtained first can be walked from. query fetches its seeds
with the filters and walks from those; expand fetches its anchor
unfiltered and walks; eviction reads timestamps with the lookup. No
walk is asked for when expand_context is zero.

The seed keys reach PostgreSQL as typed bound parameters in a row set
rather than a VALUES list, which SQLAlchemy would recompile on every
call, since the data would be part of the statement's cache key.
Measured against the previous head, alternating runs on one
PostgreSQL container with a fresh analyzed schema per run, the
search-shaped read is a wash within noise and the SQLite read pays
one more connection checkout per call; the numbers are in the pull
request.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
…orhood)

A hit carried a flat list and an index into it, which the lookup and
the walk had to be flattened into and the reader had to index back
out of. QueryHit holds the seed the query matched and the Neighborhood
around it, the shape expand returns, so walking further from a hit
composes with expand and there is no index invariant to keep. window()
gives the flat list where one is wanted: rendering and the server's
episode folding.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The contract is a store's, not a table's: segments are immutable, and
a walk starts from a segment, not from a lookup of it.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The typed filters a search takes were all on the lookup and the walk
except session_ids. It is one of them now, selecting what a read
returns as source_ids does; the walk's confinement to the given
segment's session is a separate rule, so a seed with no session walks
every session and session_ids narrows what it shows. expand takes it
with the other filters a search takes, and query passes it through to
both reads.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The test helper gives every event a session, so the anchor had one
and its walk stayed in it. The anchor now has none, which is the case
the test is about.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
…fault

The session is the unit the store's order is partitioned into, and the
walk's confinement already treated it that way; the one case without
one needed an unconfined walk, a second ordering index and a session
filter on the walk to scope it, and the timeline it produced
interleaved unrelated conversations. Every event now belongs to one
stream: session_id is a required, non-empty, bounded string on Event,
Segment and Derivative, the column is NOT NULL, the reserved key is
always written, and a walk always pins its seed's session, so
get_segment_neighborhoods takes no session_ids. get_segments keeps it
as the visibility filter; expand passes it to the lookup, so an anchor
outside the named sessions is not found. The source stays nullable:
nothing is partitioned by it.

The legacy API carries no conversation id, so LongTermMemory writes
every event to memmachine_default, one stream per partition under a
reserved name a caller cannot use; the new API requires a session.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
A walk that took segments spared one point lookup and cost every
caller above the store a segment object: an API caller expanding by
tool call would have had to rebuild the segment exactly or keep a
cache of recent ones, or the server would have kept one for it.
get_segment_neighborhoods takes seed uuids again, locates them
unfiltered, and walks from what it finds; an unknown seed is absent.
query walks from the uuids of the seeds its lookup admitted, and
expand walks from the anchor's uuid, checking it against session_ids
with a lookup only when sessions are named.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
A naive datetime names no instant. Reading it as UTC is a guess a
caller has to know about, and a wrong guess silently shifts an event
by hours in the store's order and in every rendered date. The models
take AwareDatetime, as the episode model already does, and the typed
bounds since and until reject a naive value on both store reads.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The summaries of the two reads repeated their method docstrings; the
walk's own contract now names the order it follows.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
… column once

before and after must be nonnegative on the walk, and expand_context
on query; a negative value raised nothing before and produced an empty
or odd window. The store no longer converts a naive timestamp
anywhere: the column's type, UtcInstant, binds an aware value as its
UTC instant and rejects a naive one, and decodes the naive result
SQLite returns as the UTC instant the column holds, so the two
read-back conversions and the bound conversions go. The bounds are
still checked at the method, before a session is opened, so the error
names the parameter. Docstrings say "timestamps" of the segments or
neighbors, plural.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
Only the default stream's name is reserved; a caller may name a
session memmachine_anything.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
Eviction and what only it requires leave this change for one stacked
on it, so each can be reviewed alone: EvictionOptions and the
eviction parameter, the eviction stage of encode_events and the
batch's temporal sort it depends on, the predecessor, target and
stored-timestamp helpers, and delete_derivatives on the store
contract, the SQLAlchemy store and the fake, with their tests.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The parameters say what the filters do, and the total order says
where an event's other segments sit.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
A session is a session, a source a source, a context a context; the
timestamp is timezone-aware and the rejection of a naive value is the
type's, not the description's. The neighborhood's sides are nouns
like the other descriptions.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
edwinyyyu and others added 17 commits September 15, 2026 11:56
The docstring named context parts and block renderings, which arrive
with the blocks change; here a header is the timestamp and the
producer's name, and the pieces' text follows.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
`render` said nothing about what it takes; `Block.render` and the
context parts' `render` are per-object, and the memory's is over
segments.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
With three secondary indexes led by `incarnation`, PostgreSQL's planner
had, on a table it has no statistics for, a cost tie between the primary
key and `(incarnation, event_uuid)` for the link table's foreign-key
check `incarnation = $1 AND uuid = $2`, and took the latter: an index
scan on the incarnation alone with the uuid as a filter over every row
of the partition, 57 ms per thousand links against 2.6 ms on the primary
key, and growing with the table (measured with `EXPLAIN ANALYZE` on a
fresh table; the old store's identical tie fell the other way). With the
incarnation second, no secondary index can serve that lookup at all, so
the primary key is the only candidate and no statistics are needed.
Every read that used a secondary index names its leading key and the
incarnation together, so it is served as before: walks by session (and
source), lookups by event; `EXPLAIN` on both dialects shows the same
index conditions. Ingest of a thousand segments with links on a fresh
PostgreSQL table: 246 ms before, 82 ms after, the old store's 74-105.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The lateral walk ran one statement pair per distinct seed session, the
session a literal so the session-led index would serve it: a search
whose twenty seeds lie in twenty sessions ran forty statements and took
53 ms against the old store's 5.7 ms (twenty thousand segments, forty
sessions). The seeds are now one row set, `unnest` over six bound
arrays with the seed's session among them, and the lateral pins
`session_id` to the seed row's; the planner parameterizes that
equality per seed, so the index serves every seed in one statement per
direction (`EXPLAIN` shows the index condition on
`seeds.seed_session_id`), and six parameters whatever the seed count
keep the statement small and cacheable by shape. The seeds are located
by their order keys alone, without their payload columns or ORM rows.
The search-shaped read is 7.6-9.6 ms now: the old store's 5.7 plus the
walk's own locate statement, which keying the walk by uuid costs; a
filtered one is 8-10 ms against the old store's 42, since the filter
selects the seeds before any walk.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The original lateral shape serves the new walk: the seeds subquery,
which selects the seed rows by uuid inside the statement, gains the
seed's `session_id`, and the lateral pins `session_id` to it, an
equality the planner parameterizes per seed. That is the whole
difference from speedkick's walk, and it makes `_SeedKey`, the locate
of seed keys, the `unnest` row set and `_session_condition` unnecessary;
the seed rows the entry point already fetched drive the SQLite loop as
they did before. Same plan (`EXPLAIN`: the session-led index with the
seed's session as an index condition) and the same cost as the bound
row set: a 20-seed search 7.9-8.5 ms against the old store's 6.5-7.3 on
one container run.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
Two `if`s say what a loop over a tuple of names said; `_in_values` is a
static method under `_row_conditions`, the one place that calls it; and
the comment on an empty lookup says what the registry read is for.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The order that put the incarnation second removed a competitor from the
planner's candidates for the link table's foreign-key check; it did not
make the planner choose well, statistics do. A fresh PostgreSQL table
misplans until its first ANALYZE whatever the indexes (the lookup by
uuid runs a sequential scan in that window too), and autovacuum's first
pass, or an ANALYZE after an initial import, ends it for the table's
lifetime. So the indexes read as the rest of the store does, scoped by
the incarnation first, and the PR body states the practice.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The source-pinned walk index served a walk filtered by one source, and
no measured workload runs one; the session-led index serves every walk,
and a filter on source or kind scans past the session's other rows, tens
of microseconds on a 200,000-row table. An index is paid for on every
insert for as long as it exists, and adding one later is a one-off
background build (2.5 s per million rows on PostgreSQL, concurrently),
so the store indexes the reads it has: the primary key, lookup by
event, and the walk. A walk filtered by source or by kind earns its
index when a workload shows it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
`encode_events` no longer forgets the batch's events before encoding
them: every call adds, and encoding an event a second time stores a
second copy, as speedkick's encode does. The forget-first step widened a
race that speedkick already has: a forget that runs between an encode's
segment commit and its vector upsert orphans the encode's records, and
with encode forgetting first, a concurrent re-encode of one event took
that path too. A follow-up moves the upsert inside the write transaction
and rejects a reused event uuid at the store; until then encode is a
plain insert.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The segment store gains a write transaction, `write()`, whose block the
caller may fill with work of its own: the event memory upserts its vector
records inside it, so segments commit only once the vector store has
acknowledged their records, and a failed upsert rolls them back. A forget
can then only see links whose records exist, and its delete is issued
after the upsert's acknowledgment, which closes the orphan race between
encode and forget rather than repairing after it.

An event is held at most once: a new event table, keyed by incarnation
and event uuid, is inserted first with ON CONFLICT DO NOTHING RETURNING,
and a batch naming a held event is rejected whole with
`SegmentStoreEventAlreadyStoredError` before anything is stored. Segments
carry a cascading foreign key to the event row; `delete_events` replaces
forget's by-event path, `delete_segments` stays for eviction and leaves
the event held, and `get_derivative_uuids_by_event_uuids` replaces the
two lookups whose only caller was forget. The purge reclaims event rows
after the segments, on the same budget.

The residue a crash can leave, a record acknowledged by the vector store
whose commit never happened, is repaired on retrieval: a hit without a
link is re-checked under `write(exclusive=True)`, which waits for every
write in flight, and a record still without a link is deleted after the
fence is released. An upsert that fails deletes the same ids before the
error propagates, since it may have been applied first.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
Deleting the segment partition first waits for every write in flight,
whose records land before it commits, and blocks new ones, so the
collection deletion that follows removes every record that could ever
have landed in it.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
Mechanical: the store now holds events, segments and links, so its name
follows its dependent. `SegmentStore` and every derived name become
`EventMemoryStore` and theirs, the module and test directories move with
them, the tables and indexes take the `event_memory_store_` prefix, and
the configuration key `segment_store` becomes `event_memory_store` in the
API spec, the client, the docs and the sample configs. No behavior
changes; tables are recreated, as on MemMachine#1597.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
edwinyyyu added a commit to edwinyyyu/MemMachine that referenced this pull request Sep 17, 2026
…'s new name

Records the MemMachine#1659 decisions: the store's write() transaction inside
which the event memory upserts its vector records, the event rows that
hold an event once with rejection by the database's primary key rather
than a caller convention, delete_events and the by-event derivative
lookup, the exclusive fence read repair uses, the purge order, the
tenant deletion order, the residue analysis and the designs rejected for
it (record-state ledger, change data capture, vector outbox), and the
rename to EventMemoryStore, config key included. The shared-tables doc
keeps its name here; MemMachine#1659 renames it.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
@edwinyyyu

Copy link
Copy Markdown
Contributor Author

Closed in favor of its main port, #1686, which carries this PR's content at its final tip; the review history stays here. See the "Source and review" section of #1686 for what was reviewed and what was adapted.

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