Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion onlion/backend/append-log.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions onlion/backend/worker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*
Expand Down
35 changes: 35 additions & 0 deletions onlion/scripts/test-add-log-mode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions onlion/scripts/test-append-log.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion onlion/scripts/test-backend.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
Loading