From adfa1f9ce3740a402c4a38a44ffd44ffd8a52168 Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:50:02 +0800 Subject: [PATCH] fix(onlion): fold at one batch so the read window covers everything waiting Reading the newest entries fixed the streaming case and left the mirror image: the window holds eight entries, the standing backlog lived between the batch and the trigger (16..23 at a trigger of 24), so whichever end the window took, part of it was invisible until it folded. The backlog is what has to change, not the end. Folding as soon as one batch is waiting keeps pending below the window, and it costs no extra partition writes: a fold absorbs exactly one batch, so the cadence is one fold per batch of accepts at any trigger above the batch. What the lower trigger removes is the standing tail, not throughput. `MAX_PENDING_READ` grows to two batches because the trigger is not a hard bound - with concurrent accepts a few more can arrive before the fold commits, and anything past the window is invisible. The cost is one embedding call per entry actually pending, normally one batch and never this bound. Pinned by a test that adds twenty facts one at a time and requires all twenty to be searchable with the log still live, plus the pending count below the window. Verified: npm run verify passes (34 PASS). --- onlion/backend/append-log.mjs | 9 ++++++- onlion/backend/worker.mjs | 12 ++++++++-- onlion/scripts/test-add-log-mode.mjs | 35 ++++++++++++++++++++++++++++ onlion/scripts/test-append-log.mjs | 7 +++--- onlion/scripts/test-backend.mjs | 2 +- 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/onlion/backend/append-log.mjs b/onlion/backend/append-log.mjs index 34b9135d..71207249 100644 --- a/onlion/backend/append-log.mjs +++ b/onlion/backend/append-log.mjs @@ -23,7 +23,14 @@ import { Buffer } from 'node:buffer'; export const LOG_ENTRY_VERSION = 1; // must equal the HTTP payloads' `v` -export const MAX_PENDING_READ = 8; // pending entries scored per search +/** Pending entries scored per search. + * + * Sized against the fold trigger, not picked for taste: entries fold once a full batch is + * waiting, but with concurrent accepts a few more can arrive before that fold commits, and + * anything past this bound is invisible to a search. Two batches covers the trigger plus the + * accepts that can be in flight; the cost is one embedding call per entry actually pending, + * which is normally one batch and never this bound. */ +export const MAX_PENDING_READ = 16; // received_ms is padded to a fixed 13 digits (enough for any millisecond // timestamp until year 2286), so plain string order is arrival order. diff --git a/onlion/backend/worker.mjs b/onlion/backend/worker.mjs index ed8288c0..322320de 100644 --- a/onlion/backend/worker.mjs +++ b/onlion/backend/worker.mjs @@ -951,8 +951,16 @@ const addMode = (env) => (env.NMG_ADD_MODE === "sync" ? "sync" : "log"); /** Entries folded into a partition by one request. A fold is one partition write * regardless of how many entries it absorbs, so a 16-way burst needs two folds. */ const FOLD_BATCH = 8; -/** Accept stops accepting and folds instead once this many entries are waiting. */ -const FOLD_TRIGGER = 24; +/** Fold as soon as a full batch is waiting. + * + * This is a read-window contract, not a tuning knob: a search reads at most + * `MAX_PENDING_READ` entries, so anything beyond that is invisible until it folds. With a + * higher trigger the standing backlog lived between the batch and the trigger (16..23 at + * the old value of 24) and every search hid part of it - whichever end the window took. + * Folding at one batch keeps the backlog below the window, and costs no extra partition + * writes: a fold absorbs exactly one batch, so the cadence is one fold per batch of + * accepts at any trigger value above it. */ +const FOLD_TRIGGER = FOLD_BATCH; /** Features to embed and search per clause, bounded by `MAX_QUERY_FEATURES`. * diff --git a/onlion/scripts/test-add-log-mode.mjs b/onlion/scripts/test-add-log-mode.mjs index 6d91661b..6ece54c1 100644 --- a/onlion/scripts/test-add-log-mode.mjs +++ b/onlion/scripts/test-add-log-mode.mjs @@ -249,6 +249,41 @@ try { assert.equal(finished.status, 200, JSON.stringify(finished.body)); assert.equal(finished.body.remaining, 0); + + // The read window must cover everything that is waiting. With the fold trigger at one + // batch, a run of Adds leaves at most a batch pending, so every accepted fact stays + // searchable - the failure this pins is a standing backlog larger than the window, which + // hid whichever end the window did not take. + { + const windowUser = "window-" + process.pid; + const markers = []; + for (let index = 0; index < 20; index += 1) { + const content = `marker ${index} says the vault code is ${2000 + index}`; + markers.push(content); + const added = await call( + "/add", + addBody(windowUser, `win-${index}`, content), + ); + assert.equal(added.status, 200, JSON.stringify(added.body)); + } + const windowCapacity = await call(`/capacity?user_id=${windowUser}`); + assert.ok( + windowCapacity.body.pending_entries < 8, + `pending must stay below the read window, got ${windowCapacity.body.pending_entries}`, + ); + for (const [index, content] of markers.entries()) { + const found = await call("/search", { + user_id: windowUser, + query: `vault code ${2000 + index}`, + top_k: 100, + }); + assert.ok( + (found.body.data ?? []).some((item) => String(item.content).includes(content)), + `marker ${index} must be searchable while the log is live`, + ); + } + } + // Reset drains the log as well as the partitions. const reset = await call("/reset", { user_id: "alice", diff --git a/onlion/scripts/test-append-log.mjs b/onlion/scripts/test-append-log.mjs index af59dca1..6ee6fd51 100644 --- a/onlion/scripts/test-append-log.mjs +++ b/onlion/scripts/test-append-log.mjs @@ -97,7 +97,7 @@ const entry = (receivedAt, requestHash, marker, messages = [{ role: 'user', cont // --- frozen surface ------------------------------------------------------- assert.equal(LOG_ENTRY_VERSION, 1); -assert.equal(MAX_PENDING_READ, 8); +assert.equal(MAX_PENDING_READ, 16); const moduleSource = fs.readFileSync( path.join(path.dirname(fileURLToPath(import.meta.url)), '../backend/append-log.mjs'), 'utf8', @@ -291,8 +291,9 @@ assert.throws(() => logEntryName(1700000000000, `${'A'.repeat(64)}`), RangeError const buriedRead = await listPending(buried, USER, { limit: 2, generation: 'g2' }); assert.deepEqual(buriedRead, [], 'a bounded scan may return fewer matches than the limit'); assert.ok( - buried.stats.gets <= 8, - `a filtered read must not read the whole prefix (read ${buried.stats.gets} entries)`, + buried.stats.gets <= MAX_PENDING_READ, + `a filtered read must stay inside its scan window, max(MAX_PENDING_READ, 4 * limit) ` + + `(read ${buried.stats.gets} entries)`, ); // The documented bound, in code rather than prose: search asks for at most // MAX_PENDING_READ, so a filter scans at most max(MAX_PENDING_READ, 4 * limit) keys. diff --git a/onlion/scripts/test-backend.mjs b/onlion/scripts/test-backend.mjs index 18d6be94..bc8fb317 100644 --- a/onlion/scripts/test-backend.mjs +++ b/onlion/scripts/test-backend.mjs @@ -895,7 +895,7 @@ print(json.dumps(r))`, max_query_chunks: MAX_QUERY_WINDOWS, max_query_features: 24, add_mode: "sync", - pending_read_limit: 8, + pending_read_limit: 16, merge_batch: 8, }); const longQuery = await call("a", "/search", {