Skip to content

U2: keyed CRUD for the Mem0/Cognee adapters — investigation + design (get drops from O(N) to ≤3 requests) #69

Description

@YellowSnnowmann

Promised in #68 ("keyed CRUD for Mem0/Cognee (#18 U2) needs vendor-API research first; filing separately") — the research is done. Three-angle investigation: a code audit of the adapters' current enumeration cost, and vendor-API research for Mem0 (cloud + OSS server) and Cognee (cloud + OSS), synthesized into the design below. Headline: Cognee's 1+D+N-request keyed get shrinks to 3 requests using listing data the adapter already receives but ignores; Mem0 cloud has true server-side metadata filtering the adapter already writes the keys for. Every claim carries its file:line or URL; unverified vendor claims are flagged with mitigations.


U2 design — keyed CRUD / write-path indexing for the Mem0 and Cognee remote adapters (issue #18)

Scope: adapters/remote/src/{common,mem0,cognee,supermemory}.rs on branch feat/a4-typed-errors-retry-health. All code claims below are from the code audit (Report 1, paths verified in the working tree at /Users/shreyanshsharma/Desktop/Openhuman/tinymemory); vendor claims carry their doc/source URLs (Reports 2–3). Conflicts between the reports are flagged inline and collected in §6.

Notation: N = records the adapter owns, D = Cognee dataset count (one per namespace), n_ns = records in one namespace, T = Supermemory container-tag count, L = export page limit.


1. The problem, quantified

Root cause is structural, not per-adapter. The Dialect trait (common.rs:529-548) has exactly five ops — upsert, entries() ("Enumerates every record owned by this adapter", common.rs:535), search, delete, health — i.e. no keyed read exists in the dialect vocabulary. RemoteMemory<D> therefore resolves every keyed read by full enumeration + client-side filter: get (common.rs:640-648), list (:651-667), count (:700-702), namespace_summaries (:675-697). Each dialect's upsert and (for Mem0) delete additionally do their own enumeration to discover remote_id, which is never cached anywhere — RemoteMemory holds only the dialect (common.rs:552-554), dialects hold only the HTTP client, and remote_id has zero references outside the four adapter files.

Per-op HTTP request cost today:

OpMem0 cloud (page=200)Mem0 self-hostedCogneeSupermemory
get(ns,key)⌈N/200⌉1 while N<1000; hard error at N≥1000 (mem0.rs:272-281)1 + D + N (serial raw fetch per record, cognee.rs:231-249)1 + Σ_t ⌈n_t/200⌉ (whole account)
list / count / namespace_summaries⌈N/200⌉ eachsame1 + D + N eachsame
store (new or re-store)⌈N/200⌉ + 11 + 1 / bail(1 + D + N) + 1⌈n_ns/200⌉ + 1 (already namespace-scoped, supermemory.rs:313-319)
forget(ns,key)⌈N/200⌉ + 11 + 1 / bail2 + n_ns + 1 (already dataset-scoped, cognee.rs:420-434)⌈n_ns/200⌉ + 1
recall1111

Concrete: on a 10k-record store, one Mem0-cloud get is 50 requests downloading the entire account body; one Cognee get is ~10,002 serial requests. Reads retry up to 3× on transient failure (common.rs:307-342), so worst cases triple.

Amplification in the Portability path (mandatory layer, not adapter-fixable alone): export_page calls namespace_summaries()andlist(ns,..) per page (api/src/mandatory/mod.rs:287, :305-308) — 2 full enumerations per exported page. Mem0 cloud, 10k records, L=100 ≈ 10,100 requests to export what 50 can download; Cognee ≈ >2,000,000. Import calls store_with_taint per record → one existence-probe enumeration of the growing destination per record: ≈N²/400 requests into Mem0 cloud, O(N²/2) into Cognee.

The cost is acknowledged in-tree (mem0.rs:234-236, :241-255; supermemory.rs:253-257 — the latter records that this exact regression was already fixed once, for Supermemory's writes only). No TODO/FIXME, no cache, no index exists.


2. What each vendor actually offers

2.1 Mem0

Cloud (api.mem0.ai) — server-side keyed lookup exists:

  • POST /v3/memories/ ("Get Memories") takes a required filters object (must include ≥1 entity ID) with AND/OR/NOT and comparison operators; paginated {count, next, previous, results[]}, page_size 1–200 (default 100). https://docs.mem0.ai/api-reference/memory/get-memories
  • Metadata filtering: {"metadata": {...}} clauses — but top-level metadata keys only, operators eq/contains/ne only; in/gt/lt on metadata raise FilterValidationError; nested keys unsupported. https://docs.mem0.ai/platform/features/v2-memory-filters. Our metadata keys tinymemory_namespace/tinymemory_key (mem0.rs:399-410) are top-level and need only equality — inside the documented envelope.
  • By-ID CRUD: GET/PUT/DELETE /v1/memories/{id}/ (update rewrites text and metadata). https://docs.mem0.ai/api-reference/memory/get-memory, .../update-memory, .../delete-memory
  • Batch: PUT|DELETE /v1/batch/ (≤1000/request); DELETE /v1/memories/ accepts a metadata filter param (server-side delete-by-metadata, async); memory_ids is filterable in list filters (batch get by id). https://docs.mem0.ai/api-reference/memory/batch-update, .../batch-delete, .../delete-memories
  • v1/v2 endpoints the adapter already uses "continue to work… no requirement to migrate" — https://docs.mem0.ai/migration/platform-v2-to-v3.
  • Caveat: AND-ing user_id + agent_id returns only records with both set (v2-memory-filters page). Safe for us: creates send user_id = namespace (mem0.rs:441) and the existing cloud enumeration/search already filter on agent_id/AND[agent_id,user_id] successfully (mem0.rs:304, :500-506), so both fields are populated.
  • Rate limits exist but are numerically undocumented (changelog only: https://docs.mem0.ai/changelog/platform).

OSS server (mem0ai/mem0 server/main.pyhttps://github.com/mem0ai/mem0/blob/main/server/main.py):

  • GET /memories filters only by user_id/run_id/agent_id (top_kALL_MEMORIES_LIMIT = 1000). No metadata parameter on the list route. But user_id filtering means namespace-scoped listing is available server-side today, since the adapter already writes user_id = namespace.
  • POST /search passes filters straight through, and custom metadata keys are first-class, written bare (e.g. {"user_id": "alice", "AND": [{"priority": {"gte": 7}}]}) — note the grammar differs from cloud (bare keys vs {"metadata": {...}} wrapper). Reliability is vector-store dependent: Qdrant/pgvector full; Chroma basic; Pinecone partial; Weaviate filters only entity IDs; "if an operator is unsupported, most stores silently ignore it or fall back to equality." https://docs.mem0.ai/open-source/features/metadata-filtering
  • By-ID CRUD exists (GET/PUT/DELETE /memories/{id}); no batch routes.
  • Cloud rejects empty search queries (minLength: 1); OSS behavior for empty query undocumented — use a short dummy query and treat filters, not scores, as truth.

Ambiguities preserved from Report 2: v3 endpoint docs give no metadata example (grammar comes from the v2-named filters page; whether v3 relaxes the operator limits is unstated); the cloud search endpoint's separate metadata body param vs metadata-inside-filters interaction is unstated. The design below avoids both by using the list route with a single-key metadata clause.

2.2 Cognee

No server-side keyed or filtered lookup exists, Cloud or OSS. What exists:


3. Design options per adapter

3.0 Shared precondition (both options depend on it)

Add keyed reads to the Dialect trait, with default impls that fall back to today's entries()-filtering so no dialect breaks:

// common.rs — additions, defaults preserve current behaviorasyncfnentry(&self,namespace:&str,key:&str) -> Result<Option<StoredEntry>>;asyncfnnamespace_entries(&self,namespace:&str) -> Result<Vec<StoredEntry>>;

Rewire RemoteMemory::getdialect.entry(), RemoteMemory::list(Some(ns),..)dialect.namespace_entries() (common.rs:640-667). This alone upgrades Supermemory for free: its find_entry(namespace,key) (supermemory.rs:313-319) is exactly the keyed-get seam RemoteMemory::get currently cannot reach, and memories_in_tag (supermemory.rs:258-309) is namespace_entries.

Universal correctness rule for every option below — verify-after-resolve: whatever record a filtered/name-matched/cached path returns, decode it and check tinymemory_namespace/tinymemory_key equality client-side before surfacing it. This makes silent server-side filter degradation (Mem0 OSS stores) and name collisions (Cognee duplicates) fail safe into the enumeration fallback instead of returning the wrong record.

3.1 Mem0

(a) Server-side filter route

  • Cloud keyed get = 1 × POST /v3/memories/ with
    filters: {"AND": [{"agent_id": "tinymemory"}, {"user_id": <ns>}, {"metadata": {"tinymemory_key": <key>}}]}, page_size small. Single metadata key + equality keeps us inside the documented limits (top-level, eq only) and sidesteps the undocumented multi-key-metadata semantics; user_id carries the namespace as an indexed entity filter. Result yields the mem0 id; then PUT|DELETE /v1/memories/{id}/ for upsert-write/forget.
  • Cloud namespace list = paged POST /v3/memories/ with AND[agent_id, user_id=ns] → ⌈n_ns/200⌉ requests. Cloud count = 1 request reading the envelope's count with page_size=1 (envelope shape per https://docs.mem0.ai/api-reference/memory/get-memories; that count = total matches is inferred from the standard paginated envelope — confirm once against the live API).
  • OSS keyed get, two tiers:
    1. Fast path: 1 × POST /search with dummy query + bare-key filters {"AND":[{"user_id": <ns>}, {"tinymemory_key": <key>}]}, scores ignored, verify-after-resolve mandatory (silent-degradation risk).
    2. Portable floor: 1 × GET /memories?user_id=<ns>&top_k=1000 + client-side key match. This is strictly better than today: the enumeration and the hard 1000-record bail become per-namespace instead of per-store (the bail must stay — a full page is still indistinguishable from truncation — but it now triggers at 1000 records in one namespace).
      Then by-id PUT/DELETE /memories/{id}.
  • Costs after (a): cloud get = 1; store = 2 (resolve + write); forget = 2; list(ns) = ⌈n_ns/200⌉; count = 1. OSS get = 1–2; store/forget = 2–3; ceiling per-namespace. Not improved:namespace_summaries (and list(None)) — Mem0 has no group-by; full enumeration ⌈N/200⌉ remains (optionally slimmed via the fields param on cloud).
  • Correctness risks: (i) cloud metadata-operator limits are documented on a v2-named page and v3 behavior is not restated — pin with a live integration test; (ii) OSS silent filter degradation → wrong-record risk, closed by verify-after-resolve + fallback to tier 2; (iii) grammar divergence (cloud {"metadata":{...}} wrapper vs OSS bare keys) must be encoded per-flavour — the adapter already branches on flavour.
  • Conformance needs: mock-server request-count ceilings (get ≤ 2 regardless of N); wrong-record guard (mock returns a non-matching record under a filter → adapter must not surface it, must fall back); per-namespace-bail test (1000 in ns A must not break ops on ns B); external-delete staleness (get after out-of-band delete returns None, not a ghost); flavour-grammar tests for both filter shapes.

(b) Client-side write-path index

A (namespace,key) → remote_id map maintained on store/forget/import, consulted before any enumeration.

  • Persisted where? Three sub-options: (1) process-memory only — zero new config, lost on restart, first miss per key rebuilds via one enumeration; (2) local sidecar file (JSON/SQLite keyed by endpoint+account) — survives restarts but introduces a local-state surface the remote adapters deliberately don't have today (they hold only the HTTP client, mem0.rs:226-229), plus multi-process coherence and cleanup problems; (3) server-side — no vendor surface for it; not available.
  • Hard limitation: a cache cannot make a cold or negative lookup cheap — a miss is indistinguishable from "not stored" without an authoritative resolver, so get of an unknown/absent key still costs a full enumeration, and negative caching is unsafe under concurrent writers. So (b) alone fixes repeated-op and import cost but not first-touch get.
  • Correctness risks: staleness under concurrent writers (external delete → cached id 404s → must invalidate + re-resolve, never error through; external delete+re-create → cached id points at a dead record — 404-invalidate covers it since mem0 ids are stable per Report 2); index loss = one rebuild enumeration (today's cost, once).
  • Cost after (b): warm keyed ops O(1)+write; import into a store warmed by one initial enumeration drops from ≈N²/400 to N+⌈N/200⌉; cold get unchanged (O(N)).
  • Conformance needs: everything in (a)'s staleness list plus 404-invalidate-and-retry, cache-poisoning (two adapter instances interleaving writes must converge), and rebuild-after-loss.

(c) Hybrid

(a) as the authoritative resolver + a per-process (ns,key) → id memo (no persistence) to skip re-resolution on repeated ops, with 404/mismatch → invalidate → re-resolve via (a). All of (a)'s costs become the worst case instead of the every-time case; none of (b)'s persistence problems.

3.2 Cognee

(a′) "Narrow resolution" (there is no server-side filter route — this is the closest achievable analogue)

  • namespace → dataset_id: 1 × POST /api/v1/datasets{name: computed} (get-or-create; name computable offline, cognee.rs:192-194). Replaces the GET /datasets enumeration.
  • key → data_id: 1 × GET /datasets/{id}/data, match client-side against the computed post-normalization name (stable_id("key", key) + ".tinymemory"). Eliminates the per-record raw-fetch loop entirely. On ≥ v1.5.0, additionally stamp labels/external_metadata = {tinymemory_namespace, tinymemory_key} at add-time and prefer matching those listing fields — immune to filename normalization; feature-detect via the listing DTO (presence of label) or the tenant's OpenAPI.
  • Keyed ops by id: get = +1 GET .../raw (envelope remains authoritative — verify ns/key inside it); forget = DELETE .../data/{data_id}; upsert-update = PATCH /api/v1/update (data_id pinned) with delete-by-id + re-add fallback where PATCH is absent (Cloud unverified) — accepting that the fallback changes data_id. Never blind re-add (duplicate-row hazard, §2.2).
  • Costs after (a′): get = 3 (dataset resolve + listing + raw); store = 3 (resolve + listing probe + write); forget = 3 (down from 2+n_ns+1). The listing is unpaginated — one request but O(n_ns) payload of metadata rows; that is the floor.
  • What stays expensive, version-gated: stable_id is one-way, so key and namespace values are not recoverable from names. Pre-1.5.0: list(ns) needs 1 raw per returned record (content + key live only in the envelope) and namespace_summaries needs ≥1 raw per dataset to learn the namespace name (all envelopes in a dataset share it → 1 + D + D total, already a huge cut from 1+D+N). On ≥1.5.0 with stamped metadata: namespace_summaries and key-only listings become raw-free (1 + D requests); list/export with content still pay 1 raw per record — the envelope is the content store; that is not solvable.
  • Correctness risks: (i) duplicate names in one dataset are possible if a blind re-add ever happened historically — on multi-match the adapter must not pick arbitrarily: define deterministic newest-createdAt-wins + warning (or hard error), pinned by conformance; (ii) filename normalization (percent-decode, final-extension strip) — non-issue for stable_id output today but must be pinned by test so a future stable_id/extension change can't silently break matching; (iii) PATCH absence on a Cloud tenant → fallback changes ids (only matters if a data_id cache exists); (iv) docs/version skew — label/externalMetadata must be probed, never assumed.

(b) Client-side write-path index

(ns,key) → "{dataset_id}:{data_id}" (the composite remote_id format already exists, cognee.rs:252). The namespace → dataset_id half is uniquely safe to cache forever (deterministic name + idempotent get-or-create). The data_id half is cache-friendly because PATCH pins ids, but external delete+re-add mints a new id → same 404-invalidate rule. Marginal value: it only saves the one listing call that (a′) already reduced things to; cold miss = one listing (cheap). Persistence is not worth introducing.

(c) Hybrid

(a′) + a per-process namespace → dataset_id memo (permanent) + optionally a (ns,key) → data_id memo (404-invalidate). Get drops to 2 requests warm (listing skipped only with the data_id memo: 1 raw + occasional re-resolve).

Conformance needs (Cognee): request-count ceilings (get ≤ 3 regardless of N and D; forget ≤ 3); a no-raw-fan-out assertion (keyed ops perform ≤1 raw fetch); name-normalization pinning (keys containing dots, %xx sequences, unicode roundtrip through the naming scheme); duplicate-name determinism test; PATCH-absent fallback path test; both sides of the ≥1.5.0 label gate (stamped and unstamped stores must both resolve correctly, and a pre-1.5.0-written store read by a ≥1.5.0 adapter must still match by name).


4. Recommendation

Ship the shared Dialect seam first (§3.0) — it is a precondition for everything, is behavior-preserving via default impls, and immediately fixes Supermemory's get/list (whole-account → per-tag) using code that already exists.

Mem0: hybrid (c), with (a) as the substance. Cloud gets true server-side keyed lookup (1-request get, 2-request writes) using the metadata filter the adapter already writes for exactly this purpose (mem0.rs:399-410 — the seam Report 1 identified as "one body change away"). OSS gets the user_id-scoped list as the portable floor (works on every vector store, since entity-ID filtering is the one universally supported dimension — https://docs.mem0.ai/open-source/features/metadata-filtering) with the /search metadata fast path where the store supports it, guarded by verify-after-resolve. Skip the persistent index (b) — its persistence surface buys nothing the memo doesn't, and it can't fix cold gets anyway. Honestly not solvable on Mem0: namespace_summaries/list(None) remain full enumerations (no server-side group-by on either flavour); the OSS 1000-row truncation ambiguity remains — it just becomes a per-namespace ceiling instead of a whole-store death sentence; Weaviate-backed OSS never gets sub-namespace server-side filtering.

Cognee: (c) = narrow resolution (a′) + dataset_id memo + ≥1.5.0 metadata stamping. There is no server-side keyed lookup to adopt — Report 3 is unambiguous — so the win comes from exploiting the two deterministic names the adapter already computes (cognee.rs:192-198) against listing data it already receives but ignores. That converts 1+D+N into 2–3 requests per keyed op and O(N²) imports into O(N). Honestly not solvable on Cognee: one unpaginated per-namespace listing per keyed op is the floor (no filter, no pagination on the data route); content retrieval is 1 raw per record forever (the envelope is the content store), so content-bearing list/export remain O(n_ns) raws; pre-1.5.0 servers additionally pay raws for key/namespace recovery as scoped in §3.2.

Follow-up, separate issue (mandatory layer, not U2):export_page's per-page namespace_summaries() + list() double-call (mod.rs:287, :305-308) should compute summaries once per export, not per page. The adapter fixes above shrink each call it makes; the call-count shape is upstream of the adapters.


5. Sizing and shipping order

#Work itemScopeLOC (rough)Ships independently?
1Dialect entry/namespace_entries + defaults + RemoteMemory rewiring + Supermemory plug-in of find_entry/memories_in_tagcommon.rs, supermemory.rs100–150Yes — first. Behavior-preserving for Mem0/Cognee (defaults), immediate Supermemory win
2Mem0 cloud filtered resolver + by-id read/write path + verify-after-resolvemem0.rs150–250Yes (after 1)
3Mem0 OSS user_id-scoped list + /search fast path + per-flavour filter grammar + per-namespace bailmem0.rs150–250Yes (after 1); independent of 2
4Cognee narrow resolution (get-or-create dataset, name-match listing, by-id ops, PATCH-with-fallback upsert); deletes the raw-fetch loopcognee.rs200–300 (net smaller)Yes (after 1)
5Cognee ≥1.5.0 labels/external_metadata stamping + feature detection + label-preferred matchingcognee.rs100–150Yes (after 4); pure enhancement
6Per-process memos (Mem0 id memo; Cognee dataset_id/data_id memo) with 404-invalidatecommon.rs or per-dialect80–120 eachOptional, last; pure perf
7Counting-mock-server conformance harness + the request-ceiling / wrong-record / staleness / normalization tests in §3test infra200–300 sharedWith 1; each adapter adds its cases with its item
8Export-page double-enumeration restructureapi/src/mandatory/mod.rsSeparate issue (out of U2 scope)

Items 2, 3, 4 are mutually independent; a partial landing (e.g. 1+4 only) still removes the worst offender (Cognee's 1+D+N).


6. Report conflicts & discrepancies (flagged, not papered over)

  1. Metadata key names: Report 2's closing recipe filters on metadata: {"namespace":…, "key":…}, but the adapter actually stores tinymemory_namespace/tinymemory_key (Report 1, mem0.rs:399-410). Design uses the real names; both are top-level, so the cloud limits still hold.
  2. Namespace→entity mapping: Report 2 recommends encoding namespace into run_id/agent_id "keeping user_id for the tenant"; Report 1 shows the shipped scheme is already user_id = namespace, agent_id = "tinymemory" (mem0.rs:441, :304). Resolved in favor of the existing scheme — it already delivers entity-scoped namespace filtering on both flavours and every OSS store, and switching would require re-tagging all existing data for no capability gain.
  3. Cognee listing-name shape: Report 3's worked example (mykey.tinymemory → name mykey) uses a different filename than the adapter uploads (stable_id + ".tinymemory.json", Report 1 cognee.rs:196-198); with only the final extension stripped, our listing name is stable_id + ".tinymemory". Report 1 independently confirms the adapter's decode already handles a ".json-stripping quirk" (cognee.rs:235-241). Consistent once reconciled, but the exact post-normalization name must be pinned by a conformance test, not assumed.
  4. "Raw fetches are necessary" vs "unnecessary": Report 1 (current code) raw-fetches every record because ns/key live in the envelope; Report 3 says one listing suffices. Not a contradiction — Report 1 itself flags the filename seam as present-but-unused — but note the limit: the envelope stays authoritative for content and for pre-1.5.0 key/namespace values (hashes are one-way), so raws don't disappear from content-bearing paths (§3.2).
  5. Unverified vendor claims the design depends on (each carried with a mitigation): Mem0 v3 metadata-operator limits are documented only on a v2-named page (integration-test pin); the cloud list envelope's count semantics for a 1-request count() (verify once live); Cognee PATCH /api/v1/update on Cloud tenants (OpenAPI probe + delete+add fallback); Cognee label/externalMetadata fields on any given server (feature-detect).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions