Skip to content

Commit aff4b79

Browse files
Merge pull request #65 from YellowSnnowmann/feat/final-review-fixes
Close five defects the final production review found
2 parents 7235ee9 + 7f60516 commit aff4b79

8 files changed

Lines changed: 342 additions & 27 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,21 +67,41 @@ let provider = Arc::new(tinymemory::remote::supermemory_provider(backend));
6767
The remote adapter reaches only crates.io dependencies, so cargo resolves it
6868
without any `[patch]` entries.
6969

70-
**The embedded engine (TinyCortex) — three patch entries:**
70+
**The embedded engine (TinyCortex) — vendor this repository as a submodule.**
71+
72+
The remote recipe above works by git because the remote adapter reaches only
73+
published crates. The embedded engine does not: it pulls `tinycortex`,
74+
`tinycortex-api` and `tinyagents`, none of which are published, and
75+
`tinycortex-api` takes `tinymemory-api` *by git*, which cargo will resolve as a
76+
second copy of a crate this workspace also provides by path. Patching that away
77+
needs the crates on disk, so the embedded path is a submodule dependency until
78+
these crates are published:
79+
80+
```sh
81+
git submodule add https://github.com/tinyhumansai/tinymemory vendor/tinymemory
82+
git -C vendor/tinymemory submodule update --init --recursive
83+
```
7184

7285
```toml
7386
[dependencies]
74-
tinymemory = { git = "https://github.com/tinyhumansai/tinymemory", features = ["tinycortex"] }
87+
tinymemory = { path = "vendor/tinymemory", features = ["tinycortex"] }
7588

76-
# The engine and its api are unpublished; without these, cargo resolves a
77-
# second copy of each from the network and type identities split at the seam.
89+
# All four are required. The first three are unpublished crates the engine
90+
# needs; the fourth collapses `tinycortex-api`'s git dependency on
91+
# `tinymemory-api` onto the copy in this tree — without it two distinct
92+
# `tinymemory_api::MemoryEntry` types exist and the seam stops type-checking.
7893
[patch.crates-io]
79-
tinycortex = { git = "https://github.com/tinyhumansai/tinycortex" }
80-
tinycortex-api = { git = "https://github.com/tinyhumansai/tinycortex" }
94+
tinycortex = { path = "vendor/tinymemory/vendor/tinycortex" }
95+
tinycortex-api = { path = "vendor/tinymemory/vendor/tinycortex/api" }
96+
tinyagents = { path = "vendor/tinymemory/vendor/tinyagents" }
8197
[patch."https://github.com/tinyhumansai/tinymemory"]
82-
tinymemory-api = { git = "https://github.com/tinyhumansai/tinymemory" }
98+
tinymemory-api = { path = "vendor/tinymemory/api" }
8399
```
84100

101+
This exact patch set is what the reference consumer in `examples/` and the
102+
repository's own root manifest use; a build missing any of the four fails at
103+
resolution, before compiling a line.
104+
85105
```rust,ignore
86106
use std::sync::Arc;
87107
use tinymemory::tinycortex::{provider, InMemoryMemoryStore};

adapters/remote/Cargo.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,14 @@ async-trait = "0.1"
1616
# The storage trait deliberately uses opaque backend errors.
1717
anyhow = "1"
1818
# Native self-hosted APIs are HTTP/JSON; multipart is required by Cognee.
19-
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
19+
# `stream` is for `bytes_stream()`: response bodies are read against a byte
20+
# cap rather than buffered whole, because the endpoint is operator-supplied
21+
# and a broken or hostile one must not be able to OOM the host.
22+
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream"] }
2023
# Remote records are translated through a private, lossless envelope.
2124
serde = { version = "1", features = ["derive"] }
25+
# Streaming a capped body needs a Stream combinator.
26+
futures = "0.3"
2227
serde_json = "1"
2328
# Supermemory custom ids are bounded, so namespace/key identities use SHA-256.
2429
sha2 = "0.10"

adapters/remote/src/common.rs

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,56 @@ impl std::fmt::Debug for HttpClient {
4040
}
4141
}
4242

43+
/// Largest response body any hosted engine may return.
44+
///
45+
/// The endpoint is operator-supplied (`SupermemoryMemory::api`,
46+
/// `Mem0Memory::new`, `CogneeMemory::self_hosted` all take an arbitrary URL),
47+
/// so a broken or hostile server must not be able to exhaust the host's
48+
/// memory. 64 MiB is far above any real memory payload -- the largest thing
49+
/// these APIs return is a page of records -- and far below a size that
50+
/// threatens a process.
51+
const MAX_RESPONSE_BYTES: u64 = 64 * 1024 * 1024;
52+
53+
/// Read a response body, failing once it exceeds [`MAX_RESPONSE_BYTES`].
54+
///
55+
/// `Response::json()`/`text()` buffer the whole body before any size check, so
56+
/// a server that omits or understates `Content-Length` (a chunked response,
57+
/// say) could OOM the process despite a declared limit. Reading incrementally
58+
/// enforces the cap while the bytes arrive. Same argument, and same shape, as
59+
/// `tinymemory-sources`' `read_body_capped` -- that guard was written for the
60+
/// web-page reader and simply had not been applied on this path.
61+
async fn read_capped(response: reqwest::Response, path: &str) -> anyhow::Result<Vec<u8>> {
62+
use futures::StreamExt;
63+
if let Some(len) = response.content_length() {
64+
if len > MAX_RESPONSE_BYTES {
65+
anyhow::bail!(
66+
"memory API {path} response exceeds {MAX_RESPONSE_BYTES}-byte limit \
67+
(Content-Length={len})"
68+
);
69+
}
70+
}
71+
let mut body = Vec::new();
72+
let mut stream = response.bytes_stream();
73+
while let Some(chunk) = stream.next().await {
74+
let chunk = chunk.with_context(|| format!("memory API {path} body read failed"))?;
75+
// Check BEFORE appending: one oversized chunk would otherwise be
76+
// allocated in full before the limit is noticed, which is the
77+
// allocation this cap exists to prevent.
78+
let next_len = body
79+
.len()
80+
.checked_add(chunk.len())
81+
.context("memory API response length overflowed")?;
82+
if next_len as u64 > MAX_RESPONSE_BYTES {
83+
anyhow::bail!(
84+
"memory API {path} response exceeds {MAX_RESPONSE_BYTES}-byte limit \
85+
(would reach {next_len} bytes)"
86+
);
87+
}
88+
body.extend_from_slice(&chunk);
89+
}
90+
Ok(body)
91+
}
92+
4393
impl HttpClient {
4494
/// Builds a client that optionally authenticates with a bearer token.
4595
pub(crate) fn bearer(endpoint: &str, credential: Option<&str>) -> anyhow::Result<Self> {
@@ -168,9 +218,8 @@ impl HttpClient {
168218
if !status.is_success() {
169219
return Err(self.status_error(path, status));
170220
}
171-
response
172-
.json()
173-
.await
221+
let body = read_capped(response, path).await?;
222+
serde_json::from_slice(&body)
174223
.with_context(|| format!("memory API {path} returned invalid JSON"))
175224
}
176225

@@ -185,10 +234,8 @@ impl HttpClient {
185234
if !status.is_success() {
186235
return Err(self.status_error(path, status));
187236
}
188-
response
189-
.text()
190-
.await
191-
.context("memory API response was unreadable")
237+
let body = read_capped(response, path).await?;
238+
String::from_utf8(body).context("memory API response was not valid UTF-8")
192239
}
193240

194241
/// Sends a request whose successful response body is not needed.

adapters/remote/src/mem0.rs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,17 +119,52 @@ struct Mem0Dialect {
119119
}
120120

121121
impl Mem0Dialect {
122+
/// Largest listing this adapter will request in one call.
123+
///
124+
/// Every exact-CRUD path here enumerates through [`Self::values`], so this
125+
/// is the ceiling on the whole store, not on one page.
126+
const LISTING_TOP_K: usize = 1000;
127+
122128
/// Fetches Mem0's administrative memory listing.
129+
///
130+
/// # A hard ceiling, deliberately loud
131+
///
132+
/// This is a single unpaginated request, and it is the ONLY enumeration
133+
/// path in this adapter -- `get`, `list`, `count` and `export_page` all
134+
/// route through it. Past the ceiling the results are not merely
135+
/// incomplete, they are silently WRONG: `get(ns, key)` for a record beyond
136+
/// the cut-off returns `Ok(None)`, which the contract defines as "no such
137+
/// entry", so a caller reads "deleted" where the truth is "present but
138+
/// past the window".
139+
///
140+
/// Returning an error instead is the honest failure. A full response is
141+
/// indistinguishable from a truncated one -- both are exactly `top_k`
142+
/// items -- so this cannot detect truncation, only its own boundary, and
143+
/// it refuses at that boundary rather than answering wrongly. Paginating
144+
/// properly needs Mem0's paging parameters verified against a live
145+
/// service; guessing them here would trade a loud failure for a quiet one.
123146
async fn values(&self) -> anyhow::Result<Vec<Value>> {
147+
let top_k = Self::LISTING_TOP_K;
124148
let response: Value = self
125149
.client
126-
.json(Method::GET, "memories?top_k=1000", None)
150+
.json(Method::GET, &format!("memories?top_k={top_k}"), None)
127151
.await?;
128-
Ok(response
152+
let results = response
129153
.get("results")
130154
.and_then(Value::as_array)
131155
.cloned()
132-
.unwrap_or_default())
156+
.unwrap_or_default();
157+
if results.len() >= top_k {
158+
anyhow::bail!(
159+
"mem0 returned {} memories, this adapter's unpaginated listing ceiling. \
160+
Exact reads (get/list/count/export) cannot be answered correctly beyond \
161+
it -- a record past the window would read as absent -- so the adapter \
162+
refuses rather than answering wrongly. Recall is unaffected (it queries \
163+
mem0's search API directly).",
164+
results.len()
165+
);
166+
}
167+
Ok(results)
133168
}
134169

135170
/// Decodes a Mem0 result containing TinyMemory-owned metadata.

adapters/tinycortex/src/engine/mod.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,7 @@ impl MemorySourceSink for TinycortexProvider {
10241024
items: Vec<SourceItem>,
10251025
taint: MemoryTaint,
10261026
) -> Result<IngestOutcome, MemoryError> {
1027+
let items_len = items.len();
10271028
let namespace = format!("source:{source_id}");
10281029
let mut outcome = IngestOutcome::default();
10291030
for item in items {
@@ -1063,8 +1064,20 @@ impl MemorySourceSink for TinycortexProvider {
10631064
outcome.written = outcome.written.saturating_add(1);
10641065
outcome.ids.push(id);
10651066
}
1066-
Err(_) => {
1067-
outcome.skipped = outcome.skipped.saturating_add(1);
1067+
// A write failure is NOT `skipped`. The contract defines that
1068+
// field as "units the driver recognised as already present"
1069+
// (`IngestOutcome::skipped`), so counting a failed write there
1070+
// reports a locked database, a full disk or a dead embedder as
1071+
// a successful no-op: the sync caller marks the items done and
1072+
// they are never written. Propagate instead — a partial batch
1073+
// has no truthful representation in `IngestOutcome`, and a
1074+
// caller that wants best-effort ingestion can catch this.
1075+
Err(error) => {
1076+
return Err(MemoryError::Other(anyhow::anyhow!(
1077+
"source ingest failed after {} of {} item(s) were written: {error}",
1078+
outcome.written,
1079+
items_len
1080+
)));
10681081
}
10691082
}
10701083
}

0 commit comments

Comments
 (0)