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", {