mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
fix(daemon): slice-membership batch — SQLite NUL parity + cross-operation freshness
Corrective gate on the batch membership index (c8f85802): guard reproduced
three semantic regressions vs the parent per-slice SQL, all reachable
through shipped surfaces.
1. Haystack NUL parity: SQLite LIKE stops matching at the first raw U+0000
in the scanned text (reachable via POST /api/queue/create JSON bodies).
sqliteVisiblePrefix now truncates BOTH body and tags haystacks before
ASCII fold / prefilter / confirm, so a term occurring only after a NUL
never matches — exactly like the parent.
2. Pattern-side NUL parity: the parent bound the FULL '%term%' LIKE
pattern, and SQLite truncates patterns too — a NUL inside a term (e.g. a
rail-item read by the minimal frontmatter parser) loses the trailing
wildcard, leaving an end-anchored '%prefix'. Translation now builds the
bound pattern and truncates it: the untruncated common case stays an
UNANCHORED contains-match (wildcard-free terms remain pure literals on
the regex engine's fast alternation path — an anchored-everything form
measured an ~80x benchmark regression before bundling), while a
truncated pattern translates verbatim with ^...$ anchors.
3. Cross-operation freshness: the index is now OPERATION-scoped, not
TTL-scoped — it lives inside exactly one cold list() rebuild or one
uncached get() (try/finally clears it), so a qitem written between
operations is visible on the very next uncached read, matching the
parent's per-call SQL freshness. One cold list still shares one batch
(the <=4 LIKE-execution load contract holds); listing/detail TTL
semantics unchanged.
Pins added: haystack-NUL exclusion with before-NUL control; pattern-side
truncated-%RAIL end-anchor with mid-body control; isolated typed and
fallback cross-operation freshness vectors. Bench at 28-mission/353-slice/
8000x3KB scale: cold 93.1ms (parent baseline 11442.9ms).
qitem-20260720042203-18f3300d (corrective gate on qitem-ccf87c0d)
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
c8f8580205
commit
75245ed69b
2 files changed
+146
-17
No files matched your search
@@ -152,11 +152,9 @@ interface MembershipIndex {
|
||||
/** fallback term -> qitem ids in row (rowid) order, capped 500/term. */
|
||||
fallbackByTerm: Map<string, string[]>;
|
||||
/** location names the index was built from — a get() on a folder created
|
||||
* mid-generation (not in this set) forces a rebuild so fresh folders
|
||||
* resolve membership without waiting for invalidate/TTL, exactly like
|
||||
* the per-slice path did. */
|
||||
* mid-operation (not in this set) forces a rebuild so fresh folders
|
||||
* resolve membership exactly like the per-slice path did. */
|
||||
knownSlices: Set<string>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// --- SQL-LIKE-equivalent matching (qitem-ccf87c0d) -------------------------
|
||||
@@ -178,11 +176,22 @@ function asciiFold(s: string): string {
|
||||
return s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
|
||||
}
|
||||
|
||||
/** Translate one LIKE term (already ascii-folded) to a RegExp source.
|
||||
* Compile the result with LIKE_REGEXP_FLAGS. */
|
||||
function likeTermToRegExpSource(term: string): string {
|
||||
/** qitem-18f3300d — SQLite LIKE stops matching at the first raw U+0000 in
|
||||
* the haystack (guard-reproduced against the parent per-slice SQL; a NUL
|
||||
* is production-reachable via POST /api/queue/create JSON bodies). Apply
|
||||
* BEFORE asciiFold/prefilter/confirm so a term occurring only after a NUL
|
||||
* never matches, exactly like the parent. */
|
||||
function sqliteVisiblePrefix(s: string): string {
|
||||
const i = s.indexOf("\u0000");
|
||||
return i === -1 ? s : s.slice(0, i);
|
||||
}
|
||||
|
||||
/** Translate a run of LIKE-pattern characters (%/_ wildcards, everything
|
||||
* else literal with regex specials escaped). No anchors — callers add
|
||||
* them only where LIKE full-match semantics diverge from contains. */
|
||||
function likePatternBodyToRegExpSource(patternBody: string): string {
|
||||
let out = "";
|
||||
for (const ch of term) {
|
||||
for (const ch of patternBody) {
|
||||
if (ch === "%") out += ".*";
|
||||
else if (ch === "_") out += ".";
|
||||
else out += ch.replace(/[.*+?^${}()|[\]\\]/, "\\$&");
|
||||
@@ -190,6 +199,30 @@ function likeTermToRegExpSource(term: string): string {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** qitem-18f3300d pattern-side facet — translate the FULL bound LIKE
|
||||
* pattern for one fallback term, exactly as the parent SQL saw it:
|
||||
* build `%<term>%` and truncate THE PATTERN at the first U+0000 (SQLite
|
||||
* truncates patterns too — a NUL inside the term LOSES the trailing
|
||||
* wildcard, leaving an end-anchored `%prefix`).
|
||||
* - Untruncated pattern (the overwhelmingly common no-NUL case):
|
||||
* `%term%` is exactly a contains-match, so the term translates
|
||||
* UNANCHORED — a wildcard-free term compiles to a pure literal, which
|
||||
* keeps the combined prefilter alternation on the regex engine's fast
|
||||
* literal path (an anchored `^.*x.*$` form measured a ~80x benchmark
|
||||
* regression: every non-matching row pays a greedy scan per branch).
|
||||
* - Truncated pattern: LIKE full-match semantics now matter (the lost
|
||||
* trailing `%` end-anchors the remainder), so translate the truncated
|
||||
* pattern verbatim and anchor `^...$`.
|
||||
* Compile with LIKE_REGEXP_FLAGS. */
|
||||
function likeBoundPatternToRegExpSource(term: string): string {
|
||||
const bound = `%${term}%`;
|
||||
const pattern = sqliteVisiblePrefix(bound);
|
||||
if (pattern === bound) {
|
||||
return likePatternBodyToRegExpSource(asciiFold(term));
|
||||
}
|
||||
return "^" + likePatternBodyToRegExpSource(asciiFold(pattern)) + "$";
|
||||
}
|
||||
|
||||
const FRONTMATTER_DELIM = "---";
|
||||
const DEFAULT_CACHE_TTL_MS = 60_000;
|
||||
|
||||
@@ -330,7 +363,15 @@ export class SliceIndexer {
|
||||
return this.listingCache.entries;
|
||||
}
|
||||
const locations = this.readSliceLocations();
|
||||
const entries: SliceListEntry[] = locations.map((location) => this.toListEntry(location));
|
||||
// ONE batch membership index serves this whole rebuild (the <=4 LIKE-
|
||||
// execution load contract), then drops so later uncached operations
|
||||
// read current queue state (qitem-18f3300d operation scoping).
|
||||
let entries: SliceListEntry[];
|
||||
try {
|
||||
entries = locations.map((location) => this.toListEntry(location));
|
||||
} finally {
|
||||
this.membershipIndex = null;
|
||||
}
|
||||
this.listingCache = { entries, expiresAt: now + this.cacheTtlMs };
|
||||
return entries;
|
||||
}
|
||||
@@ -345,7 +386,14 @@ export class SliceIndexer {
|
||||
const location = this.findSliceLocation(name);
|
||||
if (!location) return null;
|
||||
|
||||
const record = this.buildRecord(location);
|
||||
// Uncached get(): its own operation-scoped batch, dropped afterward so
|
||||
// the next uncached operation sees current queue state (qitem-18f3300d).
|
||||
let record: SliceRecord;
|
||||
try {
|
||||
record = this.buildRecord(location);
|
||||
} finally {
|
||||
this.membershipIndex = null;
|
||||
}
|
||||
this.detailCache.set(name, { record, expiresAt: now + this.cacheTtlMs });
|
||||
return record;
|
||||
}
|
||||
@@ -668,8 +716,12 @@ export class SliceIndexer {
|
||||
* Degradations preserved: tags column missing -> typed tier empty + body-
|
||||
* only fallback scan; queue_items absent -> throws to matchQitems' catch. */
|
||||
private ensureMembershipIndex(): MembershipIndex {
|
||||
const now = Date.now();
|
||||
if (this.membershipIndex && this.membershipIndex.expiresAt > now) {
|
||||
// qitem-18f3300d — OPERATION-scoped, never TTL-scoped: the index lives
|
||||
// only for the duration of one public cold list() or one uncached
|
||||
// get() (the public entry points clear it), so separate uncached
|
||||
// operations always see current queue state — parent-equivalent
|
||||
// freshness — while one cold list still shares one batch.
|
||||
if (this.membershipIndex) {
|
||||
return this.membershipIndex;
|
||||
}
|
||||
|
||||
@@ -722,16 +774,16 @@ export class SliceIndexer {
|
||||
const termList = Array.from(fallbackTerms);
|
||||
const matchers = termList.map((term) => ({
|
||||
term,
|
||||
re: new RegExp(likeTermToRegExpSource(asciiFold(term)), LIKE_REGEXP_FLAGS),
|
||||
re: new RegExp(likeBoundPatternToRegExpSource(term), LIKE_REGEXP_FLAGS),
|
||||
}));
|
||||
const prefilter = new RegExp(matchers.map((m) => m.re.source).join("|"), LIKE_REGEXP_FLAGS);
|
||||
const prefilter = new RegExp(matchers.map((m) => `(?:${m.re.source})`).join("|"), LIKE_REGEXP_FLAGS);
|
||||
for (const term of termList) fallbackByTerm.set(term, []);
|
||||
const scan = tagsColumnAvailable
|
||||
? this.db.prepare(`SELECT qitem_id, body, tags FROM queue_items`)
|
||||
: this.db.prepare(`SELECT qitem_id, body FROM queue_items`);
|
||||
for (const r of scan.iterate() as Iterable<{ qitem_id: string; body: string; tags?: string | null }>) {
|
||||
const hayBody = asciiFold(r.body);
|
||||
const hayTags = r.tags != null ? asciiFold(r.tags) : null;
|
||||
const hayBody = asciiFold(sqliteVisiblePrefix(r.body));
|
||||
const hayTags = r.tags != null ? asciiFold(sqliteVisiblePrefix(r.tags)) : null;
|
||||
if (!prefilter.test(hayBody) && !(hayTags !== null && prefilter.test(hayTags))) continue;
|
||||
for (const m of matchers) {
|
||||
if (m.re.test(hayBody) || (hayTags !== null && m.re.test(hayTags))) {
|
||||
@@ -747,7 +799,7 @@ export class SliceIndexer {
|
||||
}
|
||||
}
|
||||
|
||||
this.membershipIndex = { typedBySlice, fallbackByTerm, knownSlices, expiresAt: now + this.cacheTtlMs };
|
||||
this.membershipIndex = { typedBySlice, fallbackByTerm, knownSlices };
|
||||
return this.membershipIndex;
|
||||
}
|
||||
|
||||
|
||||
@@ -864,3 +864,80 @@ describe("qitem-ccf87c0d — LIKE '_' Unicode code-point parity (guard blocker p
|
||||
expect(indexer.get("emoji_probe")!.qitemIds).toEqual(["q-astral"]);
|
||||
});
|
||||
});
|
||||
|
||||
// qitem-18f3300d — guard's two candidate-blocking regressions vs parent
|
||||
// 7b19b73e, RED against sealed candidate c8f85802 (test-only gate).
|
||||
describe("qitem-18f3300d — NUL parity + cross-operation membership freshness (RED vs candidate)", () => {
|
||||
let db: Database.Database;
|
||||
let cleanup: string;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb();
|
||||
migrate(db, [coreSchema, eventsSchema, streamItemsSchema, queueItemsSchema]);
|
||||
db.prepare(`INSERT INTO rigs (id, name) VALUES ('r-1', 'rig')`).run();
|
||||
cleanup = fs.mkdtempSync(path.join(os.tmpdir(), "slice-indexer-gate2-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(cleanup, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("RED: SQLite LIKE truncates the haystack at raw U+0000 — a fallback term occurring ONLY AFTER a NUL must be EXCLUDED (parent behavior); before-NUL control stays included", () => {
|
||||
// Production-reachable: POST /api/queue/create JSON-decodes
|
||||
// "before\u0000slice-after" to a JS string with a raw NUL and stores it
|
||||
// in queue_items.body unrejected. Parent per-slice SQL (body LIKE
|
||||
// '%<term>%') returns NO row when the term sits after the NUL; the
|
||||
// candidate's JS regex scans through NUL and wrongly includes it.
|
||||
const flatRoot = path.join(cleanup, "flat-nul");
|
||||
writeSlice(flatRoot, "nul-term-slice", { "README.md": "---\nstatus: active\n---\n" });
|
||||
insertQitem(db, { qitemId: "q-after-nul", body: "prefix\u0000nul-term-slice suffix", tags: [] });
|
||||
insertQitem(db, { qitemId: "q-before-nul", body: "nul-term-slice mentioned\u0000trailing junk", tags: [] });
|
||||
const indexer = new SliceIndexer({ slicesRoot: flatRoot, dogfoodEvidenceRoot: null, db });
|
||||
expect(indexer.get("nul-term-slice")!.qitemIds).toEqual(["q-before-nul"]);
|
||||
});
|
||||
|
||||
it("RED: NUL in the PATTERN side — rail-item 'RAIL\\u0000TAIL' binds %RAIL\\u0000TAIL%; SQLite truncates the PATTERN to %RAIL, LOSING the trailing wildcard: body ENDING in RAIL matches, RAIL mid-body does NOT", () => {
|
||||
// Parent SQL binds the full pattern '%RAIL\u0000TAIL%'; SQLite truncates
|
||||
// it at the NUL to '%RAIL' — now END-ANCHORED because the trailing '%'
|
||||
// was lost. The railItem reaches the bound pattern through the minimal
|
||||
// frontmatter parser with no NUL rejection (production-reachable).
|
||||
const flatRoot = path.join(cleanup, "flat-nul-pattern");
|
||||
writeSlice(flatRoot, "plain-slice", {
|
||||
"README.md": "---\nstatus: active\nrail-item: RAIL\u0000TAIL\n---\n",
|
||||
});
|
||||
insertQitem(db, { qitemId: "q-parent-match", body: "ends with RAIL", tags: [] });
|
||||
insertQitem(db, { qitemId: "q-mid-rail", body: "RAIL in middle stuff", tags: [] });
|
||||
const indexer = new SliceIndexer({ slicesRoot: flatRoot, dogfoodEvidenceRoot: null, db });
|
||||
// Parent: q-parent-match via truncated '%RAIL' (ends-with); q-mid-rail
|
||||
// excluded (the lost trailing wildcard matters).
|
||||
expect(indexer.get("plain-slice")!.qitemIds).toEqual(["q-parent-match"]);
|
||||
});
|
||||
|
||||
it("RED: cross-operation freshness (TYPED) — a typed qitem written for ALREADY-KNOWN B is visible on B's FIRST uncached get(), no invalidate/TTL", () => {
|
||||
// Parent ran per-slice SQL on every uncached get(), so a row written
|
||||
// after get(A) was immediately visible to the first get(B). The
|
||||
// candidate's generation-scoped index (B already in knownSlices) serves
|
||||
// stale empty membership until invalidate/TTL.
|
||||
const flatRoot = path.join(cleanup, "flat-fresh-typed");
|
||||
writeSlice(flatRoot, "fresh-a", { "README.md": "---\nstatus: active\n---\n" });
|
||||
writeSlice(flatRoot, "fresh-b-typed", { "README.md": "---\nstatus: active\n---\n" });
|
||||
const indexer = new SliceIndexer({ slicesRoot: flatRoot, dogfoodEvidenceRoot: null, db });
|
||||
expect(indexer.get("fresh-a")!.qitemIds).toEqual([]); // builds the index generation
|
||||
insertQitem(db, { qitemId: "q-b-typed", body: "no name mention", tags: ["slice:fresh-b-typed"] });
|
||||
expect(indexer.get("fresh-b-typed")!.qitemIds).toEqual(["q-b-typed"]);
|
||||
});
|
||||
|
||||
it("RED: cross-operation freshness (FALLBACK) — a body-mention qitem written for ALREADY-KNOWN C is visible on C's FIRST uncached get(), no invalidate/TTL", () => {
|
||||
// Isolated from the typed vector (own indexer + fixture) so this
|
||||
// assertion executes and fails independently on c8f85802 — a
|
||||
// typed-triggered rebuild elsewhere must not mask the fallback tier.
|
||||
const flatRoot = path.join(cleanup, "flat-fresh-fallback");
|
||||
writeSlice(flatRoot, "fresh-a2", { "README.md": "---\nstatus: active\n---\n" });
|
||||
writeSlice(flatRoot, "fresh-c-fallback", { "README.md": "---\nstatus: active\n---\n" });
|
||||
const indexer = new SliceIndexer({ slicesRoot: flatRoot, dogfoodEvidenceRoot: null, db });
|
||||
expect(indexer.get("fresh-a2")!.qitemIds).toEqual([]); // builds the index generation
|
||||
insertQitem(db, { qitemId: "q-c-fallback", body: "fresh-c-fallback body mention", tags: [] });
|
||||
expect(indexer.get("fresh-c-fallback")!.qitemIds).toEqual(["q-c-fallback"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user