tiny.smg.search
Defined in tiny.smg.
API (13)
Actions
Public operations.
dbPathlocationnormalizeQueryrebuildStoredsearchStoredsnippetsplitIdentifiersyncStoredFromDatabase
Types and contracts
Public types and contracts.
Source
Source: tools/smg/src/root.zig:31
zig
pub const search = @import("search.zig");Source: tools/smg/src/search.zig
zig
const std = @import("std");const alloc_phase = @import("alloc_phase");const sql = @import("sql");const sys = @import("sys");const graph_mod = @import("graph.zig");const limits_mod = @import("limits/root.zig");const model = @import("model.zig");const storage = @import("storage/root.zig");const fs_io = std.Options.debug_io;pub const Error = error{ InvalidSearchQuery, SearchCacheRepairRequired,};const db_name = "search.sql";const wal_name = "search.sql.wal";const temp_db_name = "search.sql.tmp";const temp_wal_name = "search.sql.tmp.wal";const cache_schema: u64 = 3;const search_field_names = [_][]const u8{ "name_tokens", "docstring" };pub const SyncKind = enum { matched, incremental, rebuilt,};pub const Hit = struct { rank: i64, name: []const u8, kind: []const u8, file: ?[]const u8, line_start: ?i64, line_end: ?i64, docstring: ?[]const u8, score: f64,};pub const Result = struct { hits: []const Hit, total: i64,};pub const DocumentPlan = struct { pub const Limits = struct { documents: usize, text_bytes: usize, }; pub const Capacity = struct { documents: usize, text_bytes: usize, bytes: usize, pub fn derive(limits: Limits) error{CapacityOverflow}!Capacity { const document_bytes = try alloc_phase.capacity.mul( usize, limits.documents, @sizeOf(sql.SearchDocument), ); return .{ .documents = limits.documents, .text_bytes = limits.text_bytes, .bytes = try alloc_phase.capacity.add( usize, document_bytes, limits.text_bytes, ), }; } }; pub const InitError = std.mem.Allocator.Error || error{CapacityOverflow}; pub const claim: alloc_phase.capacity.Declaration = .{ .source = .{ .id = "smg.search_document_plan", .kind = .phase_static, .limit_source = .caller, .storage = .{ .covered = &.{ .{ .id = "one_search_document_record_per_persisted_node_row", .lifetime = .steady, .detail = "one search document record per persisted node row", }, .{ .id = "one_contiguous_bounded_search_text_region_admitted_bf91a1d01c82", .lifetime = .steady, .detail = "one contiguous bounded search-text region admitted from exact row views", }, }, .excluded = &.{ "persisted graph relation scans and borrowed row bytes", "graph database pager catalog and operating-system read storage", "SQL search postings keys lengths write staging WAL and cache database", }, }, .capacity = .{ .inputs = &.{ alloc_phase.capacity.bindInput(Limits, "documents", "documents"), alloc_phase.capacity.bindInput(Limits, "text_bytes", "text_bytes"), }, .type_selectors = &.{ alloc_phase.capacity.bindType(sql.SearchDocument, "search_document"), }, .nodes = &.{ .{ .input = 0 }, .{ .scale = .{ .node = 0, .coefficient = .{ .size_of_concrete_type = 0 } } }, .{ .input = 1 }, .{ .add = .{ .left = 1, .right = 2 } }, }, .assertions = &.{.{ .scope = .closure_total, .measure = .retained, .relation = .upper_bound, .expression = 3, }}, }, .overload = .{ .kind = .reject_before_seal, .detail = "checked record and rendered-text arithmetic plus exact acquisitions reject overflow or OOM before the second relation pass", }, .risks = .{ .transitive = .{ .status = .open, .detail = "identifier rendering and row views are sealed in the witness but lack a transitive allocation-closure certificate", }, .foreign = .{ .status = .excluded, .detail = "graph and search database effects occur outside the plan-owned slices", }, }, .obligations = &.{ .{ .key = "smg_search_document_plan_capacity_capacity_model", .role = .capacity_model }, .{ .key = "smg_search_document_plan_capacity_overload", .role = .overload }, .{ .key = "smg_search_document_plan_oom", .role = .overload }, .{ .key = "smg_search_document_plan_sealed", .role = .transitive_risk }, .{ .key = "smg_search_document_plan_integration", .role = .foreign_risk }, }, }, .bindings = .{ .owner = @This(), .seal = .{ .family = alloc_phase.capacity.selector(@This().activate), .premise = .{ .class = .checked_semantic_fact, .authority = .checker, }, }, .teardown = .{ .family = alloc_phase.capacity.selector(@This().deinit), .premise = .{ .class = .checked_semantic_fact, .authority = .checker, }, }, }, }; phase: alloc_phase.capacity.Phase, capacity: Capacity, documents: []sql.SearchDocument, text: []u8, documents_filled: usize, text_filled: usize, pub fn init(allocator: std.mem.Allocator, limits: Limits) InitError!DocumentPlan { const capacity = try Capacity.derive(limits); const documents = try documentAllocSlice(sql.SearchDocument, allocator, capacity.documents); errdefer documentFreeSlice(sql.SearchDocument, allocator, documents); return .{ .phase = .initialization, .capacity = capacity, .documents = documents, .text = try documentAllocSlice(u8, allocator, capacity.text_bytes), .documents_filled = 0, .text_filled = 0, }; } pub fn append(self: *DocumentPlan, rowid: i64, name: []const u8, docstring: ?[]const u8) error{CapacityOverflow}!void { std.debug.assert(self.phase == .initialization); if (self.documents_filled == self.documents.len) return error.CapacityOverflow; const text_bytes = try nodeSearchTextBytes(name, docstring); const text_end = std.math.add(usize, self.text_filled, text_bytes) catch return error.CapacityOverflow; if (text_end > self.text.len) return error.CapacityOverflow; const destination = self.text[self.text_filled..text_end]; fillNodeSearchText(destination, name, docstring); self.documents[self.documents_filled] = .{ .rowid = rowid, .text = destination }; self.documents_filled += 1; self.text_filled = text_end; } pub fn activate(self: *DocumentPlan) void { std.debug.assert(self.phase == .initialization); std.debug.assert(self.documents_filled == self.documents.len); std.debug.assert(self.text_filled == self.text.len); self.phase = .steady; } pub fn deinit(self: *DocumentPlan, allocator: std.mem.Allocator) void { std.debug.assert(self.phase != .teardown); self.phase = .teardown; documentFreeSlice(sql.SearchDocument, allocator, self.documents); documentFreeSlice(u8, allocator, self.text); self.* = undefined; }};comptime { alloc_phase.capacity.requireAllocatorExactOwnerShape(DocumentPlan);}const StoredDocuments = struct { plan: DocumentPlan, head: sql.Hash,};const DocumentChanges = struct { added: std.ArrayList(sql.SearchDocument) = .empty, updated: std.ArrayList(sql.SearchDocument) = .empty, fn deinit(self: *DocumentChanges, allocator: std.mem.Allocator) void { self.added.deinit(allocator); self.updated.deinit(allocator); self.* = undefined; }};fn changedDocuments( allocator: std.mem.Allocator, node_puts: []const storage.rows.NodePut,) !DocumentPlan { var text_bytes: usize = 0; for (node_puts) |put| { text_bytes = std.math.add( usize, text_bytes, try nodeSearchTextBytes(put.node.name, put.node.docstring), ) catch return error.CapacityOverflow; } var plan = try DocumentPlan.init(allocator, .{ .documents = node_puts.len, .text_bytes = text_bytes, }); errdefer plan.deinit(allocator); for (node_puts) |put| { try plan.append(put.rowid, put.node.name, put.node.docstring); } plan.activate(); return plan;}pub fn rebuildStored( allocator: std.mem.Allocator, root: []const u8, limits: limits_mod.Limits,) !usize { var opened = try storage.store.open(allocator, root, limits.storage); defer opened.close(); const head = (try opened.database.connection.checkout()).head; return try rebuildStoredFromDatabase( allocator, root, &opened.database, head, limits.search, );}pub fn syncStoredFromDatabase( allocator: std.mem.Allocator, root: []const u8, database: *storage.database.Database, previous_head: sql.Hash, head: sql.Hash, node_puts: []const storage.rows.NodePut, node_deletes: []const i64, limits: limits_mod.Search,) !SyncKind { if (node_puts.len == 0 and node_deletes.len == 0 and try cacheMatchesSource(allocator, root, sourceForHead(head))) { if (!(try storage.names.matchesHead(allocator, root, head))) { try storage.names.writeForHead(allocator, root, database, head); } return .matched; } if (try updateStoredFromChanges( allocator, root, previous_head, head, node_puts, node_deletes, limits, )) { try storage.names.writeForHead(allocator, root, database, head); return .incremental; } _ = try rebuildStoredFromDatabase(allocator, root, database, head, limits); return .rebuilt;}fn updateStoredFromChanges( allocator: std.mem.Allocator, root: []const u8, previous_head: sql.Hash, head: sql.Hash, node_puts: []const storage.rows.NodePut, node_deletes: []const i64, limits: limits_mod.Search,) !bool { if (node_deletes.len > limits.incremental_delete_count) return false; var documents = try changedDocuments(allocator, node_puts); defer documents.deinit(allocator); if (documents.documents.len > limits.rebuild_batch_documents or documents.text.len > limits.rebuild_batch_text_bytes) { return false; } var workspace = try cacheWorkspace( allocator, sql.file.default_max_wal_bytes, ); defer workspace.deallocate(allocator); var cache_database = openCache(allocator, &workspace, root) catch |err| { if (err == error.FileNotFound) return false; if (searchCacheReadFailure(err)) return false; return err; }; defer cache_database.deinit(); var index = sql.Search.open(&cache_database, .{ .index_prefixes = false, .field_names = &search_field_names, }) catch |err| { if (searchCacheReadFailure(err)) return false; return err; }; const source_matches = index.sourceMatches( allocator, sourceForHead(previous_head), ) catch |err| { if (incrementalRepairRequired(err)) return false; return err; }; if (!source_matches) return false; var changes = (try classifyChangedDocuments( allocator, &index, documents.documents, )) orelse return false; defer changes.deinit(allocator); if (!(try applyDocumentChanges( allocator, &index, changes, node_deletes, ))) return false; return try advanceIncrementalCache( allocator, &cache_database, &index, previous_head, head, );}fn classifyChangedDocuments( allocator: std.mem.Allocator, index: *sql.Search, documents: []const sql.SearchDocument,) !?DocumentChanges { var changes: DocumentChanges = .{}; errdefer changes.deinit(allocator); try changes.added.ensureTotalCapacityPrecise(allocator, documents.len); try changes.updated.ensureTotalCapacityPrecise(allocator, documents.len); for (documents) |document| { const previous = index.getText(allocator, document.rowid) catch |err| { if (incrementalRepairRequired(err)) { changes.deinit(allocator); return null; } return err; }; if (previous) |text| { defer allocator.free(text); if (!std.mem.eql(u8, text, document.text)) { changes.updated.appendAssumeCapacity(document); } } else { changes.added.appendAssumeCapacity(document); } } return changes;}fn applyDocumentChanges( allocator: std.mem.Allocator, index: *sql.Search, changes: DocumentChanges, node_deletes: []const i64,) !bool { for (node_deletes) |rowid| { _ = index.delete(allocator, rowid, .{ .durability = .buffered }) catch |err| { if (incrementalRepairRequired(err)) return false; return err; }; } if (changes.added.items.len != 0) { _ = index.loadAllNew( allocator, changes.added.items, .{ .durability = .buffered }, ) catch |err| { if (incrementalRepairRequired(err)) return false; return err; }; } if (changes.updated.items.len != 0) { _ = index.putAll( allocator, changes.updated.items, .{ .durability = .buffered }, ) catch |err| { if (incrementalRepairRequired(err)) return false; return err; }; } return true;}fn advanceIncrementalCache( allocator: std.mem.Allocator, cache_database: *sql.FileDatabase, index: *sql.Search, previous_head: sql.Hash, head: sql.Hash,) !bool { const advanced = index.advanceSource( allocator, sourceForHead(previous_head), sourceForHead(head), .{ .durability = .buffered }, ) catch |err| { if (incrementalRepairRequired(err)) return false; return err; }; if (advanced == null) return false; try cache_database.syncWal(); _ = try cache_database.checkpoint(.{ .restart_header = cacheHeader(2) }); return true;}fn incrementalRepairRequired(err: anyerror) bool { if (searchCacheReadFailure(err)) return true; return switch (err) { error.KeyNotFound, error.SearchIndexRebuildRequired, error.TransactionTooLarge, => true, else => false, };}fn rebuildStoredFromDatabase( allocator: std.mem.Allocator, root: []const u8, database: *storage.database.Database, head: sql.Hash, limits: limits_mod.Search,) !usize { var stored = try storedDocuments(allocator, database, head); defer stored.plan.deinit(allocator); try rebuildDocuments( allocator, root, stored.plan.documents, sourceForHead(stored.head), limits, ); try storage.names.writeForHead(allocator, root, database, stored.head); return stored.plan.documents.len;}fn storedDocuments(allocator: std.mem.Allocator, database: *storage.database.Database, head: sql.Hash) !StoredDocuments { const limits = try storedDocumentLimits(allocator, database); var plan = try DocumentPlan.init(allocator, limits); errdefer plan.deinit(allocator); try fillStoredDocuments(allocator, database, &plan); plan.activate(); return .{ .plan = plan, .head = head, };}fn storedDocumentLimits(allocator: std.mem.Allocator, database: *storage.database.Database) !DocumentPlan.Limits { var handle = database.connection.catalog.openRelation(allocator, storage.rows.nodes_relation) catch |err| switch (err) { error.RelationNotFound => return .{ .documents = 0, .text_bytes = 0 }, else => return err, }; defer handle.deinit(); var scan: sql.TableScan = undefined; try handle.relation.scan(&scan, allocator, null, null); defer scan.deinit(); var documents: usize = 0; var text_bytes: usize = 0; while (try scan.next()) |entry| { const name = try storage.rows.nodeNameView(entry.bytes); const docstring = try storage.rows.nodeDocstringView(entry.bytes); text_bytes = std.math.add(usize, text_bytes, try nodeSearchTextBytes(name, docstring)) catch return error.CapacityOverflow; documents = std.math.add(usize, documents, 1) catch return error.CapacityOverflow; } const limits = DocumentPlan.Limits{ .documents = documents, .text_bytes = text_bytes, }; _ = try DocumentPlan.Capacity.derive(limits); return limits;}fn fillStoredDocuments(allocator: std.mem.Allocator, database: *storage.database.Database, plan: *DocumentPlan) !void { var handle = database.connection.catalog.openRelation(allocator, storage.rows.nodes_relation) catch |err| switch (err) { error.RelationNotFound => if (plan.documents.len == 0) return else return error.MalformedRow, else => return err, }; defer handle.deinit(); var scan: sql.TableScan = undefined; try handle.relation.scan(&scan, allocator, null, null); defer scan.deinit(); while (try scan.next()) |entry| { const name = try storage.rows.nodeNameView(entry.bytes); const docstring = try storage.rows.nodeDocstringView(entry.bytes); try plan.append(entry.rowid, name, docstring); } if (plan.documents_filled != plan.documents.len or plan.text_filled != plan.text.len) return error.MalformedRow;}fn rebuildDocuments( allocator: std.mem.Allocator, root: []const u8, documents: []const sql.SearchDocument, source: sql.SearchSource, limits: limits_mod.Search,) !void { const dir_path = try cacheDir(allocator, root); defer allocator.free(dir_path); try sys.fs.createDirPath(dir_path); try deleteTempCacheFiles(allocator, root); errdefer deleteTempCacheFiles(allocator, root) catch {}; var dir = try std.Io.Dir.openDirAbsolute(fs_io, dir_path, .{}); defer dir.close(fs_io); var workspace = try cacheWorkspace(allocator, limits.rebuild_wal_bytes); defer workspace.deallocate(allocator); { var cache_database = try sql.FileDatabase.open(allocator, &workspace, dir, .{ .paths = .{ .database = temp_db_name, .wal = temp_wal_name }, .header = cacheHeader(1), .max_wal_bytes = limits.rebuild_wal_bytes, }); defer cache_database.deinit(); var cache = try sql.Search.open(&cache_database, .{ .index_prefixes = false, .field_names = &search_field_names }); try loadDocumentBatches(&cache_database, &cache, allocator, documents, source, .{ .documents = limits.rebuild_batch_documents, .text_bytes = limits.rebuild_batch_text_bytes, }); } try replaceCacheFiles(allocator, root);}const DocumentBatchLimits = struct { documents: usize, text_bytes: usize,};fn loadDocumentBatches( database: *sql.FileDatabase, cache: *sql.Search, allocator: std.mem.Allocator, documents: []const sql.SearchDocument, source: sql.SearchSource, limits: DocumentBatchLimits,) !void { if (documents.len == 0) { _ = try cache.loadAllForSource(allocator, documents, source, .{ .durability = .buffered }); try database.syncWal(); _ = try database.checkpoint(.{ .restart_header = cacheHeader(2) }); return; } var start: usize = 0; var first = true; while (start < documents.len) { var end = documentBatchEnd(documents, start, limits); while (true) { loadDocumentBatch(cache, allocator, documents[start..end], source, first) catch |err| switch (err) { error.TransactionTooLarge => { if (end - start == 1) return err; end = start + (end - start) / 2; continue; }, else => return err, }; break; } const final = end == documents.len; if (final) try database.syncWal(); _ = try database.checkpoint(.{ .restart_header = cacheHeader(if (final) 2 else 1) }); start = end; first = false; }}fn loadDocumentBatch( cache: *sql.Search, allocator: std.mem.Allocator, documents: []const sql.SearchDocument, source: sql.SearchSource, first: bool,) !void { if (first) { _ = try cache.loadAllForSource(allocator, documents, source, .{ .durability = .buffered }); } else { _ = try cache.loadAllNew(allocator, documents, .{ .durability = .buffered }); }}fn documentBatchEnd(documents: []const sql.SearchDocument, start: usize, limits: DocumentBatchLimits) usize { std.debug.assert(limits.documents > 0); std.debug.assert(limits.text_bytes >= sql.SearchMaxDocumentBytes); std.debug.assert(start < documents.len); var end = start; var text_bytes: usize = 0; while (end < documents.len and end - start < limits.documents) : (end += 1) { const document_bytes = @min(documents[end].text.len, sql.SearchMaxDocumentBytes); if (document_bytes > limits.text_bytes - text_bytes) break; text_bytes += document_bytes; } std.debug.assert(end > start); return end;}fn sourceForHead(head: sql.Hash) sql.SearchSource { return .{ .schema = cache_schema, .head = head };}pub fn searchStored( allocator: std.mem.Allocator, root: []const u8, raw_query: []const u8, kind: ?[]const u8, limit: i64, limits: limits_mod.Storage,) !Result { const normalized = try normalizeQuery(allocator, raw_query); defer allocator.free(normalized); if (invalidQuery(normalized)) return Error.InvalidSearchQuery; var opened = try storage.store.openRead(allocator, root, limits); defer opened.close(); var workspace = try cacheWorkspace( allocator, sql.file.default_max_wal_bytes, ); defer workspace.deallocate(allocator); var database = try openCacheReadOnly(allocator, &workspace, root); defer database.deinit(); const index = sql.search.Reader.open(database.snapshot(), .{ .index_prefixes = false, .field_names = &search_field_names, }) catch |err| { if (searchCacheReadFailure(err)) return Error.SearchCacheRepairRequired; return err; }; const source_matches = index.sourceMatches(allocator, sourceForHead(opened.reader.head)) catch |err| { if (searchCacheReadFailure(err)) return Error.SearchCacheRepairRequired; return err; }; if (!source_matches) return Error.SearchCacheRepairRequired; const index_limit = searchIndexLimit(kind, limit); var matched = querySearchIndex(&index, allocator, normalized, index_limit) catch |err| { if (searchCacheReadFailure(err)) return Error.SearchCacheRepairRequired; return err; }; defer matched.deinit(); return try selectStoredMatches(allocator, &opened.reader, matched.hits, matched.total, kind, limit);}fn searchCacheReadFailure(err: anyerror) bool { return switch (err) { error.ColumnOutOfBounds, error.InvalidChecksum, error.InvalidDatabaseFile, error.InvalidKey, error.InvalidPage, error.InvalidPageId, error.InvalidRange, error.InvalidRecord, error.InvalidRow, error.InvalidSearchIndex, error.InvalidWal, error.OutputTooSmall, error.SecondaryIndexCorrupt, error.TreeIdentityMissing, error.TreeSpaceMismatch, error.TreeTooDeep, error.UnsupportedPageSize, => true, else => false, };}fn searchIndexLimit(kind: ?[]const u8, limit: i64) usize { if (kind != null or limit <= 0) return 0; return @intCast(limit);}fn querySearchIndex(index: anytype, allocator: std.mem.Allocator, query: []const u8, limit: usize) !sql.SearchResults { if (limit == 0) return index.query(allocator, query, 0) catch |err| switch (err) { error.InvalidQuery => Error.InvalidSearchQuery, else => return err, }; return index.queryScoreBand(allocator, query, limit) catch |err| switch (err) { error.InvalidQuery => Error.InvalidSearchQuery, else => return err, };}pub fn dbPath(allocator: std.mem.Allocator, root: []const u8) ![]const u8 { return try std.fs.path.join(allocator, &.{ root, storage.paths.smg_dir_name, db_name });}pub fn splitIdentifier(allocator: std.mem.Allocator, name: []const u8) ![]const u8 { const empty = @constCast((&[_]u8{})[0..]); const bytes = try identifierTokensInto(empty, name); const out = try allocator.alloc(u8, bytes); errdefer allocator.free(out); const filled = try identifierTokensInto(out, name); std.debug.assert(filled == out.len); return out;}const IdentifierSink = struct { out: []u8, produced: usize = 0, last_space: bool = false, fn append(self: *IdentifierSink, byte: u8) error{CapacityOverflow}!void { if (self.produced == std.math.maxInt(usize)) return error.CapacityOverflow; if (self.produced < self.out.len) self.out[self.produced] = byte; self.produced += 1; self.last_space = byte == ' '; } fn appendSpace(self: *IdentifierSink) error{CapacityOverflow}!void { if (self.produced == 0 or self.last_space) return; try self.append(' '); } fn finish(self: *IdentifierSink) usize { if (self.produced != 0 and self.last_space) self.produced -= 1; return self.produced; }};fn identifierTokensInto(out: []u8, name: []const u8) error{CapacityOverflow}!usize { var sink = IdentifierSink{ .out = out }; var previous: ?u8 = null; for (name, 0..) |byte, index| { if (byte == '.' or byte == '_') { try sink.appendSpace(); previous = ' '; continue; } const next = if (index + 1 < name.len) name[index + 1] else 0; if (previous) |prev| { if (needsCamelSpace(prev, byte, next)) try sink.appendSpace(); } try sink.append(std.ascii.toLower(byte)); previous = byte; } return sink.finish();}pub fn normalizeQuery(allocator: std.mem.Allocator, raw: []const u8) ![]const u8 { const trimmed = std.mem.trim(u8, raw, " \t\r\n"); if (trimmed.len == 0) return try allocator.dupe(u8, trimmed); if (!identifierShaped(trimmed)) return try allocator.dupe(u8, trimmed); var words = std.mem.splitAny(u8, trimmed, " \t\r\n"); while (words.next()) |word| { if (word.len == 0) continue; if (std.mem.eql(u8, word, "AND") or std.mem.eql(u8, word, "OR") or std.mem.eql(u8, word, "NOT") or std.mem.eql(u8, word, "NEAR")) return try allocator.dupe(u8, trimmed); } const split = try splitIdentifier(allocator, trimmed); defer allocator.free(split); if (split.len == 0) return try allocator.dupe(u8, trimmed); var out: std.Io.Writer.Allocating = .init(allocator); errdefer out.deinit(); var parts = std.mem.splitScalar(u8, split, ' '); var first = true; while (parts.next()) |part| { if (part.len == 0) continue; if (!first) try out.writer.writeAll(" AND "); first = false; try out.writer.writeAll(part); } if (first) return try allocator.dupe(u8, trimmed); return try out.toOwnedSlice();}pub fn location(allocator: std.mem.Allocator, hit: Hit) ![]const u8 { if (hit.file) |file| { if (hit.line_start) |line| return try std.fmt.allocPrint(allocator, "{s}:{d}", .{ file, line }); return file; } return "-";}pub fn snippet(hit: Hit) []const u8 { const doc = hit.docstring orelse return "-"; if (doc.len == 0) return "-"; const first_line = if (std.mem.indexOfScalar(u8, doc, '\n')) |index| doc[0..index] else doc; const trimmed = std.mem.trim(u8, first_line, " \t\r\n"); return trimmed[0..@min(trimmed.len, 80)];}fn cacheDir(allocator: std.mem.Allocator, root: []const u8) ![]const u8 { return try std.fs.path.join(allocator, &.{ root, storage.paths.smg_dir_name });}fn walPath(allocator: std.mem.Allocator, root: []const u8) ![]const u8 { return try std.fs.path.join(allocator, &.{ root, storage.paths.smg_dir_name, wal_name });}fn tempDbPath(allocator: std.mem.Allocator, root: []const u8) ![]const u8 { return try std.fs.path.join(allocator, &.{ root, storage.paths.smg_dir_name, temp_db_name });}fn tempWalPath(allocator: std.mem.Allocator, root: []const u8) ![]const u8 { return try std.fs.path.join(allocator, &.{ root, storage.paths.smg_dir_name, temp_wal_name });}fn cacheMatchesSource(allocator: std.mem.Allocator, root: []const u8, source: sql.SearchSource) !bool { const path = try dbPath(allocator, root); defer allocator.free(path); if (!sys.fs.exists(path)) return false; var workspace = try cacheWorkspace( allocator, sql.file.default_max_wal_bytes, ); defer workspace.deallocate(allocator); var database = openCache(allocator, &workspace, root) catch |err| { if (searchCacheReadFailure(err)) return false; return err; }; defer database.deinit(); const index = sql.Search.open(&database, .{ .index_prefixes = false, .field_names = &search_field_names }) catch |err| { if (searchCacheReadFailure(err)) return false; return err; }; return index.sourceMatches(allocator, source) catch |err| { if (searchCacheReadFailure(err)) return false; return err; };}fn deleteTempCacheFiles(allocator: std.mem.Allocator, root: []const u8) !void { const database_path = try tempDbPath(allocator, root); defer allocator.free(database_path); const wal_path = try tempWalPath(allocator, root); defer allocator.free(wal_path); try deleteOptionalFile(database_path); try deleteOptionalFile(wal_path);}fn deleteOptionalFile(path: []const u8) !void { sys.fs.deleteFile(path) catch |err| switch (err) { error.FileNotFound => {}, else => return err, };}fn replaceCacheFiles(allocator: std.mem.Allocator, root: []const u8) !void { const wal_path = try walPath(allocator, root); defer allocator.free(wal_path); const temporary_database_path = try tempDbPath(allocator, root); defer allocator.free(temporary_database_path); const database_path = try dbPath(allocator, root); defer allocator.free(database_path); const temporary_wal_path = try tempWalPath(allocator, root); defer allocator.free(temporary_wal_path); try deleteOptionalFile(wal_path); try sys.fs.rename(temporary_database_path, database_path); try deleteOptionalFile(wal_path); try sys.fs.rename(temporary_wal_path, wal_path);}fn cacheWorkspace( allocator: std.mem.Allocator, max_wal_bytes: usize,) !sql.FileDatabase.Workspace { return try sql.FileDatabase.Workspace.allocate( allocator, cacheWorkspaceLimits(max_wal_bytes), );}fn cacheWorkspaceLimits(max_wal_bytes: usize) sql.FileDatabase.Workspace.Limits { return .{ .header = cacheHeader(1), .max_wal_bytes = max_wal_bytes, .path_storage = .{ .database_bytes = @max(db_name.len, temp_db_name.len), .wal_bytes = @max(wal_name.len, temp_wal_name.len), }, };}test "search cache workspace path capacity spans current and rebuild names" { const limits = cacheWorkspaceLimits(sql.file.default_max_wal_bytes).path_storage; try std.testing.expectEqual( @max(db_name.len, temp_db_name.len), limits.database_bytes, ); try std.testing.expectEqual( @max(wal_name.len, temp_wal_name.len), limits.wal_bytes, );}fn openCache( allocator: std.mem.Allocator, workspace: *sql.FileDatabase.Workspace, root: []const u8,) !sql.FileDatabase { const dir_path = try cacheDir(allocator, root); defer allocator.free(dir_path); var dir = try std.Io.Dir.openDirAbsolute(fs_io, dir_path, .{}); defer dir.close(fs_io); return try sql.FileDatabase.open(allocator, workspace, dir, .{ .paths = .{ .database = db_name, .wal = wal_name }, .header = cacheHeader(3), .read_cache_capacity = 16, });}fn openCacheForTesting( allocator: std.mem.Allocator, root: []const u8,) !sql.FileDatabase { const dir_path = try cacheDir(allocator, root); defer allocator.free(dir_path); var dir = try std.Io.Dir.openDirAbsolute(fs_io, dir_path, .{}); defer dir.close(fs_io); return try sql.FileDatabase.openForTesting(allocator, dir, .{ .paths = .{ .database = db_name, .wal = wal_name }, .header = cacheHeader(3), .read_cache_capacity = 16, });}fn openCacheReadOnly( allocator: std.mem.Allocator, workspace: *sql.FileDatabase.Workspace, root: []const u8,) !sql.FileReadOnlyDatabase { const dir_path = try cacheDir(allocator, root); defer allocator.free(dir_path); var dir = try std.Io.Dir.openDirAbsolute(fs_io, dir_path, .{}); defer dir.close(fs_io); const opened = try sql.FileReadOnlyDatabase.openExisting(allocator, workspace, dir, .{ .paths = .{ .database = db_name, .wal = wal_name }, .header = cacheHeader(3), .read_cache_capacity = 16, }); return switch (opened) { .ready => |ready| ready, .repair_required => Error.SearchCacheRepairRequired, };}fn cacheHeader(sequence: u32) sql.wal.Header { return .{ .sequence = sequence, .salt = .{ .first = 0x534d_4702 + sequence, .second = 0x5155_4553 + sequence }, };}fn nodeSearchTextBytes(name: []const u8, docstring: ?[]const u8) error{CapacityOverflow}!usize { const token_bytes = try identifierTokensInto(@constCast((&[_]u8{})[0..]), name); const doc = docstring orelse return @min(token_bytes, sql.SearchMaxDocumentBytes); if (doc.len == 0) return @min(token_bytes, sql.SearchMaxDocumentBytes); const separator_end = std.math.add(usize, token_bytes, 1) catch return error.CapacityOverflow; const full_bytes = std.math.add(usize, separator_end, doc.len) catch return error.CapacityOverflow; return @min(full_bytes, sql.SearchMaxDocumentBytes);}fn fillNodeSearchText(out: []u8, name: []const u8, docstring: ?[]const u8) void { const token_bytes = identifierTokensInto(@constCast((&[_]u8{})[0..]), name) catch unreachable; const rendered_bytes = identifierTokensInto(out[0..@min(out.len, token_bytes)], name) catch unreachable; std.debug.assert(rendered_bytes == token_bytes); const doc = docstring orelse { std.debug.assert(out.len == @min(token_bytes, sql.SearchMaxDocumentBytes)); return; }; if (doc.len == 0) { std.debug.assert(out.len == @min(token_bytes, sql.SearchMaxDocumentBytes)); return; } if (token_bytes >= out.len) return; out[token_bytes] = sql.SearchFieldSeparator; const doc_start = token_bytes + 1; @memcpy(out[doc_start..], doc[0 .. out.len - doc_start]);}const SelectedHit = struct { node: *const model.Node, score: usize,};fn selectStoredMatches(allocator: std.mem.Allocator, reader: *storage.database.Reader, hits: []const sql.SearchHit, matched_total: usize, kind: ?[]const u8, limit: i64) !Result { const candidate_hits = storedCandidateHits(hits, kind, limit); if (candidate_hits.len == 0) return .{ .hits = &.{}, .total = if (kind == null) @intCast(matched_total) else 0 }; const rowids = try hitRowids(allocator, candidate_hits); defer if (rowids.len != 0) allocator.free(rowids); const rows = try storage.nodes.rowsByIdFromReader(allocator, reader, rowids); defer storage.nodes.freeRows(allocator, rows); var selected: std.ArrayList(SelectedHit) = .empty; defer selected.deinit(allocator); const max_hits: usize = if (limit > 0) @intCast(limit) else 0; const capped = max_hits != 0; var total: i64 = if (kind == null) @intCast(matched_total) else 0; for (candidate_hits) |hit| { const node = nodePointerForStoredRow(rows, hit.rowid) orelse continue; if (kind != null and !std.mem.eql(u8, node.type, kind.?)) continue; if (kind != null) total += 1; const selected_hit: SelectedHit = .{ .node = node, .score = hit.score }; if (!capped or selected.items.len < max_hits) { try selected.append(allocator, selected_hit); if (capped and selected.items.len == max_hits) std.mem.sort(SelectedHit, selected.items, {}, selectedHitLess); continue; } if (!selectedHitLess({}, selected_hit, selected.items[selected.items.len - 1])) continue; selected.items[selected.items.len - 1] = selected_hit; restoreSelectedOrder(selected.items); } std.mem.sort(SelectedHit, selected.items, {}, selectedHitLess); const out = try allocator.alloc(Hit, selected.items.len); var filled: usize = 0; errdefer { freeHits(allocator, out[0..filled]); allocator.free(out); } for (selected.items, 0..) |selected_hit, index| { out[index] = try hitFromNode(allocator, selected_hit.node.*, selected_hit.score); out[index].rank = @intCast(index + 1); filled += 1; } return .{ .hits = out, .total = total };}fn storedCandidateHits(hits: []const sql.SearchHit, kind: ?[]const u8, limit: i64) []const sql.SearchHit { if (kind != null or limit <= 0) return hits; const max_hits: usize = @intCast(limit); if (hits.len <= max_hits) return hits; const cutoff = hits[max_hits - 1].score; var count = max_hits; while (count < hits.len and hits[count].score >= cutoff) count += 1; return hits[0..count];}fn hitRowids(allocator: std.mem.Allocator, hits: []const sql.SearchHit) ![]i64 { if (hits.len == 0) return &.{}; const rowids = try allocator.alloc(i64, hits.len); for (hits, 0..) |hit, index| rowids[index] = hit.rowid; return rowids;}fn nodePointerForStoredRow(rows: []const storage.nodes.NodeRow, rowid: i64) ?*const model.Node { var low: usize = 0; var high = rows.len; while (low < high) { const mid = low + (high - low) / 2; if (rows[mid].rowid == rowid) return &rows[mid].node; if (rows[mid].rowid < rowid) { low = mid + 1; } else { high = mid; } } return null;}fn restoreSelectedOrder(hits: []SelectedHit) void { var index = hits.len - 1; while (index > 0 and selectedHitLess({}, hits[index], hits[index - 1])) : (index -= 1) { std.mem.swap(SelectedHit, &hits[index], &hits[index - 1]); }}fn selectedHitLess(_: void, left: SelectedHit, right: SelectedHit) bool { if (left.score != right.score) return left.score > right.score; return std.mem.lessThan(u8, left.node.name, right.node.name);}fn hitFromNode(allocator: std.mem.Allocator, node: model.Node, score: usize) !Hit { const name = try allocator.dupe(u8, node.name); errdefer allocator.free(name); const kind = try allocator.dupe(u8, node.type); errdefer allocator.free(kind); const file = try dupOptional(allocator, node.file); errdefer if (file) |value| allocator.free(value); const docstring = try dupOptional(allocator, node.docstring); errdefer if (docstring) |value| allocator.free(value); return .{ .rank = 0, .name = name, .kind = kind, .file = file, .line_start = node.line, .line_end = node.end_line, .docstring = docstring, .score = @floatFromInt(score), };}fn freeHits(allocator: std.mem.Allocator, hits: []const Hit) void { for (hits) |hit| { allocator.free(hit.name); allocator.free(hit.kind); if (hit.file) |file| allocator.free(file); if (hit.docstring) |docstring| allocator.free(docstring); }}fn dupOptional(allocator: std.mem.Allocator, value: ?[]const u8) !?[]const u8 { return if (value) |text| try allocator.dupe(u8, text) else null;}fn invalidQuery(query: []const u8) bool { const trimmed = std.mem.trim(u8, query, " \t\r\n"); if (trimmed.len == 0) return true; var offset: usize = 0; var saw_operand = false; var expects_operand = true; while (nextQueryPart(trimmed, &offset)) |part| { if (!part.quoted and booleanOperator(part.text)) { if (expects_operand or !saw_operand) return true; expects_operand = true; continue; } saw_operand = true; expects_operand = false; } return !saw_operand or expects_operand;}const QueryPart = struct { text: []const u8, quoted: bool,};fn nextQueryPart(source: []const u8, offset: *usize) ?QueryPart { while (offset.* < source.len and std.ascii.isWhitespace(source[offset.*])) offset.* += 1; if (offset.* >= source.len) return null; if (source[offset.*] == '"') { const start = offset.* + 1; var end = start; while (end < source.len and source[end] != '"') end += 1; offset.* = if (end < source.len) end + 1 else end; return .{ .text = source[start..end], .quoted = true }; } const start = offset.*; while (offset.* < source.len and !std.ascii.isWhitespace(source[offset.*])) offset.* += 1; return .{ .text = source[start..offset.*], .quoted = false };}fn booleanOperator(text_value: []const u8) bool { return std.mem.eql(u8, text_value, "AND") or std.mem.eql(u8, text_value, "OR") or std.mem.eql(u8, text_value, "NOT");}fn identifierShaped(value: []const u8) bool { for (value) |byte| { if (std.ascii.isAlphanumeric(byte) or byte == '.' or byte == '_' or byte == ' ' or byte == '\t' or byte == '\r' or byte == '\n') continue; return false; } return true;}fn needsCamelSpace(previous: u8, current: u8, next: u8) bool { if ((std.ascii.isLower(previous) or std.ascii.isDigit(previous)) and std.ascii.isUpper(current)) return true; if (std.ascii.isUpper(previous) and std.ascii.isUpper(current) and next != 0 and std.ascii.isLower(next)) return true; return false;}fn documentAllocSlice(comptime T: type, allocator: std.mem.Allocator, count: usize) std.mem.Allocator.Error![]T { if (count == 0) return @constCast((&[_]T{})[0..]); return try allocator.alloc(T, count);}fn documentFreeSlice(comptime T: type, allocator: std.mem.Allocator, values: []T) void { if (values.len != 0) allocator.free(values);}fn modelDocumentPlanBytes(limits: DocumentPlan.Limits) ?usize { if (limits.documents > std.math.maxInt(usize) / @sizeOf(sql.SearchDocument)) return null; const document_bytes = limits.documents * @sizeOf(sql.SearchDocument); if (document_bytes > std.math.maxInt(usize) - limits.text_bytes) return null; return document_bytes + limits.text_bytes;}test "search document plan capacity matches an independent bounded text model" { comptime { @stardustClaim( @import("alloc_phase").capacity.witness(DocumentPlan, "smg_search_document_plan_capacity_capacity_model"), null, null, null, null, null, null, ); } comptime { @stardustClaim( @import("alloc_phase").capacity.witness(DocumentPlan, "smg_search_document_plan_capacity_overload"), null, null, null, null, null, null, ); } for (0..64) |documents| { const limits = DocumentPlan.Limits{ .documents = documents, .text_bytes = documents * 19, }; const capacity = try DocumentPlan.Capacity.derive(limits); try std.testing.expectEqual(modelDocumentPlanBytes(limits).?, capacity.bytes); } const overflow = DocumentPlan.Limits{ .documents = std.math.maxInt(usize), .text_bytes = 1, }; try std.testing.expect(modelDocumentPlanBytes(overflow) == null); try std.testing.expectError(error.CapacityOverflow, DocumentPlan.Capacity.derive(overflow)); var long_doc: [sql.SearchMaxDocumentBytes + 64]u8 = undefined; @memset(&long_doc, 'x'); try std.testing.expectEqual( @as(usize, sql.SearchMaxDocumentBytes), try nodeSearchTextBytes("app.main", &long_doc), );}fn checkDocumentPlanInitAllocationFailures(allocator: std.mem.Allocator) !void { var plan = try DocumentPlan.init(allocator, .{ .documents = 3, .text_bytes = 64 }); plan.deinit(allocator);}test "search document plan initialization cleans allocation failure and retries" { comptime { @stardustClaim( @import("alloc_phase").capacity.witness(DocumentPlan, "smg_search_document_plan_oom"), null, null, null, null, null, null, ); } try std.testing.checkAllAllocationFailures( std.testing.allocator, checkDocumentPlanInitAllocationFailures, .{}, ); const expected = "app main" ++ [_]u8{sql.SearchFieldSeparator} ++ "entry"; var plan = try DocumentPlan.init(std.testing.allocator, .{ .documents = 1, .text_bytes = expected.len }); defer plan.deinit(std.testing.allocator); try plan.append(7, "app.main", "entry"); plan.activate(); try std.testing.expectEqual(alloc_phase.capacity.Phase.steady, plan.phase);}test "search document plan fills exact records and bounded text while sealed" { comptime { @stardustClaim( @import("alloc_phase").capacity.witness(DocumentPlan, "smg_search_document_plan_sealed"), null, null, null, null, null, null, ); } const expected = [_][]const u8{ "", "app main", "parse html document" ++ [_]u8{sql.SearchFieldSeparator} ++ "Read markup", }; const limits = DocumentPlan.Limits{ .documents = expected.len, .text_bytes = expected[0].len + expected[1].len + expected[2].len, }; var phase_allocator = try alloc_phase.SealedPhaseAllocator.init(std.testing.allocator); var plan = try DocumentPlan.init(phase_allocator.initializationAllocator(), limits); defer { if (phase_allocator.phase() == .initialization) phase_allocator.abortInitialization(); if (phase_allocator.phase() == .steady) phase_allocator.beginTeardown(); plan.deinit(phase_allocator.teardownAllocator()); phase_allocator.deinit(); } const documents_pointer = plan.documents.ptr; const text_pointer = plan.text.ptr; phase_allocator.seal(); try plan.append(3, "", null); try plan.append(5, "app.main", null); try plan.append(9, "parseHTMLDocument", "Read markup"); plan.activate(); for (expected, plan.documents) |text_value, document| { try std.testing.expectEqualStrings(text_value, document.text); } try std.testing.expectEqual(@as(i64, 3), plan.documents[0].rowid); try std.testing.expectEqual(@as(i64, 5), plan.documents[1].rowid); try std.testing.expectEqual(@as(i64, 9), plan.documents[2].rowid); try std.testing.expect(plan.documents[1].text.ptr == plan.text[expected[0].len..].ptr); try std.testing.expectEqual(documents_pointer, plan.documents.ptr); try std.testing.expectEqual(text_pointer, plan.text.ptr); try std.testing.expectEqual(@as(u64, 0), phase_allocator.violations().total());}test "stored search document plan preserves rowids and rendered fields without node snapshots" { comptime { @stardustClaim( @import("alloc_phase").capacity.witness(DocumentPlan, "smg_search_document_plan_integration"), null, null, null, null, null, null, ); } var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); const root = try std.fmt.allocPrint(allocator, "/tmp/smg-search-document-plan-{x}", .{@as(u64, @intCast(@max(0, sys.time.realMilliTimestamp())))}); defer sys.fs.deleteTree(root) catch {}; try sys.fs.createDirPath(root); _ = try storage.initProject(allocator, root, testingLimits().storage); var graph = graph_mod.init(allocator); try graph_mod.addNode(&graph, .{ .name = "z.item", .type = model.NodeType.function, .docstring = "Last entry", .metadata = try model.sourcePair(allocator, "scan"), }); try graph_mod.addNode(&graph, .{ .name = "a.HTTPServer", .type = model.NodeType.class, .docstring = "Serve requests", .metadata = try model.sourcePair(allocator, "manual"), }); var opened = try storage.store.open(allocator, root, testingLimits().storage); defer opened.close(); const saved = try storage.graph.replace(allocator, &opened.database, graph); var stored = try storedDocuments(std.testing.allocator, &opened.database, saved); defer stored.plan.deinit(std.testing.allocator); try std.testing.expect(std.mem.eql(u8, &saved, &stored.head)); try std.testing.expectEqual(@as(usize, 2), stored.plan.documents.len); try std.testing.expectEqual(@as(i64, 1), stored.plan.documents[0].rowid); try std.testing.expectEqualStrings( "a http server" ++ [_]u8{sql.SearchFieldSeparator} ++ "Serve requests", stored.plan.documents[0].text, ); try std.testing.expectEqual(@as(i64, 2), stored.plan.documents[1].rowid); try std.testing.expectEqualStrings( "z item" ++ [_]u8{sql.SearchFieldSeparator} ++ "Last entry", stored.plan.documents[1].text, );}test "search document batches cap records and indexed text" { const large_text = @as([(sql.SearchMaxDocumentBytes / 2 + 1)]u8, @splat('x')); const documents = [_]sql.SearchDocument{ .{ .rowid = 1, .text = &large_text }, .{ .rowid = 2, .text = &large_text }, .{ .rowid = 3, .text = "tail" }, .{ .rowid = 4, .text = "last" }, }; const text_limited = DocumentBatchLimits{ .documents = documents.len, .text_bytes = sql.SearchMaxDocumentBytes, }; try std.testing.expectEqual(@as(usize, 1), documentBatchEnd(&documents, 0, text_limited)); try std.testing.expectEqual(@as(usize, 4), documentBatchEnd(&documents, 1, text_limited)); const record_limited = DocumentBatchLimits{ .documents = 2, .text_bytes = testingLimits().search.rebuild_batch_text_bytes, }; try std.testing.expectEqual(@as(usize, 2), documentBatchEnd(&documents, 0, record_limited)); try std.testing.expectEqual(@as(usize, 4), documentBatchEnd(&documents, 2, record_limited));}test "search document batches preserve source and postings across reopen" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const source = sql.SearchSource{ .schema = cache_schema, .head = @as([32]u8, @splat(7)), }; const documents = [_]sql.SearchDocument{ .{ .rowid = 1, .text = "shared alpha" }, .{ .rowid = 2, .text = "shared beta" }, .{ .rowid = 3, .text = "shared gamma" }, .{ .rowid = 4, .text = "shared delta" }, .{ .rowid = 5, .text = "shared epsilon" }, }; { var database = try sql.FileDatabase.openForTesting(std.testing.allocator, tmp.dir, .{ .paths = .{ .database = temp_db_name, .wal = temp_wal_name }, .header = cacheHeader(1), .max_wal_bytes = testingLimits().search.rebuild_wal_bytes, }); defer database.deinit(); var cache = try sql.Search.open(&database, .{ .index_prefixes = false, .field_names = &search_field_names }); try loadDocumentBatches(&database, &cache, std.testing.allocator, &.{}, source, .{ .documents = 2, .text_bytes = sql.SearchMaxDocumentBytes, }); try std.testing.expect(try cache.sourceMatches(std.testing.allocator, source)); try loadDocumentBatches(&database, &cache, std.testing.allocator, &documents, source, .{ .documents = 2, .text_bytes = sql.SearchMaxDocumentBytes, }); try std.testing.expect(try cache.sourceMatches(std.testing.allocator, source)); } var reopened = try sql.FileDatabase.openForTesting(std.testing.allocator, tmp.dir, .{ .paths = .{ .database = temp_db_name, .wal = temp_wal_name }, .header = cacheHeader(3), .max_wal_bytes = testingLimits().search.rebuild_wal_bytes, }); defer reopened.deinit(); const cache = try sql.Search.open(&reopened, .{ .index_prefixes = false, .field_names = &search_field_names }); try std.testing.expect(try cache.sourceMatches(std.testing.allocator, source)); var shared = try cache.query(std.testing.allocator, "shared", 0); defer shared.deinit(); try std.testing.expectEqual(documents.len, shared.total); for (shared.hits, 0..) |hit, index| try std.testing.expectEqual(documents[index].rowid, hit.rowid); var epsilon = try cache.query(std.testing.allocator, "epsilon", 0); defer epsilon.deinit(); try std.testing.expectEqual(@as(usize, 1), epsilon.total); try std.testing.expectEqual(@as(i64, 5), epsilon.hits[0].rowid);}test "search document batches retry within the WAL capacity" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var text: [sql.SearchMaxDocumentBytes]u8 = undefined; for (&text, 0..) |*byte, index| byte.* = "alpha "[index % "alpha ".len]; var documents: [8]sql.SearchDocument = undefined; for (&documents, 0..) |*document, index| document.* = .{ .rowid = @intCast(index + 1), .text = &text, }; const source = sql.SearchSource{ .schema = cache_schema, .head = @as([32]u8, @splat(9)), }; var database = try sql.FileDatabase.openForTesting(std.testing.allocator, tmp.dir, .{ .paths = .{ .database = temp_db_name, .wal = temp_wal_name }, .header = cacheHeader(1), .max_wal_bytes = 256 * 1024, }); defer database.deinit(); var cache = try sql.Search.open(&database, .{ .index_prefixes = false, .field_names = &search_field_names }); try std.testing.expectError( error.TransactionTooLarge, cache.loadAllForSource(std.testing.allocator, &documents, source, .{ .durability = .buffered }), ); try loadDocumentBatches(&database, &cache, std.testing.allocator, &documents, source, .{ .documents = documents.len, .text_bytes = documents.len * sql.SearchMaxDocumentBytes, }); try std.testing.expect(try cache.sourceMatches(std.testing.allocator, source)); var alpha = try cache.query(std.testing.allocator, "alpha", 0); defer alpha.deinit(); try std.testing.expectEqual(documents.len, alpha.total);}test "split identifiers like python search schema" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try std.testing.expectEqualStrings("smg cli helpers truncate", try splitIdentifier(allocator, "smg.cli.helpers._truncate")); try std.testing.expectEqualStrings("parse html document", try splitIdentifier(allocator, "parseHTMLDocument")); try std.testing.expectEqualStrings("smg graph sem graph analyze hot", try splitIdentifier(allocator, "smg.graph.SemGraph.analyze_hot"));}test "normalize identifier queries for fts" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); try std.testing.expectEqualStrings("smg AND cli AND helpers AND truncate", try normalizeQuery(allocator, "smg.cli.helpers._truncate")); try std.testing.expectEqualStrings("foo OR", try normalizeQuery(allocator, "foo OR")); try std.testing.expectEqualStrings("foo AND or AND bar", try normalizeQuery(allocator, "foo or bar"));}test "reject malformed search queries like python sqlite fts" { try std.testing.expect(invalidQuery("")); try std.testing.expect(invalidQuery("foo OR")); try std.testing.expect(invalidQuery("foo AND")); try std.testing.expect(invalidQuery("foo NOT")); try std.testing.expect(invalidQuery("OR foo")); try std.testing.expect(invalidQuery("NOT foo")); try std.testing.expect(invalidQuery("foo AND NOT bar")); try std.testing.expect(!invalidQuery("foo OR bar")); try std.testing.expect(!invalidQuery("foo AND bar")); try std.testing.expect(!invalidQuery("foo NOT bar")); try std.testing.expect(!invalidQuery("NEAR")); try std.testing.expect(!invalidQuery("foo AND or AND bar"));}test "sync incrementally applies graph changes and advances the source head" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); const root = try std.fmt.allocPrint(allocator, "/tmp/smg-search-sync-test-{x}", .{@as(u64, @intCast(@max(0, sys.time.realMilliTimestamp())))}); defer sys.fs.deleteTree(root) catch {}; try sys.fs.createDirPath(try std.fs.path.join(allocator, &.{ root, storage.paths.smg_dir_name })); var graph = graph_mod.init(allocator); try graph_mod.addNode(&graph, .{ .name = "sync.target", .type = model.NodeType.function, .file = "src/sync.py", .line = 3, .docstring = "sync probe", }); const saved = try replaceAndSyncForTest(allocator, graph, root); try std.testing.expectEqual(SyncKind.rebuilt, saved.kind); { var database = try openCacheForTesting(allocator, root); defer database.deinit(); const index = try sql.Search.open(&database, .{ .index_prefixes = false, .field_names = &search_field_names }); try std.testing.expect(try index.sourceMatches(allocator, sourceForHead(saved.head))); } try std.testing.expectEqual( SyncKind.matched, try syncStoredForTest(allocator, root, saved.head), ); try graph_mod.addNode(&graph, .{ .name = "sync.other", .type = model.NodeType.function, .file = "src/sync.py", .line = 9, .docstring = "second probe", }); const changed = try replaceAndSyncForTest(allocator, graph, root); try std.testing.expectEqual(SyncKind.incremental, changed.kind); try std.testing.expect(!std.mem.eql(u8, &saved.head, &changed.head)); { var database = try openCacheForTesting(allocator, root); defer database.deinit(); const index = try sql.Search.open(&database, .{ .index_prefixes = false, .field_names = &search_field_names }); try std.testing.expect(try index.sourceMatches(allocator, sourceForHead(changed.head))); } const result = try searchStored( allocator, root, "sync.other", null, 10, testingLimits().storage, ); try std.testing.expectEqual(@as(i64, 1), result.total); const other_index = graph.node_index.get("sync.other").?; graph.nodes.items[other_index].line = 10; const metadata = try replaceAndSyncForTest(allocator, graph, root); try std.testing.expectEqual(SyncKind.incremental, metadata.kind); const retained = try searchStored( allocator, root, "second probe", null, 10, testingLimits().storage, ); try std.testing.expectEqual(@as(i64, 1), retained.total); graph.nodes.items[other_index].docstring = "replacement marker"; const updated = try replaceAndSyncForTest(allocator, graph, root); try std.testing.expectEqual(SyncKind.incremental, updated.kind); const replacement = try searchStored( allocator, root, "replacement marker", null, 10, testingLimits().storage, ); try std.testing.expectEqual(@as(i64, 1), replacement.total); const retired = try searchStored( allocator, root, "second probe", null, 10, testingLimits().storage, ); try std.testing.expectEqual(@as(i64, 0), retired.total); try graph_mod.removeNodes(&graph, &.{"sync.other"}); const removed = try replaceAndSyncForTest(allocator, graph, root); try std.testing.expectEqual(SyncKind.incremental, removed.kind); const absent = try searchStored( allocator, root, "replacement marker", null, 10, testingLimits().storage, ); try std.testing.expectEqual(@as(i64, 0), absent.total);}test "stored search rejects a mismatched cache source without rebuilding" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); const root = try std.fmt.allocPrint(allocator, "/tmp/smg-search-source-test-{x}", .{@as(u64, @intCast(@max(0, sys.time.realMilliTimestamp())))}); defer sys.fs.deleteTree(root) catch {}; try sys.fs.createDirPath(try std.fs.path.join(allocator, &.{ root, storage.paths.smg_dir_name })); var graph = graph_mod.init(allocator); try graph_mod.addNode(&graph, .{ .name = "source.target", .type = model.NodeType.function, .docstring = "source probe", }); const head = try replaceGraphForTest(allocator, graph, root); _ = try syncStoredForTest(allocator, root, head); const expected = sourceForHead(head); const mismatched = sql.SearchSource{ .schema = cache_schema + 1, .head = head }; { var database = try openCacheForTesting(allocator, root); defer database.deinit(); var index = try sql.Search.open(&database, .{ .index_prefixes = false, .field_names = &search_field_names, }); try std.testing.expect((try index.advanceSource( allocator, expected, mismatched, .{ .durability = .synced }, )) != null); } try std.testing.expectError( Error.SearchCacheRepairRequired, searchStored( allocator, root, "source", null, 10, testingLimits().storage, ), ); var database = try openCacheForTesting(allocator, root); defer database.deinit(); const index = try sql.Search.open(&database, .{ .index_prefixes = false, .field_names = &search_field_names, }); try std.testing.expect(try index.sourceMatches(allocator, mismatched));}test "matched graph publication rebuilds a malformed search cache" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); const root = try std.fmt.allocPrint(allocator, "/tmp/smg-search-matched-repair-{x}", .{ @as(u64, @intCast(@max(0, sys.time.realMilliTimestamp()))), }); defer sys.fs.deleteTree(root) catch {}; try sys.fs.createDirPath(root); _ = try storage.initProject(allocator, root, testingLimits().storage); const source = try storage.graph.loadSnapshot( allocator, allocator, root, testingLimits().storage, ); var graph = graph_mod.init(allocator); try graph_mod.addNode(&graph, .{ .name = "repair.target", .type = model.NodeType.function, .docstring = "matched search repair", }); const published = try storage.graph.publishTimed( allocator, root, graph, source.head, testingLimits(), ); try sys.fs.writeFile(try dbPath(allocator, root), "invalid search cache"); sys.fs.deleteFile(try walPath(allocator, root)) catch |err| switch (err) { error.FileNotFound => {}, else => return err, }; const repaired = try storage.graph.publishMatchedTimed( allocator, root, graph, published.head, testingLimits(), ); try std.testing.expect(sql.version.same(published.head, repaired.head)); try std.testing.expectEqual(@as(u64, 0), repaired.graph_ns); try std.testing.expectEqual(SyncKind.rebuilt, repaired.index_kind); try std.testing.expectEqual(@as(usize, 0), repaired.node_puts); try std.testing.expectEqual(@as(usize, 0), repaired.node_deletes); const result = try searchStored( allocator, root, "matched search repair", null, 10, testingLimits().storage, ); try std.testing.expectEqual(@as(i64, 1), result.total); try std.testing.expect(try storage.names.matchesHead(allocator, root, published.head));}test "rebuild and query native search index" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const allocator = arena.allocator(); const root = try std.fmt.allocPrint(allocator, "/tmp/smg-search-test-{x}", .{@as(u64, @intCast(@max(0, sys.time.realMilliTimestamp())))}); defer sys.fs.deleteTree(root) catch {}; try sys.fs.createDirPath(try std.fs.path.join(allocator, &.{ root, storage.paths.smg_dir_name })); var graph = graph_mod.init(allocator); try graph_mod.addNode(&graph, .{ .name = "smg.cli.helpers._truncate", .type = model.NodeType.function, .file = "src/smg/cli/_helpers.py", .line = 42, .docstring = "Trim a string to width", }); try graph_mod.addNode(&graph, .{ .name = "smg.graph.SemGraph", .type = model.NodeType.class, .file = "src/smg/graph.py", .line = 10, .docstring = "Core graph structure", }); try graph_mod.addNode(&graph, .{ .name = "aaa.truncate", .type = model.NodeType.function, .file = "src/smg/aaa.py", .line = 7, .docstring = "Truncate helper", }); try graph_mod.addNode(&graph, .{ .name = "zzz.truncate", .type = model.NodeType.class, .file = "src/smg/zzz.py", .line = 8, .docstring = "Truncate class", }); const saved = try replaceGraphForTest(allocator, graph, root); try std.testing.expectEqual( @as(usize, 4), try rebuildStored(allocator, root, testingLimits()), ); try std.testing.expect(sys.fs.exists(try dbPath(allocator, root))); { var database = try openCacheForTesting(allocator, root); defer database.deinit(); const index = try sql.Search.open(&database, .{ .index_prefixes = false, .field_names = &search_field_names }); try std.testing.expect(try index.sourceMatches(allocator, sourceForHead(saved))); } const result = try searchStored( allocator, root, "smg.cli.helpers._truncate", model.NodeType.function, 10, testingLimits().storage, ); try std.testing.expectEqual(@as(i64, 1), result.total); try std.testing.expectEqualStrings("smg.cli.helpers._truncate", result.hits[0].name); try std.testing.expectEqualStrings("src/smg/cli/_helpers.py:42", try location(allocator, result.hits[0])); try std.testing.expectEqualStrings("Trim a string to width", snippet(result.hits[0])); const ranked = try searchStored( allocator, root, "truncate", null, 2, testingLimits().storage, ); try std.testing.expectEqual(@as(i64, 3), ranked.total); try std.testing.expectEqual(@as(usize, 2), ranked.hits.len); try std.testing.expectEqualStrings("aaa.truncate", ranked.hits[0].name); try std.testing.expectEqual(@as(i64, 1), ranked.hits[0].rank); try std.testing.expectEqualStrings("zzz.truncate", ranked.hits[1].name); try std.testing.expectEqual(@as(i64, 2), ranked.hits[1].rank); const filtered = try searchStored( allocator, root, "truncate", model.NodeType.class, 10, testingLimits().storage, ); try std.testing.expectEqual(@as(i64, 1), filtered.total); try std.testing.expectEqualStrings("zzz.truncate", filtered.hits[0].name);}fn syncStoredForTest( allocator: std.mem.Allocator, root: []const u8, head: sql.Hash,) !SyncKind { var opened = try storage.store.open(allocator, root, testingLimits().storage); defer opened.close(); return try syncStoredFromDatabase( allocator, root, &opened.database, head, head, &.{}, &.{}, testingLimits().search, );}const TestSync = struct { head: sql.Hash, kind: SyncKind,};fn replaceAndSyncForTest( allocator: std.mem.Allocator, graph: graph_mod.Graph, root: []const u8,) !TestSync { var opened = try storage.store.open(allocator, root, testingLimits().storage); defer opened.close(); const previous_head = (try opened.database.connection.checkout()).head; var replaced = try storage.graph.replaceObserved( allocator, &opened.database, graph, ); defer replaced.deinit(allocator); return .{ .head = replaced.head, .kind = try syncStoredFromDatabase( allocator, root, &opened.database, previous_head, replaced.head, replaced.changes.node_puts, replaced.changes.node_deletes, testingLimits().search, ), };}fn replaceGraphForTest(allocator: std.mem.Allocator, graph: graph_mod.Graph, root: []const u8) !sql.Hash { var opened = try storage.store.open(allocator, root, testingLimits().storage); defer opened.close(); return try storage.graph.replace(allocator, &opened.database, graph);}fn testingLimits() limits_mod.Limits { return @import("root.zig").default_limits;}Complete caller list for search.searchStored
8 direct callers.
tools.smg.src.command.session.testSearchStored[function] — private; no exact target attools/smg/src/command/session.zig:204in nearest public ownertiny.smg.command.sessiontools.smg.src.concepts.test_concept_publication_sorts_by_name_like_python_storage[function] — test; no exact target attools/smg/src/concepts.zig:884in nearest public ownertiny.smg.conceptstools.smg.src.concepts.test_stale_concept_publication_preserves_the_winner_and_supports_reapply[function] — test; no exact target attools/smg/src/concepts.zig:798in nearest public ownertiny.smg.conceptstools.smg.src.search.test_matched_graph_publication_rebuilds_a_malformed_search_cache[function] — test; no exact target attools/smg/src/search.zig:1694in nearest public ownertiny.smg.searchtools.smg.src.search.test_rebuild_and_query_native_search_index[function] — test; no exact target attools/smg/src/search.zig:1753in nearest public ownertiny.smg.searchtools.smg.src.search.test_stored_search_rejects_a_mismatched_cache_source_without_rebuilding[function] — test; no exact target attools/smg/src/search.zig:1642in nearest public ownertiny.smg.searchtools.smg.src.search.test_sync_incrementally_applies_graph_changes_and_advances_the_source_head[function] — test; no exact target attools/smg/src/search.zig:1537in nearest public ownertiny.smg.searchtools.smg.src.storage.graph.test_stale_graph_publication_preserves_the_winning_generation[function] — test; no exact target attools/smg/src/storage/graph.zig:637in nearest public ownertiny.smg.storage.graph
Complete call list for search.searchStored
10 direct calls.
tools.smg.src.search.cacheWorkspace[function] — private; no exact target attools/smg/src/search.zig:901in nearest public ownertiny.smg.searchtools.smg.src.search.invalidQuery[function] — private; no exact target attools/smg/src/search.zig:1143in nearest public ownertiny.smg.searchtiny.smg.search.normalizeQuery[function] attools/smg/src/search.zig:788tools.smg.src.search.openCacheReadOnly[function] — private; no exact target attools/smg/src/search.zig:965in nearest public ownertiny.smg.searchtools.smg.src.search.querySearchIndex[function] — private; no exact target attools/smg/src/search.zig:721in nearest public ownertiny.smg.searchtools.smg.src.search.searchCacheReadFailure[function] — private; no exact target attools/smg/src/search.zig:692in nearest public ownertiny.smg.searchtools.smg.src.search.searchIndexLimit[function] — private; no exact target attools/smg/src/search.zig:716in nearest public ownertiny.smg.searchtools.smg.src.search.selectStoredMatches[function] — private; no exact target attools/smg/src/search.zig:1024in nearest public ownertiny.smg.searchtools.smg.src.search.sourceForHead[function] — private; no exact target attools/smg/src/search.zig:647in nearest public ownertiny.smg.searchtiny.smg.storage.store.openRead[function] attools/smg/src/storage/store.zig:114
Audit
| Definitions | 13 |
|---|---|
| Public names | 13 |
| Members | 15 |
| Version | 26.7.0 |
| Revision | daab053ee433 |