Memory API on the record-log backends: availability, latency, and the rest of the mem0 surface - #133
Merged
Merged
Conversation
… rest of the mem0 surface
Every memory method except ingest and retrieve misbehaved on the record-log backends -- the ones
that actually ship -- while looking correct on the JSONL one, and the fix for that could not stay
merged because the gateway stopped answering under it. Both halves are here, plus the latency work
that came out of chasing the second one.
## The read path
`MatrixArkLocalAdapter` precedes the direct mixins in the native adapter's MRO and defines
JSONL-only readers and writers. Each returns [] -- or writes nothing -- the moment
`_local_jsonl_enabled` is False, which is exactly what a native backend sets. So `read_all`,
`_read_all_compacted`, `_read_raw_records` and `append_many` all resolved to an implementation
that silently did nothing, and the native read ran only the last of the serving pipeline's three
stages. forget served deleted memories straight back, history reported an empty log, keyed upsert
never superseded, and one ingested memory listed as two.
## Why it could not stay merged, and what it actually was
The background summary refresher calls `refresh_summaries({"scope": {}})` on a fixed 1000 ms
timer. That pass is O(store): it reads the whole record log and writes the refreshed summaries
back through the SAME proxy lane the request path uses. In `shared_process_mode`, write, read and
control share ONE lane behind a single permit, so once a pass outlasts the interval the loop holds
that permit continuously -- requests queue, block to the request timeout, and are rejected on lane
backpressure, and nothing recovers because the store only grows.
The read fix did not cause that. It ACTIVATED it: before, `read_all()` returned [] on a native
backend, so the always-unbounded loop did no work.
Two things were needed, and the first alone was not enough:
* the delay between passes is now derived from what the last pass cost, capping the loop at
MATRIXARK_SUMMARY_REFRESH_MAX_DUTY (0.2) of wall-clock;
* and the cost is INSIDE one pass, not between them -- a pass opens with `read_all()`, which on a
105 MB store is minutes. Dirty state only changes when records are appended and the record count
is a single point read, so a pass whose count is unchanged since the last one AND whose last one
refreshed nothing is skipped. That state is persisted, or every restart pays one full pass.
16 ingests then 8 retrieves, refresher at the shipping default on every arm:
fixed interval ingest 391 -> 22392 ms; 3 retrieves ok, then a 120 s hang, then every
retrieve rejected at 40 s
refresher off ingest 568 -> 2412 ms; 8/8 retrieves 179-263 ms
cost-proportional ingest 699 -> 2808 ms; 8/8 retrieves 229-760 ms
On a 105 MB store with no load at all, immediately after a restart: 8/8 rejected at 40 s before,
8/8 answered in 67-583 ms after, ~104 ms warm.
## Ingest cost: 19 -> 5.7 full-store reads
Three scans replaced by keyed indexes, each backfilled once per store behind a persisted marker
(entries written BEFORE the marker, so a crash rebuilds rather than trusting a half-built index):
* idempotency was a POINT question -- "have I seen this key" -- answered by walking
`reversed(read_all())`, and asked four times per ingest;
* `ensure_context_node_path` read the whole log only to collect which nodes already had an
embedding, three times per ingest. The persisted marker is load-bearing here: the native
adapters deliberately start their context-node caches empty, so an in-process-only index would
re-embed every node after a restart;
* memory subjects are indexed on append rather than scanned.
Ingest, 24 into one scope, median of the second half: 2431 -> 1617 ms. Retrieve unchanged.
## The rest of the mem0 surface
`users()`, `batch_update` and `batch_delete`, matching mem0's shapes and its 1000-entry ceiling.
The batch calls are deliberately NOT atomic and say so: an update here is a supersede and a delete
a tombstone, with no cross-memory transaction behind them, so failures are reported per memory
rather than aborting the batch.
`users()` re-scopes a request to another subject, and that is subtle enough to have been wrong
twice: a request scope carries identity fields DERIVED from the caller, and `get_all` filters on
those hashes, never on `user_id`. Swapping only `user_id` resolves back to the caller (users with
2 and 1 memories both reported 3); dropping the hashes yields user_hash == 0, read as "no filter",
returning the whole tenant. The subject's hashes are recomputed with `identity_hashes`, the same
function the ingest path uses.
## A guard, and a correction
The four broken methods were one shape, and nothing stopped a fifth, so that check is now a test.
It flags a method whose own CODE reaches the local log -- not merely one that resolves to a local
module, since most of those are backend-agnostic orchestration going through the overridden seams
-- strips docstrings first so the overrides that explain the trap do not report themselves, and
checks every native adapter rather than one.
Three comments recorded the wrong cause for the wedge, naming the pipeline split and a lock-order
inversion. Neither was it. Left alone they send the next person to the same wrong places, which
had already happened twice, so they are corrected.
## Verification
Full mem0 surface asserted on VALUES rather than status codes, driven with a real ten-turn
conversation: 15/15 APIs behaving as expected, including that a corrected fact supersedes on the
keyed path, TTL expires, forget empties the subject and reset empties the tenant.
The direct record cache is also keyed by the store it belongs to now -- namespace and table, not
just the storage prefix, which defaults to the same string for every adapter and let two stores in
one process share an entry.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The memory API on the record-log backends — the ones that actually ship. Two availability
defects that made it unusable under load, three O(store) costs on the ingest path, the missing
part of the mem0 surface, and a guard so the defect class that started this cannot come back
silently.
Availability
The background summary refresher could hold the only proxy lane
_summary_refresh_loopcallsrefresh_summaries({"scope": {}})on a fixed 1000 ms timer. Thatpass is O(store): it reads the whole record log and writes the refreshed summaries back through
the same proxy lane the request path uses. In
shared_process_mode,build_lane_poolspointswrite,readandcontrolat ONE lane behind a single permit — one permit for the wholegateway. Once a pass costs longer than the interval, the loop holds that permit essentially
continuously: requests queue on the semaphore, block to the request timeout, then are rejected on
lane backpressure. Nothing recovers, because the store only grows.
This is why the native record-log read had to be reverted the same day it landed. Before it,
read_all()returned[]on a native backend, so a pass did no work and this always-unboundedloop was harmless. Making the read work turned it into a permanent occupant of the lane. The
read path was never the problem.
16 ingests then 8 retrieves, refresher at the shipping default on every arm:
…and bounding the interval between passes was not enough
Testing at a realistic size exposed that the first fix was incomplete. The cost is inside one
pass, not between them: the pass opens with
read_all(), which on a 105 MB store takes minutes andholds the lane throughout. With no client load and nothing to refresh, eight retrieves in a row
were rejected at 40 s.
Dirty state only changes when records are appended, and the record count is a single point read. So
when the count is unchanged since the last pass AND that pass refreshed nothing, this pass would
read the entire store to reach the same conclusion — skip it. The state is persisted, because an
in-memory-only token makes every restart pay one full-store pass.
Ingest cost: 19 → 5.7 full-store reads
Three scans replaced by keyed indexes, each backfilled once per store behind a persisted marker.
reversed(read_all()), and asked 4× per ingest (replay + before-store, across two toolcalls).
ensure_context_node_pathread the whole log just to collect which nodesalready had an embedding, 3× per ingest. The persisted marker is load-bearing: the native
adapters deliberately start their context-node caches empty, so an in-process-only index would
re-embed every node after a restart. Proven across a restart — fresh store creates 3, restart
creates 0.
users(), below) are indexed on append rather than scanned.Ingest, 24 into one scope, median of the second half: 2431 → 1617 ms (−33%). Retrieve unchanged
at 270–400 ms.
mem0 surface
batch_update/batch_delete— mem0's shape, capped at the documented 1000. Deliberatelynot atomic and documented as such: an update here is a supersede with no cross-memory
transaction, so failures are reported per memory rather than aborting the batch, because a
partial batch the caller cannot see is worse than one they can.
users()— the users/agents/runs that hold memories, which is not the question theaccount-level user list answers. The re-scoping was subtly wrong twice: a request scope carries
identity fields derived from the caller (
user_hash,scope_key), andget_allfilters onthose, never on
user_id. Swapping onlyuser_idmade every user report the caller's count(users with 2 and 1 memories both reported 3); dropping the hashes made
user_hash == 0, read as"no filter", returning the whole tenant. Neither errors — both answer confidently and wrongly.
Hashes are now recomputed with
identity_hashes, the same function ingest uses.add()now finalizes by default — a behaviour change, stated plainly.addwas a streamingingest, so a memory only appeared once a debounce elapsed, and every further write to the scope
pushed that debounce out. Three
addcalls followed byget_allreturned 0 memories againsta live gateway. Written against mem0, that reads as data loss.
finalize=Falsekeeps the oldbehaviour.
A guard for the defect class
The original five defects were one shape:
MatrixArkLocalAdapterprecedes the direct mixins inevery native adapter's MRO and defines JSONL-only readers/writers that return
[]once_local_jsonl_enabledis False — which is what a native backend sets. Four methods were broken atonce, silently. Nothing stopped a fifth, so that check is now a test.
It flags a method whose own code reaches for the local log, not merely one that resolves to a
local module (most of those are backend-agnostic orchestration going through the overridden seams).
Docstrings are stripped first, because the native overrides explain this very trap and would report
themselves. It checks all three native adapters — auditing one checks a backend nobody may be
running, which is exactly what hid the one real defect:
backend_metricsontemporalstore-directinherited the JSONL implementation and reportedmode: "local-jsonl"withthe path of an unused sentinel file, so a native deployment was told it was running the wrong
engine.
Verification
Full mem0 surface over the native path, asserted on values, not status codes — the existing
matrix harness scores
get_alloncount > 0andhistoryonhttp == 200, and both passedwhile
get_allreturned twice the rows it should andhistoryreturned an empty log:22/22 — add, get, get_all (exact count AND content), search, update, history, delete, forget,
keyed upsert, keyed recall, TTL, reset. Plus
users(),batch_update,batch_deleteend to end.mainexactly. Every other failing suite was confirmedpre-existing by running it in place with and without these changes, and again against
main;test_retrieval_audit_is_off_by_defaultis a flaky teardown race that fires 3/5 onmainand1/5 here
misattribution had already sent two investigations to the wrong place
Scope of evidence, so it is not overstated:
temporalstore-rustis verified live throughout.temporalstore-directcould not be exercised here — it fails at construction because the PythonSDK in this tree does not export
Client— so thebackend_metricsfix rests on the guard test,which does fail without it.
Known remaining cost
About 3 full-store reads per ingest remain:
drain_due_idle_session_commits,batch_extract, and_read_raw_recordsviasession_commit. The first looked straightforward — a native scoped scanalready exists — but
_native_candidate_scanends incompact_latest_context_state_records, so itdoes not return the append-ordered log that the drain's last-write-wins logic depends on. That has
to be established before switching, not assumed.