From 9b3a1e28c86960ba4f18222bd447bf4ee841a9b1 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:17:32 +0800 Subject: [PATCH 01/27] fix: prevent OOB crash in jsonValue on unterminated strings and lower GC interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsonValue previously broke out of the loop on unterminated strings then accessed json[start..i+1] where i could equal json.len — one byte past the buffer. In ReleaseFast (no bounds checks) this is UB/segfault. Now returns early on closing quote and returns null on unterminated input. Also lowers MVCC GC_INTERVAL from 10,000 to 500 to prevent OOM during large bulk ingests — version chains now get cleaned up 20x more often. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/collection.zig | 2 +- src/server.zig | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/collection.zig b/src/collection.zig index ac78fbb..027224d 100644 --- a/src/collection.zig +++ b/src/collection.zig @@ -164,7 +164,7 @@ fn extractJsonFloatArray(json: []const u8, field_name: []const u8, out: []f32) ? pub const Collection = struct { pub const STRIPE_COUNT = 1024; // MVCC GC: trigger version chain cleanup every GC_INTERVAL inserts. - const GC_INTERVAL: u64 = 10_000; + const GC_INTERVAL: u64 = 500; name_buf: [128]u8, name_len: u8, diff --git a/src/server.zig b/src/server.zig index 7a61a20..11e108b 100644 --- a/src/server.zig +++ b/src/server.zig @@ -996,9 +996,9 @@ fn jsonValue(json: []const u8, key: []const u8) ?[]const u8 { var i = start + 1; while (i < json.len) : (i += 1) { if (json[i] == '\\' and i + 1 < json.len) { i += 1; continue; } - if (json[i] == '"') break; + if (json[i] == '"') return json[start .. i + 1]; // include both quotes } - return json[start .. i + 1]; // include both quotes + return null; // unterminated string } else if (ch == '{' or ch == '[') { // Object or array — find matching close bracket const close: u8 = if (ch == '{') '}' else ']'; From 82dc5cdbbcd48e3cec9575519816fda675568c1a Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:27:52 +0800 Subject: [PATCH 02/27] fix: read full body for small bulk requests to prevent silent data loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleConn only had a read loop for bulk bodies > 64KB (big_buf path). For bodies <= 64KB it did a single read() and assumed TCP delivered everything — but TCP can fragment data across packets, so most of the NDJSON body was never read. This caused ~85% of bulk insert lines to be silently skipped (no key found → continue). Now small bulk requests also loop until Content-Length bytes are received, using the existing req buffer without extra allocation. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server.zig | 63 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/src/server.zig b/src/server.zig index 11e108b..bc5e080 100644 --- a/src/server.zig +++ b/src/server.zig @@ -227,34 +227,47 @@ fn handleConn(srv: *Server, conn: std.net.Server.Connection) void { // The initial read may only contain part of a large body. const initial = bufs.req[0..n]; const content_length = extractContentLength(initial); - if (content_length > MAX_REQ and content_length <= MAX_BULK) { - // Check this is actually a bulk request before allocating - const is_bulk = std.mem.indexOf(u8, initial[0..@min(n, 256)], "/bulk") != null; - if (is_bulk) { - // Find where headers end - const header_end = if (std.mem.indexOf(u8, initial, "\r\n\r\n")) |p| p + 4 - else if (std.mem.indexOf(u8, initial, "\n\n")) |p| p + 2 - else n; - const total_size = header_end + content_length; - if (total_size <= MAX_BULK) { - const big_buf = std.heap.page_allocator.alloc(u8, total_size) catch { - const resp_len = dispatch(srv, initial, std.heap.page_allocator); - conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; - continue; - }; - defer std.heap.page_allocator.free(big_buf); - @memcpy(big_buf[0..n], initial); - // Read remaining bytes - while (n < total_size) { - const r = conn.stream.read(big_buf[n..total_size]) catch break; - if (r == 0) break; - n += r; - } - const resp_len = dispatch(srv, big_buf[0..n], std.heap.page_allocator); + const is_bulk = std.mem.indexOf(u8, initial[0..@min(n, 256)], "/bulk") != null; + + if (content_length > MAX_REQ and content_length <= MAX_BULK and is_bulk) { + // Large bulk: allocate a big buffer and read the full body. + const header_end = if (std.mem.indexOf(u8, initial, "\r\n\r\n")) |p| p + 4 + else if (std.mem.indexOf(u8, initial, "\n\n")) |p| p + 2 + else n; + const total_size = header_end + content_length; + if (total_size <= MAX_BULK) { + const big_buf = std.heap.page_allocator.alloc(u8, total_size) catch { + const resp_len = dispatch(srv, initial, std.heap.page_allocator); conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; continue; + }; + defer std.heap.page_allocator.free(big_buf); + @memcpy(big_buf[0..n], initial); + // Read remaining bytes + while (n < total_size) { + const r = conn.stream.read(big_buf[n..total_size]) catch break; + if (r == 0) break; + n += r; } + const resp_len = dispatch(srv, big_buf[0..n], std.heap.page_allocator); + conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + continue; + } + } else if (content_length > 0 and content_length <= MAX_REQ and is_bulk) { + // Small bulk: body fits in the req buffer but the initial read may + // not have received it all. Keep reading until we have the full body. + const header_end = if (std.mem.indexOf(u8, initial, "\r\n\r\n")) |p| p + 4 + else if (std.mem.indexOf(u8, initial, "\n\n")) |p| p + 2 + else n; + const total_size = @min(header_end + content_length, bufs.req.len); + while (n < total_size) { + const r = conn.stream.read(bufs.req[n..total_size]) catch break; + if (r == 0) break; + n += r; } + const resp_len = dispatch(srv, bufs.req[0..n], std.heap.page_allocator); + conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + continue; } const resp_len = dispatch(srv, initial, std.heap.page_allocator); @@ -998,7 +1011,7 @@ fn jsonValue(json: []const u8, key: []const u8) ?[]const u8 { if (json[i] == '\\' and i + 1 < json.len) { i += 1; continue; } if (json[i] == '"') return json[start .. i + 1]; // include both quotes } - return null; // unterminated string + return null; // unterminated string — don't read past buffer } else if (ch == '{' or ch == '[') { // Object or array — find matching close bracket const close: u8 = if (ch == '{') '}' else ']'; From ac9f3668473fc824b1fbdae583f7c19246eeb3a8 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:10:07 +0800 Subject: [PATCH 03/27] fix: extractContentLength fully case-insensitive for Content-Length header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old parser only handled C/c for the first character but used a case-sensitive match for the rest ("ontent-length: "). curl and most HTTP clients send "Content-Length" (capital L), which didn't match "content-length" (lowercase l). This meant the bulk read loop never triggered for bodies > 64KB — the server only processed the first read() chunk and silently dropped the rest of the NDJSON payload. Now matches each character of "Content-Length:" independently. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server.zig | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/server.zig b/src/server.zig index bc5e080..0a6ca1a 100644 --- a/src/server.zig +++ b/src/server.zig @@ -959,13 +959,28 @@ fn respond(code: u16, status: []const u8, body: []const u8) usize { // ─── mini parsers ──────────────────────────────────────────────────────── fn extractContentLength(raw: []const u8) usize { - // Case-insensitive search for Content-Length header + // Fully case-insensitive search for Content-Length header. const headers = raw[0..@min(raw.len, 2048)]; // only scan headers - const needle = "ontent-length: "; // skip first char for case insensitivity var i: usize = 0; - while (i + needle.len < headers.len) : (i += 1) { - if ((headers[i] == 'C' or headers[i] == 'c') and std.mem.eql(u8, headers[i + 1 .. i + 1 + needle.len], needle)) { - const start = i + 1 + needle.len; + while (i + 16 < headers.len) : (i += 1) { + if ((headers[i] == 'C' or headers[i] == 'c') and + (headers[i + 1] == 'o' or headers[i + 1] == 'O') and + (headers[i + 2] == 'n' or headers[i + 2] == 'N') and + (headers[i + 3] == 't' or headers[i + 3] == 'T') and + (headers[i + 4] == 'e' or headers[i + 4] == 'E') and + (headers[i + 5] == 'n' or headers[i + 5] == 'N') and + (headers[i + 6] == 't' or headers[i + 6] == 'T') and + headers[i + 7] == '-' and + (headers[i + 8] == 'L' or headers[i + 8] == 'l') and + (headers[i + 9] == 'e' or headers[i + 9] == 'E') and + (headers[i + 10] == 'n' or headers[i + 10] == 'N') and + (headers[i + 11] == 'g' or headers[i + 11] == 'G') and + (headers[i + 12] == 't' or headers[i + 12] == 'T') and + (headers[i + 13] == 'h' or headers[i + 13] == 'H') and + headers[i + 14] == ':') + { + var start = i + 15; + while (start < headers.len and (headers[start] == ' ' or headers[start] == '\t')) start += 1; var end = start; while (end < headers.len and headers[end] >= '0' and headers[end] <= '9') : (end += 1) {} if (end > start) { From 95d54b38ec45ae25f7ad5a6b31b6bb3f5f22decb Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:15:04 +0800 Subject: [PATCH 04/27] fix: collection drop now deletes the backing page file on disk dropCollectionForTenant removed the collection from the in-memory hashmap but left the .pages file on disk. The next access to the same collection name re-opened the old file and all data reappeared, making DROP appear to do nothing. Now reconstructs the page file path and unlinks it after closing the collection, so data is actually gone. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/collection.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/collection.zig b/src/collection.zig index 027224d..40ba031 100644 --- a/src/collection.zig +++ b/src/collection.zig @@ -1603,6 +1603,13 @@ pub const Database = struct { kv.value.close(); self.alloc.free(kv.key); } + + // Delete the backing page file so data doesn't reappear on re-access. + var storage_name_buf: [MAX_TENANT_ID_LEN + MAX_COLLECTION_NAME_LEN + 4]u8 = undefined; + const storage_name = makeStorageName(&storage_name_buf, tenant_id, name) catch return; + var path_buf: [512]u8 = undefined; + const page_path = std.fmt.bufPrint(&path_buf, "{s}/{s}.pages", .{ self.dataDir(), storage_name }) catch return; + std.fs.cwd().deleteFile(page_path) catch {}; } pub fn listCollectionsForTenant(self: *Database, tenant_id: []const u8, alloc: std.mem.Allocator) !std.ArrayList([]const u8) { From ab8d669c2e721632c3e98deab5246959d53d8708 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:23:35 +0800 Subject: [PATCH 05/27] fix: prevent OOB crash in jsonValue on unterminated strings and lower GC interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cap query limits to prevent client-driven OOM: scan max 1000, search max 500, context max 100. Add buffer overflow guards in scan/search response serialization — stop writing docs when the 64KB body buffer is nearly full instead of silently truncating JSON. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server.zig | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/server.zig b/src/server.zig index 0a6ca1a..54e62fe 100644 --- a/src/server.zig +++ b/src/server.zig @@ -349,7 +349,7 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { const q_raw = qparam(query, "q") orelse return err(400, "missing q parameter"); var decode_buf: [4096]u8 = undefined; const q = urlDecode(q_raw, &decode_buf) orelse q_raw; - const limit_val: u32 = qparamInt(query, "limit") orelse 50; + const limit_val: u32 = @min(qparamInt(query, "limit") orelse 50, 500); return handleSearch(srv, requestTenant(raw, query), col_name, q, limit_val, alloc); } @@ -359,7 +359,7 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { const q_raw = qparam(query, "q") orelse return err(400, "missing q parameter"); var decode_buf: [4096]u8 = undefined; const q = urlDecode(q_raw, &decode_buf) orelse q_raw; - const limit_val: u32 = qparamInt(query, "limit") orelse 20; + const limit_val: u32 = @min(qparamInt(query, "limit") orelse 20, 100); return handleDiscoverContext(srv, requestTenant(raw, query), col_name, q, limit_val, alloc); } @@ -585,7 +585,7 @@ fn handleDelete(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: fn handleScan(srv: *Server, tenant_id: []const u8, col_name: []const u8, query_str: []const u8, as_of: ?i64, alloc: std.mem.Allocator) usize { const start_ns = std.time.nanoTimestamp(); - const limit: u32 = qparamInt(query_str, "limit") orelse 20; + const limit: u32 = @min(qparamInt(query_str, "limit") orelse 20, 1000); const offset: u32 = qparamInt(query_str, "offset") orelse 0; srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); @@ -603,6 +603,8 @@ fn handleScan(srv: *Server, tenant_id: []const u8, col_name: []const u8, query_s std.fmt.format(w, "{{\"tenant\":\"{s}\",\"collection\":\"{s}\",\"count\":{d},\"docs\":[", .{ tenant_id, col_name, result.docs.len }) catch {}; for (result.docs, 0..) |d, i| { + // Stop writing if buffer is nearly full to avoid truncated JSON. + if (fbs.pos + 256 >= MAX_BODY) break; if (i > 0) w.writeByte(',') catch {}; const val = if (d.value.len > 0) d.value else "{}"; const is_json = val.len > 0 and (val[0] == '{' or val[0] == '[' or val[0] == '"'); @@ -620,8 +622,8 @@ fn handleScan(srv: *Server, tenant_id: []const u8, col_name: []const u8, query_s } w.writeAll("]}") catch {}; return ok(getBodyBuf()[0..fbs.pos]); -} +} fn handleDrop(srv: *Server, tenant_id: []const u8, col_name: []const u8) usize { const start_ns = std.time.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); @@ -652,6 +654,7 @@ fn handleSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, query "\",\"hits\":{d},\"candidates\":{d},\"total_docs\":{d},\"total_files\":{d},\"results\":[", .{ result.docs.len, result.candidate_paths.len, col.docCount(), result.total_files }) catch {}; for (result.docs, 0..) |d, i| { + if (fbs.pos + 256 >= MAX_BODY) break; if (i > 0) w.writeByte(',') catch {}; // Output value as valid JSON — objects/arrays as-is, strings quoted const val = if (d.value.len > 0) d.value else "{}"; From 54addd288aba6bdcac7a7c74c923a36f8aa01358 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:24:36 +0800 Subject: [PATCH 06/27] fix: harden against segfaults and OOM across core subsystems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cap query limits: scan max 1000, search max 500, context max 100 - Add buffer overflow guards in scan/search/context response loops — stop serializing when the 64KB body buffer is nearly full instead of silently truncating JSON - Bulk insert now checks tenant storage quota before processing (was bypassing ensureTenantStorageAvailable entirely) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/server.zig b/src/server.zig index 54e62fe..b647dee 100644 --- a/src/server.zig +++ b/src/server.zig @@ -480,6 +480,7 @@ fn handleBulkInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, b _ = alloc; const start_ns = std.time.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + srv.db.ensureTenantStorageAvailable(tenant_id, body.len) catch return err(429, "tenant storage quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); var inserted: u32 = 0; @@ -697,16 +698,19 @@ fn handleDiscoverContext(srv: *Server, tenant_id: []const u8, col_name: []const } w.writeAll("\",\"matching_files\":[") catch {}; for (result.matching_files, 0..) |d, i| { + if (fbs.pos + 128 >= MAX_BODY) break; if (i > 0) w.writeByte(',') catch {}; std.fmt.format(w, "{{\"key\":\"{s}\",\"size\":{d}}}", .{ d.key, d.value.len }) catch {}; } w.writeAll("],\"related_files\":[") catch {}; for (result.related_files, 0..) |d, i| { + if (fbs.pos + 128 >= MAX_BODY) break; if (i > 0) w.writeByte(',') catch {}; std.fmt.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch {}; } w.writeAll("],\"test_files\":[") catch {}; for (result.test_files, 0..) |d, i| { + if (fbs.pos + 128 >= MAX_BODY) break; if (i > 0) w.writeByte(',') catch {}; std.fmt.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch {}; } From 2f16de2770333da3be6edfcf14c9956c13fdc088 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:35:34 +0800 Subject: [PATCH 07/27] fix: bound index memory growth and truncate WAL after checkpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trigram index: cap posting lists at 10K entries per trigram. Trigrams appearing in more docs than this are too common to be discriminative for search — skip them to prevent unbounded memory growth on large collections (13K+ files could otherwise consume hundreds of MB). Word index: cap hits per word at 5K. Common words ("the", "var", "if") accumulate thousands of hits across a codebase — beyond this limit they waste memory for negligible search value. WAL: truncate the file after checkpoint. All data is already durable in page files at checkpoint time, so the WAL can be safely cleared to reclaim disk space. Prevents unbounded WAL growth on long-running instances. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/codeindex.zig | 8 ++++++++ src/storage/wal.zig | 9 ++++++++- src/trigram.zig | 7 +++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/codeindex.zig b/src/codeindex.zig index 8d4e0fc..e68204a 100644 --- a/src/codeindex.zig +++ b/src/codeindex.zig @@ -15,6 +15,11 @@ pub const WordIndex = struct { file_words: std.StringHashMap(std.StringHashMap(void)), allocator: std.mem.Allocator, + /// Cap hits per word to bound memory. Common words ("the", "var", "if") + /// accumulate thousands of hits — beyond this they waste memory for + /// negligible search value. + const MAX_HITS_PER_WORD: usize = 5_000; + pub fn init(allocator: std.mem.Allocator) WordIndex { return .{ .index = std.StringHashMap(std.ArrayList(WordHit)).init(allocator), @@ -102,6 +107,9 @@ pub const WordIndex = struct { gop.value_ptr.* = .{}; } + // Skip overly common words to bound memory. + if (gop.value_ptr.items.len >= MAX_HITS_PER_WORD) continue; + if (gop.value_ptr.items.len > 0) { const last = gop.value_ptr.items[gop.value_ptr.items.len - 1]; if (std.mem.eql(u8, last.path, owned_path) and last.line_num == line_num) { diff --git a/src/storage/wal.zig b/src/storage/wal.zig index f5ee3b1..7e4dfb9 100644 --- a/src/storage/wal.zig +++ b/src/storage/wal.zig @@ -283,7 +283,9 @@ pub const WAL = struct { // ── Checkpoint ──────────────────────────────────────────────────────────── - /// Write a checkpoint barrier entry and update checkpoint_lsn. + /// Write a checkpoint barrier entry, flush, then truncate the WAL file. + /// After checkpoint all data is durable in page files, so the WAL can be + /// safely cleared to reclaim disk space. pub fn checkpoint(self: *WAL, db_tag: u8) !void { var p: [8]u8 = undefined; std.mem.writeInt(u64, &p, self.checkpoint_lsn, .little); @@ -298,6 +300,11 @@ pub const WAL = struct { self.file.writeAll(to_write.items) catch {}; to_write.deinit(self.allocator); try self.file.sync(); + + // Truncate WAL — all data is checkpointed to page files. + self.file.seekTo(0) catch {}; + self.file.setEndPos(0) catch {}; + self.mu.lock(); self.synced_lsn = lsn; self.flushing = false; diff --git a/src/trigram.zig b/src/trigram.zig index d5f9c20..12670e9 100644 --- a/src/trigram.zig +++ b/src/trigram.zig @@ -24,6 +24,11 @@ pub const TrigramIndex = struct { doc_count: u64, trigram_count: u64, + /// Maximum posting list length per trigram. Trigrams appearing in more docs + /// than this are too common to be useful for search — stop tracking them to + /// bound memory. 10K docs × 8 bytes = 80KB per trigram, manageable. + const MAX_POSTINGS_PER_TRIGRAM: usize = 10_000; + pub fn init(alloc: std.mem.Allocator) TrigramIndex { return .{ .postings = std.AutoHashMap(u24, std.ArrayList(u64)).init(alloc), @@ -53,6 +58,8 @@ pub const TrigramIndex = struct { self.trigram_count += 1; } const list = gop.value_ptr; + // Skip overly common trigrams to bound memory. + if (list.items.len >= MAX_POSTINGS_PER_TRIGRAM) continue; if (list.items.len == 0 or list.items[list.items.len - 1] != doc_id) { try list.append(self.alloc, doc_id); } From 5190777dd896c3988c96a219415c99a062180da2 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:45:17 +0800 Subject: [PATCH 08/27] fix: harden against segfaults and OOM across core subsystems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Throughput and stability under high concurrent load: - WAL: add 8MB write buffer backpressure — writers yield when buffer is full instead of growing unboundedly toward OOM - Thread stack: 256KB explicit stack size (was OS default ~2MB). 512 threads × 2MB = 1GB just for stacks; now 512 × 256KB = 128MB - Connection rejection: return HTTP 503 with JSON body instead of silent TCP RST when at MAX_CONNECTIONS - Stripe locks: 1024 → 4096 to reduce contention under 100+ concurrent writers (birthday paradox collision rate drops 4x) - Index queue: 32K → 128K capacity to absorb write bursts before falling back to synchronous indexing - Second index worker thread spawned to double indexing throughput Co-Authored-By: Claude Opus 4.6 (1M context) --- src/collection.zig | 8 ++++---- src/server.zig | 9 +++++++-- src/storage/wal.zig | 11 +++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/collection.zig b/src/collection.zig index 40ba031..ef9beba 100644 --- a/src/collection.zig +++ b/src/collection.zig @@ -30,7 +30,7 @@ pub const MAX_COLLECTION_NAME_LEN: usize = 64; // Lock-free MPSC queue for deferring trigram+word index builds to a background thread. // Insert path pushes owned (key, value) pairs; background thread pops and indexes. pub const IndexQueue = struct { - const CAPACITY = 32768; // 32K entries per queue + const CAPACITY = 131072; // 128K entries — sized for 100+ concurrent writers const Entry = struct { key: []const u8, @@ -162,7 +162,7 @@ fn extractJsonFloatArray(json: []const u8, field_name: []const u8, out: []f32) ? } // ─── Collection ────────────────────────────────────────────────────────── pub const Collection = struct { - pub const STRIPE_COUNT = 1024; + pub const STRIPE_COUNT = 4096; // MVCC GC: trigger version chain cleanup every GC_INTERVAL inserts. const GC_INTERVAL: u64 = 500; @@ -261,10 +261,10 @@ pub const Collection = struct { col.name_len = @intCast(n); try col.rebuildIndexes(); - // Start background indexer thread. If spawn fails, inserts fall back to sync + // Start background indexer threads. If spawn fails, inserts fall back to sync // indexing via the queue-full path (queue stays empty, retries exhaust immediately). col.index_thread = std.Thread.spawn(.{}, indexWorkerQ, .{ col, &col.index_queue }) catch null; - col.index_thread2 = null; // Second worker slot — available for future use + col.index_thread2 = std.Thread.spawn(.{}, indexWorkerQ, .{ col, &col.index_queue2 }) catch null; return col; } diff --git a/src/server.zig b/src/server.zig index b647dee..b273076 100644 --- a/src/server.zig +++ b/src/server.zig @@ -111,11 +111,14 @@ pub const Server = struct { }; // Reject if at connection limit to prevent OOM from thread exhaustion. if (self.active_conns.load(.monotonic) >= MAX_CONNECTIONS) { + // Return HTTP 503 instead of silent RST so clients can retry. + const reject = "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 31\r\nConnection: close\r\n\r\n{\"error\":\"too many connections\"}"; + conn.stream.writeAll(reject) catch {}; conn.stream.close(); _ = self.err_count.fetchAdd(1, .monotonic); continue; } - const t = std.Thread.spawn(.{}, handleConnWrapped, .{self, conn}) catch { + const t = std.Thread.spawn(.{ .stack_size = 256 * 1024 }, handleConnWrapped, .{self, conn}) catch { conn.stream.close(); _ = self.err_count.fetchAdd(1, .monotonic); continue; @@ -149,11 +152,13 @@ pub const Server = struct { const stream = std.net.Stream{ .handle = client_fd }; const conn = std.net.Server.Connection{ .stream = stream, .address = std.net.Address.initUnix(path) catch continue }; if (self.active_conns.load(.monotonic) >= MAX_CONNECTIONS) { + const reject = "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 31\r\nConnection: close\r\n\r\n{\"error\":\"too many connections\"}"; + conn.stream.writeAll(reject) catch {}; conn.stream.close(); _ = self.err_count.fetchAdd(1, .monotonic); continue; } - const t = std.Thread.spawn(.{}, handleConnWrapped, .{ self, conn }) catch { + const t = std.Thread.spawn(.{ .stack_size = 256 * 1024 }, handleConnWrapped, .{ self, conn }) catch { conn.stream.close(); continue; }; diff --git a/src/storage/wal.zig b/src/storage/wal.zig index 7e4dfb9..81f89fa 100644 --- a/src/storage/wal.zig +++ b/src/storage/wal.zig @@ -118,6 +118,9 @@ pub const WAL = struct { flush_thread: ?std.Thread, flush_running: std.atomic.Value(bool), + /// Backpressure: block writers if pending buffer exceeds this. + const MAX_WRITE_BUF: usize = 8 * 1024 * 1024; // 8 MiB + // ── Lifecycle ───────────────────────────────────────────────────────────── pub fn open(path: [:0]const u8, allocator: std.mem.Allocator) !WAL { @@ -201,6 +204,7 @@ pub const WAL = struct { /// Encode an entry into the shared write buffer and return its LSN. /// Thread-safe. Does NOT guarantee durability — call commit(lsn) for that. + /// Blocks if write buffer exceeds MAX_WRITE_BUF to apply backpressure. pub fn write( self: *WAL, txn_id: u64, @@ -226,6 +230,13 @@ pub const WAL = struct { hdr.crc32 = entryChecksum(hdr_bytes, payload); self.mu.lock(); + // Backpressure: wait until flusher drains the buffer below threshold. + var waits: u32 = 0; + while (self.write_buf.items.len >= MAX_WRITE_BUF and waits < 100) : (waits += 1) { + self.mu.unlock(); + std.Thread.yield() catch {}; + self.mu.lock(); + } defer self.mu.unlock(); try self.write_buf.appendSlice(self.allocator, std.mem.asBytes(&hdr)); try self.write_buf.appendSlice(self.allocator, payload); From 806fd361a13195081f851dffab48b3076841073e Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:58:37 +0800 Subject: [PATCH 09/27] feat: WebSocket transport for persistent streaming connections Clients can upgrade any HTTP connection to WebSocket by sending a standard Upgrade: websocket request. After the 101 handshake, each WS text message is dispatched as a request using the format: METHOD /path\n{body} e.g. "POST /db/mycol\n{\"key\":\"k\",\"value\":{}}" The response body (without HTTP headers) is sent back as a WS text frame. This enables: - Streaming bulk inserts over one connection (bypasses Cloudflare Workers 1000 subrequest limit) - Lower latency (no HTTP overhead per request) - Persistent connections for real-time operations Implementation: SHA1+Base64 handshake, RFC 6455 frame parsing with masking, ping/pong keepalive, 16MB max message (heap-allocated). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/server.zig | 227 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/src/server.zig b/src/server.zig index b273076..b1838e3 100644 --- a/src/server.zig +++ b/src/server.zig @@ -228,9 +228,16 @@ fn handleConn(srv: *Server, conn: std.net.Server.Connection) void { if (n == 0) return; _ = srv.req_count.fetchAdd(1, .monotonic); + const initial = bufs.req[0..n]; + + // WebSocket upgrade: switch to persistent framed mode. + if (headerContains(initial, "upgrade", "websocket")) { + handleWebSocket(srv, conn, initial) catch {}; + return; // WS handler owns the connection until close + } + // For bulk inserts: read the full body based on Content-Length. // The initial read may only contain part of a large body. - const initial = bufs.req[0..n]; const content_length = extractContentLength(initial); const is_bulk = std.mem.indexOf(u8, initial[0..@min(n, 256)], "/bulk") != null; @@ -1124,6 +1131,224 @@ fn hexVal(c: u8) ?u4 { return null; } +// ─── WebSocket ─────────────────────────────────────────────────────────── + +/// Read exactly `buf.len` bytes from the stream, looping as needed. +fn wsReadExact(conn: std.net.Server.Connection, buf: []u8) !void { + var filled: usize = 0; + while (filled < buf.len) { + const n = conn.stream.read(buf[filled..]) catch return error.ReadFailed; + if (n == 0) return error.ConnectionClosed; + filled += n; + } +} +/// Case-insensitive header value check. +fn headerContains(raw: []const u8, name: []const u8, value: []const u8) bool { + const headers = raw[0..@min(raw.len, 4096)]; + var i: usize = 0; + while (i + name.len + 2 < headers.len) : (i += 1) { + if (headers[i] == '\n') { + const line_start = i + 1; + if (line_start + name.len + 2 >= headers.len) continue; + var match = true; + for (0..name.len) |j| { + const a = headers[line_start + j]; + const b = name[j]; + const al = if (a >= 'A' and a <= 'Z') a + 32 else a; + const bl = if (b >= 'A' and b <= 'Z') b + 32 else b; + if (al != bl) { match = false; break; } + } + if (!match) continue; + if (headers[line_start + name.len] != ':') continue; + // Found header — check value (case-insensitive) + var vs = line_start + name.len + 1; + while (vs < headers.len and (headers[vs] == ' ' or headers[vs] == '\t')) vs += 1; + const ve = std.mem.indexOfScalarPos(u8, headers, vs, '\r') orelse + std.mem.indexOfScalarPos(u8, headers, vs, '\n') orelse headers.len; + const hval = headers[vs..ve]; + if (hval.len < value.len) continue; + var vmatch = true; + for (0..value.len) |j| { + const a = hval[j]; + const b = value[j]; + const al = if (a >= 'A' and a <= 'Z') a + 32 else a; + const bl = if (b >= 'A' and b <= 'Z') b + 32 else b; + if (al != bl) { vmatch = false; break; } + } + if (vmatch) return true; + } + } + return false; +} + +/// Extract a specific header value from raw HTTP request. +fn headerValue(raw: []const u8, name: []const u8) ?[]const u8 { + const headers = raw[0..@min(raw.len, 4096)]; + var i: usize = 0; + while (i + name.len + 2 < headers.len) : (i += 1) { + if (headers[i] == '\n') { + const ls = i + 1; + if (ls + name.len + 2 >= headers.len) continue; + var match = true; + for (0..name.len) |j| { + const a = headers[ls + j]; + const b = name[j]; + const al = if (a >= 'A' and a <= 'Z') a + 32 else a; + const bl = if (b >= 'A' and b <= 'Z') b + 32 else b; + if (al != bl) { match = false; break; } + } + if (!match) continue; + if (headers[ls + name.len] != ':') continue; + var vs = ls + name.len + 1; + while (vs < headers.len and (headers[vs] == ' ' or headers[vs] == '\t')) vs += 1; + const ve = std.mem.indexOfScalarPos(u8, headers, vs, '\r') orelse + std.mem.indexOfScalarPos(u8, headers, vs, '\n') orelse headers.len; + return headers[vs..ve]; + } + } + return null; +} + +const WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +/// Perform WebSocket upgrade handshake, then loop reading WS frames. +/// Each text message is a JSON request dispatched through the normal +/// HTTP handler; the response body is sent back as a WS text frame. +fn handleWebSocket(srv: *Server, conn: std.net.Server.Connection, initial: []const u8) !void { + // Extract Sec-WebSocket-Key + const ws_key = headerValue(initial, "Sec-WebSocket-Key") orelse return error.MissingKey; + + // Compute accept hash: SHA1(key ++ magic), base64-encoded + var sha = std.crypto.hash.Sha1.init(.{}); + sha.update(ws_key); + sha.update(WS_MAGIC); + const digest = sha.finalResult(); + var accept_buf: [28]u8 = undefined; + const accept = std.base64.standard.Encoder.encode(&accept_buf, &digest); + + // Send upgrade response + var resp_buf: [256]u8 = undefined; + const resp_len = std.fmt.bufPrint(&resp_buf, + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {s}\r\n\r\n", + .{accept}) catch return error.FormatFailed; + conn.stream.writeAll(resp_len) catch return error.WriteFailed; + + // WebSocket frame loop — heap-allocate frame buffer (too large for stack). + const frame_buf = std.heap.page_allocator.alloc(u8, MAX_BULK) catch return error.OutOfMemory; + defer std.heap.page_allocator.free(frame_buf); + while (true) { + // Read frame header (2 bytes minimum) + var hdr: [14]u8 = undefined; + wsReadExact(conn, hdr[0..2]) catch return; + const fin = hdr[0] & 0x80 != 0; + const opcode = hdr[0] & 0x0F; + const masked = hdr[1] & 0x80 != 0; + var payload_len: u64 = hdr[1] & 0x7F; + + if (opcode == 0x8) return; // close frame + if (opcode == 0x9) { // ping → pong + hdr[0] = 0x8A; // FIN + pong + conn.stream.writeAll(hdr[0..2]) catch return; + continue; + } + + // Extended payload length + if (payload_len == 126) { + wsReadExact(conn, hdr[2..4]) catch return; + payload_len = std.mem.readInt(u16, hdr[2..4], .big); + } else if (payload_len == 127) { + wsReadExact(conn, hdr[2..10]) catch return; + payload_len = std.mem.readInt(u64, hdr[2..10], .big); + } + + if (payload_len > MAX_BULK) { + wsWriteClose(conn, 1009); // message too big + return; + } + + // Read mask key (4 bytes if masked) + var mask: [4]u8 = .{0, 0, 0, 0}; + if (masked) wsReadExact(conn, &mask) catch return; + + // Read payload + const plen: usize = @intCast(payload_len); + const payload = frame_buf[0..plen]; + wsReadExact(conn, payload) catch return; + + // Unmask + if (masked) { + for (payload, 0..) |*b, j| b.* ^= mask[j % 4]; + } + + _ = fin; // TODO: handle fragmented messages + + if (opcode == 0x1) { // text frame — dispatch as request + _ = srv.req_count.fetchAdd(1, .monotonic); + + // Build a synthetic HTTP request from the WS message. + // Expected format: {"op":"insert","col":"name","key":"k","value":{...}} + // Or direct HTTP path: "POST /db/mycol\n{...body...}" + const resp_body = wsDispatch(srv, payload); + wsWriteText(conn, resp_body) catch return; + } + } +} + +/// Dispatch a WebSocket text message. Supports two formats: +/// 1. Raw HTTP: "METHOD /path\n{body}" — reuses dispatch() +/// 2. JSON ops: {"op":"insert","col":"c","key":"k","value":{}} (future) +fn wsDispatch(srv: *Server, msg: []const u8) []const u8 { + // Wrap as a minimal HTTP request so we can reuse dispatch(). + // Find first \n to split method+path from body. + const nl = std.mem.indexOfScalar(u8, msg, '\n') orelse msg.len; + const req_line = msg[0..nl]; + const body = if (nl < msg.len) msg[nl + 1 ..] else ""; + + // Build HTTP/1.1 request with Content-Length. + // Heap-allocate since WS messages can be up to 16MB. + const needed = req_line.len + 64 + body.len; + const http_buf = std.heap.page_allocator.alloc(u8, needed) catch + return "{\"error\":\"request too large\"}"; + defer std.heap.page_allocator.free(http_buf); + const http_len = std.fmt.bufPrint(http_buf, + "{s} HTTP/1.1\r\nContent-Length: {d}\r\n\r\n{s}", + .{ req_line, body.len, body }) catch return "{\"error\":\"request too large\"}"; + + const resp_len = dispatch(srv, http_len, std.heap.page_allocator); + const resp = getRespBuf()[0..resp_len]; + + // Strip HTTP headers from response, return just the body + if (std.mem.indexOf(u8, resp, "\r\n\r\n")) |p| return resp[p + 4 ..]; + return resp; +} + +fn wsWriteText(conn: std.net.Server.Connection, payload: []const u8) !void { + var hdr: [10]u8 = undefined; + hdr[0] = 0x81; // FIN + text + var hdr_len: usize = 2; + if (payload.len < 126) { + hdr[1] = @intCast(payload.len); + } else if (payload.len < 65536) { + hdr[1] = 126; + std.mem.writeInt(u16, hdr[2..4], @intCast(payload.len), .big); + hdr_len = 4; + } else { + hdr[1] = 127; + std.mem.writeInt(u64, hdr[2..10], @intCast(payload.len), .big); + hdr_len = 10; + } + try conn.stream.writeAll(hdr[0..hdr_len]); + try conn.stream.writeAll(payload); +} + +fn wsWriteClose(conn: std.net.Server.Connection, code: u16) void { + var frame: [4]u8 = undefined; + frame[0] = 0x88; // FIN + close + frame[1] = 2; // payload = 2 bytes (status code) + std.mem.writeInt(u16, frame[2..4], code, .big); + conn.stream.writeAll(&frame) catch {}; +} + test "parse as_of accepts seconds and milliseconds" { try std.testing.expectEqual(@as(?i64, 1_700_000_000_000), parseAsOfTimestamp("1700000000")); try std.testing.expectEqual(@as(?i64, 1_700_000_000_123), parseAsOfTimestamp("1700000000123")); From cd77279957447967723c7ed6a481ce51f9f19919 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:17:50 +0800 Subject: [PATCH 10/27] fix: prevent OOB crash in jsonValue on unterminated strings and lower GC interval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four correctness and safety fixes: CDC: workerMain captured subscriptions slice pointer then released the lock — registerWebhook could reallocate the ArrayList, dangling the pointer. Now iterates by index, re-reading under lock each iteration. WAL: backpressure yield-loop gave up after 100 retries and let writes proceed anyway, defeating the purpose. Now uses cond.wait() so writers truly block until the flusher drains below threshold. doc.zig: decode() now rejects val_len > 64MB as corrupt before using it in size arithmetic, preventing OOB slice creation from corrupted page data. Moved key_len check before the total size calculation. branch.zig: write() freed old entry before allocating new copies — if the allocation failed, the hashmap entry had dangling pointers. Now allocates first, frees second, with proper errdefer on both copies. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/branch.zig | 11 +++++++---- src/cdc.zig | 15 +++++++++++++-- src/doc.zig | 6 ++++-- src/storage/wal.zig | 9 ++++----- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/branch.zig b/src/branch.zig index 9fa7f88..3b11f00 100644 --- a/src/branch.zig +++ b/src/branch.zig @@ -54,14 +54,17 @@ pub const Branch = struct { /// Write a key-value pair on this branch (CoW — only stores the delta) pub fn write(self: *Branch, key: []const u8, value: []const u8, epoch: u64) !void { const key_hash = fnv1a(key); - // Free old write if exists + // Allocate new copies BEFORE freeing old ones — if alloc fails, + // the existing entry stays valid. + const owned_key = try self.allocator.dupe(u8, key); + errdefer self.allocator.free(owned_key); + const owned_val = try self.allocator.dupe(u8, value); + errdefer self.allocator.free(owned_val); + // Free old write if exists (safe — new copies already allocated) if (self.writes.getPtr(key_hash)) |old| { if (old.key.len > 0) self.allocator.free(old.key); if (old.value.len > 0) self.allocator.free(old.value); } - const owned_key = try self.allocator.dupe(u8, key); - errdefer self.allocator.free(owned_key); - const owned_val = try self.allocator.dupe(u8, value); try self.writes.put(key_hash, .{ .key = owned_key, .value = owned_val, diff --git a/src/cdc.zig b/src/cdc.zig index 65fe906..73e50c8 100644 --- a/src/cdc.zig +++ b/src/cdc.zig @@ -198,10 +198,21 @@ pub const CDCManager = struct { return; } const ev = self.pending.orderedRemove(0); - const subs = self.subscriptions.items; + // Snapshot subscription count under lock — iterate by index so we + // re-read items pointer each iteration (safe if ArrayList grows). + const sub_count = self.subscriptions.items.len; self.mu.unlock(); - for (subs) |sub| { + var si: usize = 0; + while (si < sub_count) : (si += 1) { + self.mu.lock(); + if (si >= self.subscriptions.items.len) { + self.mu.unlock(); + break; + } + const sub = self.subscriptions.items[si]; + self.mu.unlock(); + if (!matches(sub, ev)) continue; const delivery = makeDelivery(sub, ev); self.mu.lock(); diff --git a/src/doc.zig b/src/doc.zig index 55a9947..a3fec37 100644 --- a/src/doc.zig +++ b/src/doc.zig @@ -76,9 +76,11 @@ pub const Doc = struct { pub fn decode(buf: []const u8) error{TooShort,Corrupt}!struct { doc: Doc, consumed: usize } { if (buf.len < DocHeader.size) return error.TooShort; const hdr: DocHeader = std.mem.bytesToValue(DocHeader, buf[0..DocHeader.size]); - const total = DocHeader.size + hdr.key_len + hdr.val_len; - if (buf.len < total) return error.TooShort; if (hdr.key_len > 1024) return error.Corrupt; + // Sanity check: reject impossibly large values (>64MB) as corruption. + if (hdr.val_len > 64 * 1024 * 1024) return error.Corrupt; + const total = DocHeader.size + @as(usize, hdr.key_len) + @as(usize, hdr.val_len); + if (buf.len < total) return error.TooShort; return .{ .doc = .{ .header = hdr, diff --git a/src/storage/wal.zig b/src/storage/wal.zig index 81f89fa..f671037 100644 --- a/src/storage/wal.zig +++ b/src/storage/wal.zig @@ -231,11 +231,10 @@ pub const WAL = struct { self.mu.lock(); // Backpressure: wait until flusher drains the buffer below threshold. - var waits: u32 = 0; - while (self.write_buf.items.len >= MAX_WRITE_BUF and waits < 100) : (waits += 1) { - self.mu.unlock(); - std.Thread.yield() catch {}; - self.mu.lock(); + // Use condition variable instead of yield-loop so the flusher can wake us. + while (self.write_buf.items.len >= MAX_WRITE_BUF) { + // Release lock and wait for flusher to signal. + self.cond.wait(&self.mu); } defer self.mu.unlock(); try self.write_buf.appendSlice(self.allocator, std.mem.asBytes(&hdr)); From e2bdf26fa50de488fce17fef06ccb981b68d5976 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Mon, 13 Apr 2026 01:32:26 +0800 Subject: [PATCH 11/27] fix: add mutex to WordIndex to prevent crash from concurrent indexer threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WordIndex had no synchronization. With two background index worker threads both calling words.indexFile() concurrently, the unsynchronized StringHashMap operations (getOrPut, append, put) corrupted internal state during resize — causing a panic or segfault in ReleaseSafe. This was the root cause of the vitess ingest crash (msg #41): the second index worker (spawned in commit 5190777) raced with the first on the shared WordIndex, corrupting the hashmap. Added mu: Mutex to WordIndex, acquired in indexFile() and search(). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/codeindex.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/codeindex.zig b/src/codeindex.zig index e68204a..fc4f427 100644 --- a/src/codeindex.zig +++ b/src/codeindex.zig @@ -14,6 +14,8 @@ pub const WordIndex = struct { /// path → set of words contributed (for efficient re-index cleanup) file_words: std.StringHashMap(std.StringHashMap(void)), allocator: std.mem.Allocator, + /// Mutex for concurrent access (background indexer writes, search reads). + mu: std.Thread.Mutex = .{}, /// Cap hits per word to bound memory. Common words ("the", "var", "if") /// accumulate thousands of hits — beyond this they waste memory for @@ -82,6 +84,8 @@ pub const WordIndex = struct { /// Index a file's content — tokenizes into words and records hits. pub fn indexFile(self: *WordIndex, path: []const u8, content: []const u8) !void { + self.mu.lock(); + defer self.mu.unlock(); // Clean up old entries first self.removeFile(path); @@ -139,6 +143,8 @@ pub const WordIndex = struct { /// Look up all hits for a word. O(1) lookup + O(hits) iteration. pub fn search(self: *WordIndex, word: []const u8) []const WordHit { + self.mu.lock(); + defer self.mu.unlock(); if (self.index.get(word)) |hits| { return hits.items; } From fd682a22f92ef93be5474759a3599a203f838dc6 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:16:03 +0800 Subject: [PATCH 12/27] fix: harden document transport and wal paths --- .github/workflows/benchmark.yml | 4 +- .github/workflows/ci.yml | 12 +- .gitignore | 5 +- AGENTS.md | 34 ++ README.md | 6 +- build.zig | 71 ++- build.zig.zon | 2 +- optimizations/docs.md | 17 + src/activity.zig | 13 +- src/art.zig | 5 +- src/auth.zig | 3 +- src/bench_native.zig | 17 +- src/bench_partition.zig | 15 +- src/bench_regression.zig | 21 +- src/bench_wal.zig | 109 +++++ src/branch.zig | 3 +- src/branching.zig | 67 +-- src/btree.zig | 3 +- src/bwtree.zig | 3 +- src/cdc.zig | 9 +- src/client.zig | 11 +- src/codeindex.zig | 43 +- src/collection.zig | 547 ++++++++++++++------- src/compat.zig | 814 ++++++++++++++++++++++++++++++++ src/crypto.zig | 5 +- src/disk_index.zig | 29 +- src/errors.zig | 5 +- src/ffi.zig | 17 +- src/io_engine.zig | 37 +- src/lsm.zig | 289 ++++++++---- src/main.zig | 17 +- src/marketplace.zig | 81 ++-- src/page.zig | 3 +- src/partition.zig | 4 +- src/profile_index.zig | 48 +- src/registry/api.zig | 29 +- src/registry/auth.zig | 7 +- src/registry/cli.zig | 35 +- src/registry/config.zig | 15 +- src/registry/hash.zig | 5 +- src/registry/main.zig | 15 +- src/registry/manifest.zig | 11 +- src/registry/provenance.zig | 11 +- src/registry/registry.zig | 47 +- src/registry/resolver.zig | 17 +- src/registry/sign.zig | 24 +- src/registry/store.zig | 43 +- src/replication/calvin.zig | 7 +- src/replication/peer.zig | 13 +- src/replication/sequencer.zig | 3 +- src/replication/shard.zig | 3 +- src/scale_bench.zig | 49 +- src/server.zig | 517 ++++++++++++++------ src/storage/epoch.zig | 5 +- src/storage/mmap.zig | 30 +- src/storage/parallel_wal.zig | 150 +++++- src/storage/wal.zig | 578 ++++++++++++++++++----- src/tdb.zig | 48 +- src/test_calvin_e2e.zig | 19 +- src/ttl.zig | 9 +- src/wire.zig | 292 +++++++++--- 61 files changed, 3277 insertions(+), 1074 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/bench_wal.zig create mode 100644 src/compat.zig diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 91d838d..845cd97 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -39,10 +39,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Zig 0.15.2 + - name: Install Zig 0.16.0 uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Install Python deps run: pip install pymongo psycopg2-binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bcbfac..1357a50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,10 +17,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Zig 0.15 + - name: Install Zig 0.16 uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Build (Debug) run: zig build @@ -53,10 +53,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Zig 0.15 + - name: Install Zig 0.16 uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Build shared library run: zig build @@ -78,10 +78,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Zig 0.15 + - name: Install Zig 0.16 uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Build (ReleaseFast) run: zig build -Doptimize=ReleaseFast diff --git a/.gitignore b/.gitignore index 3059984..08734d0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,6 @@ __pycache__/ access/ preprocessed_configs/ -# Optimization docs (tracked) -# optimizations/ +# Local agent/MCP workspaces +optimizations/ +MCP/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0b5630a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Agent Runtime Rules + +## Container-Only Runtime + +Do not run TurboDB, `turbodb-gateway`, database services, long-lived servers, +stress tests, or deployment simulations directly on the host macOS machine. + +Runtime work must use Apple `container`: + +```sh +container --help +``` + +Reference: https://github.com/apple/container + +Allowed on the host: + +- Editing files. +- Inspecting files. +- Building binaries when no server is started. +- One-shot commands that do not bind ports, create launch agents, or start + persistent services. + +Not allowed on the host: + +- `launchctl` service installation for TurboDB or its gateway. +- Starting `turbodb` or `turbodb-gateway` directly on macOS. +- Binding TurboDB, gateway, or test services to host ports. +- Moving live database data into host-managed service directories. + +Before any database runtime, create or use an Apple `container` environment and +mount the intended data directory explicitly. Database updates must preserve +existing data: take a backup/snapshot first, reuse the existing data directory, +and never delete or reinitialize data unless the user explicitly asks for that. diff --git a/README.md b/README.md index 9fc8864..ea300f6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

PyPI npm - Zig 0.15 + Zig 0.16 MIT Alpha

@@ -118,7 +118,7 @@ npm install turbodatabase # Node.js ### Build from source ```bash -# Requires Zig 0.15+ +# Requires Zig 0.16+ git clone https://github.com/justrach/turbodb cd turbodb zig build @@ -353,7 +353,7 @@ bash bench/setup_shard_bench.sh # Full shard comparison (needs Docker + ## Contributing -Contributions welcome! TurboDB is written in Zig 0.15 with no external dependencies. +Contributions welcome! TurboDB is written in Zig 0.16 with no external dependencies. ## License diff --git a/build.zig b/build.zig index 53619fc..ef4e04c 100644 --- a/build.zig +++ b/build.zig @@ -4,35 +4,53 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + // ── Compat module (Zig 0.16 compatibility layer) ───────────────────────── + const compat_mod = b.createModule(.{ + .root_source_file = b.path("src/compat.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + // ── Storage modules (WAL, mmap, epoch, seqlock) ───────────────────────── const mmap_mod = b.createModule(.{ .root_source_file = b.path("src/storage/mmap.zig"), .target = target, .optimize = optimize, + .link_libc = true, }); + mmap_mod.addImport("compat", compat_mod); const seqlock_mod = b.createModule(.{ .root_source_file = b.path("src/storage/seqlock.zig"), .target = target, .optimize = optimize, + .link_libc = true, }); + seqlock_mod.addImport("compat", compat_mod); const epoch_mod = b.createModule(.{ .root_source_file = b.path("src/storage/epoch.zig"), .target = target, .optimize = optimize, + .link_libc = true, }); + epoch_mod.addImport("compat", compat_mod); const wal_mod = b.createModule(.{ .root_source_file = b.path("src/storage/wal.zig"), .target = target, .optimize = optimize, + .link_libc = true, }); + wal_mod.addImport("compat", compat_mod); + // ── Helper: wire storage imports into a module ────────────────────────── const wireStorage = struct { - fn f(mod: *std.Build.Module, mmap: *std.Build.Module, wal: *std.Build.Module, epoch: *std.Build.Module, seqlock: *std.Build.Module) void { + fn f(mod: *std.Build.Module, mmap: *std.Build.Module, wal: *std.Build.Module, epoch: *std.Build.Module, seqlock: *std.Build.Module, compat: *std.Build.Module) void { mod.addImport("mmap", mmap); mod.addImport("wal", wal); mod.addImport("epoch", epoch); mod.addImport("seqlock", seqlock); + mod.addImport("compat", compat); } }.f; @@ -43,7 +61,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - wireStorage(turbodb_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(turbodb_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const turbodb = b.addExecutable(.{ .name = "turbodb", @@ -58,7 +76,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - wireStorage(tdb_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(tdb_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const tdb = b.addExecutable(.{ .name = "tdb", @@ -78,7 +96,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - wireStorage(ffi_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(ffi_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const lib = b.addLibrary(.{ .linkage = .dynamic, @@ -97,6 +115,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); + zagdb_mod.addImport("compat", compat_mod); const zagdb = b.addExecutable(.{ .name = "zagdb", @@ -116,6 +135,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + reg_test_mod.addImport("compat", compat_mod); const reg_tests = b.addTest(.{ .name = "zagdb-tests", .root_module = reg_test_mod, @@ -131,6 +151,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); + zag_mod.addImport("compat", compat_mod); const zag_exe = b.addExecutable(.{ .name = "zag", @@ -156,7 +177,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - wireStorage(scale_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(scale_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const scale_exe = b.addExecutable(.{ .name = "scale-bench", @@ -177,7 +198,7 @@ pub fn build(b: *std.Build) void { .optimize = .ReleaseSafe, // ALWAYS safe — catches segfaults .link_libc = true, }); - wireStorage(profile_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(profile_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const profile_exe = b.addExecutable(.{ .name = "profile", @@ -198,7 +219,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - wireStorage(bench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(bench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const bench_exe = b.addExecutable(.{ .name = "bench-native", @@ -211,6 +232,24 @@ pub fn build(b: *std.Build) void { const bench_step = b.step("bench", "Run native Zig benchmark"); bench_step.dependOn(&bench_run.step); + // ── WAL microbenchmark ────────────────────────────────────────────────── + const walbench_mod = b.createModule(.{ + .root_source_file = b.path("src/bench_wal.zig"), + .target = target, + .optimize = .ReleaseFast, + .link_libc = true, + }); + wireStorage(walbench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); + + const walbench_exe = b.addExecutable(.{ + .name = "bench-wal", + .root_module = walbench_mod, + }); + + const walbench_run = b.addRunArtifact(walbench_exe); + const walbench_step = b.step("bench-wal", "Run WAL microbenchmark"); + walbench_step.dependOn(&walbench_run.step); + // ── Regression benchmark ──────────────────────────────────────────────── const regbench_mod = b.createModule(.{ .root_source_file = b.path("src/bench_regression.zig"), @@ -218,7 +257,7 @@ pub fn build(b: *std.Build) void { .optimize = .ReleaseFast, .link_libc = true, }); - wireStorage(regbench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(regbench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const regbench_exe = b.addExecutable(.{ .name = "bench-regression", @@ -238,7 +277,7 @@ pub fn build(b: *std.Build) void { .optimize = .ReleaseFast, .link_libc = true, }); - wireStorage(partbench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(partbench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const partbench_exe = b.addExecutable(.{ .name = "bench-partition", @@ -258,7 +297,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - wireStorage(calvin_test_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(calvin_test_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const calvin_test_exe = b.addExecutable(.{ .name = "test-calvin", @@ -276,16 +315,18 @@ pub fn build(b: *std.Build) void { // Helper: add a test module with storage imports const addTestMod = struct { - fn f(b2: *std.Build, src: []const u8, tgt: std.Build.ResolvedTarget, opt: std.builtin.OptimizeMode, mm: *std.Build.Module, wl: *std.Build.Module, ep: *std.Build.Module, sl: *std.Build.Module) *std.Build.Step.Compile { + fn f(b2: *std.Build, src: []const u8, tgt: std.Build.ResolvedTarget, opt: std.builtin.OptimizeMode, mm: *std.Build.Module, wl: *std.Build.Module, ep: *std.Build.Module, sl: *std.Build.Module, cm: *std.Build.Module) *std.Build.Step.Compile { const mod = b2.createModule(.{ .root_source_file = b2.path(src), .target = tgt, .optimize = opt, + .link_libc = true, }); mod.addImport("mmap", mm); mod.addImport("wal", wl); mod.addImport("epoch", ep); mod.addImport("seqlock", sl); + mod.addImport("compat", cm); // Extract just the filename without path for the test name. const basename = std.fs.path.stem(src); return b2.addTest(.{ .name = basename, .root_module = mod }); @@ -297,7 +338,9 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/doc.zig"), .target = target, .optimize = optimize, + .link_libc = true, }); + test_mod.addImport("compat", compat_mod); const unit_tests = b.addTest(.{ .name = "turbodb-tests", .root_module = test_mod, @@ -341,18 +384,18 @@ pub fn build(b: *std.Build) void { test_all_step.dependOn(&run_tests.step); for (new_test_files) |src| { - const t = addTestMod(b, src, target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + const t = addTestMod(b, src, target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const run_t = b.addRunArtifact(t); test_all_step.dependOn(&run_t.step); } // Also add collection test with storage imports - const col_test = addTestMod(b, "src/collection.zig", target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + const col_test = addTestMod(b, "src/collection.zig", target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const run_col_test = b.addRunArtifact(col_test); test_all_step.dependOn(&run_col_test.step); // Parallel WAL test - const pwal_test = addTestMod(b, "src/storage/parallel_wal.zig", target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + const pwal_test = addTestMod(b, "src/storage/parallel_wal.zig", target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const run_pwal_test = b.addRunArtifact(pwal_test); test_all_step.dependOn(&run_pwal_test.step); } diff --git a/build.zig.zon b/build.zig.zon index c5e2840..f31a0a8 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,7 +2,7 @@ .name = .turbodb, .version = "0.1.0", .fingerprint = 0x2d3f19d996a45887, - .minimum_zig_version = "0.15.0", + .minimum_zig_version = "0.16.0", .dependencies = .{}, .paths = .{ "build.zig", diff --git a/optimizations/docs.md b/optimizations/docs.md index 3f1d428..ec8ecb2 100644 --- a/optimizations/docs.md +++ b/optimizations/docs.md @@ -50,6 +50,23 @@ compare_branches → side-by-side multi-branch review - 6 HTTP endpoints (`/branch/:col/...`) - CDC auto-triggers on merge via existing insert→emitChange pipeline +## TigerBeetle Contrast + +Reviewed against `tigerbeetle/tigerbeetle@210336d2dc3c68e56d285f0cf380b86cb54a7d28` +through `codedb_remote`. + +| # | Title | What | Key Technique | +|---|---|---|---| +| [7](optimization7_tigerbeetle_resource_bounds.md) | Tenant Resource Bounds | Predictable multi-tenant agent load | API-key tenant context + bounded admission before allocation/mutation | +| [8](optimization8_tigerbeetle_deterministic_recovery.md) | Deterministic Recovery Tests | Crash/fault confidence for WAL, branches, tenants | Seeded operation model + WAL truncation/corruption replay checks | +| [9](optimization9_tigerbeetle_batch_wire.md) | Batch-Shaped Wire Path | Higher swarm throughput | Bounded binary batch op that amortizes parse, dispatch, WAL, and response writes | + +**Contrast point:** TigerBeetle is a specialized replicated ledger with +deterministic physical repair, static resource bounds, and batch-first execution. +TurboDB is a general document/code/vector database for agent workloads. The +borrowed optimization is not the ledger data model; it is the discipline around +bounded resources, deterministic replay tests, and batching. + ## Architecture ``` diff --git a/src/activity.zig b/src/activity.zig index 2f26f99..8c46704 100644 --- a/src/activity.zig +++ b/src/activity.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); pub const ResourceState = enum(u8) { deep_sleep, @@ -13,7 +14,7 @@ pub const ActivityTracker = struct { queries_in_window: std.atomic.Value(u64), pub fn init() ActivityTracker { - const now = std.time.milliTimestamp(); + const now = compat.milliTimestamp(); return .{ .last_query_ms = std.atomic.Value(i64).init(now), .window_start_ms = std.atomic.Value(i64).init(now), @@ -22,7 +23,7 @@ pub const ActivityTracker = struct { } pub fn recordQuery(self: *ActivityTracker) void { - const now = std.time.milliTimestamp(); + const now = compat.milliTimestamp(); self.last_query_ms.store(now, .release); const start = self.window_start_ms.load(.acquire); @@ -43,7 +44,7 @@ pub const ActivityTracker = struct { } pub fn state(self: *const ActivityTracker) ResourceState { - const now = std.time.milliTimestamp(); + const now = compat.milliTimestamp(); const idle_ms = now - self.last_query_ms.load(.acquire); const qps = self.queries_in_window.load(.acquire); if (idle_ms >= 30 * 60 * 1000) return .deep_sleep; @@ -55,13 +56,13 @@ pub const ActivityTracker = struct { test "activity tracker state machine transitions" { var tracker = ActivityTracker.init(); - tracker.last_query_ms.store(std.time.milliTimestamp() - 31 * 60 * 1000, .release); + tracker.last_query_ms.store(compat.milliTimestamp() - 31 * 60 * 1000, .release); try std.testing.expectEqual(ResourceState.deep_sleep, tracker.state()); - tracker.last_query_ms.store(std.time.milliTimestamp() - 2 * 60 * 1000, .release); + tracker.last_query_ms.store(compat.milliTimestamp() - 2 * 60 * 1000, .release); try std.testing.expectEqual(ResourceState.light_sleep, tracker.state()); - tracker.last_query_ms.store(std.time.milliTimestamp(), .release); + tracker.last_query_ms.store(compat.milliTimestamp(), .release); tracker.queries_in_window.store(150, .release); try std.testing.expectEqual(ResourceState.hot, tracker.state()); diff --git a/src/art.zig b/src/art.zig index 7197d21..27c5a70 100644 --- a/src/art.zig +++ b/src/art.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const btree = @import("btree.zig"); pub const BTreeEntry = btree.BTreeEntry; @@ -1106,7 +1107,7 @@ test "ART: concurrent insert and search" { // Serialize all writes with a mutex. ART's optimistic lock coupling // provides safe concurrent reads alongside a single writer. - var write_mu = std.Thread.Mutex{}; + var write_mu = compat.Mutex{}; // Pre-insert keys under mutex (single-threaded here, but same pattern) for (0..keys_per_thread) |i| { @@ -1121,7 +1122,7 @@ test "ART: concurrent insert and search" { const Context = struct { tree: *ART, thread_id: usize, - mu: *std.Thread.Mutex, + mu: *compat.Mutex, fn insertWork(ctx: @This()) void { const base = (ctx.thread_id + 1) * 10000; diff --git a/src/auth.zig b/src/auth.zig index 10f0550..fd0a91e 100644 --- a/src/auth.zig +++ b/src/auth.zig @@ -8,6 +8,7 @@ /// /// No auth configured → open access (dev mode). const std = @import("std"); +const compat = @import("compat"); const crypto = @import("crypto.zig"); const Allocator = std.mem.Allocator; @@ -45,7 +46,7 @@ pub const AuthStore = struct { keys: [MAX_KEYS]KeyEntry = undefined, count: u32 = 0, enabled: bool = false, - lock: std.Thread.RwLock = .{}, + lock: compat.RwLock = .{}, /// Add an API key. Returns the BLAKE3 hash for storage. pub fn addKey(self: *AuthStore, raw_key: []const u8, name: []const u8, perm: Permission) [32]u8 { diff --git a/src/bench_native.zig b/src/bench_native.zig index 97b0113..66c15f1 100644 --- a/src/bench_native.zig +++ b/src/bench_native.zig @@ -1,10 +1,11 @@ const std = @import("std"); +const compat = @import("compat"); const client = @import("client.zig"); pub fn main() !void { const alloc = std.heap.c_allocator; const data_dir = "/tmp/turbodb_native_bench"; - std.fs.cwd().deleteTree(data_dir) catch {}; + compat.cwd().deleteTree(data_dir) catch {}; var db = try client.Db.open(alloc, data_dir); defer db.close(); @@ -17,37 +18,37 @@ pub fn main() !void { const value = "{}"; // ── INSERT benchmark ── - const t0 = std.time.nanoTimestamp(); + const t0 = compat.nanoTimestamp(); var i: usize = 0; while (i < N) : (i += 1) { const k = std.fmt.bufPrint(&key_buf, "k{d:0>9}", .{i}) catch continue; _ = db.insert("bench", k, value) catch continue; } - const t1 = std.time.nanoTimestamp(); + const t1 = compat.nanoTimestamp(); const insert_ns: f64 = @floatFromInt(@as(i128, t1 - t0)); const insert_ops: f64 = @as(f64, @floatFromInt(N)) / (insert_ns / 1e9); // ── GET benchmark (random lookups) ── var rng = std.Random.DefaultPrng.init(42); - const t2 = std.time.nanoTimestamp(); + const t2 = compat.nanoTimestamp(); var g: usize = 0; while (g < N) : (g += 1) { const idx = rng.random().intRangeAtMost(usize, 0, N - 1); const k = std.fmt.bufPrint(&key_buf, "k{d:0>9}", .{idx}) catch continue; _ = db.get("bench", k) catch null; } - const t3 = std.time.nanoTimestamp(); + const t3 = compat.nanoTimestamp(); const get_ns: f64 = @floatFromInt(@as(i128, t3 - t2)); const get_ops: f64 = @as(f64, @floatFromInt(N)) / (get_ns / 1e9); // ── SEARCH benchmark (brute-force path since trigram index is empty) ── - const t4 = std.time.nanoTimestamp(); + const t4 = compat.nanoTimestamp(); var s: usize = 0; while (s < 1000) : (s += 1) { var sr = db.search("bench", "k00000", 10, alloc) catch continue; sr.deinit(); } - const t5 = std.time.nanoTimestamp(); + const t5 = compat.nanoTimestamp(); const search_ns: f64 = @floatFromInt(@as(i128, t5 - t4)); const search_ops: f64 = 1000.0 / (search_ns / 1e9); @@ -59,5 +60,5 @@ pub fn main() !void { std.debug.print(" SEARCH : {d:.0} ops/s ({d:.1}us/op)\n", .{ search_ops, search_ns / 1000.0 / 1000.0 }); std.debug.print("\n", .{}); - std.fs.cwd().deleteTree(data_dir) catch {}; + compat.cwd().deleteTree(data_dir) catch {}; } diff --git a/src/bench_partition.zig b/src/bench_partition.zig index 6de7d38..174d800 100644 --- a/src/bench_partition.zig +++ b/src/bench_partition.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const collection_mod = @import("collection.zig"); const partition_mod = @import("partition.zig"); const doc_mod = @import("doc.zig"); @@ -74,7 +75,7 @@ fn record(name_src: []const u8, json_src: []const u8, ops: usize, elapsed_ns: i1 // ─── Timing helper ────────────────────────────────────────────────────────── inline fn now() i128 { - return std.time.nanoTimestamp(); + return compat.nanoTimestamp(); } // ─── Partition benchmark for a given partition count ───────────────────────── @@ -84,10 +85,10 @@ fn benchPartitionCount(n_partitions: u16) !void { const data_dir = std.fmt.bufPrint(&dir_buf, "/tmp/turbodb_partition_bench_{d}", .{n_partitions}) catch return; // Cleanup any previous run - std.fs.cwd().deleteTree(data_dir) catch {}; + compat.cwd().deleteTree(data_dir) catch {}; // Create data dir - std.fs.cwd().makeDir(data_dir) catch |e| switch (e) { + compat.cwd().makeDir(data_dir) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return e, }; @@ -181,14 +182,14 @@ fn benchPartitionCount(n_partitions: u16) !void { pc.close(); wal_log.close(); epochs.deinit(); - std.fs.cwd().deleteTree(data_dir) catch {}; + compat.cwd().deleteTree(data_dir) catch {}; } // ─── JSON emitter ─────────────────────────────────────────────────────────── fn emitJSON() void { // Timestamp - const ts = std.time.timestamp(); + const ts = compat.timestamp(); const epoch_secs: u64 = @intCast(ts); const secs_in_day: u64 = 86400; const days = epoch_secs / secs_in_day; @@ -224,7 +225,7 @@ fn emitJSON() void { // Also write to a JSON file for CI consumption const json_path = "/tmp/turbodb_shard_bench.json"; - const file = std.fs.cwd().createFile(json_path, .{}) catch return; + const file = compat.cwd().createFile(json_path, .{}) catch return; defer file.close(); var buf: [16384]u8 = undefined; @@ -282,6 +283,6 @@ pub fn main() !void { for (partition_counts) |n| { var dir_buf: [128]u8 = undefined; const data_dir = std.fmt.bufPrint(&dir_buf, "/tmp/turbodb_partition_bench_{d}", .{n}) catch continue; - std.fs.cwd().deleteTree(data_dir) catch {}; + compat.cwd().deleteTree(data_dir) catch {}; } } diff --git a/src/bench_regression.zig b/src/bench_regression.zig index 4b80358..0a7783f 100644 --- a/src/bench_regression.zig +++ b/src/bench_regression.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const client = @import("client.zig"); const compression = @import("compression.zig"); const art_mod = @import("art.zig"); @@ -57,13 +58,13 @@ fn record(name: []const u8, json_key: []const u8, ops: usize, elapsed_ns: i128) // ─── Timing helper ────────────────────────────────────────────────────────── inline fn now() i128 { - return std.time.nanoTimestamp(); + return compat.nanoTimestamp(); } // ─── Benchmark: Core Path (INSERT / GET / UPDATE / DELETE) ────────────────── fn benchCorePath() !void { - std.fs.cwd().deleteTree(DATA_DIR) catch {}; + compat.cwd().deleteTree(DATA_DIR) catch {}; var db = try client.Db.open(alloc, DATA_DIR); defer db.close(); @@ -114,7 +115,7 @@ fn benchCorePath() !void { record("Core DELETE", "core_delete_ops_sec", N, now() - t0); } - std.fs.cwd().deleteTree(DATA_DIR) catch {}; + compat.cwd().deleteTree(DATA_DIR) catch {}; } // ─── Benchmark: Compression ───────────────────────────────────────────────── @@ -251,11 +252,11 @@ fn benchQuery() void { fn benchLSM() !void { const N: usize = 100_000; - std.fs.cwd().deleteTree(LSM_DIR) catch {}; + compat.cwd().deleteTree(LSM_DIR) catch {}; var lsm = try lsm_mod.LSMTree.init(alloc, LSM_DIR); defer { lsm.deinit(); - std.fs.cwd().deleteTree(LSM_DIR) catch {}; + compat.cwd().deleteTree(LSM_DIR) catch {}; } // Put @@ -288,7 +289,7 @@ fn benchLSM() !void { { // Re-init with fresh data for flush timing lsm.deinit(); - std.fs.cwd().deleteTree(LSM_DIR) catch {}; + compat.cwd().deleteTree(LSM_DIR) catch {}; lsm = try lsm_mod.LSMTree.init(alloc, LSM_DIR); // Fill memtable to ~4MB @@ -408,7 +409,7 @@ fn benchMVCC() !void { fn emitJSON() void { // Timestamp - const ts = std.time.timestamp(); + const ts = compat.timestamp(); const epoch_secs: u64 = @intCast(ts); const secs_in_day: u64 = 86400; const days = epoch_secs / secs_in_day; @@ -436,7 +437,7 @@ fn emitJSON() void { // Also write to a JSON file for CI consumption using fmt.bufPrint + writeAll const json_path = "/tmp/turbodb_regression_bench.json"; - const file = std.fs.cwd().createFile(json_path, .{}) catch return; + const file = compat.cwd().createFile(json_path, .{}) catch return; defer file.close(); var buf: [8192]u8 = undefined; @@ -501,6 +502,6 @@ pub fn main() !void { emitJSON(); // Cleanup - std.fs.cwd().deleteTree(DATA_DIR) catch {}; - std.fs.cwd().deleteTree(LSM_DIR) catch {}; + compat.cwd().deleteTree(DATA_DIR) catch {}; + compat.cwd().deleteTree(LSM_DIR) catch {}; } diff --git a/src/bench_wal.zig b/src/bench_wal.zig new file mode 100644 index 0000000..e7afa3d --- /dev/null +++ b/src/bench_wal.zig @@ -0,0 +1,109 @@ +const std = @import("std"); +const compat = @import("compat"); +const wal_mod = @import("wal"); + +const BASE_DIR = "/tmp/turbodb_wal_bench"; +const WRITE_ONLY_PATH: [:0]const u8 = "/tmp/turbodb_wal_bench/write_only.wal"; +const COMMIT_PATH: [:0]const u8 = "/tmp/turbodb_wal_bench/commit.wal"; + +const WRITE_ONLY_ITERS: usize = 50_000; +const COMMIT_ITERS: usize = 1_000; +const PAYLOAD_SIZE: usize = 64; + +const Result = struct { + name: []const u8, + iterations: usize, + bytes: usize, + elapsed_ns: u64, +}; + +fn paddedEntrySize(payload_len: usize) usize { + const raw = wal_mod.HEADER_SIZE + payload_len; + return raw + ((8 - (raw % 8)) % 8); +} + +fn elapsedSince(start_ns: i128) u64 { + return @intCast(compat.nanoTimestamp() - start_ns); +} + +fn printResult(result: Result) void { + const ns_f: f64 = @floatFromInt(result.elapsed_ns); + const ops_f: f64 = @floatFromInt(result.iterations); + const ops_s = ops_f / (ns_f / 1e9); + const us_op = ns_f / ops_f / 1000.0; + const mb_s = (@as(f64, @floatFromInt(result.bytes)) / (1024.0 * 1024.0)) / (ns_f / 1e9); + + std.debug.print(" {s:<32} {d:>12.0} ops/s {d:>8.2} us/op {d:>8.1} MiB/s\n", .{ + result.name, + ops_s, + us_op, + mb_s, + }); +} + +fn benchWriteOnly(allocator: std.mem.Allocator, payload: []const u8) !Result { + var wal = try wal_mod.WAL.open(WRITE_ONLY_PATH, allocator); + defer wal.close(); + + const start_ns = compat.nanoTimestamp(); + var i: usize = 0; + while (i < WRITE_ONLY_ITERS) : (i += 1) { + _ = try wal.write( + @intCast(i + 1), + .doc_insert, + wal_mod.DB_TAG_DOC, + wal_mod.FLAG_COMMIT, + payload, + ); + } + + return .{ + .name = "buffered committed append", + .iterations = WRITE_ONLY_ITERS, + .bytes = WRITE_ONLY_ITERS * paddedEntrySize(payload.len), + .elapsed_ns = elapsedSince(start_ns), + }; +} + +fn benchCommit(allocator: std.mem.Allocator, payload: []const u8) !Result { + var wal = try wal_mod.WAL.open(COMMIT_PATH, allocator); + defer wal.close(); + + const bytes_per_txn = paddedEntrySize(payload.len) + paddedEntrySize(16); + const start_ns = compat.nanoTimestamp(); + var i: usize = 0; + while (i < COMMIT_ITERS) : (i += 1) { + const txn_id: u64 = @intCast(i + 1); + _ = try wal.write(txn_id, .doc_insert, wal_mod.DB_TAG_DOC, 0, payload); + try wal.commit(txn_id, wal_mod.DB_TAG_DOC); + } + + return .{ + .name = "commit plus fsync", + .iterations = COMMIT_ITERS, + .bytes = COMMIT_ITERS * bytes_per_txn, + .elapsed_ns = elapsedSince(start_ns), + }; +} + +pub fn main() !void { + const allocator = std.heap.smp_allocator; + + compat.cwd().deleteTree(BASE_DIR) catch {}; + try compat.cwd().makePath(BASE_DIR); + defer compat.cwd().deleteTree(BASE_DIR) catch {}; + + var payload: [PAYLOAD_SIZE]u8 = undefined; + @memset(payload[0..], 0xA5); + + const write_only = try benchWriteOnly(allocator, payload[0..]); + const commit = try benchCommit(allocator, payload[0..]); + + std.debug.print("\nTurboDB WAL Microbenchmark\n", .{}); + std.debug.print(" payload: {d} bytes\n", .{PAYLOAD_SIZE}); + std.debug.print(" write-only iterations: {d}\n", .{WRITE_ONLY_ITERS}); + std.debug.print(" commit iterations: {d}\n\n", .{COMMIT_ITERS}); + printResult(write_only); + printResult(commit); + std.debug.print("\n", .{}); +} diff --git a/src/branch.zig b/src/branch.zig index 3b11f00..1e1f748 100644 --- a/src/branch.zig +++ b/src/branch.zig @@ -5,6 +5,7 @@ //! Merging applies branch changes to main with conflict detection. const std = @import("std"); +const compat = @import("compat"); const Allocator = std.mem.Allocator; pub const BranchStatus = enum(u8) { @@ -431,7 +432,7 @@ pub const BranchManager = struct { .name = undefined, .name_len = name_len, .base_epoch = self.next_epoch.load(.acquire), - .created_at = std.time.milliTimestamp(), + .created_at = compat.milliTimestamp(), .status = .active, .agent_id = undefined, .agent_id_len = aid_len, diff --git a/src/branching.zig b/src/branching.zig index dd797ef..23273a7 100644 --- a/src/branching.zig +++ b/src/branching.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const collection = @import("collection.zig"); const cdc_mod = @import("cdc.zig"); const doc_mod = @import("doc.zig"); @@ -116,20 +117,11 @@ pub const BranchedDatabase = struct { const branches_dir = try std.fmt.allocPrint(alloc, "{s}/branches", .{base_data_dir}); defer alloc.free(branches_dir); - std.fs.cwd().makePath(branches_dir) catch |e| switch (e) { - error.PathAlreadyExists => {}, - else => return e, - }; - std.fs.cwd().makePath(branch_root) catch |e| switch (e) { - error.PathAlreadyExists => {}, - else => return e, - }; - std.fs.cwd().makePath(overlay_dir) catch |e| switch (e) { - error.PathAlreadyExists => {}, - else => return e, - }; + try compat.cwd().makePath(branches_dir); + try compat.cwd().makePath(branch_root); + try compat.cwd().makePath(overlay_dir); - var file = try std.fs.createFileAbsolute(manifest_path, .{ .truncate = true }); + var file = try compat.createFileAbsolute(manifest_path, .{ .truncate = true }); defer file.close(); try file.writeAll(base_data_dir); @@ -142,7 +134,7 @@ pub const BranchedDatabase = struct { pub fn open(alloc: std.mem.Allocator, branch_root: []const u8) !BranchedDatabase { const manifest_path = try std.fmt.allocPrint(alloc, "{s}/manifest.txt", .{branch_root}); defer alloc.free(manifest_path); - const parent_path = try std.fs.cwd().readFileAlloc(alloc, manifest_path, 4096); + const parent_path = try compat.cwd().readFileAlloc(alloc, manifest_path, 4096); errdefer alloc.free(parent_path); const overlay_dir = try std.fmt.allocPrint(alloc, "{s}/overlay", .{branch_root}); errdefer alloc.free(overlay_dir); @@ -188,7 +180,7 @@ pub const BranchedDatabase = struct { const snapshot_overlay = try std.fmt.allocPrint(self.alloc, "{s}/overlay", .{snapshot_root}); errdefer self.alloc.free(snapshot_overlay); - try std.fs.cwd().makePath(snapshot_overlay); + try compat.cwd().makePath(snapshot_overlay); try copyDirFilesAbsolute(self.overlay_dir, snapshot_overlay); return .{ @@ -222,7 +214,7 @@ pub const PublishedSnapshot = struct { const manifest_path = try std.fmt.allocPrint(alloc, "{s}/manifest.txt", .{replica_root}); defer alloc.free(manifest_path); - try std.fs.cwd().makePath(replica_overlay); + try compat.cwd().makePath(replica_overlay); try writeFileAbsolute(manifest_path, self.parent_path); try copyDirFilesAbsolute(self.overlay_dir, replica_overlay); @@ -236,7 +228,7 @@ fn branchNameFromRoot(branch_root: []const u8) []const u8 { } fn copyDirFilesAbsolute(src_dir: []const u8, dest_dir: []const u8) !void { - var src = try std.fs.openDirAbsolute(src_dir, .{ .iterate = true }); + var src = try compat.openDirAbsolute(src_dir, .{ .iterate = true }); defer src.close(); var it = src.iterate(); @@ -253,17 +245,28 @@ fn copyDirFilesAbsolute(src_dir: []const u8, dest_dir: []const u8) !void { } fn copyFileAbsolute(src_path: []const u8, dest_path: []const u8) !void { - var src_file = try std.fs.openFileAbsolute(src_path, .{}); + var src_file = try compat.openFileAbsolute(src_path, .{}); defer src_file.close(); - var dst_file = try std.fs.createFileAbsolute(dest_path, .{ .truncate = true }); + var dst_file = try compat.createFileAbsolute(dest_path, .{ .truncate = true }); defer dst_file.close(); const stat = try src_file.stat(); - _ = try src_file.copyRangeAll(0, dst_file, 0, stat.size); + const buf_len: usize = @min(@as(usize, 64 * 1024), @max(@as(usize, 1), @as(usize, @intCast(stat.size)))); + const buf = try std.heap.page_allocator.alloc(u8, buf_len); + defer std.heap.page_allocator.free(buf); + + var remaining = stat.size; + while (remaining > 0) { + const want: usize = @intCast(@min(remaining, buf.len)); + const n = try src_file.read(buf[0..want]); + if (n == 0) return error.UnexpectedEof; + try dst_file.writeAll(buf[0..n]); + remaining -= @intCast(n); + } } fn writeFileAbsolute(path: []const u8, content: []const u8) !void { - var file = try std.fs.createFileAbsolute(path, .{ .truncate = true }); + var file = try compat.createFileAbsolute(path, .{ .truncate = true }); defer file.close(); try file.writeAll(content); } @@ -271,9 +274,9 @@ fn writeFileAbsolute(path: []const u8, content: []const u8) !void { test "branched database reads from base and writes to overlay" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_branching_test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const base = try Database.open(alloc, tmp_dir); defer base.close(); @@ -296,9 +299,9 @@ test "branched database reads from base and writes to overlay" { test "published snapshot retains branch source metadata" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_branch_publish"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const base = try Database.open(alloc, tmp_dir); defer base.close(); @@ -317,11 +320,11 @@ test "published snapshot materializes embedded replica state" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_branch_replica"; const replica_root = "/tmp/turbodb_branch_replica_embedded"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - std.fs.cwd().deleteTree(replica_root) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(replica_root) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(replica_root) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(replica_root) catch {}; const base = try Database.open(alloc, tmp_dir); defer base.close(); diff --git a/src/btree.zig b/src/btree.zig index 14f4fca..137dff9 100644 --- a/src/btree.zig +++ b/src/btree.zig @@ -11,6 +11,7 @@ /// BTreeEntry = {key_hash u64, page_no u32, page_off u16, doc_id u64} = 22 bytes /// max entries per leaf = (PAGE_USABLE - 2) / 22 = 184 const std = @import("std"); +const compat = @import("compat"); const page_mod = @import("page.zig"); const PageFile = page_mod.PageFile; const PAGE_USABLE = page_mod.PAGE_USABLE; @@ -37,7 +38,7 @@ const MIN_KEYS: usize = MAX_KEYS_PER_INTERNAL / 2; pub const BTree = struct { pf: *PageFile, root: u32, // root page number (0 = not yet created) - mu: std.Thread.RwLock, + mu: compat.RwLock, pub fn init(pf: *PageFile, root_page: u32) BTree { return .{ .pf = pf, .root = root_page, .mu = .{} }; diff --git a/src/bwtree.zig b/src/bwtree.zig index 2789e13..02b212a 100644 --- a/src/bwtree.zig +++ b/src/bwtree.zig @@ -7,6 +7,7 @@ /// Insert/delete prepend a delta record via CAS. /// Consolidation merges delta chain into a new base page when chain > 8. const std = @import("std"); +const compat = @import("compat"); // ─── Entry (22 bytes packed) ───────────────────────────────────────────── @@ -51,7 +52,7 @@ pub const BwTree = struct { // Deferred reclamation: old chains retired after consolidation are parked here // and freed on the next consolidation or deinit (simple two-phase approach). retired: std.ArrayList(*Page), - retired_mu: std.Thread.Mutex, + retired_mu: compat.Mutex, pub fn init(allocator: std.mem.Allocator) !BwTree { var tree: BwTree = undefined; diff --git a/src/cdc.zig b/src/cdc.zig index 73e50c8..57f6a24 100644 --- a/src/cdc.zig +++ b/src/cdc.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const crypto = @import("crypto.zig"); pub const Op = enum(u8) { @@ -102,8 +103,8 @@ pub const CDCManager = struct { deliveries: std.ArrayList(Delivery), next_subscription_id: std.atomic.Value(u64), next_seq: std.atomic.Value(u64), - mu: std.Thread.Mutex, - cond: std.Thread.Condition, + mu: compat.Mutex, + cond: compat.Condition, running: std.atomic.Value(bool), worker: ?std.Thread, @@ -279,7 +280,7 @@ test "cdc filters by tenant and collection and signs payload" { cdc.emit("tenant-a", "users", "u1", "{\"name\":\"alice\"}", 1, .insert); cdc.emit("tenant-a", "orders", "o1", "{\"id\":1}", 2, .insert); cdc.emit("tenant-b", "users", "u2", "{\"name\":\"bob\"}", 3, .update); - std.Thread.sleep(20_000_000); + compat.sleep(20_000_000); const a = try cdc.listDeliveries(alloc, "tenant-a"); defer alloc.free(a); @@ -304,7 +305,7 @@ test "cdc preserves event order under queueing" { cdc.emit("tenant-a", "users", "u1", "{\"v\":1}", 1, .insert); cdc.emit("tenant-a", "users", "u1", "{\"v\":2}", 1, .update); cdc.emit("tenant-a", "users", "u1", "", 1, .delete); - std.Thread.sleep(20_000_000); + compat.sleep(20_000_000); const deliveries = try cdc.listDeliveries(alloc, "tenant-a"); defer alloc.free(deliveries); diff --git a/src/client.zig b/src/client.zig index 5d93259..b4a057b 100644 --- a/src/client.zig +++ b/src/client.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const collection_mod = @import("collection.zig"); const doc_mod = @import("doc.zig"); @@ -15,7 +16,7 @@ pub const Db = struct { /// Open a TurboDB database at the given directory. pub fn open(alloc: std.mem.Allocator, data_dir: []const u8) !Db { // Ensure directory exists - std.fs.cwd().makeDir(data_dir) catch |e| switch (e) { + compat.cwd().makeDir(data_dir) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return e, }; @@ -107,7 +108,7 @@ test "embedded client: open, insert, get, search" { // Use a temp dir const tmp_dir = "/tmp/turbodb_client_test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var db = try Db.open(alloc, tmp_dir); defer db.close(); @@ -133,17 +134,17 @@ test "embedded client: open, insert, get, search" { try std.testing.expect(gone == null); // Cleanup - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } test "embedded client isolates tenants" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_client_tenants"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var db = try Db.open(alloc, tmp_dir); defer db.close(); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; _ = try db.insertForTenant("tenant-a", "users", "u1", "{\"name\":\"alice\"}"); _ = try db.insertForTenant("tenant-b", "users", "u1", "{\"name\":\"bob\"}"); diff --git a/src/codeindex.zig b/src/codeindex.zig index fc4f427..f8a9847 100644 --- a/src/codeindex.zig +++ b/src/codeindex.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); // ── Inverted word index ───────────────────────────────────── // Maps word → list of (path, line) hits. O(1) word lookup. @@ -15,7 +16,7 @@ pub const WordIndex = struct { file_words: std.StringHashMap(std.StringHashMap(void)), allocator: std.mem.Allocator, /// Mutex for concurrent access (background indexer writes, search reads). - mu: std.Thread.Mutex = .{}, + mu: compat.Mutex = .{}, /// Cap hits per word to bound memory. Common words ("the", "var", "if") /// accumulate thousands of hits — beyond this they waste memory for @@ -108,7 +109,7 @@ pub const WordIndex = struct { if (!gop.found_existing) { const duped_word = try self.allocator.dupe(u8, word); gop.key_ptr.* = duped_word; - gop.value_ptr.* = .{}; + gop.value_ptr.* = .empty; } // Skip overly common words to bound memory. @@ -277,14 +278,14 @@ pub const TrigramIndex = struct { /// When true, deinit frees the path strings in id_to_path (set by readFromDisk). owns_paths: bool = false, /// Mutex for concurrent access (background indexer writes, search reads). - mu: std.Thread.Mutex = .{}, + mu: compat.Mutex = .{}, pub fn init(allocator: std.mem.Allocator) TrigramIndex { return .{ .index = std.AutoHashMap(Trigram, PostingList).init(allocator), .file_trigrams = std.AutoHashMap(u32, std.ArrayList(Trigram)).init(allocator), .path_to_id = std.StringHashMap(u32).init(allocator), - .id_to_path = .{}, + .id_to_path = .empty, .allocator = allocator, }; } @@ -382,7 +383,7 @@ pub const TrigramIndex = struct { self.removeFileById(file_id); } - var tri_list: std.ArrayList(Trigram) = .{}; + var tri_list: std.ArrayList(Trigram) = .empty; errdefer tri_list.deinit(self.allocator); try tri_list.ensureTotalCapacity(self.allocator, unique_tris.count()); @@ -393,7 +394,7 @@ pub const TrigramIndex = struct { const idx_gop = try self.index.getOrPut(tri); if (!idx_gop.found_existing) { - idx_gop.value_ptr.* = .{}; + idx_gop.value_ptr.* = .empty; } postingSortedInsert(idx_gop.value_ptr, self.allocator, .{ .file_id = file_id, .mask = PostingMask{ .loc_mask = 0xFF, .next_mask = 0xFF } }); } @@ -460,7 +461,7 @@ pub const TrigramIndex = struct { self.removeFileById(file_id); } - var tri_list: std.ArrayList(Trigram) = .{}; + var tri_list: std.ArrayList(Trigram) = .empty; tri_list.ensureTotalCapacity(self.allocator, tris.len) catch continue; for (tris) |tri| { @@ -468,7 +469,7 @@ pub const TrigramIndex = struct { const idx_gop = self.index.getOrPut(tri) catch continue; if (!idx_gop.found_existing) { - idx_gop.value_ptr.* = .{}; + idx_gop.value_ptr.* = .empty; } postingSortedInsert(idx_gop.value_ptr, self.allocator, .{ .file_id = file_id, .mask = PostingMask{ .loc_mask = 0xFF, .next_mask = 0xFF } }); } @@ -496,7 +497,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All _ = unique.getOrPut(tri) catch return null; } - var sets: std.ArrayList(*PostingList) = .{}; + var sets: std.ArrayList(*PostingList) = .empty; defer sets.deinit(allocator); sets.ensureTotalCapacity(allocator, unique.count()) catch return null; @@ -523,7 +524,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All } } - var result: std.ArrayList([]const u8) = .{}; + var result: std.ArrayList([]const u8) = .empty; errdefer result.deinit(allocator); result.ensureTotalCapacity(allocator, min_count) catch return null; @@ -663,7 +664,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All if (result_set == null) return null; // Convert file_ids to paths - var result: std.ArrayList([]const u8) = .{}; + var result: std.ArrayList([]const u8) = .empty; errdefer result.deinit(allocator); result.ensureTotalCapacity(allocator, result_set.?.count()) catch return null; var it = result_set.?.keyIterator(); @@ -761,7 +762,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All defer self.allocator.free(postings_final); { - const file = try std.fs.cwd().createFile(postings_tmp, .{}); + const file = try compat.cwd().createFile(postings_tmp, .{}); defer file.close(); // Header v3: magic(4) + version(2) + file_count(4) + head_len(1) + head(40) = 51 bytes @@ -793,7 +794,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All const postings_bytes = std.mem.sliceAsBytes(postings_buf.items); try file.writeAll(postings_bytes); } - try std.fs.cwd().rename(postings_tmp, postings_final); + try compat.cwd().rename(postings_tmp, postings_final); // Step 5: Write lookup file atomically (random suffix prevents collisions) const lk_rand = std.crypto.random.int(u64); @@ -803,7 +804,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All defer self.allocator.free(lookup_final); { - const file = try std.fs.cwd().createFile(lookup_tmp, .{}); + const file = try compat.cwd().createFile(lookup_tmp, .{}); defer file.close(); // Header: magic(4) + version(2) + pad(2) + entry_count(4) = 12 bytes @@ -821,7 +822,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All const entry_bytes = std.mem.sliceAsBytes(lookup_entries.items); try file.writeAll(entry_bytes); } - try std.fs.cwd().rename(lookup_tmp, lookup_final); + try compat.cwd().rename(lookup_tmp, lookup_final); } /// Load index from disk files into a fresh TrigramIndex. @@ -837,9 +838,9 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All defer allocator.free(lookup_path); // Read both files - const postings_data = std.fs.cwd().readFileAlloc(allocator, postings_path, 64 * 1024 * 1024) catch return null; + const postings_data = compat.cwd().readFileAlloc(allocator, postings_path, 64 * 1024 * 1024) catch return null; defer allocator.free(postings_data); - const lookup_data = std.fs.cwd().readFileAlloc(allocator, lookup_path, 64 * 1024 * 1024) catch return null; + const lookup_data = compat.cwd().readFileAlloc(allocator, lookup_path, 64 * 1024 * 1024) catch return null; defer allocator.free(lookup_data); // Validate postings header (v1: 8 bytes, v2: 49 bytes, v3: 51 bytes) @@ -979,7 +980,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All const postings_path = try std.fmt.allocPrint(allocator, "{s}/trigram.postings", .{dir_path}); defer allocator.free(postings_path); - const file = std.fs.cwd().openFile(postings_path, .{}) catch return null; + const file = compat.cwd().openFile(postings_path, .{}) catch return null; defer file.close(); var buf: [51]u8 = undefined; @@ -1428,7 +1429,7 @@ fn finishFrequencyTable(counts: *const [256][256]u64) [256][256]u16 { /// Persist a frequency table as a raw binary blob to `/pair_freq.bin`. /// Uses tmp+rename for atomic writes. pub fn writeFrequencyTable(table: *const [256][256]u16, dir_path: []const u8) !void { - var dir = try std.fs.cwd().openDir(dir_path, .{}); + var dir = try compat.cwd().openDir(dir_path, .{}); defer dir.close(); { const tmp = try dir.createFile("pair_freq.bin.tmp", .{}); @@ -1450,7 +1451,7 @@ pub fn writeFrequencyTable(table: *const [256][256]u16, dir_path: []const u8) !v pub fn readFrequencyTable(dir_path: []const u8, allocator: std.mem.Allocator) !?*[256][256]u16 { const path = try std.fmt.allocPrint(allocator, "{s}/pair_freq.bin", .{dir_path}); defer allocator.free(path); - const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) { + const file = compat.cwd().openFile(path, .{}) catch |err| switch (err) { error.FileNotFound => return null, else => return err, }; @@ -1695,7 +1696,7 @@ pub const SparseNgramIndex = struct { return allocator.alloc([]const u8, 0) catch null; } - var result: std.ArrayList([]const u8) = .{}; + var result: std.ArrayList([]const u8) = .empty; errdefer result.deinit(allocator); result.ensureTotalCapacity(allocator, seen_files.count()) catch return null; diff --git a/src/collection.zig b/src/collection.zig index ef9beba..354b796 100644 --- a/src/collection.zig +++ b/src/collection.zig @@ -1,5 +1,6 @@ /// TurboDB — Collection (MVCC document store + query engine) const std = @import("std"); +const compat = @import("compat"); const doc_mod = @import("doc.zig"); const page_mod = @import("page.zig"); const btree_mod = @import("btree.zig"); @@ -26,6 +27,15 @@ pub const DEFAULT_TENANT = "default"; pub const MAX_TENANT_ID_LEN: usize = 64; pub const MAX_COLLECTION_NAME_LEN: usize = 64; +fn writeCommittedDocumentWal(wal_log: *WAL, op: wal_mod.OpCode, payload: []const u8) !void { + const txn = wal_log.next_lsn.load(.monotonic); + if (comptime @hasDecl(WAL, "writeCommitted")) { + _ = try wal_log.writeCommitted(txn, op, wal_mod.DB_TAG_DOC, payload); + } else { + _ = try wal_log.write(txn, op, wal_mod.DB_TAG_DOC, wal_mod.FLAG_COMMIT, payload); + } +} + // ─── IndexQueue ────────────────────────────────────────────────────────── // Lock-free MPSC queue for deferring trigram+word index builds to a background thread. // Insert path pushes owned (key, value) pairs; background thread pops and indexes. @@ -178,7 +188,10 @@ pub const Collection = struct { next_doc_id: std.atomic.Value(u64), // Record-level latching: 1024 stripe locks replace single write_mu. // Two writers to different keys proceed in parallel; same-key writes serialize. - stripe_locks: [STRIPE_COUNT]std.Thread.Mutex, + stripe_locks: [STRIPE_COUNT]compat.Mutex, + // Protects shared page/index/MVCC metadata that is not internally thread-safe. + meta_mu: compat.Mutex, + cache_mu: compat.Mutex, hash_idx: std.AutoHashMap(u64, BTreeEntry), key_doc_ids: std.AutoHashMap(u64, u64), /// Per-key modification epoch — tracks when each key was last written on main. @@ -240,6 +253,8 @@ pub const Collection = struct { for (&col.stripe_locks) |*lock| { lock.* = .{}; } + col.meta_mu = .{}; + col.cache_mu = .{}; col.hash_idx = std.AutoHashMap(u64, BTreeEntry).init(alloc); col.key_doc_ids = std.AutoHashMap(u64, u64).init(alloc); col.key_epochs = std.AutoHashMap(u64, u64).init(alloc); @@ -305,6 +320,9 @@ pub const Collection = struct { } pub fn configureVectors(self: *Collection, dims: u32, field_name: []const u8) !void { + self.meta_mu.lock(); + defer self.meta_mu.unlock(); + if (self.vectors) |old| { old.deinit(self.alloc); self.alloc.destroy(old); @@ -325,7 +343,7 @@ pub const Collection = struct { pub fn flushIndex(self: *Collection) void { var waited: u32 = 0; while ((self.index_queue.len() > 0 or self.index_queue2.len() > 0 or self.indexing_count.load(.acquire) > 0) and waited < 300_000) : (waited += 1) { - std.Thread.sleep(100_000); // 100µs + compat.sleep(100_000); // 100µs } } @@ -377,86 +395,90 @@ pub const Collection = struct { }; const enc = try d.encodeBuf(buf); - // Write to WAL buffer (background flusher will commit periodically). - const txn = self.wal_log.next_lsn.load(.monotonic); - _ = try self.wal_log.write(txn, .doc_insert, 0, 0, enc); - - // Find (or allocate) a leaf page with enough space. - const pno = try self.findOrAllocLeaf(enc.len); - const page_off = self.pf.leafAppend(pno, enc) orelse - return error.PageFull; - - // Index the document. - const entry = BTreeEntry{ - .key_hash = hdr.key_hash, - .doc_id = doc_id, - .page_no = pno, - .page_off = page_off, - }; - try self.idx.insert(entry); - self.hash_idx.put(hdr.key_hash, entry) catch {}; - - // MVCC: register version in the version chain. - const epoch = self.epochs.advance(); - self.versions.appendVersion(self.alloc, doc_id, pno, page_off, epoch) catch {}; - self.key_doc_ids.put(hdr.key_hash, doc_id) catch {}; - self.key_epochs.put(hdr.key_hash, doc_id) catch {}; // epoch = doc_id (monotonic) - - // Async trigram + word indexing — push to background queue, sync fallback if full or no worker. - if (value.len >= 3) { - if (self.index_thread == null) { - // No background worker — index synchronously to avoid losing data. - self.tri.indexFile(key, value) catch {}; - self.words.indexFile(key, value) catch {}; - } else { - const owned_key = self.alloc.dupe(u8, key) catch null; - const owned_val = self.alloc.dupe(u8, value) catch null; - if (owned_key != null and owned_val != null) { - const q = &self.index_queue; - var retries: u32 = 0; - while (!q.push(owned_key.?, owned_val.?)) { - retries += 1; - if (retries > 1000) { - // Queue full — fall back to synchronous indexing. - self.tri.indexFile(owned_key.?, owned_val.?) catch {}; - self.words.indexFile(owned_key.?, owned_val.?) catch {}; - self.alloc.free(owned_key.?); - self.alloc.free(owned_val.?); - break; + // Single-entry document writes are self-committed for recovery. + try writeCommittedDocumentWal(self.wal_log, .doc_insert, enc); + + self.meta_mu.lock(); + { + defer self.meta_mu.unlock(); + + // Find (or allocate) a leaf page with enough space. + const pno = try self.findOrAllocLeaf(enc.len); + const page_off = self.pf.leafAppend(pno, enc) orelse + return error.PageFull; + + // Index the document. + const entry = BTreeEntry{ + .key_hash = hdr.key_hash, + .doc_id = doc_id, + .page_no = pno, + .page_off = page_off, + }; + try self.idx.insert(entry); + self.hash_idx.put(hdr.key_hash, entry) catch {}; + + // MVCC: register version in the version chain. + const epoch = self.epochs.advance(); + self.versions.appendVersion(self.alloc, doc_id, pno, page_off, epoch) catch {}; + self.key_doc_ids.put(hdr.key_hash, doc_id) catch {}; + self.key_epochs.put(hdr.key_hash, doc_id) catch {}; // epoch = doc_id (monotonic) + + // Async trigram + word indexing — push to background queue, sync fallback if full or no worker. + if (value.len >= 3) { + if (self.index_thread == null) { + // No background worker — index synchronously to avoid losing data. + self.tri.indexFile(key, value) catch {}; + self.words.indexFile(key, value) catch {}; + } else { + const owned_key = self.alloc.dupe(u8, key) catch null; + const owned_val = self.alloc.dupe(u8, value) catch null; + if (owned_key != null and owned_val != null) { + const q = &self.index_queue; + var retries: u32 = 0; + while (!q.push(owned_key.?, owned_val.?)) { + retries += 1; + if (retries > 1000) { + // Queue full — fall back to synchronous indexing. + self.tri.indexFile(owned_key.?, owned_val.?) catch {}; + self.words.indexFile(owned_key.?, owned_val.?) catch {}; + self.alloc.free(owned_key.?); + self.alloc.free(owned_val.?); + break; + } + std.Thread.yield() catch {}; } - std.Thread.yield() catch {}; } } } - } - // Extract and store vector embedding if configured. - // Uses stack buffer to avoid heap allocation per insert. - if (self.vectors) |vc| { - const field = self.vector_field[0..self.vector_field_len]; - const dims: usize = vc.dims; - var embed_stack: [4096]f32 = undefined; - const emb = if (dims <= 4096) embed_stack[0..dims] else blk: { - break :blk self.alloc.alloc(f32, dims) catch null; - }; - if (emb) |e| { - defer if (dims > 4096) self.alloc.free(e); - if (extractJsonFloatArray(value, field, e)) |count| { - if (count == dims) { - vc.append(self.alloc, e) catch {}; - self.vec_entries.append(self.alloc, entry) catch {}; + // Extract and store vector embedding if configured. + // Uses stack buffer to avoid heap allocation per insert. + if (self.vectors) |vc| { + const field = self.vector_field[0..self.vector_field_len]; + const dims: usize = vc.dims; + var embed_stack: [4096]f32 = undefined; + const emb = if (dims <= 4096) embed_stack[0..dims] else blk: { + break :blk self.alloc.alloc(f32, dims) catch null; + }; + if (emb) |e| { + defer if (dims > 4096) self.alloc.free(e); + if (extractJsonFloatArray(value, field, e)) |count| { + if (count == dims) { + vc.append(self.alloc, e) catch {}; + self.vec_entries.append(self.alloc, entry) catch {}; + } } } } + + // Periodic MVCC version chain GC to prevent unbounded memory growth. + if (self.gc_counter.fetchAdd(1, .monotonic) % GC_INTERVAL == GC_INTERVAL - 1) { + _ = self.versions.gc(self.alloc); + } } emitChange(self, .insert, key, value, doc_id); - // Periodic MVCC version chain GC to prevent unbounded memory growth. - if (self.gc_counter.fetchAdd(1, .monotonic) % GC_INTERVAL == GC_INTERVAL - 1) { - _ = self.gcVersions(); - } - return doc_id; } @@ -466,6 +488,8 @@ pub const Collection = struct { const doc_id = self.insert(key, value) catch |e| return e; // insert() already handles vector extraction from JSON, but if embedding // was passed directly AND the JSON extraction didn't find it, append now. + self.meta_mu.lock(); + defer self.meta_mu.unlock(); if (self.vectors) |vc| { // Check if insert() already appended (vec_entries grew) // vec_entries.len should equal vc.count after insert if JSON had embedding @@ -490,28 +514,39 @@ pub const Collection = struct { /// The Doc's key/value slices are valid until the next write to this collection. pub fn get(self: *Collection, key: []const u8) ?Doc { const key_hash = doc_mod.fnv1a(key); + self.meta_mu.lock(); + defer self.meta_mu.unlock(); // L1: Hot cache (O(1), no hash table overhead) - if (self.cache.lookup(key_hash)) |loc| { + self.cache_mu.lock(); + const cached = self.cache.lookup(key_hash); + self.cache_mu.unlock(); + if (cached) |loc| { if (self.readLoc(loc.page_no, loc.page_off)) |d| return d; } // L2: Hash index (O(1), but hash table lookup) if (self.hash_idx.get(key_hash)) |entry| { if (self.readEntry(entry)) |d| { + self.cache_mu.lock(); self.cache.insert(key_hash, entry.page_no, entry.page_off); + self.cache_mu.unlock(); return d; } } // L3: B-tree (O(log n)) const entry = self.idx.search(key_hash) orelse return null; const d = self.readEntry(entry) orelse return null; + self.cache_mu.lock(); self.cache.insert(key_hash, entry.page_no, entry.page_off); + self.cache_mu.unlock(); return d; } pub fn getAsOfEpoch(self: *Collection, key: []const u8, epoch: u64) ?Doc { const key_hash = doc_mod.fnv1a(key); + self.meta_mu.lock(); + defer self.meta_mu.unlock(); const doc_id = self.key_doc_ids.get(key_hash) orelse return null; const ver = self.versions.getAtEpoch(doc_id, epoch) orelse return null; return self.readLoc(ver.page_no, ver.page_off); @@ -524,6 +559,8 @@ pub const Collection = struct { /// Look up a document by doc_id using a linear scan of the index. pub fn getById(self: *Collection, doc_id: u64) ?Doc { + self.meta_mu.lock(); + defer self.meta_mu.unlock(); const total_pages = self.pf.next_alloc.load(.acquire); var pno: u32 = 0; while (pno < total_pages) : (pno += 1) { @@ -553,37 +590,44 @@ pub const Collection = struct { self.stripe_locks[stripe].lock(); defer self.stripe_locks[stripe].unlock(); - const old_entry = self.idx.search(key_hash) orelse return false; - const old_doc = self.readEntry(old_entry) orelse return false; + var doc_id: u64 = undefined; + { + self.meta_mu.lock(); + defer self.meta_mu.unlock(); - const doc_id = old_doc.header.doc_id; - var new_hdr = doc_mod.newHeader(doc_id, key, new_value); - new_hdr.version = old_doc.header.version +% 1; - // MVCC: encode old location into next_ver field. - new_hdr.next_ver = (@as(u64, old_entry.page_no) << 16) | old_entry.page_off; + const old_entry = self.idx.search(key_hash) orelse return false; + const old_doc = self.readEntry(old_entry) orelse return false; - const d = Doc{ .header = new_hdr, .key = key, .value = new_value }; - var enc_buf: [65536]u8 = undefined; - const enc = try d.encodeBuf(&enc_buf); + doc_id = old_doc.header.doc_id; + var new_hdr = doc_mod.newHeader(doc_id, key, new_value); + new_hdr.version = old_doc.header.version +% 1; + // MVCC: encode old location into next_ver field. + new_hdr.next_ver = (@as(u64, old_entry.page_no) << 16) | old_entry.page_off; - const txn = self.wal_log.next_lsn.load(.monotonic); - _ = try self.wal_log.write(txn, .doc_update, 0, 0, enc); + const d = Doc{ .header = new_hdr, .key = key, .value = new_value }; + var enc_buf: [65536]u8 = undefined; + const enc = try d.encodeBuf(&enc_buf); - const pno = try self.findOrAllocLeaf(enc.len); - const page_off = self.pf.leafAppend(pno, enc) orelse return error.PageFull; + try writeCommittedDocumentWal(self.wal_log, .doc_update, enc); - const new_entry = BTreeEntry{ - .key_hash = key_hash, - .doc_id = doc_id, - .page_no = pno, - .page_off = page_off, - }; - self.hash_idx.put(key_hash, new_entry) catch {}; - self.cache.invalidate(key_hash); + const pno = try self.findOrAllocLeaf(enc.len); + const page_off = self.pf.leafAppend(pno, enc) orelse return error.PageFull; - // MVCC: register new version in the version chain (links to old automatically). - const epoch = self.epochs.advance(); - self.versions.appendVersion(self.alloc, doc_id, pno, page_off, epoch) catch {}; + const new_entry = BTreeEntry{ + .key_hash = key_hash, + .doc_id = doc_id, + .page_no = pno, + .page_off = page_off, + }; + self.hash_idx.put(key_hash, new_entry) catch {}; + self.cache_mu.lock(); + self.cache.invalidate(key_hash); + self.cache_mu.unlock(); + + // MVCC: register new version in the version chain (links to old automatically). + const epoch = self.epochs.advance(); + self.versions.appendVersion(self.alloc, doc_id, pno, page_off, epoch) catch {}; + } emitChange(self, .update, key, new_value, doc_id); return true; @@ -597,26 +641,34 @@ pub const Collection = struct { self.stripe_locks[stripe].lock(); defer self.stripe_locks[stripe].unlock(); - const entry = self.idx.search(key_hash) orelse return false; - - const old_doc = self.readEntry(entry) orelse return false; - var tomb_hdr = doc_mod.newHeader(old_doc.header.doc_id, key, ""); - tomb_hdr.flags |= DocHeader.DELETED; - tomb_hdr.version = old_doc.header.version +% 1; - tomb_hdr.next_ver = (@as(u64, entry.page_no) << 16) | entry.page_off; - const txn = self.wal_log.next_lsn.load(.monotonic); - _ = try self.wal_log.write(txn, .doc_delete, 0, 0, std.mem.asBytes(&tomb_hdr)); - const tomb_doc = Doc{ .header = tomb_hdr, .key = key, .value = "" }; - var enc_buf: [65536]u8 = undefined; - const enc = try tomb_doc.encodeBuf(&enc_buf); - const pno = try self.findOrAllocLeaf(enc.len); - const page_off = self.pf.leafAppend(pno, enc) orelse return error.PageFull; - const epoch = self.epochs.advance(); - self.versions.appendVersion(self.alloc, old_doc.header.doc_id, pno, page_off, epoch) catch {}; - self.idx.delete(key_hash); - _ = self.hash_idx.remove(key_hash); - self.cache.invalidate(key_hash); - emitChange(self, .delete, key, "", old_doc.header.doc_id); + var doc_id: u64 = undefined; + { + self.meta_mu.lock(); + defer self.meta_mu.unlock(); + + const entry = self.idx.search(key_hash) orelse return false; + + const old_doc = self.readEntry(entry) orelse return false; + doc_id = old_doc.header.doc_id; + var tomb_hdr = doc_mod.newHeader(doc_id, key, ""); + tomb_hdr.flags |= DocHeader.DELETED; + tomb_hdr.version = old_doc.header.version +% 1; + tomb_hdr.next_ver = (@as(u64, entry.page_no) << 16) | entry.page_off; + const tomb_doc = Doc{ .header = tomb_hdr, .key = key, .value = "" }; + var enc_buf: [65536]u8 = undefined; + const enc = try tomb_doc.encodeBuf(&enc_buf); + try writeCommittedDocumentWal(self.wal_log, .doc_delete, enc); + const pno = try self.findOrAllocLeaf(enc.len); + const page_off = self.pf.leafAppend(pno, enc) orelse return error.PageFull; + const epoch = self.epochs.advance(); + self.versions.appendVersion(self.alloc, doc_id, pno, page_off, epoch) catch {}; + self.idx.delete(key_hash); + _ = self.hash_idx.remove(key_hash); + self.cache_mu.lock(); + self.cache.invalidate(key_hash); + self.cache_mu.unlock(); + } + emitChange(self, .delete, key, "", doc_id); return true; } @@ -804,9 +856,16 @@ pub const Collection = struct { offset: u32, alloc: std.mem.Allocator, ) !ScanResult { + if (limit == 0) { + return ScanResult{ .docs = try alloc.alloc(Doc, 0), .alloc = alloc }; + } + var results: std.ArrayList(Doc) = .empty; errdefer results.deinit(alloc); + self.meta_mu.lock(); + defer self.meta_mu.unlock(); + const total_pages = self.pf.next_alloc.load(.acquire); var skipped: u32 = 0; var pno: u32 = 0; @@ -830,9 +889,16 @@ pub const Collection = struct { } pub fn scanAsOfEpoch(self: *Collection, epoch: u64, limit: u32, offset: u32, alloc: std.mem.Allocator) !ScanResult { + if (limit == 0) { + return ScanResult{ .docs = try alloc.alloc(Doc, 0), .alloc = alloc }; + } + var results: std.ArrayList(Doc) = .empty; errdefer results.deinit(alloc); + self.meta_mu.lock(); + defer self.meta_mu.unlock(); + var skipped: u32 = 0; var it = self.key_doc_ids.iterator(); while (it.next()) |entry| { @@ -1476,7 +1542,7 @@ pub const Database = struct { data_dir_buf: [256]u8, data_dir_len: usize, alloc: std.mem.Allocator, - mu: std.Thread.RwLock, + mu: compat.RwLock, auth: @import("auth.zig").AuthStore, pub const TenantQuota = struct { @@ -1609,7 +1675,7 @@ pub const Database = struct { const storage_name = makeStorageName(&storage_name_buf, tenant_id, name) catch return; var path_buf: [512]u8 = undefined; const page_path = std.fmt.bufPrint(&path_buf, "{s}/{s}.pages", .{ self.dataDir(), storage_name }) catch return; - std.fs.cwd().deleteFile(page_path) catch {}; + compat.cwd().deleteFile(page_path) catch {}; } pub fn listCollectionsForTenant(self: *Database, tenant_id: []const u8, alloc: std.mem.Allocator) !std.ArrayList([]const u8) { @@ -1659,7 +1725,7 @@ pub const Database = struct { const quota = self.tenant_quotas.get(tenant_id) orelse return; if (quota.max_ops_per_second == std.math.maxInt(u32)) return; - const now_ms: i64 = std.time.milliTimestamp(); + const now_ms: i64 = compat.milliTimestamp(); const window_ms = now_ms - @mod(now_ms, 1000); const usage = try self.getOrCreateTenantUsageLocked(tenant_id); if (usage.ops_window_ms != window_ms) { @@ -1745,7 +1811,7 @@ fn validateCollectionComponent(name: []const u8) !void { } fn makeStorageName(buf: []u8, tenant_id: []const u8, collection_name: []const u8) ![]const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); try appendSanitizedComponent(w, tenant_id); try w.writeAll("__"); @@ -1765,32 +1831,92 @@ fn appendSanitizedComponent(writer: anytype, input: []const u8) !void { fn ensureDataDir(alloc: std.mem.Allocator, data_dir: []const u8) ![]u8 { try ensureDirPath(data_dir); - return try std.fs.realpathAlloc(alloc, data_dir); + return blk: { + var buf: [4096]u8 = undefined; + var z_buf: [4096]u8 = undefined; + @memcpy(z_buf[0..data_dir.len], data_dir); + z_buf[data_dir.len] = 0; + const rp = std.c.realpath(@ptrCast(&z_buf), @ptrCast(&buf)); + if (rp == null) break :blk error.FileNotFound; + const len = std.mem.indexOfScalar(u8, &buf, 0) orelse buf.len; + break :blk try alloc.dupe(u8, buf[0..len]); + }; } fn ensureDirPath(data_dir: []const u8) !void { - if (std.fs.path.isAbsolute(data_dir)) { - var root = try std.fs.openDirAbsolute("/", .{}); - defer root.close(); - const rel = std.mem.trimLeft(u8, data_dir, "/"); - if (rel.len > 0) root.makePath(rel) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; - } else { - std.fs.cwd().makePath(data_dir) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; - } + compat.cwd().makePath(data_dir) catch return error.MkdirError; } +const CollectionWalReplayProbe = struct { + const MAX_ENTRIES = 8; + + var count: usize = 0; + var ops: [MAX_ENTRIES]wal_mod.OpCode = undefined; + var flags: [MAX_ENTRIES]u16 = undefined; + var delete_payload: [256]u8 = undefined; + var delete_payload_len: usize = 0; + + fn reset() void { + count = 0; + delete_payload_len = 0; + } + + fn apply(entry: wal_mod.Entry) !void { + switch (entry.op_code) { + .doc_insert, .doc_update, .doc_delete => {}, + else => return, + } + + if (count >= MAX_ENTRIES) return error.TooManyCollectionWalEntries; + ops[count] = entry.op_code; + flags[count] = entry.flags; + count += 1; + + if (entry.op_code == .doc_delete) { + if (entry.payload.len > delete_payload.len) return error.DeletePayloadTooLarge; + delete_payload_len = entry.payload.len; + @memcpy(delete_payload[0..delete_payload_len], entry.payload); + } + } + + fn deletePayload() []const u8 { + return delete_payload[0..delete_payload_len]; + } +}; + +const ConcurrentInsertProbe = struct { + const inserts_per_worker = 128; + + col: *Collection, + worker_id: usize, + errors: *std.atomic.Value(u32), + + fn run(self: *ConcurrentInsertProbe) void { + var i: usize = 0; + while (i < inserts_per_worker) : (i += 1) { + var key_buf: [64]u8 = undefined; + var value_buf: [96]u8 = undefined; + const key = std.fmt.bufPrint(&key_buf, "worker-{d}-doc-{d}", .{ self.worker_id, i }) catch { + _ = self.errors.fetchAdd(1, .monotonic); + continue; + }; + const value = std.fmt.bufPrint(&value_buf, "{{\"worker\":{d},\"doc\":{d}}}", .{ self.worker_id, i }) catch { + _ = self.errors.fetchAdd(1, .monotonic); + continue; + }; + _ = self.col.insert(key, value) catch { + _ = self.errors.fetchAdd(1, .monotonic); + }; + } + } +}; + test "tenant collections are isolated" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_multi_tenant_test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1810,9 +1936,9 @@ test "tenant collections are isolated" { test "tenant collection quota limits new collections" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_tenant_quota_test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1825,9 +1951,9 @@ test "tenant collection quota limits new collections" { test "tenant ops quota is enforced per second" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_tenant_ops_test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1838,12 +1964,89 @@ test "tenant ops quota is enforced per second" { try std.testing.expectError(error.TenantOpsQuotaExceeded, db.recordTenantOperation("tenant-a")); } +test "collection WAL replays committed document writes and full tombstone" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_collection_wal_committed"; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + { + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + + const col = try db.collection("users"); + _ = try col.insert("u1", "{\"name\":\"alice\"}"); + try std.testing.expect(try col.update("u1", "{\"name\":\"alice-v2\"}")); + try std.testing.expect(try col.delete("u1")); + } + + var path_buf: [512]u8 = undefined; + const wal_path = try std.fmt.bufPrintZ(&path_buf, "{s}/doc.wal", .{tmp_dir}); + var wal = try WAL.open(wal_path, alloc); + defer wal.close(); + + CollectionWalReplayProbe.reset(); + try wal.recover(0, CollectionWalReplayProbe.apply, alloc); + + try std.testing.expectEqual(@as(usize, 3), CollectionWalReplayProbe.count); + try std.testing.expectEqual(wal_mod.OpCode.doc_insert, CollectionWalReplayProbe.ops[0]); + try std.testing.expectEqual(wal_mod.OpCode.doc_update, CollectionWalReplayProbe.ops[1]); + try std.testing.expectEqual(wal_mod.OpCode.doc_delete, CollectionWalReplayProbe.ops[2]); + for (CollectionWalReplayProbe.flags[0..CollectionWalReplayProbe.count]) |flags| { + try std.testing.expect(flags & wal_mod.FLAG_COMMIT != 0); + } + + const decoded = try doc_mod.decode(CollectionWalReplayProbe.deletePayload()); + try std.testing.expect(decoded.doc.isDeleted()); + try std.testing.expectEqualStrings("u1", decoded.doc.key); + try std.testing.expectEqualStrings("", decoded.doc.value); + try std.testing.expectEqual(DocHeader.size + "u1".len, decoded.consumed); +} + +test "collection concurrent inserts keep shared indexes stable" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_collection_concurrent_insert"; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + + const col = try db.collection("load"); + const worker_count = 8; + var errors = std.atomic.Value(u32).init(0); + var probes: [worker_count]ConcurrentInsertProbe = undefined; + var threads: [worker_count]std.Thread = undefined; + + for (&probes, 0..) |*probe, i| { + probe.* = .{ + .col = col, + .worker_id = i, + .errors = &errors, + }; + threads[i] = try std.Thread.spawn(.{}, ConcurrentInsertProbe.run, .{probe}); + } + for (threads) |thread| thread.join(); + + try std.testing.expectEqual(@as(u32, 0), errors.load(.acquire)); + col.flushIndex(); + + const expected = worker_count * ConcurrentInsertProbe.inserts_per_worker; + const scan = try col.scan(expected, 0, alloc); + defer scan.deinit(); + try std.testing.expectEqual(@as(usize, expected), scan.docs.len); + try std.testing.expect(col.get("worker-0-doc-0") != null); + try std.testing.expect(col.get("worker-7-doc-127") != null); +} + test "time travel get returns historical version after update" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_time_travel_update"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1861,9 +2064,9 @@ test "time travel get returns historical version after update" { test "time travel get survives delete" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_time_travel_delete"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1880,9 +2083,9 @@ test "time travel get survives delete" { test "time travel scan uses historical snapshot" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_time_travel_scan"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1902,9 +2105,9 @@ test "time travel scan uses historical snapshot" { test "cdc emits signed ordered deliveries for tenant mutations" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_cdc_integration"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1914,7 +2117,7 @@ test "cdc emits signed ordered deliveries for tenant mutations" { _ = try col.insert("u1", "{\"name\":\"alice\"}"); _ = try col.update("u1", "{\"name\":\"alice-2\"}"); try std.testing.expect(try col.delete("u1")); - std.Thread.sleep(20_000_000); + compat.sleep(20_000_000); const deliveries = try db.listWebhookDeliveries(alloc, "tenant-a"); defer alloc.free(deliveries); @@ -1929,9 +2132,9 @@ test "cdc emits signed ordered deliveries for tenant mutations" { test "tenant collections isolate keys and listings" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_tenant_isolation"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1956,12 +2159,30 @@ test "tenant collections isolate keys and listings" { try std.testing.expectEqualStrings("users", tenant_a_cols.items[0]); } +test "collection scan limit zero returns no documents" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_collection_scan_limit_zero"; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + + const col = try db.collection("users"); + _ = try col.insert("u1", "{\"name\":\"one\"}"); + + var result = try col.scan(0, 0, alloc); + defer result.deinit(); + try std.testing.expectEqual(@as(usize, 0), result.docs.len); +} + test "tenant quotas apply per tenant" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_tenant_quotas"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); diff --git a/src/compat.zig b/src/compat.zig new file mode 100644 index 0000000..6190053 --- /dev/null +++ b/src/compat.zig @@ -0,0 +1,814 @@ +/// Zig 0.16 compatibility layer for TurboDB +/// +/// Provides backward-compatible APIs for code written against Zig 0.15's +/// std.fs, std.Thread, std.io, and std.time. Uses POSIX/C functions directly +/// to avoid threading std.Io through the entire codebase. +const std = @import("std"); +const c = std.c; +const posix = std.posix; + +// ═══════════════════════════════════════════════════════════════════════════════ +// File — drop-in replacement for std.fs.File (0.15) +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const File = struct { + handle: posix.fd_t, + + pub const Reader = struct { + file: File, + + pub fn readAll(self: Reader, buf: []u8) !usize { + return self.file.readAll(buf); + } + + pub fn read(self: Reader, buf: []u8) !usize { + return self.file.read(buf); + } + }; + + pub const Writer = struct { + file: File, + + pub fn writeAll(self: Writer, data: []const u8) !void { + return self.file.writeAll(data); + } + + pub fn write(self: Writer, data: []const u8) !usize { + return self.file.writePartial(data); + } + + pub fn print(self: Writer, comptime fmt_str: []const u8, args: anytype) !void { + var buf: [4096]u8 = undefined; + const result = std.fmt.bufPrint(&buf, fmt_str, args) catch return error.NoSpaceLeft; + try self.writeAll(result); + } + }; + + pub const Stat = struct { + size: u64, + }; + + pub fn close(self: File) void { + _ = c.close(self.handle); + } + + pub fn writeAll(self: File, data: []const u8) !void { + var written: usize = 0; + while (written < data.len) { + const n = c.write(self.handle, data[written..].ptr, data[written..].len); + if (n < 0) return error.WriteError; + if (n == 0) return error.WriteError; + written += @intCast(n); + } + } + + pub fn readAll(self: File, buf: []u8) !usize { + var total: usize = 0; + while (total < buf.len) { + const n = c.read(self.handle, buf[total..].ptr, buf[total..].len); + if (n < 0) return error.ReadError; + if (n == 0) break; + total += @intCast(n); + } + return total; + } + + pub fn read(self: File, buf: []u8) !usize { + const n = c.read(self.handle, buf.ptr, buf.len); + if (n < 0) return error.ReadError; + return @intCast(n); + } + + pub fn write(self: File, data: []const u8) !usize { + const n = c.write(self.handle, data.ptr, data.len); + if (n < 0) return error.WriteError; + return @intCast(n); + } + + pub fn stat(self: File) !Stat { + const current = c.lseek(self.handle, 0, c.SEEK.CUR); + if (current < 0) return error.StatError; + const end = c.lseek(self.handle, 0, c.SEEK.END); + if (end < 0) return error.StatError; + _ = c.lseek(self.handle, current, c.SEEK.SET); + return .{ .size = @intCast(end) }; + } + + pub fn seekTo(self: File, pos: u64) !void { + _ = c.lseek(self.handle, @intCast(pos), c.SEEK.SET); + } + + pub fn seekBy(self: File, delta: i64) !void { + _ = c.lseek(self.handle, @intCast(delta), c.SEEK.CUR); + } + + pub fn getPos(self: File) !u64 { + const pos = c.lseek(self.handle, 0, c.SEEK.CUR); + if (pos < 0) return error.SeekError; + return @intCast(pos); + } + + pub fn sync(self: File) !void { + if (c.fsync(self.handle) != 0) return error.SyncError; + } + + pub fn pread(self: File, buf: []u8, offset: u64) !usize { + const n = c.pread(self.handle, buf.ptr, buf.len, @intCast(offset)); + if (n < 0) return error.ReadError; + return @intCast(n); + } + pub fn setEndPos(self: File, pos: u64) !void { + if (c.ftruncate(self.handle, @intCast(pos)) != 0) return error.TruncateError; + } + + pub fn reader(self: File) Reader { + return .{ .file = self }; + } + + pub fn writer(self: File) Writer { + return .{ .file = self }; + } +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Dir / cwd() — drop-in for std.fs.cwd() +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn cwd() Dir { + return .{}; +} + +pub const Dir = struct { + pub fn makeDir(_: Dir, sub_path: []const u8) !void { + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + const rc = c.mkdir(z, @as(c.mode_t, 0o755)); + if (rc != 0) { + const e = std.posix.errno(rc); + return switch (e) { + .EXIST => error.PathAlreadyExists, + else => error.MkdirError, + }; + } + } + + pub fn makePath(_: Dir, sub_path: []const u8) !void { + var buf: [4096]u8 = undefined; + if (sub_path.len >= buf.len) return error.NameTooLong; + @memcpy(buf[0..sub_path.len], sub_path); + buf[sub_path.len] = 0; + + var i: usize = 1; + while (i <= sub_path.len) : (i += 1) { + if (i == sub_path.len or buf[i] == '/') { + const saved = buf[i]; + buf[i] = 0; + _ = c.mkdir(@ptrCast(&buf), @as(c.mode_t, 0o755)); + buf[i] = saved; + } + } + } + + pub fn deleteTree(_: Dir, sub_path: []const u8) !void { + var cmd_buf: [4200]u8 = undefined; + const cmd = std.fmt.bufPrint(&cmd_buf, "rm -rf '{s}'\x00", .{sub_path}) catch return error.NameTooLong; + _ = system(@ptrCast(cmd.ptr)); + } + + pub fn deleteFile(_: Dir, sub_path: []const u8) !void { + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + if (c.unlink(z) != 0) return error.DeleteError; + } + + pub fn createFile(_: Dir, sub_path: []const u8, opts: anytype) !File { + _ = opts; + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + const fd = c.open(z, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(c.mode_t, 0o644)); + if (fd < 0) return error.CreateFileError; + return .{ .handle = fd }; + } + + pub fn openFile(_: Dir, sub_path: []const u8, opts: anytype) !File { + _ = opts; + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + const fd = c.open(z, .{ .ACCMODE = .RDWR }, @as(c.mode_t, 0)); + if (fd < 0) { + const fd2 = c.open(z, .{ .ACCMODE = .RDONLY }, @as(c.mode_t, 0)); + if (fd2 < 0) return error.FileNotFound; + return .{ .handle = fd2 }; + } + return .{ .handle = fd }; + } + + pub fn openDir(_: Dir, sub_path: []const u8, opts: anytype) !IterableDir { + _ = opts; + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + const dp = c.opendir(z); + if (dp == null) return error.FileNotFound; + return .{ .dir_stream = dp.?, .path = sub_path }; + } + + pub fn rename(_: Dir, old: []const u8, new_path: []const u8) !void { + var buf1: [4096]u8 = undefined; + var buf2: [4096]u8 = undefined; + const z1 = sliceToZ(&buf1, old) orelse return error.NameTooLong; + const z2 = sliceToZ(&buf2, new_path) orelse return error.NameTooLong; + if (c.rename(z1, z2) != 0) return error.RenameError; + } + + pub fn readFileAlloc(_: Dir, allocator: std.mem.Allocator, sub_path: []const u8, max_size: usize) ![]u8 { + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + const fd = c.open(z, .{ .ACCMODE = .RDONLY }, @as(c.mode_t, 0)); + if (fd < 0) return error.FileNotFound; + defer _ = c.close(fd); + + const end = c.lseek(fd, 0, c.SEEK.END); + if (end < 0) return error.StatError; + _ = c.lseek(fd, 0, c.SEEK.SET); + const size: usize = @intCast(end); + if (size > max_size) return error.FileTooBig; + + const data = try allocator.alloc(u8, size); + errdefer allocator.free(data); + + var total: usize = 0; + while (total < size) { + const n = c.read(fd, data[total..].ptr, data[total..].len); + if (n < 0) return error.ReadError; + if (n == 0) break; + total += @intCast(n); + } + return data[0..total]; + } + + pub fn access(_: Dir, sub_path: []const u8, opts: anytype) !void { + _ = opts; + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + if (c.access(z, c.F_OK) != 0) return error.FileNotFound; + } +}; + +pub fn openDirAbsolute(path: []const u8, opts: anytype) !IterableDir { + _ = opts; + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, path) orelse return error.NameTooLong; + const dp = c.opendir(z); + if (dp == null) return error.FileNotFound; + return .{ .dir_stream = dp.?, .path = path }; +} + +pub fn createFileAbsolute(path: []const u8, opts: anytype) !File { + return cwd().createFile(path, opts); +} + +pub fn openFileAbsolute(path: []const u8, opts: anytype) !File { + return cwd().openFile(path, opts); +} + +pub const IterableDir = struct { + dir_stream: *c.DIR, + path: []const u8, + + pub fn close(self: *IterableDir) void { + _ = c.closedir(self.dir_stream); + } + + pub fn iterate(self: *IterableDir) Iterator { + return .{ .dir_stream = self.dir_stream }; + } + + pub const Iterator = struct { + dir_stream: *c.DIR, + + pub const Entry = struct { + name: []const u8, + kind: Kind, + }; + + pub const Kind = enum { file, directory, sym_link, unknown }; + + pub fn next(self: *Iterator) !?Entry { + while (true) { + const entry = c.readdir(self.dir_stream); + if (entry == null) return null; + const name_ptr: [*:0]const u8 = @ptrCast(&entry.?.name); + const name = std.mem.sliceTo(name_ptr, 0); + if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) continue; + const kind: Kind = switch (entry.?.type) { + c.DT.REG => .file, + c.DT.DIR => .directory, + c.DT.LNK => .sym_link, + else => .unknown, + }; + return .{ .name = name, .kind = kind }; + } + } + }; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Mutex — drop-in for std.Thread.Mutex +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const Mutex = struct { + inner: c.pthread_mutex_t = c.PTHREAD_MUTEX_INITIALIZER, + + pub fn lock(m: *Mutex) void { + _ = c.pthread_mutex_lock(&m.inner); + } + + pub fn unlock(m: *Mutex) void { + _ = c.pthread_mutex_unlock(&m.inner); + } + + pub fn tryLock(m: *Mutex) bool { + return c.pthread_mutex_trylock(&m.inner) == 0; + } +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// RwLock — drop-in for std.Thread.RwLock +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const RwLock = struct { + inner: c.pthread_rwlock_t = std.mem.zeroes(c.pthread_rwlock_t), + + pub fn lockShared(rw: *RwLock) void { + _ = c.pthread_rwlock_rdlock(&rw.inner); + } + + pub fn unlockShared(rw: *RwLock) void { + _ = c.pthread_rwlock_unlock(&rw.inner); + } + + pub fn lock(rw: *RwLock) void { + _ = c.pthread_rwlock_wrlock(&rw.inner); + } + + pub fn unlock(rw: *RwLock) void { + _ = c.pthread_rwlock_unlock(&rw.inner); + } +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Condition — drop-in for std.Thread.Condition +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const Condition = struct { + inner: c.pthread_cond_t = c.PTHREAD_COND_INITIALIZER, + + pub fn wait(cond_var: *Condition, mutex: *Mutex) void { + _ = c.pthread_cond_wait(&cond_var.inner, &mutex.inner); + } + + pub fn signal(cond_var: *Condition) void { + _ = c.pthread_cond_signal(&cond_var.inner); + } + + pub fn broadcast(cond_var: *Condition) void { + _ = c.pthread_cond_broadcast(&cond_var.inner); + } +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// sleep — drop-in for std.Thread.sleep +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn sleep(ns: u64) void { + const ts = c.timespec{ + .sec = @intCast(ns / std.time.ns_per_s), + .nsec = @intCast(ns % std.time.ns_per_s), + }; + _ = c.nanosleep(&ts, null); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Time — drop-in for std.time.milliTimestamp / nanoTimestamp / timestamp +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn milliTimestamp() i64 { + var ts: c.timespec = undefined; + _ = c.clock_gettime(.REALTIME, &ts); + return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000); +} + +pub fn nanoTimestamp() i128 { + var ts: c.timespec = undefined; + _ = c.clock_gettime(.REALTIME, &ts); + return @as(i128, ts.sec) * std.time.ns_per_s + ts.nsec; +} + +pub fn timestamp() i64 { + var ts: c.timespec = undefined; + _ = c.clock_gettime(.REALTIME, &ts); + return ts.sec; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// FixedBufferStream — drop-in for std.io.fixedBufferStream +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn fixedBufferStream(buf: []u8) FixedBufferStream { + return .{ .buffer = buf, .pos = 0 }; +} + +pub const FixedBufferStream = struct { + buffer: []u8, + pos: usize, + + pub fn writer(self: *FixedBufferStream) FbsWriter { + return .{ .context = self }; + } + + pub fn reader(self: *FixedBufferStream) FbsReader { + return .{ .context = self }; + } + + pub fn getWritten(self: *const FixedBufferStream) []const u8 { + return self.buffer[0..self.pos]; + } + + pub fn reset(self: *FixedBufferStream) void { + self.pos = 0; + } + + pub const FbsWriter = struct { + context: *FixedBufferStream, + + pub fn writeAll(self: FbsWriter, data: []const u8) !void { + const fbs = self.context; + if (fbs.pos + data.len > fbs.buffer.len) return error.NoSpaceLeft; + @memcpy(fbs.buffer[fbs.pos..][0..data.len], data); + fbs.pos += data.len; + } + + pub fn writeByte(self: FbsWriter, byte: u8) !void { + const fbs = self.context; + if (fbs.pos >= fbs.buffer.len) return error.NoSpaceLeft; + fbs.buffer[fbs.pos] = byte; + fbs.pos += 1; + } + + pub fn writeInt(self: FbsWriter, comptime T: type, value: T, comptime endian: std.builtin.Endian) !void { + const bytes = std.mem.toBytes(if (endian == .little) value else @byteSwap(value)); + try self.writeAll(&bytes); + } + + pub fn write(self: FbsWriter, data: []const u8) !usize { + const fbs = self.context; + const avail = fbs.buffer.len - fbs.pos; + const n = @min(data.len, avail); + @memcpy(fbs.buffer[fbs.pos..][0..n], data[0..n]); + fbs.pos += n; + return n; + } + + pub fn print(self: FbsWriter, comptime fmt_str: []const u8, args: anytype) !void { + var tmp: [8192]u8 = undefined; + const result = std.fmt.bufPrint(&tmp, fmt_str, args) catch return error.NoSpaceLeft; + try self.writeAll(result); + } + }; + + pub const FbsReader = struct { + context: *FixedBufferStream, + + pub fn readAll(self: FbsReader, buf: []u8) !usize { + const fbs = self.context; + const avail = fbs.buffer.len - fbs.pos; + const n = @min(buf.len, avail); + @memcpy(buf[0..n], fbs.buffer[fbs.pos..][0..n]); + fbs.pos += n; + return n; + } + }; +}; + +pub fn constFixedBufferStream(buf: []const u8) ConstFixedBufferStream { + return .{ .buffer = buf, .pos = 0 }; +} + +pub const ConstFixedBufferStream = struct { + buffer: []const u8, + pos: usize, + + pub fn reader(self: *ConstFixedBufferStream) ConstFbsReader { + return .{ .context = self }; + } + + pub const ConstFbsReader = struct { + context: *ConstFixedBufferStream, + + pub fn readAll(self: ConstFbsReader, buf: []u8) !usize { + const fbs = self.context; + const avail = fbs.buffer.len - fbs.pos; + const n = @min(buf.len, avail); + @memcpy(buf[0..n], fbs.buffer[fbs.pos..][0..n]); + fbs.pos += n; + return n; + } + + pub fn readByte(self: ConstFbsReader) !u8 { + const fbs = self.context; + if (fbs.pos >= fbs.buffer.len) return error.EndOfStream; + const b = fbs.buffer[fbs.pos]; + fbs.pos += 1; + return b; + } + + pub fn readInt(self: ConstFbsReader, comptime T: type, comptime endian: std.builtin.Endian) !T { + const bytes_needed = @sizeOf(T); + const fbs = self.context; + if (fbs.pos + bytes_needed > fbs.buffer.len) return error.EndOfStream; + var bytes: [@sizeOf(T)]u8 = undefined; + @memcpy(&bytes, fbs.buffer[fbs.pos..][0..bytes_needed]); + fbs.pos += bytes_needed; + const raw = std.mem.bytesToValue(T, &bytes); + return if (endian == .little) raw else @byteSwap(raw); + } + }; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// BufferedReader — drop-in for std.io.BufferedReader / std.io.bufferedReader +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn GenericBufferedReader(comptime buf_size: usize, comptime ReaderType: type) type { + return struct { + unbuffered: ReaderType, + buf: [buf_size]u8 = undefined, + start: usize = 0, + end: usize = 0, + + const Self = @This(); + + pub fn reader(self: *Self) InnerReader { + return .{ .context = self }; + } + + pub const InnerReader = struct { + context: *Self, + + pub fn readAll(self_inner: InnerReader, out: []u8) !usize { + var total: usize = 0; + while (total < out.len) { + const br = self_inner.context; + if (br.start < br.end) { + const buffered = br.end - br.start; + const n = @min(out.len - total, buffered); + @memcpy(out[total..][0..n], br.buf[br.start..][0..n]); + br.start += n; + total += n; + continue; + } + const n = try br.unbuffered.readAll(br.buf[0..]); + if (n == 0) break; + br.start = 0; + br.end = n; + } + return total; + } + }; + }; +} + +pub fn bufferedReader(underlying: anytype) GenericBufferedReader(4096, @TypeOf(underlying)) { + return .{ .unbuffered = underlying }; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Crypto helper — random bytes without Io +// ═══════════════════════════════════════════════════════════════════════════════ + +extern "c" fn arc4random_buf(buf: *anyopaque, nbytes: usize) void; +extern "c" fn system(command: [*:0]const u8) c_int; + +pub fn randomBytes(buf: []u8) void { + arc4random_buf(buf.ptr, buf.len); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// fmt.format replacement — writes to any writer with .writeAll +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn format(w: anytype, comptime fmt_str: []const u8, args: anytype) !void { + var tmp: [8192]u8 = undefined; + const result = std.fmt.bufPrint(&tmp, fmt_str, args) catch return error.NoSpaceLeft; + try w.writeAll(result); +} + + + +// ═══════════════════════════════════════════════════════════════════════════════ +// TCP Networking — drop-in for std.net (removed in 0.16) +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const net = struct { + pub const Address = struct { + family: u8, + port: u16, + addr: u32, + + pub fn parseIp(ip: []const u8, port: u16) !Address { + return .{ .family = 2, .port = port, .addr = try parseIpv4(ip) }; + } + + pub fn listen(self: Address, opts: anytype) !Server { + const fd = c.socket(2, 1, 0); // AF_INET=2, SOCK_STREAM=1 + if (fd < 0) return error.SocketError; + + // SO_REUSEADDR + if (opts.reuse_address) { + var opt_val: c_int = 1; + _ = c.setsockopt(fd, 0xFFFF, 0x0004, @ptrCast(&opt_val), @sizeOf(c_int)); // SOL_SOCKET, SO_REUSEADDR + } + + // Bind + var addr: extern struct { family: u16, port: u16 align(1), addr: u32 align(1), zero: [8]u8 align(1) } = .{ + .family = 2, // AF_INET + .port = std.mem.nativeToBig(u16, self.port), + .addr = std.mem.nativeToBig(u32, self.addr), + .zero = std.mem.zeroes([8]u8), + }; + if (c.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { + _ = c.close(fd); + return error.BindError; + } + + // Listen + const backlog: c_int = if (@hasField(@TypeOf(opts), "kernel_backlog")) @intCast(opts.kernel_backlog) else 128; + if (c.listen(fd, backlog) != 0) { + _ = c.close(fd); + return error.ListenError; + } + + return .{ .fd = fd }; + } + + pub fn initUnix(path: anytype) !Address { + _ = path; + return .{ .family = 1, .port = 0, .addr = 0 }; + } + + fn parseIpv4(ip: []const u8) !u32 { + if (std.mem.eql(u8, ip, "localhost")) return 0x7f000001; + var parts = std.mem.splitScalar(u8, ip, '.'); + var out: u32 = 0; + var count: u8 = 0; + while (parts.next()) |part| { + if (count == 4 or part.len == 0) return error.InvalidIpAddress; + const octet = try std.fmt.parseInt(u8, part, 10); + out = (out << 8) | octet; + count += 1; + } + if (count != 4) return error.InvalidIpAddress; + return out; + } + }; + + pub const Stream = struct { + handle: posix.fd_t, + + pub fn read(self: Stream, buf: []u8) !usize { + const n = c.read(self.handle, buf.ptr, buf.len); + if (n < 0) return error.ReadError; + return @intCast(n); + } + + pub fn readAll(self: Stream, buf: []u8) !usize { + var total: usize = 0; + while (total < buf.len) { + const n = try self.read(buf[total..]); + if (n == 0) break; + total += n; + } + return total; + } + + pub fn write(self: Stream, data: []const u8) !usize { + const n = c.write(self.handle, data.ptr, data.len); + if (n < 0) return error.WriteError; + return @intCast(n); + } + + pub fn writeAll(self: Stream, data: []const u8) !void { + var written: usize = 0; + while (written < data.len) { + const n = try self.write(data[written..]); + if (n == 0) return error.WriteError; + written += n; + } + } + + pub fn close(self: Stream) void { + _ = c.close(self.handle); + } + }; + + pub fn tcpConnectToAddress(address: Address) !Stream { + const fd = c.socket(2, 1, 0); + if (fd < 0) return error.SocketError; + errdefer _ = c.close(fd); + + var sockaddr: extern struct { family: u16, port: u16 align(1), addr: u32 align(1), zero: [8]u8 align(1) } = .{ + .family = 2, + .port = std.mem.nativeToBig(u16, address.port), + .addr = std.mem.nativeToBig(u32, address.addr), + .zero = std.mem.zeroes([8]u8), + }; + if (c.connect(fd, @ptrCast(&sockaddr), @sizeOf(@TypeOf(sockaddr))) != 0) { + return error.ConnectError; + } + return .{ .handle = fd }; + } + + pub const Server = struct { + fd: posix.fd_t, + + pub const Connection = struct { + stream: Stream, + address: Address, + }; + + pub fn accept(self: Server) !Connection { + const client_fd = c.accept(self.fd, null, null); + if (client_fd < 0) return error.AcceptError; + return .{ + .stream = .{ .handle = client_fd }, + .address = .{ .family = 2, .port = 0, .addr = 0 }, + }; + } + + pub fn deinit(self: *Server) void { + _ = c.close(self.fd); + } + }; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// ArrayListWriter — drop-in for ArrayList(u8).writer() (removed in 0.16) +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const ArrayListWriter = struct { + list: *std.ArrayList(u8), + gpa: std.mem.Allocator, + + pub fn writeAll(self: ArrayListWriter, data: []const u8) !void { + try self.list.appendSlice(self.gpa, data); + } + + pub fn writeByte(self: ArrayListWriter, byte: u8) !void { + try self.list.append(self.gpa, byte); + } + + pub fn print(self: ArrayListWriter, comptime fmt_str: []const u8, args: anytype) !void { + var tmp: [8192]u8 = undefined; + const result = std.fmt.bufPrint(&tmp, fmt_str, args) catch return error.NoSpaceLeft; + try self.writeAll(result); + } +}; + +pub fn arrayListWriter(list: *std.ArrayList(u8), gpa: std.mem.Allocator) ArrayListWriter { + return .{ .list = list, .gpa = gpa }; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Args — drop-in for std.process.argsAlloc (0.15) +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn argsAlloc(allocator: std.mem.Allocator, init: std.process.Init) ![][:0]const u8 { + // Count args first + var count: usize = 0; + { + var it = std.process.Args.Iterator.init(init.minimal.args); + while (it.next()) |_| count += 1; + } + const args = try allocator.alloc([:0]const u8, count); + var it = std.process.Args.Iterator.init(init.minimal.args); + var i: usize = 0; + while (it.next()) |arg| : (i += 1) { + args[i] = arg; + } + return args; +} + +pub fn argsFree(allocator: std.mem.Allocator, args: [][:0]const u8) void { + allocator.free(args); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════════════════════ + +fn sliceToZ(buf: *[4096]u8, s: []const u8) ?[*:0]const u8 { + if (s.len >= buf.len) return null; + @memcpy(buf[0..s.len], s); + buf[s.len] = 0; + return @ptrCast(buf); +} diff --git a/src/crypto.zig b/src/crypto.zig index ff6e122..461f301 100644 --- a/src/crypto.zig +++ b/src/crypto.zig @@ -8,6 +8,7 @@ /// /// Uses Zig's std.crypto — no external dependencies, no OpenSSL. const std = @import("std"); +const compat = @import("compat"); // ── Hash algorithms ────────────────────────────────────────────────────────── @@ -82,7 +83,9 @@ pub const KeyPair = struct { secret_key: [64]u8, pub fn generate() KeyPair { - const kp = Ed25519.KeyPair.generate(); + var seed: [32]u8 = undefined; + compat.randomBytes(&seed); + const kp = Ed25519.KeyPair.generateDeterministic(seed) catch unreachable; return .{ .public_key = kp.public_key.toBytes(), .secret_key = kp.secret_key.toBytes(), diff --git a/src/disk_index.zig b/src/disk_index.zig index 58fcbc4..8439939 100644 --- a/src/disk_index.zig +++ b/src/disk_index.zig @@ -14,6 +14,7 @@ /// files.tdb — file ID → path mapping: [n_files: u32][(offset: u32, len: u16)...][paths...] /// const std = @import("std"); +const compat = @import("compat"); // ─── Types ────────────────────────────────────────────────────────────────── @@ -151,7 +152,7 @@ pub const DiskIndexBuilder = struct { var posts_path_buf: [512]u8 = undefined; const posts_path = try std.fmt.bufPrint(&posts_path_buf, "{s}/posts.tdb", .{dir_path}); - const posts_file = try std.fs.cwd().createFile(posts_path, .{}); + const posts_file = try compat.cwd().createFile(posts_path, .{}); defer posts_file.close(); var offset: u32 = 0; @@ -173,7 +174,7 @@ pub const DiskIndexBuilder = struct { var idx_path_buf: [512]u8 = undefined; const idx_path = try std.fmt.bufPrint(&idx_path_buf, "{s}/index.tdb", .{dir_path}); - const idx_file = try std.fs.cwd().createFile(idx_path, .{}); + const idx_file = try compat.cwd().createFile(idx_path, .{}); defer idx_file.close(); // Header: entry count @@ -190,7 +191,7 @@ pub const DiskIndexBuilder = struct { var files_path_buf: [512]u8 = undefined; const files_path = try std.fmt.bufPrint(&files_path_buf, "{s}/files.tdb", .{dir_path}); - const files_file = try std.fs.cwd().createFile(files_path, .{}); + const files_file = try compat.cwd().createFile(files_path, .{}); defer files_file.close(); const n_files: u32 = @intCast(self.file_paths.items.len); @@ -215,7 +216,7 @@ pub const DiskIndexBuilder = struct { // ── 5. Write frequency table ──────────────────────────────────── var freq_path_buf: [512]u8 = undefined; const freq_path = try std.fmt.bufPrint(&freq_path_buf, "{s}/freq.tdb", .{dir_path}); - const freq_file = try std.fs.cwd().createFile(freq_path, .{}); + const freq_file = try compat.cwd().createFile(freq_path, .{}); defer freq_file.close(); try freq_file.writeAll(std.mem.asBytes(&self.freq.counts)); stats.freq_bytes = 256 * 256 * 4; @@ -244,7 +245,7 @@ pub const DiskIndex = struct { lookup: []const LookupEntry, lookup_mmap: []align(std.heap.page_size_min) const u8, /// Postings file handle (pread for on-demand loading) - posts_fd: std.fs.File, + posts_fd: compat.File, /// File path table (mmap'd) files_mmap: []align(std.heap.page_size_min) const u8, n_files: u32, @@ -257,10 +258,10 @@ pub const DiskIndex = struct { // mmap lookup table const idx_path = try std.fmt.bufPrint(&buf, "{s}/index.tdb", .{dir_path}); - const idx_file = try std.fs.cwd().openFile(idx_path, .{}); + const idx_file = try compat.cwd().openFile(idx_path, .{}); defer idx_file.close(); const idx_stat = try idx_file.stat(); - const idx_mmap = try std.posix.mmap(null, idx_stat.size, std.posix.PROT.READ, .{ .TYPE = .PRIVATE }, idx_file.handle, 0); + const idx_mmap = try std.posix.mmap(null, idx_stat.size, std.posix.PROT{ .READ = true }, .{ .TYPE = .PRIVATE }, idx_file.handle, 0); const n_entries = std.mem.bytesToValue(u32, idx_mmap[0..4]); const entries_start = 4; @@ -269,22 +270,22 @@ pub const DiskIndex = struct { // Open postings file for pread const posts_path = try std.fmt.bufPrint(&buf, "{s}/posts.tdb", .{dir_path}); - const posts_fd = try std.fs.cwd().openFile(posts_path, .{}); + const posts_fd = try compat.cwd().openFile(posts_path, .{}); // mmap files table const files_path = try std.fmt.bufPrint(&buf, "{s}/files.tdb", .{dir_path}); - const files_file = try std.fs.cwd().openFile(files_path, .{}); + const files_file = try compat.cwd().openFile(files_path, .{}); defer files_file.close(); const files_stat = try files_file.stat(); - const files_mmap = try std.posix.mmap(null, files_stat.size, std.posix.PROT.READ, .{ .TYPE = .PRIVATE }, files_file.handle, 0); + const files_mmap = try std.posix.mmap(null, files_stat.size, std.posix.PROT{ .READ = true }, .{ .TYPE = .PRIVATE }, files_file.handle, 0); const n_files = std.mem.bytesToValue(u32, files_mmap[0..4]); // mmap frequency table const freq_path = try std.fmt.bufPrint(&buf, "{s}/freq.tdb", .{dir_path}); - const freq_file = try std.fs.cwd().openFile(freq_path, .{}); + const freq_file = try compat.cwd().openFile(freq_path, .{}); defer freq_file.close(); const freq_stat = try freq_file.stat(); - const freq_mmap = try std.posix.mmap(null, freq_stat.size, std.posix.PROT.READ, .{ .TYPE = .PRIVATE }, freq_file.handle, 0); + const freq_mmap = try std.posix.mmap(null, freq_stat.size, std.posix.PROT{ .READ = true }, .{ .TYPE = .PRIVATE }, freq_file.handle, 0); return .{ .lookup = lookup, @@ -519,8 +520,8 @@ test "DiskIndexBuilder roundtrip" { // Write to temp dir const tmp = "/tmp/tdb_disk_test"; - std.fs.cwd().makeDir(tmp) catch {}; - defer std.fs.cwd().deleteTree(tmp) catch {}; + compat.cwd().makeDir(tmp) catch {}; + defer compat.cwd().deleteTree(tmp) catch {}; const stats = try builder.writeToDisk(tmp); try std.testing.expect(stats.n_trigrams > 0); diff --git a/src/errors.zig b/src/errors.zig index 5689575..50a8e81 100644 --- a/src/errors.zig +++ b/src/errors.zig @@ -6,6 +6,7 @@ /// Wire protocol: STATUS byte + 2-byte error code + message. /// HTTP: {"error": {"code": N, "message": "..."}} const std = @import("std"); +const compat = @import("compat"); pub const ErrorCode = enum(u16) { // ── Success ── @@ -94,7 +95,7 @@ pub const ErrorCode = enum(u16) { /// Format a JSON error response into a buffer. pub fn jsonError(buf: []u8, code: ErrorCode) []const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); w.print("{{\"error\":{{\"code\":{d},\"message\":\"{s}\"}}}}", .{ @intFromEnum(code), code.message(), @@ -104,7 +105,7 @@ pub fn jsonError(buf: []u8, code: ErrorCode) []const u8 { /// Format a JSON error with custom detail message. pub fn jsonErrorDetail(buf: []u8, code: ErrorCode, detail: []const u8) []const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); w.print("{{\"error\":{{\"code\":{d},\"message\":\"{s}\",\"detail\":\"{s}\"}}}}", .{ @intFromEnum(code), code.message(), detail, diff --git a/src/ffi.zig b/src/ffi.zig index b3ef623..8252705 100644 --- a/src/ffi.zig +++ b/src/ffi.zig @@ -7,6 +7,7 @@ /// Memory: Uses std.heap.c_allocator (libc malloc/free) so that foreign /// callers can reason about memory ownership without Zig-specific abstractions. const std = @import("std"); +const compat = @import("compat"); const collection = @import("collection.zig"); const doc_mod = @import("doc.zig"); const crypto = @import("crypto.zig"); @@ -70,7 +71,7 @@ pub const TurboScanHandle = extern struct { export fn turbodb_open(dir: [*]const u8, dir_len: usize) ?*anyopaque { // Ensure data directory exists. const dir_slice = dir[0..dir_len]; - std.fs.cwd().makeDir(dir_slice) catch |e| switch (e) { + compat.cwd().makeDir(dir_slice) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return null, }; @@ -659,7 +660,7 @@ export fn turbodb_branch_diff( const branch_mod = @import("branch.zig"); var buf: std.ArrayList(u8) = .empty; - const w = buf.writer(alloc); + const w = compat.arrayListWriter(&buf, alloc); w.writeAll("{\"files\":[") catch return -1; var first_file = true; @@ -690,7 +691,7 @@ export fn turbodb_branch_diff( .added => "added", .removed => "removed", }; - std.fmt.format(w, "{{\"no\":{d},\"kind\":\"{s}\",\"text\":\"", .{ d.line_no, kind_str }) catch return -1; + compat.format(w, "{{\"no\":{d},\"kind\":\"{s}\",\"text\":\"", .{ d.line_no, kind_str }) catch return -1; // Escape text for JSON for (d.text) |c| { switch (c) { @@ -778,24 +779,24 @@ export fn turbodb_discover_context( // Format as JSON var buf: std.ArrayList(u8) = .empty; - const w = buf.writer(alloc); + const w = compat.arrayListWriter(&buf, alloc); w.writeAll("{\"matching_files\":[") catch return -1; for (result.matching_files, 0..) |d, i| { if (i > 0) w.writeByte(',') catch return -1; - std.fmt.format(w, "{{\"key\":\"{s}\",\"size\":{d}}}", .{ d.key, d.value.len }) catch return -1; + compat.format(w, "{{\"key\":\"{s}\",\"size\":{d}}}", .{ d.key, d.value.len }) catch return -1; } w.writeAll("],\"related_files\":[") catch return -1; for (result.related_files, 0..) |d, i| { if (i > 0) w.writeByte(',') catch return -1; - std.fmt.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch return -1; + compat.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch return -1; } w.writeAll("],\"test_files\":[") catch return -1; for (result.test_files, 0..) |d, i| { if (i > 0) w.writeByte(',') catch return -1; - std.fmt.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch return -1; + compat.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch return -1; } - std.fmt.format(w, "],\"recent_versions\":{d},\"total_files\":{d}}}", .{ result.recent_versions, result.total_files }) catch return -1; + compat.format(w, "],\"recent_versions\":{d},\"total_files\":{d}}}", .{ result.recent_versions, result.total_files }) catch return -1; // Copy to output const json = buf.toOwnedSlice(alloc) catch return -1; diff --git a/src/io_engine.zig b/src/io_engine.zig index 1024ca7..4561485 100644 --- a/src/io_engine.zig +++ b/src/io_engine.zig @@ -144,7 +144,8 @@ const KqueueBackend = struct { const PendingClose = struct { fd: posix.fd_t, user_data: usize }; fn init(alloc: Allocator) !KqueueBackend { - const kq = try posix.kqueue(); + const kq = std.c.kqueue(); + if (kq < 0) return error.KqueueInitFailed; return .{ .kq = kq, .alloc = alloc, @@ -155,7 +156,7 @@ const KqueueBackend = struct { } fn deinit(self: *KqueueBackend) void { - posix.close(self.kq); + _ = std.c.close(self.kq); self.changelist.deinit(self.alloc); self.pending_accepts.deinit(self.alloc); self.pending_closes.deinit(self.alloc); @@ -216,7 +217,7 @@ const KqueueBackend = struct { var count: usize = 0; for (self.pending_closes.items) |pc| { - posix.close(pc.fd); + _ = std.c.close(pc.fd); if (count < completions.len) { completions[count] = .{ .fd = pc.fd, @@ -481,7 +482,7 @@ pub const EventLoop = struct { self.running.store(false, .release); } if (self.listen_fd >= 0) { - posix.close(self.listen_fd); + _ = std.c.close(self.listen_fd); } self.engine.deinit(); self.pool.deinit(); @@ -491,16 +492,22 @@ pub const EventLoop = struct { /// Bind and listen on the given TCP port. pub fn bind(self: *EventLoop, port: u16) !void { - const addr = try std.net.Address.resolveIp("0.0.0.0", port); - const flags: u32 = posix.SOCK.STREAM | posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC; - const fd = try posix.socket(addr.any.family, flags, posix.IPPROTO.TCP); - errdefer posix.close(fd); + const fd = std.c.socket(2, std.c.SOCK.STREAM, 0); + if (fd < 0) return error.SocketError; + errdefer _ = std.c.close(fd); // SO_REUSEADDR for fast restart. - posix.setsockopt(fd, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1))) catch {}; - - try posix.bind(fd, &addr.any, addr.getOsSockLen()); - try posix.listen(fd, 128); + var opt_val: c_int = 1; + _ = std.c.setsockopt(fd, std.c.SOL.SOCKET, std.c.SO.REUSEADDR, @ptrCast(&opt_val), @sizeOf(c_int)); + + var addr: extern struct { family: u16, port_be: u16 align(1), addr_be: u32 align(1), zero: [8]u8 align(1) } = .{ + .family = 2, + .port_be = std.mem.nativeToBig(u16, port), + .addr_be = 0, + .zero = std.mem.zeroes([8]u8), + }; + if (std.c.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return error.BindError; + if (std.c.listen(fd, 128) != 0) return error.ListenError; self.listen_fd = fd; } @@ -553,7 +560,7 @@ pub const EventLoop = struct { const idx = self.pool.acquire() orelse { // Pool exhausted — reject. - posix.close(comp.fd); + _ = std.c.close(comp.fd); return; }; @@ -563,7 +570,7 @@ pub const EventLoop = struct { // Submit a read on the new connection. self.engine.submitRead(comp.fd, &self.pool.conns[idx].read_buf, idx) catch { self.pool.release(idx); - posix.close(comp.fd); + _ = std.c.close(comp.fd); }; } @@ -575,7 +582,7 @@ pub const EventLoop = struct { // EOF or error — close. conn.state = .closing; self.engine.submitClose(conn.fd, comp.user_data) catch { - posix.close(conn.fd); + _ = std.c.close(conn.fd); self.pool.release(comp.user_data); }; return; diff --git a/src/lsm.zig b/src/lsm.zig index 89fbc86..3217f36 100644 --- a/src/lsm.zig +++ b/src/lsm.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const btree = @import("btree.zig"); const BTreeEntry = btree.BTreeEntry; @@ -82,83 +83,166 @@ pub const KVEntry = struct { // ─── MemTable ──────────────────────────────────────────────────────────────── pub const MemTable = struct { - entries: std.ArrayList(KVEntry), + /// Pre-allocated slab. Capacity is fixed at init; never grows. + entries: []KVEntry, + count: u32, size_bytes: usize, + + /// Sorted runs over `entries[0..count]`. Each run is a contiguous, ascending + /// range. New keys extend the last run when strictly greater than its tail; + /// out-of-order keys open a new run. On flush (or when MAX_RUNS is hit) we + /// stable-sort + dedup last-wins, collapsing back to a single run. + runs: [MAX_RUNS]Run, + run_count: u32, + alloc: std.mem.Allocator, - const ENTRY_OVERHEAD = @sizeOf(KVEntry); + pub const MAX_RUNS: u32 = 64; + pub const ENTRY_OVERHEAD = @sizeOf(KVEntry); + + pub const Run = struct { + start: u32, + end: u32, // exclusive + }; - pub fn init(alloc: std.mem.Allocator) MemTable { + pub fn init(alloc: std.mem.Allocator) !MemTable { + // Capacity = ceil(MEMTABLE_SIZE / entry_size) + 1 slot of slack so the + // last put that pushes size_bytes >= MEMTABLE_SIZE still fits. + const target: usize = (LSMTree.MEMTABLE_SIZE + ENTRY_OVERHEAD - 1) / ENTRY_OVERHEAD; + const cap: u32 = @intCast(target + 1); + const buf = try alloc.alloc(KVEntry, cap); return .{ - .entries = .empty, + .entries = buf, + .count = 0, .size_bytes = 0, + .runs = undefined, + .run_count = 0, .alloc = alloc, }; } pub fn deinit(self: *MemTable) void { - self.entries.deinit(self.alloc); + if (self.entries.len > 0) self.alloc.free(self.entries); + self.entries = &.{}; + self.count = 0; + self.run_count = 0; self.size_bytes = 0; } - /// Binary search for the index of key_hash. Returns the index if found, or - /// the insertion point if not. - fn findIndex(self: *const MemTable, key_hash: u64) struct { idx: usize, found: bool } { - const items = self.entries.items; - var lo: usize = 0; - var hi: usize = items.len; - while (lo < hi) { - const mid = lo + (hi - lo) / 2; - if (items[mid].key_hash < key_hash) { - lo = mid + 1; - } else { - hi = mid; + /// Append a raw KVEntry. Extends the active run when key strictly increases; + /// otherwise opens a new run (collapsing first if MAX_RUNS is hit). + fn appendEntry(self: *MemTable, e: KVEntry) !void { + if (self.count >= self.entries.len) return error.MemTableFull; + + if (self.run_count > 0) { + const last = &self.runs[self.run_count - 1]; + // last.end > last.start is invariant for any tracked run. + if (self.entries[last.end - 1].key_hash < e.key_hash) { + self.entries[self.count] = e; + last.end = self.count + 1; + self.count += 1; + self.size_bytes += ENTRY_OVERHEAD; + return; } } - const found = lo < items.len and items[lo].key_hash == key_hash; - return .{ .idx = lo, .found = found }; + + if (self.run_count >= MAX_RUNS) { + // Collapse all runs into a single sorted+deduped run, then continue. + self.sortAndDedup(); + } + + self.entries[self.count] = e; + self.runs[self.run_count] = .{ .start = self.count, .end = self.count + 1 }; + self.run_count += 1; + self.count += 1; + self.size_bytes += ENTRY_OVERHEAD; } pub fn put(self: *MemTable, key_hash: u64, entry: BTreeEntry) !void { - const result = self.findIndex(key_hash); - if (result.found) { - // Update in place — no size change. - self.entries.items[result.idx].value = .{ .live = entry }; - } else { - try self.entries.insert(self.alloc, result.idx, .{ - .key_hash = key_hash, - .value = .{ .live = entry }, - }); - self.size_bytes += ENTRY_OVERHEAD; + // Fast path: same key as the most recent entry — overwrite in place. + // Common in tight update loops; avoids opening a new run. + if (self.count > 0 and self.entries[self.count - 1].key_hash == key_hash) { + self.entries[self.count - 1].value = .{ .live = entry }; + return; + } + try self.appendEntry(.{ .key_hash = key_hash, .value = .{ .live = entry } }); + } + + pub fn delete(self: *MemTable, key_hash: u64) !void { + if (self.count > 0 and self.entries[self.count - 1].key_hash == key_hash) { + self.entries[self.count - 1].value = .tombstone; + return; } + try self.appendEntry(.{ .key_hash = key_hash, .value = .tombstone }); + } + + /// Search runs newest-first. Returns the EntryValue at the first hit + /// (tombstone or live) so callers can distinguish "deleted here" from + /// "absent". Each run is sorted, so each lookup is O(log n) per run. + pub fn lookup(self: *const MemTable, key_hash: u64) ?KVEntry.EntryValue { + var ri = self.run_count; + while (ri > 0) { + ri -= 1; + const r = self.runs[ri]; + var lo: u32 = r.start; + var hi: u32 = r.end; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (self.entries[mid].key_hash < key_hash) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo < r.end and self.entries[lo].key_hash == key_hash) { + return self.entries[lo].value; + } + } + return null; } pub fn get(self: *const MemTable, key_hash: u64) ?BTreeEntry { - const result = self.findIndex(key_hash); - if (!result.found) return null; - return switch (self.entries.items[result.idx].value) { + return switch (self.lookup(key_hash) orelse return null) { .live => |e| e, .tombstone => null, }; } - pub fn delete(self: *MemTable, key_hash: u64) !void { - const result = self.findIndex(key_hash); - if (result.found) { - self.entries.items[result.idx].value = .tombstone; - } else { - try self.entries.insert(self.alloc, result.idx, .{ - .key_hash = key_hash, - .value = .tombstone, - }); - self.size_bytes += ENTRY_OVERHEAD; - } - } - pub fn isFull(self: *const MemTable) bool { return self.size_bytes >= LSMTree.MEMTABLE_SIZE; } + /// Stable-sort `entries[0..count]` by key, then dedup last-wins. Stability + /// guarantees that equal-key duplicates retain insertion order, so the + /// last entry in each duplicate group is the newest write. Collapses to a + /// single run [0, count). + pub fn sortAndDedup(self: *MemTable) void { + if (self.count == 0) { + self.run_count = 0; + self.size_bytes = 0; + return; + } + std.mem.sort(KVEntry, self.entries[0..self.count], {}, KVEntry.orderByKey); + + var w: u32 = 0; + var i: u32 = 0; + while (i < self.count) { + var j: u32 = i + 1; + while (j < self.count and self.entries[j].key_hash == self.entries[i].key_hash) { + j += 1; + } + // j-1 is the newest occurrence of this key (stable sort preserves + // insertion order among duplicates). + self.entries[w] = self.entries[j - 1]; + w += 1; + i = j; + } + self.count = w; + self.size_bytes = @as(usize, w) * ENTRY_OVERHEAD; + self.runs[0] = .{ .start = 0, .end = w }; + self.run_count = if (w > 0) 1 else 0; + } + pub const Iterator = struct { items: []const KVEntry, pos: usize, @@ -171,8 +255,11 @@ pub const MemTable = struct { } }; - pub fn iterator(self: *const MemTable) Iterator { - return .{ .items = self.entries.items, .pos = 0 }; + /// Returns an iterator over a sorted, deduped view. Mutates the table to + /// collapse runs first (cheap if already sorted). + pub fn iterator(self: *MemTable) Iterator { + self.sortAndDedup(); + return .{ .items = self.entries[0..self.count], .pos = 0 }; } }; @@ -201,21 +288,21 @@ pub const SSTable = struct { const FLAG_TOMBSTONE: u8 = 0x00; /// Write a u64 in little-endian to a file. - fn writeU64(file: std.fs.File, val: u64) !void { + fn writeU64(file: compat.File, val: u64) !void { var buf: [8]u8 = undefined; std.mem.writeInt(u64, &buf, val, .little); try file.writeAll(&buf); } /// Write a u32 in little-endian to a file. - fn writeU32(file: std.fs.File, val: u32) !void { + fn writeU32(file: compat.File, val: u32) !void { var buf: [4]u8 = undefined; std.mem.writeInt(u32, &buf, val, .little); try file.writeAll(&buf); } /// Read a u64 in little-endian from a file. - fn readU64(file: std.fs.File) !u64 { + fn readU64(file: compat.File) !u64 { var buf: [8]u8 = undefined; const n = try file.readAll(&buf); if (n < 8) return error.UnexpectedEof; @@ -223,7 +310,7 @@ pub const SSTable = struct { } /// Read a u32 in little-endian from a file. - fn readU32(file: std.fs.File) !u32 { + fn readU32(file: compat.File) !u32 { var buf: [4]u8 = undefined; const n = try file.readAll(&buf); if (n < 4) return error.UnexpectedEof; @@ -231,7 +318,7 @@ pub const SSTable = struct { } /// Read a single byte from a file. - fn readByte(file: std.fs.File) !u8 { + fn readByte(file: compat.File) !u8 { var buf: [1]u8 = undefined; const n = try file.readAll(&buf); if (n < 1) return error.UnexpectedEof; @@ -269,11 +356,11 @@ pub const SSTable = struct { } // Write data file. - const data_file = try std.fs.cwd().createFile(dp, .{}); + const data_file = try compat.cwd().createFile(dp, .{}); defer data_file.close(); // Write index file. - const idx_file = try std.fs.cwd().createFile(ip, .{}); + const idx_file = try compat.cwd().createFile(ip, .{}); defer idx_file.close(); var data_offset: u64 = 0; @@ -322,14 +409,14 @@ pub const SSTable = struct { // Range check. if (key_hash < self.min_key or key_hash > self.max_key) return null; - const data_file = try std.fs.cwd().openFile( + const data_file = try compat.cwd().openFile( self.data_path[0..self.data_path_len], .{}, ); defer data_file.close(); // Load sparse index to find starting offset. - const idx_file = try std.fs.cwd().openFile( + const idx_file = try compat.cwd().openFile( self.index_path[0..self.index_path_len], .{}, ); @@ -386,7 +473,7 @@ pub const SSTable = struct { } pub const Iterator = struct { - file: std.fs.File, + file: compat.File, remaining: u32, pub fn next(self: *Iterator) ?KVEntry { @@ -417,7 +504,7 @@ pub const SSTable = struct { pub fn iterator(self: *const SSTable, alloc: std.mem.Allocator) !Iterator { _ = alloc; - const f = try std.fs.cwd().openFile( + const f = try compat.cwd().openFile( self.data_path[0..self.data_path_len], .{}, ); @@ -442,7 +529,7 @@ pub const LSMTree = struct { data_dir: [256]u8, data_dir_len: usize, alloc: std.mem.Allocator, - flush_mu: std.Thread.Mutex, + flush_mu: compat.Mutex, pub const MAX_LEVELS = 7; pub const MEMTABLE_SIZE = 4 * 1024 * 1024; // 4MB @@ -451,7 +538,7 @@ pub const LSMTree = struct { pub fn init(alloc: std.mem.Allocator, data_dir: []const u8) !LSMTree { var lsm: LSMTree = undefined; - lsm.active_mem = MemTable.init(alloc); + lsm.active_mem = try MemTable.init(alloc); lsm.immutable_mem = null; lsm.alloc = alloc; lsm.next_sst_id = 0; @@ -467,7 +554,7 @@ pub const LSMTree = struct { } // Ensure data directory exists. - std.fs.cwd().makeDir(data_dir) catch |err| switch (err) { + compat.cwd().makeDir(data_dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; @@ -493,12 +580,22 @@ pub const LSMTree = struct { } pub fn get(self: *const LSMTree, key_hash: u64) ?BTreeEntry { - // 1. Check active memtable. - if (memtableSearch(self.active_mem.entries.items, key_hash)) |result| return result.entry; + // 1. Check active memtable. A tombstone shadows older SSTable entries. + if (self.active_mem.lookup(key_hash)) |v| { + return switch (v) { + .live => |bte| bte, + .tombstone => null, + }; + } - // 2. Check immutable memtable. - if (self.immutable_mem) |imm| { - if (memtableSearch(imm.entries.items, key_hash)) |result| return result.entry; + // 2. Check immutable memtable (mid-flush). + if (self.immutable_mem) |*imm| { + if (imm.lookup(key_hash)) |v| { + return switch (v) { + .live => |bte| bte, + .tombstone => null, + }; + } } // 3. Check SSTables level by level, newest first. @@ -516,31 +613,6 @@ pub const LSMTree = struct { return null; } - /// Search helper for const memtable slices. Returns the BTreeEntry (or null - /// for tombstones). The outer ?FoundEntry is null when the key isn't present - /// at all — distinguishing "not here" from "deleted here". - const FoundEntry = struct { entry: ?BTreeEntry }; - - fn memtableSearch(items: []const KVEntry, key_hash: u64) ?FoundEntry { - var lo: usize = 0; - var hi: usize = items.len; - while (lo < hi) { - const mid = lo + (hi - lo) / 2; - if (items[mid].key_hash < key_hash) { - lo = mid + 1; - } else { - hi = mid; - } - } - if (lo < items.len and items[lo].key_hash == key_hash) { - return .{ .entry = switch (items[lo].value) { - .live => |e| e, - .tombstone => null, - } }; - } - return null; - } - pub fn delete(self: *LSMTree, key_hash: u64) !void { try self.active_mem.delete(key_hash); } @@ -550,11 +622,14 @@ pub const LSMTree = struct { self.flush_mu.lock(); defer self.flush_mu.unlock(); - if (self.active_mem.entries.items.len == 0) return; + if (self.active_mem.count == 0) return; + + // Collapse runs into a single sorted+deduped run for SSTable.create. + self.active_mem.sortAndDedup(); - // Rotate: move active → immutable. + // Rotate: move active → immutable, install a fresh active. var old = self.active_mem; - self.active_mem = MemTable.init(self.alloc); + self.active_mem = try MemTable.init(self.alloc); defer { old.deinit(); @@ -566,7 +641,7 @@ pub const LSMTree = struct { const id = self.next_sst_id; self.next_sst_id += 1; const sst = try SSTable.create( - old.entries.items, + old.entries[0..old.count], 0, id, self.data_dir[0..self.data_dir_len], @@ -639,8 +714,8 @@ pub const LSMTree = struct { // Close and remove old SSTables at this level. for (src.items) |*sst| { // Delete files. - std.fs.cwd().deleteFile(sst.data_path[0..sst.data_path_len]) catch {}; - std.fs.cwd().deleteFile(sst.index_path[0..sst.index_path_len]) catch {}; + compat.cwd().deleteFile(sst.data_path[0..sst.data_path_len]) catch {}; + compat.cwd().deleteFile(sst.index_path[0..sst.index_path_len]) catch {}; sst.close(); } src.clearRetainingCapacity(); @@ -659,7 +734,7 @@ fn testEntry(key: u64, doc: u64) BTreeEntry { } test "MemTable put/get/delete" { - var mt = MemTable.init(std.testing.allocator); + var mt = try MemTable.init(std.testing.allocator); defer mt.deinit(); try mt.put(100, testEntry(100, 1)); @@ -687,7 +762,7 @@ test "MemTable put/get/delete" { try std.testing.expect(mt.get(777) == null); } test "MemTable isFull threshold" { - var mt = MemTable.init(std.testing.allocator); + var mt = try MemTable.init(std.testing.allocator); defer mt.deinit(); try std.testing.expect(!mt.isFull()); @@ -702,7 +777,7 @@ test "MemTable isFull threshold" { try std.testing.expect(mt.isFull()); } test "MemTable iterator order" { - var mt = MemTable.init(std.testing.allocator); + var mt = try MemTable.init(std.testing.allocator); defer mt.deinit(); try mt.put(300, testEntry(300, 3)); @@ -745,11 +820,15 @@ test "BloomFilter add/mayContain" { try std.testing.expect(false_positives < 500); } +fn testingTmpPath(alloc: std.mem.Allocator, tmp: *const std.testing.TmpDir) ![]u8 { + return std.fmt.allocPrint(alloc, ".zig-cache/tmp/{s}", .{tmp.sub_path}); +} + test "SSTable create and read back" { // Use a tmp directory. var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const tmp_path = tmp_dir.dir.realpathAlloc(std.testing.allocator, ".") catch unreachable; + const tmp_path = testingTmpPath(std.testing.allocator, &tmp_dir) catch unreachable; defer std.testing.allocator.free(tmp_path); // Create sorted entries. @@ -778,7 +857,7 @@ test "SSTable create and read back" { test "LSMTree put/get across memtable and SSTable" { var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const tmp_path = tmp_dir.dir.realpathAlloc(std.testing.allocator, ".") catch unreachable; + const tmp_path = testingTmpPath(std.testing.allocator, &tmp_dir) catch unreachable; defer std.testing.allocator.free(tmp_path); var lsm = try LSMTree.init(std.testing.allocator, tmp_path); @@ -811,7 +890,7 @@ test "LSMTree put/get across memtable and SSTable" { test "LSMTree flush" { var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const tmp_path = tmp_dir.dir.realpathAlloc(std.testing.allocator, ".") catch unreachable; + const tmp_path = testingTmpPath(std.testing.allocator, &tmp_dir) catch unreachable; defer std.testing.allocator.free(tmp_path); var lsm = try LSMTree.init(std.testing.allocator, tmp_path); @@ -822,7 +901,7 @@ test "LSMTree flush" { try lsm.flush(); // Active memtable should be empty after flush. - try std.testing.expectEqual(@as(usize, 0), lsm.active_mem.entries.items.len); + try std.testing.expectEqual(@as(u32, 0), lsm.active_mem.count); // L0 should have one SSTable. try std.testing.expectEqual(@as(usize, 1), lsm.levels[0].items.len); // Data still readable. @@ -832,7 +911,7 @@ test "LSMTree flush" { test "Tombstone handling" { var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const tmp_path = tmp_dir.dir.realpathAlloc(std.testing.allocator, ".") catch unreachable; + const tmp_path = testingTmpPath(std.testing.allocator, &tmp_dir) catch unreachable; defer std.testing.allocator.free(tmp_path); var lsm = try LSMTree.init(std.testing.allocator, tmp_path); diff --git a/src/main.zig b/src/main.zig index f90732c..dec99ea 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,17 +1,18 @@ /// TurboDB — entry point const std = @import("std"); +const compat = @import("compat"); const collection = @import("collection.zig"); const server = @import("server.zig"); const wire = @import("wire.zig"); -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + // GPA replaced by smp_allocator for Zig 0.16 + + const alloc = std.heap.smp_allocator; // ── CLI args ────────────────────────────────────────────────────────── - const args = try std.process.argsAlloc(alloc); - defer std.process.argsFree(alloc, args); + const args = try compat.argsAlloc(alloc, init); + defer compat.argsFree(alloc, args); var data_dir: []const u8 = "./turbodb_data"; var port: u16 = 27017; @@ -99,7 +100,7 @@ pub fn main() !void { } // ── ensure data directory ───────────────────────────────────────────── - std.fs.cwd().makeDir(data_dir) catch |e| switch (e) { + compat.cwd().makeDir(data_dir) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return e, }; @@ -132,7 +133,7 @@ pub fn main() !void { const S = struct { var g_http: ?*server.Server = null; var g_wire: ?*wire.WireServer = null; - fn handler(_: c_int) callconv(.c) void { + fn handler(_: std.c.SIG) callconv(.c) void { if (g_http) |s| s.stop(); if (g_wire) |w| w.stop(); } diff --git a/src/marketplace.zig b/src/marketplace.zig index b8b25b5..f67a3c6 100644 --- a/src/marketplace.zig +++ b/src/marketplace.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const branching = @import("branching.zig"); const collection = @import("collection.zig"); const doc_mod = @import("doc.zig"); @@ -53,7 +54,7 @@ pub const Marketplace = struct { try copyDirRecursive(self.alloc, snapshot.parent_path, base_dir); try copyDirRecursive(self.alloc, snapshot.overlay_dir, overlay_dir); - const created_at_ms = std.time.milliTimestamp(); + const created_at_ms = compat.milliTimestamp(); try self.writeManifest(release_root, snapshot, description, base_dir, overlay_dir, version, created_at_ms); return .{ .dataset_name = try self.alloc.dupe(u8, snapshot.dataset_name), @@ -78,7 +79,7 @@ pub const Marketplace = struct { const dataset_dir = try std.fmt.allocPrint(self.alloc, "{s}/{s}", .{ self.root_dir, dataset_name }); defer self.alloc.free(dataset_dir); - var dir = try std.fs.openDirAbsolute(dataset_dir, .{ .iterate = true }); + var dir = try compat.openDirAbsolute(dataset_dir, .{ .iterate = true }); defer dir.close(); var versions: std.ArrayList(u32) = .empty; @@ -111,7 +112,7 @@ pub const Marketplace = struct { try copyDirRecursive(self.alloc, release.base_dir, base_dir); try copyDirRecursive(self.alloc, release.overlay_dir, overlay_dir); - var manifest = try std.fs.createFileAbsolute(manifest_path, .{ .truncate = true }); + var manifest = try compat.createFileAbsolute(manifest_path, .{ .truncate = true }); defer manifest.close(); try manifest.writeAll(base_dir); @@ -155,7 +156,7 @@ pub const Marketplace = struct { ); defer self.alloc.free(content); - var file = try std.fs.createFileAbsolute(manifest_path, .{ .truncate = true }); + var file = try compat.createFileAbsolute(manifest_path, .{ .truncate = true }); defer file.close(); try file.writeAll(content); } @@ -197,20 +198,13 @@ fn parseManifest(alloc: std.mem.Allocator, raw: []const u8) !Release { } fn ensureDir(path: []const u8) !void { - if (std.fs.path.isAbsolute(path)) { - var root = try std.fs.openDirAbsolute("/", .{}); - defer root.close(); - const rel = std.mem.trimLeft(u8, path, "/"); - if (rel.len > 0) try root.makePath(rel); - } else { - try std.fs.cwd().makePath(path); - } + try compat.cwd().makePath(path); } fn copyDirRecursive(alloc: std.mem.Allocator, src_dir: []const u8, dst_dir: []const u8) !void { try ensureDir(dst_dir); - var dir = try std.fs.openDirAbsolute(src_dir, .{ .iterate = true }); + var dir = try compat.openDirAbsolute(src_dir, .{ .iterate = true }); defer dir.close(); var it = dir.iterate(); @@ -224,27 +218,36 @@ fn copyDirRecursive(alloc: std.mem.Allocator, src_dir: []const u8, dst_dir: []co .directory => try copyDirRecursive(alloc, src_path, dst_path), .file => { if (!std.mem.endsWith(u8, entry.name, ".pages")) continue; - try copyFileAbsolute(src_path, dst_path); + try copyFileAbsolute(alloc, src_path, dst_path); }, else => {}, } } } -fn copyFileAbsolute(src_path: []const u8, dst_path: []const u8) !void { - var src = try std.fs.openFileAbsolute(src_path, .{}); +fn copyFileAbsolute(alloc: std.mem.Allocator, src_path: []const u8, dst_path: []const u8) !void { + var src = try compat.openFileAbsolute(src_path, .{}); defer src.close(); - var dst = try std.fs.createFileAbsolute(dst_path, .{ .truncate = true }); + var dst = try compat.createFileAbsolute(dst_path, .{ .truncate = true }); defer dst.close(); const stat = try src.stat(); - _ = try src.copyRangeAll(0, dst, 0, stat.size); + const buf_len: usize = @min(@as(usize, 64 * 1024), @max(@as(usize, 1), @as(usize, @intCast(stat.size)))); + const buf = try alloc.alloc(u8, buf_len); + defer alloc.free(buf); + + var remaining = stat.size; + while (remaining > 0) { + const want: usize = @intCast(@min(remaining, buf.len)); + const n = try src.read(buf[0..want]); + if (n == 0) return error.UnexpectedEof; + try dst.writeAll(buf[0..n]); + remaining -= @intCast(n); + } } fn readFileAbsoluteAlloc(alloc: std.mem.Allocator, path: []const u8, max_bytes: usize) ![]u8 { - const file = try std.fs.openFileAbsolute(path, .{}); - defer file.close(); - return try file.readToEndAlloc(alloc, max_bytes); + return try compat.cwd().readFileAlloc(alloc, path, max_bytes); } test "marketplace publish and install keeps snapshot immutable" { @@ -252,14 +255,14 @@ test "marketplace publish and install keeps snapshot immutable" { const tmp_dir = "/tmp/turbodb_marketplace_publish"; const market_dir = "/tmp/turbodb_marketplace_catalog"; const install_dir = "/tmp/turbodb_marketplace_install"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - std.fs.cwd().deleteTree(market_dir) catch {}; - std.fs.cwd().deleteTree(install_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(market_dir) catch {}; + compat.cwd().deleteTree(install_dir) catch {}; try ensureDir(tmp_dir); try ensureDir(market_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(market_dir) catch {}; - defer std.fs.cwd().deleteTree(install_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(market_dir) catch {}; + defer compat.cwd().deleteTree(install_dir) catch {}; const base = try collection.Database.open(alloc, tmp_dir); defer base.close(); @@ -300,16 +303,16 @@ test "marketplace keeps multiple release versions installable" { const market_dir = "/tmp/turbodb_marketplace_versions_catalog"; const install_v1_dir = "/tmp/turbodb_marketplace_versions_install_v1"; const install_v2_dir = "/tmp/turbodb_marketplace_versions_install_v2"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - std.fs.cwd().deleteTree(market_dir) catch {}; - std.fs.cwd().deleteTree(install_v1_dir) catch {}; - std.fs.cwd().deleteTree(install_v2_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(market_dir) catch {}; + compat.cwd().deleteTree(install_v1_dir) catch {}; + compat.cwd().deleteTree(install_v2_dir) catch {}; try ensureDir(tmp_dir); try ensureDir(market_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(market_dir) catch {}; - defer std.fs.cwd().deleteTree(install_v1_dir) catch {}; - defer std.fs.cwd().deleteTree(install_v2_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(market_dir) catch {}; + defer compat.cwd().deleteTree(install_v1_dir) catch {}; + defer compat.cwd().deleteTree(install_v2_dir) catch {}; const base = try collection.Database.open(alloc, tmp_dir); defer base.close(); @@ -354,11 +357,11 @@ test "marketplace rejects missing releases" { const alloc = std.testing.allocator; const market_dir = "/tmp/turbodb_marketplace_missing_catalog"; const install_dir = "/tmp/turbodb_marketplace_missing_install"; - std.fs.cwd().deleteTree(market_dir) catch {}; - std.fs.cwd().deleteTree(install_dir) catch {}; + compat.cwd().deleteTree(market_dir) catch {}; + compat.cwd().deleteTree(install_dir) catch {}; try ensureDir(market_dir); - defer std.fs.cwd().deleteTree(market_dir) catch {}; - defer std.fs.cwd().deleteTree(install_dir) catch {}; + defer compat.cwd().deleteTree(market_dir) catch {}; + defer compat.cwd().deleteTree(install_dir) catch {}; var marketplace = try Marketplace.init(alloc, market_dir); defer marketplace.deinit(); diff --git a/src/page.zig b/src/page.zig index 114fbc7..503bb4c 100644 --- a/src/page.zig +++ b/src/page.zig @@ -1,5 +1,6 @@ /// TurboDB — 4KB page allocator const std = @import("std"); +const compat = @import("compat"); const mmap = @import("mmap"); pub const PAGE_SIZE: usize = 65536; // 64KB — supports code files up to ~65KB per doc pub const PAGE_HEADER_SIZE: usize = 32; @@ -34,7 +35,7 @@ pub const PageFile = struct { mm: mmap.MmapFile, free_head: std.atomic.Value(u32), // head of free-list (0 = empty) next_alloc: std.atomic.Value(u32), // next never-allocated page - mu: std.Thread.Mutex, + mu: compat.Mutex, pub fn open(path: []const u8) !PageFile { var path_buf: [std.fs.max_path_bytes + 1]u8 = undefined; diff --git a/src/partition.zig b/src/partition.zig index f7ae886..c865757 100644 --- a/src/partition.zig +++ b/src/partition.zig @@ -174,7 +174,7 @@ pub const PartitionedCollection = struct { offset: u32, alloc_: std.mem.Allocator, ) !ScanResult { - var all_docs: std.ArrayList(Doc) = .{}; + var all_docs: std.ArrayList(Doc) = .empty; defer all_docs.deinit(alloc_); // Gather from each partition — request enough to cover offset + limit @@ -234,7 +234,7 @@ pub const PartitionedCollection = struct { } // Merge - var merged: std.ArrayList(Doc) = .{}; + var merged: std.ArrayList(Doc) = .empty; defer merged.deinit(alloc_); for (results) |maybe_res| { diff --git a/src/profile_index.zig b/src/profile_index.zig index ffc0bdc..73a3547 100644 --- a/src/profile_index.zig +++ b/src/profile_index.zig @@ -6,6 +6,7 @@ /// Usage: zig build profile -- /path/to/codebase /// const std = @import("std"); +const compat = @import("compat"); const collection_mod = @import("collection.zig"); const codeindex = @import("codeindex.zig"); const fast_index = @import("fast_index.zig"); @@ -34,10 +35,10 @@ const Timer = struct { start: i128 = 0, fn begin(self: *Timer) void { - self.start = std.time.nanoTimestamp(); + self.start = compat.nanoTimestamp(); } fn end(self: *Timer) void { - self.total_ns += std.time.nanoTimestamp() - self.start; + self.total_ns += compat.nanoTimestamp() - self.start; self.count += 1; } fn avgUs(self: *const Timer) f64 { @@ -53,25 +54,14 @@ const Timer = struct { } }; -pub fn main() !void { +pub fn main(init: std.process.Init) !void { // Use page_allocator for speed (no safety overhead in profiling mode) // Switch to GPA for leak/safety checks - var gpa = std.heap.GeneralPurposeAllocator(.{ - .safety = true, - .never_unmap = true, // keeps freed pages mapped for use-after-free detection - }){}; - defer { - const check = gpa.deinit(); - if (check == .leak) { - std.debug.print("\n ⚠ MEMORY LEAK DETECTED\n", .{}); - } else { - std.debug.print("\n ✓ No memory leaks\n", .{}); - } - } - const alloc = gpa.allocator(); + // smp_allocator for Zig 0.16 (no leak checking available) + const alloc = std.heap.smp_allocator; - const args = try std.process.argsAlloc(alloc); - defer std.process.argsFree(alloc, args); + const args = try compat.argsAlloc(alloc, init); + defer compat.argsFree(alloc, args); if (args.len < 2) { std.debug.print("Usage: profile \n", .{}); @@ -95,24 +85,26 @@ pub fn main() !void { files.deinit(alloc); } - var dir = try std.fs.cwd().openDir(args[1], .{ .iterate = true }); + var dir = try compat.cwd().openDir(args[1], .{ .iterate = true }); defer dir.close(); - var walker = try dir.walk(alloc); - defer walker.deinit(); + var walker = dir.iterate(); + // iterate() needs no deinit var total_bytes: u64 = 0; while (try walker.next()) |entry| { if (entry.kind != .file) continue; - if (!hasValidExt(entry.basename)) continue; + if (!hasValidExt(entry.name)) continue; - var file = dir.openFile(entry.path, .{}) catch continue; + var path_buf2: [4096]u8 = undefined; + const full_path = std.fmt.bufPrint(&path_buf2, "{s}/{s}", .{ args[1], entry.name }) catch continue; + var file = compat.cwd().openFile(full_path, .{}) catch continue; defer file.close(); var buf: [8192]u8 = undefined; const n = file.read(&buf) catch continue; if (n < 3) continue; try files.append(alloc, .{ - .path = try alloc.dupe(u8, entry.path), + .path = try alloc.dupe(u8, entry.name), .content = try alloc.dupe(u8, buf[0..n]), }); total_bytes += n; @@ -220,14 +212,14 @@ pub fn main() !void { t_build.end(); const tmp = "/tmp/tdb_profile_disk"; - std.fs.cwd().makeDir(tmp) catch |e| switch (e) { + compat.cwd().makeDir(tmp) catch |e| switch (e) { error.PathAlreadyExists => { - std.fs.cwd().deleteTree(tmp) catch {}; - std.fs.cwd().makeDir(tmp) catch {}; + compat.cwd().deleteTree(tmp) catch {}; + compat.cwd().makeDir(tmp) catch {}; }, else => return e, }; - defer std.fs.cwd().deleteTree(tmp) catch {}; + defer compat.cwd().deleteTree(tmp) catch {}; t_write.begin(); const stats = try builder.writeToDisk(tmp); diff --git a/src/registry/api.zig b/src/registry/api.zig index adf2bcc..5dacae5 100644 --- a/src/registry/api.zig +++ b/src/registry/api.zig @@ -18,6 +18,7 @@ /// - Simple dispatch routing /// - JSON responses const std = @import("std"); +const compat = @import("compat"); const registry_mod = @import("registry.zig"); const sign_mod = @import("sign.zig"); const hash_mod = @import("hash.zig"); @@ -61,7 +62,7 @@ pub const RegistryServer = struct { } pub fn run(self: *RegistryServer) !void { - const addr = try std.net.Address.parseIp("0.0.0.0", self.port); + const addr = try compat.net.Address.parseIp("0.0.0.0", self.port); var listener = try addr.listen(.{ .reuse_address = true, .kernel_backlog = 256, @@ -87,7 +88,7 @@ pub const RegistryServer = struct { } }; -fn handleConn(srv: *RegistryServer, conn: std.net.Server.Connection) void { +fn handleConn(srv: *RegistryServer, conn: compat.net.Server.Connection) void { defer conn.stream.close(); const bufs = std.heap.page_allocator.create(ConnBufs) catch return; @@ -107,7 +108,7 @@ fn handleConn(srv: *RegistryServer, conn: std.net.Server.Connection) void { fn dispatch(srv: *RegistryServer, raw: []const u8) usize { const nl = std.mem.indexOfScalar(u8, raw, '\n') orelse return err(400, "bad request"); - const req_line = std.mem.trimRight(u8, raw[0..nl], "\r"); + const req_line = std.mem.trimEnd(u8, raw[0..nl], "\r"); var parts = std.mem.splitScalar(u8, req_line, ' '); const method = parts.next() orelse return err(400, "bad request"); const full_path = parts.next() orelse return err(400, "bad request"); @@ -131,8 +132,8 @@ fn dispatch(srv: *RegistryServer, raw: []const u8) usize { // ─── /metrics ─────────────────────────────────────────────────────── if (std.mem.eql(u8, path, "/metrics")) { - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), "{{\"requests\":{d},\"errors\":{d}}}", .{ + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"requests\":{d},\"errors\":{d}}}", .{ srv.req_count.load(.acquire), srv.err_count.load(.acquire), }) catch {}; @@ -215,18 +216,18 @@ fn handleSearch(srv: *RegistryServer, query_str: []const u8, auth: AuthContext) var results: [50]registry_mod.PackageInfo = undefined; const count = srv.registry.searchAuth(q, capped, &results, auth) catch return err(500, "search failed"); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); w.writeAll("{\"results\":[") catch {}; for (0..count) |i| { if (i > 0) w.writeAll(",") catch {}; - std.fmt.format(w, "{{\"name\":\"{s}\",\"description\":\"{s}\",\"version\":\"{s}\"}}", .{ + compat.format(w, "{{\"name\":\"{s}\",\"description\":\"{s}\",\"version\":\"{s}\"}}", .{ results[i].name, results[i].description, results[i].version, }) catch {}; } - std.fmt.format(w, "],\"count\":{d}}}", .{count}) catch {}; + compat.format(w, "],\"count\":{d}}}", .{count}) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } @@ -255,8 +256,8 @@ fn handlePublish(srv: *RegistryServer, body: []const u8) usize { }; }; - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), "{{\"ok\":true,\"name\":\"{s}\",\"version\":\"{s}\",\"hash\":\"{s}\"}}", .{ + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"ok\":true,\"name\":\"{s}\",\"version\":\"{s}\",\"hash\":\"{s}\"}}", .{ result.package_name, result.version, result.source_hash_hex, @@ -318,8 +319,8 @@ fn handleDownload(srv: *RegistryServer, hash_hex: []const u8) usize { // For blobs, return raw data (not JSON) if (data.len > MAX_RESP - 256) return err(413, "blob too large for response buffer"); - var fbs = std.io.fixedBufferStream(getRespBuf()); - std.fmt.format(fbs.writer(), "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", .{data.len}) catch {}; + var fbs = compat.fixedBufferStream(getRespBuf()); + compat.format(fbs.writer(), "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", .{data.len}) catch {}; const header_len = fbs.pos; if (header_len + data.len <= MAX_RESP) { @memcpy(getRespBuf()[header_len..][0..data.len], data); @@ -361,8 +362,8 @@ fn err(code: u16, msg: []const u8) usize { } fn respond(code: u16, status: []const u8, body: []const u8) usize { - var fbs = std.io.fixedBufferStream(getRespBuf()); - std.fmt.format(fbs.writer(), "HTTP/1.1 {d} {s}\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n{s}", .{ code, status, body.len, body }) catch {}; + var fbs = compat.fixedBufferStream(getRespBuf()); + compat.format(fbs.writer(), "HTTP/1.1 {d} {s}\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n{s}", .{ code, status, body.len, body }) catch {}; return fbs.pos; } diff --git a/src/registry/auth.zig b/src/registry/auth.zig index 5176274..e39be5d 100644 --- a/src/registry/auth.zig +++ b/src/registry/auth.zig @@ -6,6 +6,7 @@ /// - Access grants (per-package, per-pubkey permissions) /// - Request signing/verification (Ed25519 over method+path+timestamp) const std = @import("std"); +const compat = @import("compat"); const sign_mod = @import("sign.zig"); // ─── Visibility ──────────────────────────────────────────────────────────────── @@ -54,7 +55,7 @@ pub const Permissions = struct { /// Serialize to JSON-friendly string: "read,publish,yank,admin" pub fn toStr(self: Permissions, buf: []u8) ![]const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); var first = true; if (self.read) { if (!first) try w.writeAll(","); try w.writeAll("read"); first = false; } @@ -204,7 +205,7 @@ pub fn signRequest(method: []const u8, path: []const u8, timestamp: i64, secret_ /// Verify an HTTP request signature. pub fn verifyRequest(method: []const u8, path: []const u8, timestamp: i64, signature: [64]u8, pubkey: [32]u8) bool { // Check timestamp is within 5 minutes - const now = std.time.timestamp(); + const now = compat.timestamp(); const diff = if (now > timestamp) now - timestamp else timestamp - now; if (diff > 300) return false; // 5 minute window @@ -362,7 +363,7 @@ test "format package key" { test "request signing and verification" { const kp = sign_mod.KeyPair.generate(); - const now = std.time.timestamp(); + const now = compat.timestamp(); const sig = signRequest("GET", "/api/v1/packages/test", now, kp.secret_key); try std.testing.expect(verifyRequest("GET", "/api/v1/packages/test", now, sig, kp.public_key)); diff --git a/src/registry/cli.zig b/src/registry/cli.zig index 5543514..d12e056 100644 --- a/src/registry/cli.zig +++ b/src/registry/cli.zig @@ -13,6 +13,7 @@ /// audit Verify all dep signatures and hashes /// keygen Generate Ed25519 keypair const std = @import("std"); +const compat = @import("compat"); const manifest_mod = @import("manifest.zig"); const sign_mod = @import("sign.zig"); const hash_mod = @import("hash.zig"); @@ -25,13 +26,13 @@ const DEFAULT_REGISTRY = "http://localhost:8080"; const ZAG_DIR = ".zag"; const GLOBAL_DIR = ".zag"; // ~/.zag/ -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + // GPA replaced by smp_allocator for Zig 0.16 - const args = try std.process.argsAlloc(alloc); - defer std.process.argsFree(alloc, args); + const alloc = std.heap.smp_allocator; + + const args = try compat.argsAlloc(alloc, init); + defer compat.argsFree(alloc, args); if (args.len < 2) { printUsage(); @@ -105,28 +106,28 @@ fn cmdInit(alloc: std.mem.Allocator, args: []const []const u8) !void { } // Check if zag.json already exists - std.fs.cwd().access("zag.json", .{}) catch |e| { + compat.cwd().access("zag.json", .{}) catch |e| { if (e == error.FileNotFound) { var buf: [2048]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); + var fbs = compat.fixedBufferStream(&buf); const w = fbs.writer(); try w.writeAll("{\n"); - try std.fmt.format(w, " \"name\": \"{s}\",\n", .{name}); + try compat.format(w, " \"name\": \"{s}\",\n", .{name}); try w.writeAll(" \"version\": \"0.1.0\",\n"); try w.writeAll(" \"description\": \"\",\n"); - try std.fmt.format(w, " \"visibility\": \"{s}\",\n", .{if (private) "private" else "public"}); + try compat.format(w, " \"visibility\": \"{s}\",\n", .{if (private) "private" else "public"}); if (org) |o| { - try std.fmt.format(w, " \"org\": \"{s}\",\n", .{o}); + try compat.format(w, " \"org\": \"{s}\",\n", .{o}); } try w.writeAll(" \"license\": \"MIT\",\n"); - try w.writeAll(" \"zig_version\": \"0.15.0\",\n"); + try w.writeAll(" \"zig_version\": \"0.16.0\",\n"); try w.writeAll(" \"dependencies\": {},\n"); try w.writeAll(" \"dev_dependencies\": {}\n"); try w.writeAll("}"); const content = fbs.getWritten(); - const file = try std.fs.cwd().createFile("zag.json", .{}); + const file = try compat.cwd().createFile("zag.json", .{}); defer file.close(); try file.writeAll(content); @@ -143,7 +144,7 @@ fn cmdInit(alloc: std.mem.Allocator, args: []const []const u8) !void { } fn cmdKeygen() !void { - const home = std.posix.getenv("HOME") orelse "/tmp"; + const home = blk: { const e = std.c.getenv("HOME"); break :blk if (e) |p| std.mem.sliceTo(p, 0) else "/tmp"; }; var path_buf: [512]u8 = undefined; const keys_dir = try std.fmt.bufPrint(&path_buf, "{s}/.zag/keys", .{home}); @@ -190,7 +191,7 @@ fn cmdSearch(alloc: std.mem.Allocator, args: []const []const u8) !void { fn cmdPublish(alloc: std.mem.Allocator) !void { // Read zag.json - const manifest_content = std.fs.cwd().readFileAlloc(alloc, "zag.json", 64 * 1024) catch { + const manifest_content = compat.cwd().readFileAlloc(alloc, "zag.json", 64 * 1024) catch { std.debug.print("No zag.json found. Run 'zag init' first.\n", .{}); return; }; @@ -217,7 +218,7 @@ fn cmdPublish(alloc: std.mem.Allocator) !void { fn cmdAudit(alloc: std.mem.Allocator) !void { // Read lockfile - const lockfile = std.fs.cwd().readFileAlloc(alloc, ".zag/lock.json", 1024 * 1024) catch { + const lockfile = compat.cwd().readFileAlloc(alloc, ".zag/lock.json", 1024 * 1024) catch { std.debug.print("No .zag/lock.json found. Run 'zag install' first.\n", .{}); return; }; @@ -236,7 +237,7 @@ fn cmdLogin(args: []const []const u8) !void { const url = args[0]; // Generate or load keypair - const home = std.posix.getenv("HOME") orelse "/tmp"; + const home = blk: { const e = std.c.getenv("HOME"); break :blk if (e) |p| std.mem.sliceTo(p, 0) else "/tmp"; }; var keys_buf: [512]u8 = undefined; const keys_dir = try std.fmt.bufPrint(&keys_buf, "{s}/.zag/keys", .{home}); diff --git a/src/registry/config.zig b/src/registry/config.zig index 7272f65..2cb983e 100644 --- a/src/registry/config.zig +++ b/src/registry/config.zig @@ -12,6 +12,7 @@ /// "default_org": null /// } const std = @import("std"); +const compat = @import("compat"); pub const RegistryEntry = struct { name: []const u8, @@ -39,21 +40,21 @@ pub const Config = struct { /// Serialize config to JSON. pub fn toJson(self: Config, buf: []u8) ![]const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); try w.writeAll("{\"registries\":{"); for (self.registries, 0..) |reg, i| { if (i > 0) try w.writeAll(","); - try std.fmt.format(w, "\"{s}\":{{\"url\":\"{s}\"", .{ reg.name, reg.url }); + try compat.format(w, "\"{s}\":{{\"url\":\"{s}\"", .{ reg.name, reg.url }); if (reg.pubkey_hex) |pk| { - try std.fmt.format(w, ",\"pubkey\":\"{s}\"", .{pk}); + try compat.format(w, ",\"pubkey\":\"{s}\"", .{pk}); } try w.writeAll("}"); } - try std.fmt.format(w, "}},\"default_registry\":\"{s}\"", .{self.default_registry}); + try compat.format(w, "}},\"default_registry\":\"{s}\"", .{self.default_registry}); if (self.default_org) |org| { - try std.fmt.format(w, ",\"default_org\":\"{s}\"", .{org}); + try compat.format(w, ",\"default_org\":\"{s}\"", .{org}); } else { try w.writeAll(",\"default_org\":null"); } @@ -89,7 +90,7 @@ pub fn globalConfigPath(buf: []u8) ![]const u8 { pub fn saveGlobalConfig(config: Config) !void { var dir_buf: [512]u8 = undefined; const dir = try globalConfigDir(&dir_buf); - std.fs.cwd().makePath(dir) catch {}; + compat.cwd().makePath(dir) catch {}; var path_buf: [512]u8 = undefined; const path = try globalConfigPath(&path_buf); @@ -97,7 +98,7 @@ pub fn saveGlobalConfig(config: Config) !void { var json_buf: [4096]u8 = undefined; const json = try config.toJson(&json_buf); - const file = try std.fs.cwd().createFile(path, .{}); + const file = try compat.cwd().createFile(path, .{}); defer file.close(); try file.writeAll(json); try file.writeAll("\n"); diff --git a/src/registry/hash.zig b/src/registry/hash.zig index 30a4825..e535842 100644 --- a/src/registry/hash.zig +++ b/src/registry/hash.zig @@ -9,6 +9,7 @@ /// /// Same source tree ALWAYS produces same hash regardless of filesystem ordering. const std = @import("std"); +const compat = @import("compat"); const Blake3 = std.crypto.hash.Blake3; /// Hash raw bytes with BLAKE3. Returns 32-byte digest. @@ -45,7 +46,7 @@ pub fn hashSourceTree(alloc: std.mem.Allocator, dir_path: []const u8) !struct { var full_path_buf: [4096]u8 = undefined; const full_path = std.fmt.bufPrint(&full_path_buf, "{s}/{s}", .{ dir_path, rel_path }) catch continue; - const content = std.fs.cwd().readFileAlloc(alloc, full_path, 64 * 1024 * 1024) catch continue; + const content = compat.cwd().readFileAlloc(alloc, full_path, 64 * 1024 * 1024) catch continue; defer alloc.free(content); // BLAKE3(path ++ \0 ++ content) @@ -88,7 +89,7 @@ fn collectFiles( else base_dir; - var dir = std.fs.cwd().openDir(dir_to_open, .{ .iterate = true }) catch return; + var dir = compat.cwd().openDir(dir_to_open, .{ .iterate = true }) catch return; defer dir.close(); var iter = dir.iterate(); diff --git a/src/registry/main.zig b/src/registry/main.zig index 07bcfdd..e125950 100644 --- a/src/registry/main.zig +++ b/src/registry/main.zig @@ -4,20 +4,21 @@ /// /// Starts the ZagDB package registry HTTP server backed by TurboDB. const std = @import("std"); +const compat = @import("compat"); const api = @import("api.zig"); const registry_mod = @import("registry.zig"); -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + // GPA replaced by smp_allocator for Zig 0.16 + + const alloc = std.heap.smp_allocator; var port: u16 = 8080; var data_dir: []const u8 = "./zagdb-data"; // Parse CLI args - const args = try std.process.argsAlloc(alloc); - defer std.process.argsFree(alloc, args); + const args = try compat.argsAlloc(alloc, init); + defer compat.argsFree(alloc, args); var i: usize = 1; while (i < args.len) : (i += 1) { @@ -56,7 +57,7 @@ pub fn main() !void { } // Ensure data directory exists - std.fs.cwd().makeDir(data_dir) catch |e| switch (e) { + compat.cwd().makeDir(data_dir) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return e, }; diff --git a/src/registry/manifest.zig b/src/registry/manifest.zig index eacd088..8ae1649 100644 --- a/src/registry/manifest.zig +++ b/src/registry/manifest.zig @@ -12,13 +12,14 @@ /// "author": "Alice", /// "license": "MIT", /// "tags": ["web", "http", "framework"], -/// "zig_version": "0.15.0", +/// "zig_version": "0.16.0", /// "dependencies": { /// "router": "^1.0.0", /// "json": {"version": "^0.5.0", "hash": "a3f9c8..."} /// } /// } const std = @import("std"); +const compat = @import("compat"); pub const Dependency = struct { name: []const u8, @@ -39,12 +40,12 @@ pub const Manifest = struct { tags: []const []const u8 = &.{}, dependencies: []const Dependency = &.{}, dev_dependencies: []const Dependency = &.{}, - zig_version: []const u8 = "0.15.0", + zig_version: []const u8 = "0.16.0", /// Serialize manifest to JSON for TurboDB storage. /// Returns a slice into buf. pub fn toJson(self: Manifest, buf: []u8) ![]const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); try w.writeAll("{"); @@ -111,7 +112,7 @@ pub fn template(name: []const u8, buf: []u8) ![]const u8 { \\ "description": "", \\ "author": "", \\ "license": "MIT", - \\ "zig_version": "0.15.0", + \\ "zig_version": "0.16.0", \\ "tags": [], \\ "dependencies": {{}}, \\ "dev_dependencies": {{}} @@ -158,7 +159,7 @@ pub fn parse(alloc: std.mem.Allocator, source: []const u8) !Manifest { .license = jsonGetStr(source, "license") orelse "", .visibility = jsonGetStr(source, "visibility") orelse "public", .org = jsonGetStr(source, "org"), - .zig_version = jsonGetStr(source, "zig_version") orelse "0.15.0", + .zig_version = jsonGetStr(source, "zig_version") orelse "0.16.0", }; } diff --git a/src/registry/provenance.zig b/src/registry/provenance.zig index f6c93df..e4346bf 100644 --- a/src/registry/provenance.zig +++ b/src/registry/provenance.zig @@ -4,6 +4,7 @@ /// by a specific builder, at a specific time. Users can verify the chain: /// source → build → artifact const std = @import("std"); +const compat = @import("compat"); const sign_mod = @import("sign.zig"); const hash_mod = @import("hash.zig"); @@ -28,7 +29,7 @@ pub fn createAttestation( buf: *AttestationBuf, ) Provenance { const pubkey_hex = sign_mod.pubkeyHex(keypair.public_key); - const now = std.time.timestamp(); + const now = compat.timestamp(); // Build message to sign const msg = std.fmt.bufPrint(&buf.msg_buf, "provenance:{s}:{s}:{s}", .{ @@ -114,7 +115,7 @@ test "create and verify attestation" { "abc123source", "aarch64-macos", "def456artifact", - "0.15.0", + "0.16.0", kp, &att_buf, ); @@ -134,7 +135,7 @@ test "attestation fails with wrong key" { "src_hash", "x86_64-linux", "art_hash", - "0.15.0", + "0.16.0", kp1, &att_buf, ); @@ -151,7 +152,7 @@ test "attestation toJson" { "source123", "aarch64-macos", "artifact456", - "0.15.0", + "0.16.0", kp, &att_buf, ); @@ -162,5 +163,5 @@ test "attestation toJson" { try std.testing.expect(std.mem.indexOf(u8, json, "source123") != null); try std.testing.expect(std.mem.indexOf(u8, json, "aarch64-macos") != null); try std.testing.expect(std.mem.indexOf(u8, json, "artifact456") != null); - try std.testing.expect(std.mem.indexOf(u8, json, "0.15.0") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "0.16.0") != null); } diff --git a/src/registry/registry.zig b/src/registry/registry.zig index 444d3f6..2ad1c06 100644 --- a/src/registry/registry.zig +++ b/src/registry/registry.zig @@ -13,6 +13,7 @@ /// blobs — key: blake3 hex → blob metadata JSON /// identities — key: ed25519 pubkey hex → profile JSON const std = @import("std"); +const compat = @import("compat"); const hash_mod = @import("hash.zig"); const sign_mod = @import("sign.zig"); const manifest_mod = @import("manifest.zig"); @@ -59,7 +60,7 @@ pub const Registry = struct { pub fn init(alloc: std.mem.Allocator, data_dir: []const u8) !Registry { // Ensure data directory exists - std.fs.cwd().makePath(data_dir) catch {}; + compat.cwd().makePath(data_dir) catch {}; return .{ .store = try store_mod.BlobStore.init(alloc, data_dir), @@ -140,7 +141,7 @@ pub const Registry = struct { // 7. Build package metadata JSON var pubkey_hex = sign_mod.pubkeyHex(pubkey); var sig_hex = sign_mod.signatureHex(signature); - const now = std.time.timestamp(); + const now = compat.timestamp(); // Package metadata (upsert — latest version wins) var pkg_buf: [2048]u8 = undefined; @@ -313,7 +314,7 @@ pub const Registry = struct { /// Register an author identity. pub fn registerIdentity(self: *Registry, pubkey: [32]u8, display_name: []const u8, email: []const u8) !void { var pubkey_hex = sign_mod.pubkeyHex(pubkey); - const now = std.time.timestamp(); + const now = compat.timestamp(); var buf: [1024]u8 = undefined; const json = try std.fmt.bufPrint(&buf, @@ -413,8 +414,8 @@ fn jsonGetField(json: []const u8, key: []const u8) ?[]const u8 { test "publish and search" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -451,8 +452,8 @@ test "publish and search" { test "publish duplicate version fails" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-dup"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -475,8 +476,8 @@ test "publish duplicate version fails" { test "invalid signature rejected" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-badsig"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -498,8 +499,8 @@ test "invalid signature rejected" { test "yank version" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-yank"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -528,8 +529,8 @@ test "yank version" { test "download after publish" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-dl"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -554,8 +555,8 @@ test "download after publish" { test "register and lookup identity" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-id"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -572,8 +573,8 @@ test "register and lookup identity" { test "private package hidden from anonymous search" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-priv-search"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -609,8 +610,8 @@ test "private package hidden from anonymous search" { test "getPackageAuth visibility" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-pkg-auth"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -654,8 +655,8 @@ test "getPackageAuth visibility" { test "grantAccess and visibility" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-grant"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -692,8 +693,8 @@ test "grantAccess and visibility" { test "org-scoped package" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-org"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); diff --git a/src/registry/resolver.zig b/src/registry/resolver.zig index ed59751..74dd584 100644 --- a/src/registry/resolver.zig +++ b/src/registry/resolver.zig @@ -12,6 +12,7 @@ /// 5. Cycle detection via visited set /// 6. Output: topologically sorted flat list const std = @import("std"); +const compat = @import("compat"); const semver = @import("semver.zig"); const registry_mod = @import("registry.zig"); const manifest_mod = @import("manifest.zig"); @@ -135,7 +136,7 @@ pub const Resolver = struct { /// Generate a deterministic lockfile JSON. pub fn toLockfile(resolved: []const ResolvedDep, buf: []u8) ![]const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); try w.writeAll("{\"locked\":["); @@ -143,7 +144,7 @@ pub const Resolver = struct { if (i > 0) try w.writeAll(","); var ver_buf: [64]u8 = undefined; const ver_str = dep.version.format(&ver_buf) catch "0.0.0"; - try std.fmt.format(w, "{{\"name\":\"{s}\",\"version\":\"{s}\",\"hash\":\"{s}\"}}", .{ + try compat.format(w, "{{\"name\":\"{s}\",\"version\":\"{s}\",\"hash\":\"{s}\"}}", .{ dep.name, ver_str, dep.source_hash, @@ -195,8 +196,8 @@ fn jsonGetField(json: []const u8, key: []const u8) ?[]const u8 { test "resolve pinned deps" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-resolver-test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try registry_mod.Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -218,8 +219,8 @@ test "resolve pinned deps" { test "conflicting versions error" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-resolver-conflict"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try registry_mod.Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -258,8 +259,8 @@ test "lockfile generation" { test "resolve from registry" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-resolver-reg"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try registry_mod.Registry.init(alloc, tmp_dir); defer reg.deinit(); diff --git a/src/registry/sign.zig b/src/registry/sign.zig index a1b8a02..ccf31c4 100644 --- a/src/registry/sign.zig +++ b/src/registry/sign.zig @@ -4,6 +4,7 @@ /// The signing target is the BLAKE3 hash of the source tarball. /// Keypairs are stored at ~/.zag/keys/ const std = @import("std"); +const compat = @import("compat"); const Ed25519 = std.crypto.sign.Ed25519; pub const KeyPair = struct { @@ -12,7 +13,9 @@ pub const KeyPair = struct { /// Generate a new random Ed25519 keypair. pub fn generate() KeyPair { - const kp = Ed25519.KeyPair.generate(); + var seed: [32]u8 = undefined; + compat.randomBytes(&seed); + const kp = Ed25519.KeyPair.generateDeterministic(seed) catch unreachable; return .{ .public_key = kp.public_key.toBytes(), .secret_key = kp.secret_key.toBytes(), @@ -98,16 +101,15 @@ fn hexDecode64(hex: []const u8) ![64]u8 { /// Creates: /default.pub (64 hex chars) and /default.sec (128 hex chars) pub fn saveKeyPair(kp: KeyPair, dir_path: []const u8) !void { // Ensure directory exists - std.fs.cwd().makePath(dir_path) catch {}; - - var dir = try std.fs.cwd().openDir(dir_path, .{}); - defer dir.close(); + compat.cwd().makePath(dir_path) catch {}; // Write public key { var hex: [64]u8 = undefined; hexEncode32(kp.public_key, &hex); - const file = try dir.createFile("default.pub", .{}); + var pub_path: [600]u8 = undefined; + const pub_p = std.fmt.bufPrint(&pub_path, "{s}/default.pub", .{dir_path}) catch return error.NameTooLong; + const file = try compat.cwd().createFile(pub_p, .{}); defer file.close(); try file.writeAll(&hex); try file.writeAll("\n"); @@ -117,7 +119,9 @@ pub fn saveKeyPair(kp: KeyPair, dir_path: []const u8) !void { { var hex: [128]u8 = undefined; hexEncode64(kp.secret_key, &hex); - const file = try dir.createFile("default.sec", .{}); + var sec_path: [600]u8 = undefined; + const sec_p = std.fmt.bufPrint(&sec_path, "{s}/default.sec", .{dir_path}) catch return error.NameTooLong; + const file = try compat.cwd().createFile(sec_p, .{}); defer file.close(); try file.writeAll(&hex); try file.writeAll("\n"); @@ -126,7 +130,7 @@ pub fn saveKeyPair(kp: KeyPair, dir_path: []const u8) !void { /// Load keypair from directory. pub fn loadKeyPair(dir_path: []const u8) !KeyPair { - var dir = try std.fs.cwd().openDir(dir_path, .{}); + var dir = try compat.cwd().openDir(dir_path, .{}); defer dir.close(); // Read public key @@ -207,7 +211,7 @@ test "save and load keypair" { const tmp_dir = "/tmp/zag-test-keys"; // Clean up from previous runs - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; try saveKeyPair(kp, tmp_dir); const loaded = try loadKeyPair(tmp_dir); @@ -220,7 +224,7 @@ test "save and load keypair" { try std.testing.expect(verify("test", sig, loaded.public_key)); // Clean up - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } test "pubkey and signature hex" { diff --git a/src/registry/store.zig b/src/registry/store.zig index e8d7bb3..3dea938 100644 --- a/src/registry/store.zig +++ b/src/registry/store.zig @@ -6,6 +6,7 @@ /// TurboDB's `blobs` collection tracks metadata only (hash, size, disk path). /// Deduplication is automatic: same content → same hash → same file. const std = @import("std"); +const compat = @import("compat"); const hash_mod = @import("hash.zig"); /// BlobStore manages content-addressed blob storage. @@ -18,7 +19,7 @@ pub const BlobStore = struct { // Ensure blobs directory exists var path_buf: [512]u8 = undefined; const blobs_dir = try std.fmt.bufPrint(&path_buf, "{s}/blobs", .{data_dir}); - try std.fs.cwd().makePath(blobs_dir); + try compat.cwd().makePath(blobs_dir); return .{ .data_dir = data_dir, @@ -44,22 +45,22 @@ pub const BlobStore = struct { if (self.existsPath(file_path)) return content_hash; // Create prefix directory - std.fs.cwd().makePath(prefix_dir) catch {}; + compat.cwd().makePath(prefix_dir) catch {}; // Write blob atomically: write to .tmp, then rename var tmp_buf: [512]u8 = undefined; const tmp_path = try std.fmt.bufPrint(&tmp_buf, "{s}.tmp", .{file_path}); { - const file = try std.fs.cwd().createFile(tmp_path, .{}); + const file = try compat.cwd().createFile(tmp_path, .{}); defer file.close(); try file.writeAll(data); } // Atomic rename - std.fs.cwd().rename(tmp_path, file_path) catch |err| { + compat.cwd().rename(tmp_path, file_path) catch |err| { // If rename fails, try to clean up tmp - std.fs.cwd().deleteFile(tmp_path) catch {}; + compat.cwd().deleteFile(tmp_path) catch {}; return err; }; @@ -88,7 +89,7 @@ pub const BlobStore = struct { hash_hex[0..], }) catch return null; - const file = std.fs.cwd().openFile(path, .{}) catch return null; + const file = compat.cwd().openFile(path, .{}) catch return null; defer file.close(); const stat = file.stat() catch return null; return stat.size; @@ -103,11 +104,11 @@ pub const BlobStore = struct { hash_hex[0..2], hash_hex[0..], }); - return std.fs.cwd().readFileAlloc(self.alloc, path, 256 * 1024 * 1024); + return compat.cwd().readFileAlloc(self.alloc, path, 256 * 1024 * 1024); } /// Open a blob file for streaming reads. Caller owns the file handle. - pub fn openBlob(self: *BlobStore, hash_hex: []const u8) !std.fs.File { + pub fn openBlob(self: *BlobStore, hash_hex: []const u8) !compat.File { if (hash_hex.len != 64) return error.InvalidHash; var path_buf: [512]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "{s}/blobs/{s}/{s}.tar.zst", .{ @@ -115,7 +116,7 @@ pub const BlobStore = struct { hash_hex[0..2], hash_hex[0..], }); - return std.fs.cwd().openFile(path, .{}); + return compat.cwd().openFile(path, .{}); } /// Get the filesystem path for a blob (for serving over HTTP). @@ -135,14 +136,14 @@ pub const BlobStore = struct { hash_hex[0..2], hash_hex[0..], }); - const now = std.time.timestamp(); + const now = compat.timestamp(); return std.fmt.bufPrint(buf, \\{{"hash":"{s}","size":{d},"content_type":"application/zstd","disk_path":"{s}","uploaded_at":{d},"ref_count":1}} , .{ hash_hex, data_len, disk_path, now }); } fn existsPath(_: *BlobStore, path: []const u8) bool { - std.fs.cwd().access(path, .{}) catch return false; + compat.cwd().access(path, .{}) catch return false; return true; } }; @@ -152,7 +153,7 @@ pub const BlobStore = struct { test "put and read blob" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-store-test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var store = try BlobStore.init(alloc, tmp_dir); @@ -172,13 +173,13 @@ test "put and read blob" { // Size should match try std.testing.expectEqual(@as(u64, data.len), store.blobSize(&hex).?); - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } test "put deduplicates" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-store-dedup"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var store = try BlobStore.init(alloc, tmp_dir); @@ -189,13 +190,13 @@ test "put deduplicates" { // Same hash try std.testing.expectEqual(h1, h2); - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } test "different content different hash" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-store-diff"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var store = try BlobStore.init(alloc, tmp_dir); @@ -212,13 +213,13 @@ test "different content different hash" { try std.testing.expect(store.exists(&hex1)); try std.testing.expect(store.exists(&hex2)); - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } test "nonexistent blob" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-store-none"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var store = try BlobStore.init(alloc, tmp_dir); @@ -226,13 +227,13 @@ test "nonexistent blob" { try std.testing.expect(!store.exists(fake_hex)); try std.testing.expectEqual(@as(?u64, null), store.blobSize(fake_hex)); - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } test "metadata json" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-store-meta"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var store = try BlobStore.init(alloc, tmp_dir); @@ -248,5 +249,5 @@ test "metadata json" { try std.testing.expect(std.mem.indexOf(u8, json, &hex) != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"size\":9") != null); - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } diff --git a/src/replication/calvin.zig b/src/replication/calvin.zig index ad5324b..f534da1 100644 --- a/src/replication/calvin.zig +++ b/src/replication/calvin.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const sequencer = @import("sequencer.zig"); pub const NodeId = u16; @@ -48,7 +49,7 @@ pub const CalvinExecutor = struct { // Execution state last_executed_epoch: u64, - execute_mu: std.Thread.Mutex, + execute_mu: compat.Mutex, pub fn init(alloc: std.mem.Allocator, node_id: NodeId) CalvinExecutor { return .{ @@ -112,7 +113,7 @@ pub const CalvinExecutor = struct { /// Each txn: [txn_id:u64][type:u8][partition:u16][key_hash:u64] /// [data_len:u32][data...][rs_len:u32][rs...][ws_len:u32][ws...] pub fn serializeBatch(batch: *const sequencer.Batch, buf: []u8) !usize { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); try w.writeInt(u64, batch.epoch, .little); @@ -143,7 +144,7 @@ pub const CalvinExecutor = struct { /// Deserialize a batch from network bytes. pub fn deserializeBatch(data: []const u8, alloc: std.mem.Allocator) !sequencer.Batch { - var fbs = std.io.fixedBufferStream(data); + var fbs = compat.constFixedBufferStream(data); const r = fbs.reader(); const epoch = try r.readInt(u64, .little); diff --git a/src/replication/peer.zig b/src/replication/peer.zig index 9edc542..68d4e6d 100644 --- a/src/replication/peer.zig +++ b/src/replication/peer.zig @@ -6,6 +6,7 @@ /// /// Protocol: simple framed TCP — [4-byte big-endian length][batch payload] const std = @import("std"); +const compat = @import("compat"); const sequencer = @import("sequencer.zig"); const calvin = @import("calvin.zig"); @@ -51,15 +52,15 @@ pub const PeerSender = struct { fn sendToPeer(peer: *const PeerAddr, payload: []const u8) !void { // Parse IP address - const addr = try std.net.Address.parseIp(peer.hostSlice(), peer.port); - const stream = try std.net.tcpConnectToAddress(addr); + const addr = try compat.net.Address.parseIp(peer.hostSlice(), peer.port); + const stream = try compat.net.tcpConnectToAddress(addr); defer stream.close(); // Write length-prefixed frame var len_buf: [4]u8 = undefined; std.mem.writeInt(u32, &len_buf, @intCast(payload.len), .big); - _ = try stream.write(&len_buf); - _ = try stream.write(payload); + try stream.writeAll(&len_buf); + try stream.writeAll(payload); } }; @@ -86,7 +87,7 @@ pub const PeerReceiver = struct { } pub fn run(self: *PeerReceiver) !void { - const addr = try std.net.Address.parseIp("0.0.0.0", self.port); + const addr = try compat.net.Address.parseIp("0.0.0.0", self.port); var listener = try addr.listen(.{ .reuse_address = true }); defer listener.deinit(); @@ -107,7 +108,7 @@ pub const PeerReceiver = struct { self.running.store(false, .release); } - fn handlePeerConn(self: *PeerReceiver, conn: std.net.Server.Connection) void { + fn handlePeerConn(self: *PeerReceiver, conn: compat.net.Server.Connection) void { defer conn.stream.close(); // Read length-prefixed frame diff --git a/src/replication/sequencer.zig b/src/replication/sequencer.zig index 99a4925..ab71299 100644 --- a/src/replication/sequencer.zig +++ b/src/replication/sequencer.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); pub const TxnType = enum(u8) { put, delete, read }; @@ -45,7 +46,7 @@ pub const Sequencer = struct { next_seq: u64, current_epoch: u64, batch_window_ns: u64, // batching window in nanoseconds (default 5ms) - mu: std.Thread.Mutex, + mu: compat.Mutex, const default_batch_window_ns: u64 = 5_000_000; // 5ms diff --git a/src/replication/shard.zig b/src/replication/shard.zig index 5c8ec02..67f6df0 100644 --- a/src/replication/shard.zig +++ b/src/replication/shard.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); pub const NodeId = u16; pub const PartitionId = u16; @@ -137,7 +138,7 @@ pub const ShardManager = struct { ring: HashRing, local_node_id: NodeId, alloc: std.mem.Allocator, - mu: std.Thread.RwLock, + mu: compat.RwLock, migrations: std.ArrayListUnmanaged(Migration), pub fn init(alloc: std.mem.Allocator, local_node_id: NodeId) ShardManager { diff --git a/src/scale_bench.zig b/src/scale_bench.zig index 2af48f4..7db0749 100644 --- a/src/scale_bench.zig +++ b/src/scale_bench.zig @@ -7,6 +7,7 @@ /// Usage: zig build scale-bench -- /path/to/openclaw/src /// const std = @import("std"); +const compat = @import("compat"); const collection_mod = @import("collection.zig"); const codeindex = @import("codeindex.zig"); const Database = collection_mod.Database; @@ -27,13 +28,13 @@ fn hasValidExt(name_: []const u8) bool { return false; } -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + // GPA replaced by smp_allocator for Zig 0.16 - const args = try std.process.argsAlloc(alloc); - defer std.process.argsFree(alloc, args); + const alloc = std.heap.smp_allocator; + + const args = try compat.argsAlloc(alloc, init); + defer compat.argsFree(alloc, args); if (args.len < 2) { std.debug.print("Usage: scale-bench \n", .{}); @@ -57,23 +58,25 @@ pub fn main() !void { files.deinit(alloc); } - var dir = try std.fs.cwd().openDir(src_dir, .{ .iterate = true }); + var dir = try compat.cwd().openDir(src_dir, .{ .iterate = true }); defer dir.close(); - var walker = try dir.walk(alloc); - defer walker.deinit(); + var walker = dir.iterate(); + // iterate() needs no deinit while (try walker.next()) |entry| { if (entry.kind != .file) continue; - if (!hasValidExt(entry.basename)) continue; + if (!hasValidExt(entry.name)) continue; - var file = dir.openFile(entry.path, .{}) catch continue; + var path_buf2: [4096]u8 = undefined; + const full_path = std.fmt.bufPrint(&path_buf2, "{s}/{s}", .{ src_dir, entry.name }) catch continue; + var file = compat.cwd().openFile(full_path, .{}) catch continue; defer file.close(); var buf: [8192]u8 = undefined; const n = file.read(&buf) catch continue; if (n < 3) continue; try files.append(alloc, .{ - .path = try alloc.dupe(u8, entry.path), + .path = try alloc.dupe(u8, entry.name), .content = try alloc.dupe(u8, buf[0..n]), }); } @@ -86,11 +89,11 @@ pub fn main() !void { std.debug.print("Phase 1: Indexing {d} files...\n", .{base_files * COPIES}); const data_dir = "/tmp/turbodb_scale_bench"; - std.fs.cwd().makeDir(data_dir) catch |e| switch (e) { + compat.cwd().makeDir(data_dir) catch |e| switch (e) { error.PathAlreadyExists => { // Clean it - std.fs.cwd().deleteTree(data_dir) catch {}; - std.fs.cwd().makeDir(data_dir) catch {}; + compat.cwd().deleteTree(data_dir) catch {}; + compat.cwd().makeDir(data_dir) catch {}; }, else => return e, }; @@ -101,7 +104,7 @@ pub fn main() !void { var indexed: u64 = 0; var total_bytes: u64 = 0; - const t_index_start = std.time.nanoTimestamp(); + const t_index_start = compat.nanoTimestamp(); var copy: u32 = 0; while (copy < COPIES) : (copy += 1) { @@ -115,7 +118,7 @@ pub fn main() !void { indexed += 1; total_bytes += f.content.len; } - const elapsed_ns = std.time.nanoTimestamp() - t_index_start; + const elapsed_ns = compat.nanoTimestamp() - t_index_start; const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / 1e9; const rate = @as(f64, @floatFromInt(indexed)) / elapsed_s; std.debug.print("\r Copy {d}/{d}: {d} files indexed ({d:.0} files/s) ", .{ @@ -123,7 +126,7 @@ pub fn main() !void { }); } - const t_index_end = std.time.nanoTimestamp(); + const t_index_end = compat.nanoTimestamp(); const index_s = @as(f64, @floatFromInt(t_index_end - t_index_start)) / 1e9; const index_rate = @as(f64, @floatFromInt(indexed)) / index_s; const total_mb = @as(f64, @floatFromInt(total_bytes)) / (1024.0 * 1024.0); @@ -171,9 +174,9 @@ pub fn main() !void { var run: u32 = 0; while (run < 3) : (run += 1) { if (last_result) |r| r.deinit(); - const t0 = std.time.nanoTimestamp(); + const t0 = compat.nanoTimestamp(); last_result = try col.searchText(q, 50, alloc); - const elapsed_ns = std.time.nanoTimestamp() - t0; + const elapsed_ns = compat.nanoTimestamp() - t0; times[run] = @as(f64, @floatFromInt(elapsed_ns)) / 1e3; } @@ -230,9 +233,9 @@ pub fn main() !void { "Promise", "setTimeout", "addEventListener", }; - const t_burst_start = std.time.nanoTimestamp(); + const t_burst_start = compat.nanoTimestamp(); var burst_count: u32 = 0; - var rng = std.Random.DefaultPrng.init(@intCast(std.time.nanoTimestamp())); + var rng = std.Random.DefaultPrng.init(@intCast(compat.nanoTimestamp())); const random = rng.random(); while (burst_count < 1000) : (burst_count += 1) { @@ -241,7 +244,7 @@ pub fn main() !void { result.deinit(); } - const t_burst_end = std.time.nanoTimestamp(); + const t_burst_end = compat.nanoTimestamp(); const burst_s = @as(f64, @floatFromInt(t_burst_end - t_burst_start)) / 1e9; const burst_qps = 1000.0 / burst_s; diff --git a/src/server.zig b/src/server.zig index b1838e3..394ff99 100644 --- a/src/server.zig +++ b/src/server.zig @@ -2,7 +2,7 @@ /// Routes: /// POST /db/:col insert document /// GET /db/:col/:key get document by key -/// PUT /db/:col/:key update document +/// PUT /db/:col/:key upsert document /// DELETE /db/:col/:key delete document /// GET /db/:col scan collection (limit, offset query params) /// DELETE /db/:col drop collection @@ -11,9 +11,12 @@ /// GET /metrics server metrics /// GET /context/:col smart context discovery (q, limit query params) const std = @import("std"); +const compat = @import("compat"); const activity = @import("activity.zig"); const auth = @import("auth.zig"); const collection = @import("collection.zig"); +const doc_mod = @import("doc.zig"); +const page_mod = @import("page.zig"); const Database = collection.Database; const MAX_REQ = 65536; // 64 KiB (initial read) @@ -28,6 +31,8 @@ const ConnBufs = struct { resp: [MAX_RESP]u8, body: [MAX_BODY]u8, }; + +const CONN_THREAD_STACK_SIZE = 1024 * 1024; threadlocal var tl_bufs: ?*ConnBufs = null; fn getRespBuf() *[MAX_RESP]u8 { return &tl_bufs.?.resp; } @@ -65,7 +70,7 @@ pub const Server = struct { billing_ring: [BILLING_LOG_CAP]QueryCost, billing_ring_head: usize, // next write position billing_ring_count: usize, // entries currently stored - billing_mu: std.Thread.Mutex, + billing_mu: compat.Mutex, activity: activity.ActivityTracker, // Connection limiter — prevents unbounded thread spawning under flood. active_conns: std.atomic.Value(u32), @@ -93,7 +98,7 @@ pub const Server = struct { } pub fn run(self: *Server) !void { - const addr = try std.net.Address.parseIp("0.0.0.0", self.port); + const addr = try compat.net.Address.parseIp("0.0.0.0", self.port); var listener = try addr.listen(.{ .reuse_address = true, .kernel_backlog = 256, @@ -118,7 +123,7 @@ pub const Server = struct { _ = self.err_count.fetchAdd(1, .monotonic); continue; } - const t = std.Thread.spawn(.{ .stack_size = 256 * 1024 }, handleConnWrapped, .{self, conn}) catch { + const t = std.Thread.spawn(.{ .stack_size = CONN_THREAD_STACK_SIZE }, handleConnWrapped, .{self, conn}) catch { conn.stream.close(); _ = self.err_count.fetchAdd(1, .monotonic); continue; @@ -129,28 +134,34 @@ pub const Server = struct { pub fn runUnix(self: *Server, path: []const u8) !void { // Remove any existing socket file - std.posix.unlink(path) catch {}; - const fd = try std.posix.socket(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0); - defer std.posix.close(fd); + // Remove existing socket + { + var zbuf: [256]u8 = undefined; + @memcpy(zbuf[0..path.len], path); + zbuf[path.len] = 0; + _ = std.c.unlink(@ptrCast(&zbuf)); + } + const fd = std.c.socket(1, 1, 0); // AF_UNIX=1, SOCK_STREAM=1 + if (fd < 0) return error.SocketError; + defer _ = std.c.close(fd); - // Construct sockaddr_un - var addr: std.posix.sockaddr.un = .{ .family = std.posix.AF.UNIX, .path = undefined }; + // Construct sockaddr_un (family u16 + path) + var addr: extern struct { family: u16, path: [104]u8 } = .{ .family = 1, .path = undefined }; @memset(&addr.path, 0); if (path.len >= addr.path.len) return error.PathTooLong; @memcpy(addr.path[0..path.len], path); - try std.posix.bind(fd, @ptrCast(&addr), @sizeOf(std.posix.sockaddr.un)); - try std.posix.listen(fd, 256); + if (std.c.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return error.BindError; + if (std.c.listen(fd, 256) != 0) return error.ListenError; self.running.store(true, .release); std.log.info("TurboDB HTTP on unix:{s}", .{path}); while (self.running.load(.acquire)) { - var client_addr: std.posix.sockaddr.un = undefined; - var addr_len: std.posix.socklen_t = @sizeOf(std.posix.sockaddr.un); - const client_fd = std.posix.accept(fd, @ptrCast(&client_addr), &addr_len, 0) catch continue; - const stream = std.net.Stream{ .handle = client_fd }; - const conn = std.net.Server.Connection{ .stream = stream, .address = std.net.Address.initUnix(path) catch continue }; + const client_fd = std.c.accept(fd, null, null); + if (client_fd < 0) continue; + const stream = compat.net.Stream{ .handle = client_fd }; + const conn = compat.net.Server.Connection{ .stream = stream, .address = compat.net.Address.initUnix(path) catch continue }; if (self.active_conns.load(.monotonic) >= MAX_CONNECTIONS) { const reject = "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 31\r\nConnection: close\r\n\r\n{\"error\":\"too many connections\"}"; conn.stream.writeAll(reject) catch {}; @@ -158,7 +169,7 @@ pub const Server = struct { _ = self.err_count.fetchAdd(1, .monotonic); continue; } - const t = std.Thread.spawn(.{ .stack_size = 256 * 1024 }, handleConnWrapped, .{ self, conn }) catch { + const t = std.Thread.spawn(.{ .stack_size = CONN_THREAD_STACK_SIZE }, handleConnWrapped, .{ self, conn }) catch { conn.stream.close(); continue; }; @@ -171,7 +182,7 @@ pub const Server = struct { } fn recordQueryCost(self: *Server, tenant_id: []const u8, op: []const u8, rows_scanned: usize, bytes_read: usize, start_ns: i128) void { - const elapsed_ns = std.time.nanoTimestamp() - start_ns; + const elapsed_ns = compat.nanoTimestamp() - start_ns; const cpu_us: u64 = @intCast(@max(@divTrunc(elapsed_ns, 1000), 0)); const cost_nanos_usd: u64 = cpu_us * 5 + @as(u64, @intCast(rows_scanned)) + @as(u64, @intCast(bytes_read / 1024)); @@ -207,13 +218,13 @@ pub const Server = struct { }; /// Wrapper that tracks active connection count around handleConn. -fn handleConnWrapped(srv: *Server, conn: std.net.Server.Connection) void { +fn handleConnWrapped(srv: *Server, conn: compat.net.Server.Connection) void { _ = srv.active_conns.fetchAdd(1, .monotonic); defer _ = srv.active_conns.fetchSub(1, .monotonic); handleConn(srv, conn); } -fn handleConn(srv: *Server, conn: std.net.Server.Connection) void { +fn handleConn(srv: *Server, conn: compat.net.Server.Connection) void { defer conn.stream.close(); const bufs = std.heap.page_allocator.create(ConnBufs) catch { @@ -236,18 +247,21 @@ fn handleConn(srv: *Server, conn: std.net.Server.Connection) void { return; // WS handler owns the connection until close } - // For bulk inserts: read the full body based on Content-Length. - // The initial read may only contain part of a large body. + // Read the full body based on Content-Length. The initial read may only + // contain part of a large body. const content_length = extractContentLength(initial); - const is_bulk = std.mem.indexOf(u8, initial[0..@min(n, 256)], "/bulk") != null; - - if (content_length > MAX_REQ and content_length <= MAX_BULK and is_bulk) { - // Large bulk: allocate a big buffer and read the full body. + if (content_length > 0) { const header_end = if (std.mem.indexOf(u8, initial, "\r\n\r\n")) |p| p + 4 else if (std.mem.indexOf(u8, initial, "\n\n")) |p| p + 2 else n; const total_size = header_end + content_length; - if (total_size <= MAX_BULK) { + if (total_size > MAX_BULK) { + const resp_len = err(413, "request too large"); + conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + continue; + } + + if (total_size > bufs.req.len) { const big_buf = std.heap.page_allocator.alloc(u8, total_size) catch { const resp_len = dispatch(srv, initial, std.heap.page_allocator); conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; @@ -264,22 +278,16 @@ fn handleConn(srv: *Server, conn: std.net.Server.Connection) void { const resp_len = dispatch(srv, big_buf[0..n], std.heap.page_allocator); conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; continue; + } else { + while (n < total_size) { + const r = conn.stream.read(bufs.req[n..total_size]) catch break; + if (r == 0) break; + n += r; + } + const resp_len = dispatch(srv, bufs.req[0..n], std.heap.page_allocator); + conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + continue; } - } else if (content_length > 0 and content_length <= MAX_REQ and is_bulk) { - // Small bulk: body fits in the req buffer but the initial read may - // not have received it all. Keep reading until we have the full body. - const header_end = if (std.mem.indexOf(u8, initial, "\r\n\r\n")) |p| p + 4 - else if (std.mem.indexOf(u8, initial, "\n\n")) |p| p + 2 - else n; - const total_size = @min(header_end + content_length, bufs.req.len); - while (n < total_size) { - const r = conn.stream.read(bufs.req[n..total_size]) catch break; - if (r == 0) break; - n += r; - } - const resp_len = dispatch(srv, bufs.req[0..n], std.heap.page_allocator); - conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; - continue; } const resp_len = dispatch(srv, initial, std.heap.page_allocator); @@ -289,7 +297,7 @@ fn handleConn(srv: *Server, conn: std.net.Server.Connection) void { fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { // Parse request line. const nl = std.mem.indexOfScalar(u8, raw, '\n') orelse return err(400, "bad request"); - const req_line = std.mem.trimRight(u8, raw[0..nl], "\r"); + const req_line = std.mem.trimEnd(u8, raw[0..nl], "\r"); var parts = std.mem.splitScalar(u8, req_line, ' '); const method = parts.next() orelse return err(400, "bad request"); const full_path = parts.next() orelse return err(400, "bad request"); @@ -316,8 +324,8 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { // Route: /metrics if (std.mem.eql(u8, path, "/metrics")) { - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"requests\":{d},\"errors\":{d},\"queries\":{d},\"rows_scanned\":{d},\"bytes_read\":{d},\"cpu_us\":{d},\"cost_nanos_usd\":{d}}}", .{ srv.req_count.load(.acquire), @@ -331,13 +339,22 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { return ok(getBodyBuf()[0..fbs.pos]); } + var auth_ctx = auth.AuthContext{ + .perm = .admin, + .tenant_id = [_]u8{0} ** 64, + .tenant_id_len = 0, + }; + var auth_ctx_bound = false; + // ── Auth gate — public endpoints above, protected endpoints below ──── if (srv.db.auth.isEnabled()) { const api_key = auth.AuthStore.extractHttpKey(raw) orelse return err(401, "unauthorized — missing X-Api-Key header"); - if (srv.db.auth.verify(api_key) == null) + auth_ctx = srv.db.auth.resolve(api_key) orelse return err(401, "unauthorized — invalid API key"); + auth_ctx_bound = true; } + const auth_ctx_ptr: ?*const auth.AuthContext = if (auth_ctx_bound) &auth_ctx else null; if (std.mem.eql(u8, path, "/billing") and std.mem.eql(u8, method, "GET")) return handleBillingLog(srv); @@ -346,14 +363,14 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { return handleResourceState(srv); if (std.mem.eql(u8, path, "/cdc/webhooks") and std.mem.eql(u8, method, "POST")) - return handleWebhookRegistration(srv, body); + return handleWebhookRegistration(srv, body, auth_ctx_ptr); if (std.mem.eql(u8, path, "/cdc/events") and std.mem.eql(u8, method, "GET")) - return handleCdcEvents(srv, qparam(query, "tenant"), alloc); + return handleCdcEvents(srv, requestTenantFilter(query, auth_ctx_ptr), alloc); // Route: /collections if (std.mem.eql(u8, path, "/collections") and std.mem.eql(u8, method, "GET")) - return handleListCollections(srv, requestTenant(raw, query), alloc); + return handleListCollections(srv, requestTenant(raw, query, auth_ctx_ptr), alloc); // Route: /search/:col?q=... if (std.mem.startsWith(u8, path, "/search/") and std.mem.eql(u8, method, "GET")) { @@ -362,7 +379,7 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { var decode_buf: [4096]u8 = undefined; const q = urlDecode(q_raw, &decode_buf) orelse q_raw; const limit_val: u32 = @min(qparamInt(query, "limit") orelse 50, 500); - return handleSearch(srv, requestTenant(raw, query), col_name, q, limit_val, alloc); + return handleSearch(srv, requestTenant(raw, query, auth_ctx_ptr), col_name, q, limit_val, alloc); } // Route: /context/:col?q=...&limit=20 @@ -372,13 +389,13 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { var decode_buf: [4096]u8 = undefined; const q = urlDecode(q_raw, &decode_buf) orelse q_raw; const limit_val: u32 = @min(qparamInt(query, "limit") orelse 20, 100); - return handleDiscoverContext(srv, requestTenant(raw, query), col_name, q, limit_val, alloc); + return handleDiscoverContext(srv, requestTenant(raw, query, auth_ctx_ptr), col_name, q, limit_val, alloc); } // Routes under /branch/:col[/:branch[/:key]] if (std.mem.startsWith(u8, path, "/branch/")) { const rest = path[8..]; // after "/branch/" - const tenant_id = requestTenant(raw, query); + const tenant_id = requestTenant(raw, query, auth_ctx_ptr); // Parse: rest = col_name[/branch_name[/key...]] if (std.mem.indexOfScalar(u8, rest, '/')) |sep1| { @@ -429,12 +446,15 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { // Routes under /db/:col if (std.mem.startsWith(u8, path, "/db/")) { + if (isBasicDbWriteMethod(method) and !canWriteDb(auth_ctx_ptr)) + return err(403, "forbidden: API key is read-only"); + const rest = path[4..]; // /db/:col/:key if (std.mem.indexOfScalar(u8, rest, '/')) |sep| { const col_name = rest[0..sep]; const key = rest[sep + 1 ..]; - const tenant_id = requestTenant(raw, query); + const tenant_id = requestTenant(raw, query, auth_ctx_ptr); // POST /db/:col/bulk — bulk insert if (std.mem.eql(u8, key, "bulk") and std.mem.eql(u8, method, "POST")) return handleBulkInsert(srv, tenant_id, col_name, body, alloc); @@ -443,7 +463,7 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { if (std.mem.eql(u8, method, "DELETE")) return handleDelete(srv, tenant_id, col_name, key); } else { const col_name = rest; - const tenant_id = requestTenant(raw, query); + const tenant_id = requestTenant(raw, query, auth_ctx_ptr); if (std.mem.eql(u8, method, "POST")) return handleInsert(srv, tenant_id, col_name, body, alloc); if (std.mem.eql(u8, method, "GET")) return handleScan(srv, tenant_id, col_name, query, requestAsOf(raw, query), alloc); if (std.mem.eql(u8, method, "DELETE")) return handleDrop(srv, tenant_id, col_name); @@ -464,7 +484,7 @@ fn handleInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: const key_raw = jsonStr(body, "key") orelse { // Auto-generate key from timestamp + counter. var kb: [32]u8 = undefined; - const k = std.fmt.bufPrint(&kb, "doc_{d}", .{std.time.milliTimestamp()}) catch + const k = std.fmt.bufPrint(&kb, "doc_{d}", .{compat.milliTimestamp()}) catch return err(400, "bad key"); return doInsert(srv, tenant_id, col_name, k, value); }; @@ -472,14 +492,15 @@ fn handleInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: } fn doInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []const u8, value: []const u8) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); + if (!documentFitsLeaf(key, value)) return err(413, "document too large"); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); srv.db.ensureTenantStorageAvailable(tenant_id, value.len) catch return err(429, "tenant storage quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); const doc_id = col.insert(key, value) catch return err(500, "insert failed"); srv.recordQueryCost(tenant_id, "insert", 1, value.len, start_ns); - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"doc_id\":{d},\"key\":\"{s}\",\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", .{ doc_id, key, col_name, tenant_id }) catch {}; return ok(getBodyBuf()[0..fbs.pos]); @@ -490,7 +511,7 @@ fn doInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []co /// Response: {"inserted":N,"errors":M,"collection":"...","tenant":"..."} fn handleBulkInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, alloc: std.mem.Allocator) usize { _ = alloc; - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); srv.db.ensureTenantStorageAvailable(tenant_id, body.len) catch return err(429, "tenant storage quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); @@ -524,14 +545,14 @@ fn handleBulkInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, b srv.recordQueryCost(tenant_id, "bulk_insert", inserted, total_bytes, start_ns); - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"inserted\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", .{ inserted, errors, col_name, tenant_id }) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } fn handleGet(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []const u8, as_of: ?i64) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); const d = if (as_of) |ts_ms| @@ -543,26 +564,16 @@ fn handleGet(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []c // Write JSON body directly into resp_buf at offset 256 (reserve space for headers) const HEADER_RESERVE = 256; var resp = getRespBuf(); - var fbs = std.io.fixedBufferStream(resp[HEADER_RESERVE..]); + var fbs = compat.fixedBufferStream(resp[HEADER_RESERVE..]); const val = if (d.value.len > 0) d.value else "{}"; - const is_json = val.len > 0 and (val[0] == '{' or val[0] == '[' or val[0] == '"'); + const is_json = isJsonValue(val); const w = fbs.writer(); - if (is_json) { - std.fmt.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"version\":{d},\"value\":{s}}}", .{ d.header.doc_id, d.key, d.header.version, val }) catch {}; - } else { - std.fmt.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"version\":{d},\"value\":\"", .{ d.header.doc_id, d.key, d.header.version }) catch {}; - for (val) |ch| { - if (ch == '"' or ch == '\\') w.writeByte('\\') catch {}; - if (ch == '\n') { w.writeAll("\\n") catch {}; continue; } - w.writeByte(ch) catch {}; - } - w.writeAll("\"}") catch {}; - } + writeDocJson(w, d, val, is_json) catch return err(500, "response too large"); const body_len = fbs.pos; // Now write headers into the reserved space at the front - var hdr_fbs = std.io.fixedBufferStream(resp[0..HEADER_RESERVE]); - std.fmt.format(hdr_fbs.writer(), + var hdr_fbs = compat.fixedBufferStream(resp[0..HEADER_RESERVE]); + compat.format(hdr_fbs.writer(), "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", .{body_len}) catch {}; const hdr_len = hdr_fbs.pos; @@ -575,19 +586,30 @@ fn handleGet(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []c } fn handleUpdate(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []const u8, body: []const u8, alloc: std.mem.Allocator) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); _ = alloc; + if (!documentFitsLeaf(key, body)) return err(413, "document too large"); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); srv.db.ensureTenantStorageAvailable(tenant_id, body.len) catch return err(429, "tenant storage quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); const updated = col.update(key, body) catch return err(500, "update failed"); - if (!updated) return err(404, "not found"); - srv.recordQueryCost(tenant_id, "update", 1, body.len, start_ns); - return ok("{\"updated\":true}"); + if (updated) { + srv.recordQueryCost(tenant_id, "update", 1, body.len, start_ns); + return ok("{\"updated\":true,\"inserted\":false}"); + } + + const doc_id = col.insert(key, body) catch return err(500, "insert failed"); + srv.recordQueryCost(tenant_id, "upsert", 1, body.len, start_ns); + + var buf: [128]u8 = undefined; + const response = std.fmt.bufPrint(&buf, + "{{\"updated\":false,\"inserted\":true,\"doc_id\":{d}}}", + .{doc_id}) catch return err(500, "format failed"); + return ok(response); } fn handleDelete(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []const u8) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); const deleted = col.delete(key) catch return err(500, "delete failed"); @@ -596,8 +618,12 @@ fn handleDelete(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: return ok("{\"deleted\":true}"); } +fn documentFitsLeaf(key: []const u8, value: []const u8) bool { + return doc_mod.DocHeader.size + key.len + value.len <= page_mod.PAGE_USABLE; +} + fn handleScan(srv: *Server, tenant_id: []const u8, col_name: []const u8, query_str: []const u8, as_of: ?i64, alloc: std.mem.Allocator) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); const limit: u32 = @min(qparamInt(query_str, "limit") orelse 20, 1000); const offset: u32 = qparamInt(query_str, "offset") orelse 0; srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); @@ -611,34 +637,114 @@ fn handleScan(srv: *Server, tenant_id: []const u8, col_name: []const u8, query_s for (result.docs) |d| bytes_read += d.key.len + d.value.len; srv.recordQueryCost(tenant_id, "scan", result.docs.len, bytes_read, start_ns); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + const emit_count = scanEmitCount(tenant_id, col_name, result.docs); + + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); - std.fmt.format(w, "{{\"tenant\":\"{s}\",\"collection\":\"{s}\",\"count\":{d},\"docs\":[", - .{ tenant_id, col_name, result.docs.len }) catch {}; - for (result.docs, 0..) |d, i| { - // Stop writing if buffer is nearly full to avoid truncated JSON. - if (fbs.pos + 256 >= MAX_BODY) break; + compat.format(w, "{{\"tenant\":\"{s}\",\"collection\":\"{s}\",\"count\":{d},\"docs\":[", + .{ tenant_id, col_name, emit_count }) catch {}; + for (result.docs[0..emit_count], 0..) |d, i| { if (i > 0) w.writeByte(',') catch {}; const val = if (d.value.len > 0) d.value else "{}"; - const is_json = val.len > 0 and (val[0] == '{' or val[0] == '[' or val[0] == '"'); - if (is_json) { - std.fmt.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"version\":{d},\"value\":{s}}}", .{ d.header.doc_id, d.key, d.header.version, val }) catch {}; - } else { - std.fmt.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"version\":{d},\"value\":\"", .{ d.header.doc_id, d.key, d.header.version }) catch {}; - for (val) |ch| { - if (ch == '"' or ch == '\\') w.writeByte('\\') catch {}; - if (ch == '\n') { w.writeAll("\\n") catch {}; continue; } - w.writeByte(ch) catch {}; - } - w.writeAll("\"}") catch {}; - } + const is_json = isJsonValue(val); + const start_pos = fbs.pos; + writeDocJson(w, d, val, is_json) catch { + fbs.pos = start_pos; + break; + }; } w.writeAll("]}") catch {}; return ok(getBodyBuf()[0..fbs.pos]); } + +fn scanEmitCount(tenant_id: []const u8, col_name: []const u8, docs: []const doc_mod.Doc) usize { + const max_count_digits = decimalLen(docs.len); + var used: usize = "{\"tenant\":\"".len + tenant_id.len + + "\",\"collection\":\"".len + col_name.len + + "\",\"count\":".len + max_count_digits + + ",\"docs\":[".len + "]}".len; + + var emit_count: usize = 0; + for (docs) |d| { + const val = if (d.value.len > 0) d.value else "{}"; + const is_json = isJsonValue(val); + const next_len = (if (emit_count > 0) @as(usize, 1) else 0) + docJsonLen(d, val, is_json); + if (used + next_len > MAX_BODY) break; + used += next_len; + emit_count += 1; + } + return emit_count; +} + +fn writeDocJson(w: anytype, d: doc_mod.Doc, val: []const u8, is_json: bool) !void { + try w.writeAll("{\"doc_id\":"); + try w.print("{d}", .{d.header.doc_id}); + try w.writeAll(",\"key\":"); + try writeJsonString(w, d.key); + try w.writeAll(",\"version\":"); + try w.print("{d}", .{d.header.version}); + try w.writeAll(",\"value\":"); + if (is_json) { + try w.writeAll(val); + } else { + try writeJsonString(w, val); + } + try w.writeByte('}'); +} + +fn isJsonValue(value: []const u8) bool { + if (value.len == 0) return false; + return std.json.validate(std.heap.page_allocator, value) catch false; +} + +fn docJsonLen(d: doc_mod.Doc, val: []const u8, is_json: bool) usize { + return "{\"doc_id\":".len + decimalLen(d.header.doc_id) + + ",\"key\":".len + jsonStringLen(d.key) + + ",\"version\":".len + decimalLen(d.header.version) + + ",\"value\":".len + if (is_json) val.len else jsonStringLen(val) + + "}".len; +} + +fn decimalLen(value: anytype) usize { + var buf: [32]u8 = undefined; + return (std.fmt.bufPrint(&buf, "{d}", .{value}) catch return 32).len; +} + +fn jsonStringLen(value: []const u8) usize { + var len: usize = 2; + for (value) |ch| { + len += switch (ch) { + '"', '\\', '\n', '\r', '\t' => 2, + 0...8, 11...12, 14...0x1f => 6, + else => 1, + }; + } + return len; +} + +fn writeJsonString(w: anytype, value: []const u8) !void { + const hex = "0123456789abcdef"; + try w.writeByte('"'); + for (value) |ch| { + switch (ch) { + '"' => try w.writeAll("\\\""), + '\\' => try w.writeAll("\\\\"), + '\n' => try w.writeAll("\\n"), + '\r' => try w.writeAll("\\r"), + '\t' => try w.writeAll("\\t"), + 0...8, 11...12, 14...0x1f => { + try w.writeAll("\\u00"); + try w.writeByte(hex[ch >> 4]); + try w.writeByte(hex[ch & 0x0f]); + }, + else => try w.writeByte(ch), + } + } + try w.writeByte('"'); +} fn handleDrop(srv: *Server, tenant_id: []const u8, col_name: []const u8) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); srv.db.dropCollectionForTenant(tenant_id, col_name); srv.recordQueryCost(tenant_id, "drop", 0, 0, start_ns); @@ -646,7 +752,7 @@ fn handleDrop(srv: *Server, tenant_id: []const u8, col_name: []const u8) usize { } fn handleSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, query: []const u8, limit: u32, alloc: std.mem.Allocator) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); const result = col.searchText(query, limit, alloc) catch return err(500, "search failed"); @@ -655,7 +761,7 @@ fn handleSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, query for (result.docs) |d| bytes_read += d.key.len + d.value.len; srv.recordQueryCost(tenant_id, "search", result.docs.len, bytes_read, start_ns); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); // Write JSON header with escaped query string w.writeAll("{\"query\":\"") catch {}; @@ -663,7 +769,7 @@ fn handleSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, query if (ch == '"' or ch == '\\') { w.writeByte('\\') catch {}; } w.writeByte(ch) catch {}; } - std.fmt.format(w, + compat.format(w, "\",\"hits\":{d},\"candidates\":{d},\"total_docs\":{d},\"total_files\":{d},\"results\":[", .{ result.docs.len, result.candidate_paths.len, col.docCount(), result.total_files }) catch {}; for (result.docs, 0..) |d, i| { @@ -671,12 +777,12 @@ fn handleSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, query if (i > 0) w.writeByte(',') catch {}; // Output value as valid JSON — objects/arrays as-is, strings quoted const val = if (d.value.len > 0) d.value else "{}"; - const is_json = val.len > 0 and (val[0] == '{' or val[0] == '[' or val[0] == '"'); + const is_json = isJsonValue(val); if (is_json) { - std.fmt.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"value\":{s}}}", .{ d.header.doc_id, d.key, val }) catch {}; + compat.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"value\":{s}}}", .{ d.header.doc_id, d.key, val }) catch {}; } else { w.writeAll("{\"doc_id\":") catch {}; - std.fmt.format(w, "{d},\"key\":\"{s}\",\"value\":\"", .{ d.header.doc_id, d.key }) catch {}; + compat.format(w, "{d},\"key\":\"{s}\",\"value\":\"", .{ d.header.doc_id, d.key }) catch {}; for (val) |ch| { if (ch == '"' or ch == '\\') w.writeByte('\\') catch {}; if (ch == '\n') { w.writeAll("\\n") catch {}; continue; } @@ -690,7 +796,7 @@ fn handleSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, query } fn handleDiscoverContext(srv: *Server, tenant_id: []const u8, col_name: []const u8, query: []const u8, limit: u32, alloc: std.mem.Allocator) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); var result = col.discoverContext(query, limit, alloc) catch return err(500, "context discovery failed"); @@ -699,7 +805,7 @@ fn handleDiscoverContext(srv: *Server, tenant_id: []const u8, col_name: []const for (result.matching_files) |d| bytes_read += d.key.len + d.value.len; srv.recordQueryCost(tenant_id, "context", result.matching_files.len, bytes_read, start_ns); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); // Write JSON: matching_files @@ -712,38 +818,38 @@ fn handleDiscoverContext(srv: *Server, tenant_id: []const u8, col_name: []const for (result.matching_files, 0..) |d, i| { if (fbs.pos + 128 >= MAX_BODY) break; if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, "{{\"key\":\"{s}\",\"size\":{d}}}", .{ d.key, d.value.len }) catch {}; + compat.format(w, "{{\"key\":\"{s}\",\"size\":{d}}}", .{ d.key, d.value.len }) catch {}; } w.writeAll("],\"related_files\":[") catch {}; for (result.related_files, 0..) |d, i| { if (fbs.pos + 128 >= MAX_BODY) break; if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch {}; + compat.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch {}; } w.writeAll("],\"test_files\":[") catch {}; for (result.test_files, 0..) |d, i| { if (fbs.pos + 128 >= MAX_BODY) break; if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch {}; + compat.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch {}; } - std.fmt.format(w, "],\"recent_versions\":{d},\"total_files\":{d}}}", .{ result.recent_versions, result.total_files }) catch {}; + compat.format(w, "],\"recent_versions\":{d},\"total_files\":{d}}}", .{ result.recent_versions, result.total_files }) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } fn handleListCollections(srv: *Server, tenant_id: []const u8, alloc: std.mem.Allocator) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); var cols = srv.db.listCollectionsForTenant(tenant_id, alloc) catch return err(500, "list collections failed"); defer { for (cols.items) |name| alloc.free(name); cols.deinit(alloc); } - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); - std.fmt.format(w, "{{\"tenant\":\"{s}\",\"collections\":[", .{tenant_id}) catch {}; + compat.format(w, "{{\"tenant\":\"{s}\",\"collections\":[", .{tenant_id}) catch {}; for (cols.items, 0..) |name, i| { if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, "\"{s}\"", .{name}) catch {}; + compat.format(w, "\"{s}\"", .{name}) catch {}; } srv.recordQueryCost(tenant_id, "list_collections", cols.items.len, 0, start_ns); w.writeAll("]}") catch {}; @@ -751,7 +857,7 @@ fn handleListCollections(srv: *Server, tenant_id: []const u8, alloc: std.mem.All } fn handleBillingLog(srv: *Server) usize { - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); w.writeAll("{\"queries\":[") catch {}; srv.billing_mu.lock(); @@ -766,7 +872,7 @@ fn handleBillingLog(srv: *Server) usize { const idx = (start_idx + i) % cap; const entry = srv.billing_ring[idx]; if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, + compat.format(w, "{{\"tenant\":\"{s}\",\"op\":\"{s}\",\"rows_scanned\":{d},\"bytes_read\":{d},\"cpu_us\":{d},\"cost_nanos_usd\":{d}}}", .{ entry.tenant_id[0..entry.tenant_id_len], @@ -783,8 +889,8 @@ fn handleBillingLog(srv: *Server) usize { } fn handleResourceState(srv: *Server) usize { - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format( + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format( fbs.writer(), "{{\"state\":\"{s}\",\"queries_per_second\":{d},\"last_query_ms\":{d}}}", .{ resourceStateName(srv.activity.state()), srv.activity.queriesPerSecond(), srv.activity.lastQueryMs() }, @@ -792,26 +898,26 @@ fn handleResourceState(srv: *Server) usize { return ok(getBodyBuf()[0..fbs.pos]); } -fn handleWebhookRegistration(srv: *Server, body: []const u8) usize { - const tenant = jsonStr(body, "tenant") orelse return err(400, "missing tenant"); +fn handleWebhookRegistration(srv: *Server, body: []const u8, auth_ctx: ?*const auth.AuthContext) usize { + const tenant = requestBodyTenant(body, auth_ctx) orelse return err(400, "missing tenant"); const webhook = jsonStr(body, "webhook_url") orelse return err(400, "missing webhook_url"); const secret = jsonStr(body, "secret") orelse return err(400, "missing secret"); const collection_name = jsonStr(body, "collection") orelse ""; const id = srv.db.registerWebhook(tenant, collection_name, webhook, secret) catch return err(500, "register webhook failed"); - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), "{{\"subscription_id\":{d}}}", .{id}) catch {}; + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"subscription_id\":{d}}}", .{id}) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } fn handleCdcEvents(srv: *Server, tenant_filter: ?[]const u8, alloc: std.mem.Allocator) usize { const deliveries = srv.db.listWebhookDeliveries(alloc, tenant_filter) catch return err(500, "cdc read failed"); defer alloc.free(deliveries); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); w.writeAll("{\"events\":[") catch {}; for (deliveries, 0..) |entry, i| { if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, + compat.format(w, "{{\"seq\":{d},\"tenant\":\"{s}\",\"collection\":\"{s}\",\"webhook_url\":\"{s}\",\"signature\":\"{s}\",\"payload\":{s}}}", .{ entry.seq, @@ -852,8 +958,8 @@ fn handleBranchRead(srv: *Server, tenant_id: []const u8, col_name: []const u8, b const br = col.getBranch(branch_name) orelse return err(404, "branch not found"); const val = col.getOnBranch(br, key) orelse return err(404, "not found"); // Write response with raw value - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), "{{\"key\":\"{s}\",\"value\":{s}}}", .{ + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"key\":\"{s}\",\"value\":{s}}}", .{ key, if (val.len > 0) val else "{}", }) catch {}; return ok(getBodyBuf()[0..fbs.pos]); @@ -867,12 +973,12 @@ fn handleBranchMerge(srv: *Server, tenant_id: []const u8, col_name: []const u8, defer result.deinit(); if (result.conflicts.len > 0) { // Return conflict count - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), "{{\"merged\":false,\"conflicts\":{d}}}", .{result.conflicts.len}) catch {}; + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"merged\":false,\"conflicts\":{d}}}", .{result.conflicts.len}) catch {}; return respond(409, "Conflict", getBodyBuf()[0..fbs.pos]); } - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), "{{\"merged\":true,\"applied\":{d}}}", .{result.applied}) catch {}; + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"merged\":true,\"applied\":{d}}}", .{result.applied}) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } @@ -883,12 +989,12 @@ fn handleBranchSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, const result = col.searchOnBranch(br, query_text, limit, alloc) catch return err(500, "branch search failed"); defer result.deinit(); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); - std.fmt.format(w, "{{\"branch\":\"{s}\",\"hits\":{d},\"results\":[", .{ branch_name, result.docs.len }) catch {}; + compat.format(w, "{{\"branch\":\"{s}\",\"hits\":{d},\"results\":[", .{ branch_name, result.docs.len }) catch {}; for (result.docs, 0..) |d, i| { if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, + compat.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"value\":{s}}}", .{ d.header.doc_id, d.key, if (d.value.len > 0) d.value else "{}" }) catch {}; @@ -903,21 +1009,54 @@ fn handleListBranches(srv: *Server, tenant_id: []const u8, col_name: []const u8, const names = col.listBranches(alloc) catch return err(500, "list branches failed"); defer alloc.free(names); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); w.writeAll("{\"branches\":[") catch {}; for (names, 0..) |name, i| { if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, "\"{s}\"", .{name}) catch {}; + compat.format(w, "\"{s}\"", .{name}) catch {}; } w.writeAll("]}") catch {}; return ok(getBodyBuf()[0..fbs.pos]); } -fn requestTenant(raw: []const u8, query: []const u8) []const u8 { +fn requestTenant(raw: []const u8, query: []const u8, auth_ctx: ?*const auth.AuthContext) []const u8 { + if (auth_ctx) |ctx| { + if (ctx.perm != .admin) return boundTenant(ctx); + } return header(raw, "X-Tenant-Id: ") orelse qparam(query, "tenant") orelse collection.DEFAULT_TENANT; } +fn requestTenantFilter(query: []const u8, auth_ctx: ?*const auth.AuthContext) ?[]const u8 { + if (auth_ctx) |ctx| { + if (ctx.perm != .admin) return boundTenant(ctx); + } + return qparam(query, "tenant"); +} + +fn requestBodyTenant(body: []const u8, auth_ctx: ?*const auth.AuthContext) ?[]const u8 { + if (auth_ctx) |ctx| { + if (ctx.perm != .admin) return boundTenant(ctx); + } + return jsonStr(body, "tenant"); +} + +fn boundTenant(ctx: *const auth.AuthContext) []const u8 { + const tenant_id = ctx.tenant(); + return if (tenant_id.len > 0) tenant_id else collection.DEFAULT_TENANT; +} + +fn isBasicDbWriteMethod(method: []const u8) bool { + return std.mem.eql(u8, method, "POST") or + std.mem.eql(u8, method, "PUT") or + std.mem.eql(u8, method, "DELETE"); +} + +fn canWriteDb(auth_ctx: ?*const auth.AuthContext) bool { + const ctx = auth_ctx orelse return true; + return ctx.perm != .read_only; +} + fn requestAsOf(raw: []const u8, query: []const u8) ?i64 { const value = header(raw, "X-As-Of: ") orelse qparam(query, "as_of") orelse return null; return parseAsOfTimestamp(value); @@ -960,7 +1099,9 @@ fn err(code: u16, msg: []const u8) usize { const status = switch (code) { 400 => "Bad Request", 401 => "Unauthorized", + 403 => "Forbidden", 429 => "Too Many Requests", + 413 => "Payload Too Large", 404 => "Not Found", else => "Internal Server Error", }; @@ -968,11 +1109,29 @@ fn err(code: u16, msg: []const u8) usize { } fn respond(code: u16, status: []const u8, body: []const u8) usize { - var fbs = std.io.fixedBufferStream(getRespBuf()); - std.fmt.format(fbs.writer(), - "HTTP/1.1 {d} {s}\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n{s}", - .{ code, status, body.len, body }) catch {}; - return fbs.pos; + const resp = getRespBuf(); + var header_buf: [256]u8 = undefined; + const header_bytes = std.fmt.bufPrint( + &header_buf, + "HTTP/1.1 {d} {s}\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", + .{ code, status, body.len }, + ) catch return 0; + + if (header_bytes.len + body.len > resp.len) { + const fallback_body = "{\"error\":\"response too large\"}"; + const fallback_header = std.fmt.bufPrint( + &header_buf, + "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{fallback_body.len}, + ) catch return 0; + @memcpy(resp[0..fallback_header.len], fallback_header); + @memcpy(resp[fallback_header.len..][0..fallback_body.len], fallback_body); + return fallback_header.len + fallback_body.len; + } + + @memcpy(resp[0..header_bytes.len], header_bytes); + @memcpy(resp[header_bytes.len..][0..body.len], body); + return header_bytes.len + body.len; } // ─── mini parsers ──────────────────────────────────────────────────────── @@ -1134,7 +1293,7 @@ fn hexVal(c: u8) ?u4 { // ─── WebSocket ─────────────────────────────────────────────────────────── /// Read exactly `buf.len` bytes from the stream, looping as needed. -fn wsReadExact(conn: std.net.Server.Connection, buf: []u8) !void { +fn wsReadExact(conn: compat.net.Server.Connection, buf: []u8) !void { var filled: usize = 0; while (filled < buf.len) { const n = conn.stream.read(buf[filled..]) catch return error.ReadFailed; @@ -1214,7 +1373,7 @@ const WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; /// Perform WebSocket upgrade handshake, then loop reading WS frames. /// Each text message is a JSON request dispatched through the normal /// HTTP handler; the response body is sent back as a WS text frame. -fn handleWebSocket(srv: *Server, conn: std.net.Server.Connection, initial: []const u8) !void { +fn handleWebSocket(srv: *Server, conn: compat.net.Server.Connection, initial: []const u8) !void { // Extract Sec-WebSocket-Key const ws_key = headerValue(initial, "Sec-WebSocket-Key") orelse return error.MissingKey; @@ -1322,7 +1481,7 @@ fn wsDispatch(srv: *Server, msg: []const u8) []const u8 { return resp; } -fn wsWriteText(conn: std.net.Server.Connection, payload: []const u8) !void { +fn wsWriteText(conn: compat.net.Server.Connection, payload: []const u8) !void { var hdr: [10]u8 = undefined; hdr[0] = 0x81; // FIN + text var hdr_len: usize = 2; @@ -1341,7 +1500,7 @@ fn wsWriteText(conn: std.net.Server.Connection, payload: []const u8) !void { try conn.stream.writeAll(payload); } -fn wsWriteClose(conn: std.net.Server.Connection, code: u16) void { +fn wsWriteClose(conn: compat.net.Server.Connection, code: u16) void { var frame: [4]u8 = undefined; frame[0] = 0x88; // FIN + close frame[1] = 2; // payload = 2 bytes (status code) @@ -1357,5 +1516,63 @@ test "parse as_of accepts seconds and milliseconds" { test "request tenant prefers header over query" { const raw = "GET /db/users?tenant=query-tenant HTTP/1.1\r\nX-Tenant-Id: header-tenant\r\n\r\n"; - try std.testing.expectEqualStrings("header-tenant", requestTenant(raw, "tenant=query-tenant")); + try std.testing.expectEqualStrings("header-tenant", requestTenant(raw, "tenant=query-tenant", null)); +} + +test "request tenant uses bound tenant for non-admin auth" { + const raw = "GET /db/users?tenant=query-tenant HTTP/1.1\r\nX-Tenant-Id: header-tenant\r\n\r\n"; + const tenant = "bound-tenant"; + var ctx = auth.AuthContext{ + .perm = .read_write, + .tenant_id = [_]u8{0} ** 64, + .tenant_id_len = @intCast(tenant.len), + }; + @memcpy(ctx.tenant_id[0..tenant.len], tenant); + + try std.testing.expectEqualStrings(tenant, requestTenant(raw, "tenant=query-tenant", &ctx)); + try std.testing.expectEqualStrings(tenant, requestTenantFilter("tenant=query-tenant", &ctx).?); + try std.testing.expectEqualStrings(tenant, requestBodyTenant("{\"tenant\":\"body-tenant\"}", &ctx).?); +} + +test "admin auth can select request tenant" { + const raw = "GET /db/users?tenant=query-tenant HTTP/1.1\r\nX-Tenant-Id: header-tenant\r\n\r\n"; + var ctx = auth.AuthContext{ + .perm = .admin, + .tenant_id = [_]u8{0} ** 64, + .tenant_id_len = 0, + }; + + try std.testing.expectEqualStrings("header-tenant", requestTenant(raw, "tenant=query-tenant", &ctx)); +} + +test "respond writes bodies larger than format scratch buffer" { + var bufs: ConnBufs = undefined; + tl_bufs = &bufs; + defer tl_bufs = null; + + var body: [9000]u8 = undefined; + @memset(&body, 'a'); + + const n = respond(200, "OK", body[0..]); + try std.testing.expect(n > body.len); + try std.testing.expect(std.mem.indexOf(u8, bufs.resp[0..n], "\r\n\r\n") != null); + try std.testing.expect(std.mem.endsWith(u8, bufs.resp[0..n], body[0..])); +} + +test "read-only auth cannot write basic db methods" { + var ctx = auth.AuthContext{ + .perm = .read_only, + .tenant_id = [_]u8{0} ** 64, + .tenant_id_len = 0, + }; + + try std.testing.expect(canWriteDb(null)); + try std.testing.expect(!canWriteDb(&ctx)); + try std.testing.expect(isBasicDbWriteMethod("POST")); + try std.testing.expect(isBasicDbWriteMethod("PUT")); + try std.testing.expect(isBasicDbWriteMethod("DELETE")); + try std.testing.expect(!isBasicDbWriteMethod("GET")); + + ctx.perm = .read_write; + try std.testing.expect(canWriteDb(&ctx)); } diff --git a/src/storage/epoch.zig b/src/storage/epoch.zig index d265f8d..a3e6882 100644 --- a/src/storage/epoch.zig +++ b/src/storage/epoch.zig @@ -14,6 +14,7 @@ //! Capacity: MAX_READERS concurrent reader threads. Each occupies one 64-byte //! slot to avoid false sharing. const std = @import("std"); +const compat = @import("compat"); pub const MAX_READERS: usize = 1024; pub const EPOCH_INACTIVE: u64 = std.math.maxInt(u64); @@ -37,7 +38,7 @@ pub const EpochManager = struct { slots: []Slot, allocator: std.mem.Allocator, timeline: std.ArrayList(EpochPoint), - timeline_mu: std.Thread.Mutex, + timeline_mu: compat.Mutex, pub fn init(allocator: std.mem.Allocator) !EpochManager { const slots = try allocator.alloc(Slot, MAX_READERS); @@ -60,7 +61,7 @@ pub const EpochManager = struct { /// Called by a writer at commit. Returns the new timestamp. pub fn advance(self: *EpochManager) u64 { - return self.advanceAt(std.time.milliTimestamp()); + return self.advanceAt(compat.milliTimestamp()); } pub fn advanceAt(self: *EpochManager, ts_ms: i64) u64 { diff --git a/src/storage/mmap.zig b/src/storage/mmap.zig index d10e842..1981155 100644 --- a/src/storage/mmap.zig +++ b/src/storage/mmap.zig @@ -7,6 +7,7 @@ //! Growth strategy: grow in GROW_CHUNK increments (256 MiB) to amortise //! ftruncate + remap syscalls. On macOS (no mremap) we munmap and remap. const std = @import("std"); +const compat = @import("compat"); const posix = std.posix; pub const GROW_CHUNK: usize = 256 * 1024 * 1024; // 256 MiB @@ -19,28 +20,31 @@ pub const MmapFile = struct { capacity: usize, // mapped (file) length; >= len, multiple of PAGE_SIZE /// Protects ptr/capacity against concurrent grow+read. /// Writers (grow) take exclusive; readers (at/slice) take shared. - rw_lock: std.Thread.RwLock, + rw_lock: compat.RwLock, // ── Open / Close ────────────────────────────────────────────────────────── pub fn open(path: [:0]const u8, initial_size: usize) !MmapFile { - const flags = posix.O{ .ACCMODE = .RDWR, .CREAT = true }; - const fd = try posix.open(path, flags, 0o644); - errdefer posix.close(fd); - - const stat = try posix.fstat(fd); - const existing: usize = @intCast(stat.size); + const fd = std.c.open(path, .{ .ACCMODE = .RDWR, .CREAT = true }, @as(std.c.mode_t, 0o644)); + if (fd < 0) return error.OpenError; + errdefer _ = std.c.close(fd); + + const existing_raw = std.c.lseek(fd, 0, std.c.SEEK.END); + if (existing_raw < 0) return error.StatError; + _ = std.c.lseek(fd, 0, std.c.SEEK.SET); + const existing: usize = @intCast(existing_raw); const capacity = alignUp( @max(existing, @max(initial_size, GROW_CHUNK)), PAGE_SIZE, ); - if (@as(usize, @intCast(stat.size)) < capacity) - try posix.ftruncate(fd, @intCast(capacity)); + if (existing < capacity) { + if (std.c.ftruncate(fd, @intCast(capacity)) != 0) return error.TruncateError; + } const ptr = try posix.mmap( null, capacity, - posix.PROT.READ | posix.PROT.WRITE, + posix.PROT{ .READ = true, .WRITE = true }, .{ .TYPE = .SHARED }, fd, 0, ); @@ -56,7 +60,7 @@ pub const MmapFile = struct { pub fn close(self: *MmapFile) void { posix.munmap(@alignCast(self.ptr[0..self.capacity])); - posix.close(self.fd); + _ = std.c.close(self.fd); } // ── Sync / Checkpoint ───────────────────────────────────────────────────── @@ -88,12 +92,12 @@ pub const MmapFile = struct { } const new_cap = alignUp(needed_len + GROW_CHUNK, PAGE_SIZE); // Extend file - try posix.ftruncate(self.fd, @intCast(new_cap)); + if (std.c.ftruncate(self.fd, @intCast(new_cap)) != 0) return error.TruncateError; // Remap (macOS has no mremap; unmap then remap) posix.munmap(@alignCast(self.ptr[0..self.capacity])); const ptr = try posix.mmap( null, new_cap, - posix.PROT.READ | posix.PROT.WRITE, + posix.PROT{ .READ = true, .WRITE = true }, .{ .TYPE = .SHARED }, self.fd, 0, ); diff --git a/src/storage/parallel_wal.zig b/src/storage/parallel_wal.zig index 1ef8b3e..fa1851b 100644 --- a/src/storage/parallel_wal.zig +++ b/src/storage/parallel_wal.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const Allocator = std.mem.Allocator; /// Operation types for WAL entries. @@ -13,13 +14,15 @@ pub const OpType = enum(u8) { const ENTRY_HEADER_SIZE = 13; /// Per-core WAL segment. Each segment owns a file and a page-aligned write -/// buffer. Appends are lock-free: writers atomically reserve space via -/// `fetchAdd` on `pos`. +/// buffer. Appends reserve space with `pos`, then publish completed entries +/// into `published_pos` in reservation order so flush never reads a partial +/// entry. pub const WALSegment = struct { segment_id: u32, - fd: std.fs.File, + fd: compat.File, buf: []align(4096) u8, pos: std.atomic.Value(u32), + published_pos: std.atomic.Value(u32), flushed_pos: u32, pub const BUF_SIZE: u32 = 65536; @@ -29,7 +32,7 @@ pub const WALSegment = struct { var path_buf: [512]u8 = undefined; const path = std.fmt.bufPrint(&path_buf, "{s}/wal_seg_{d:0>4}", .{ data_dir, id }) catch return error.PathTooLong; - const fd = try std.fs.cwd().createFile(path, .{ .truncate = true }); + const fd = try compat.cwd().createFile(path, .{ .truncate = true }); const buf = try alloc.alignedAlloc(u8, .fromByteUnits(4096), BUF_SIZE); @memset(buf, 0); @@ -39,6 +42,7 @@ pub const WALSegment = struct { .fd = fd, .buf = buf, .pos = std.atomic.Value(u32).init(0), + .published_pos = std.atomic.Value(u32).init(0), .flushed_pos = 0, }; } @@ -51,32 +55,51 @@ pub const WALSegment = struct { /// Append an entry to the segment buffer. Returns the offset where data /// was written, or `error.SegmentFull` if the buffer cannot hold the entry. pub fn append(self: *WALSegment, op: OpType, key_hash: u64, data: []const u8) !u32 { - const total: u32 = ENTRY_HEADER_SIZE + @as(u32, @intCast(data.len)); - - // Reserve space atomically — lock-free. - const offset = self.pos.fetchAdd(total, .monotonic); - if (offset + total > BUF_SIZE) { - // Roll back so other threads see accurate remaining space. - _ = self.pos.fetchSub(total, .monotonic); - return error.SegmentFull; - } + if (data.len > @as(usize, BUF_SIZE - ENTRY_HEADER_SIZE)) return error.SegmentFull; + + const data_len: u32 = @intCast(data.len); + const total: u32 = ENTRY_HEADER_SIZE + data_len; + const offset = try self.reserve(total); // Write header: [op:1][key_hash:8][len:4] self.buf[offset] = @intFromEnum(op); @memcpy(self.buf[offset + 1 .. offset + 9], std.mem.asBytes(&key_hash)); - const data_len: u32 = @intCast(data.len); @memcpy(self.buf[offset + 9 .. offset + 13], std.mem.asBytes(&data_len)); // Write payload. @memcpy(self.buf[offset + ENTRY_HEADER_SIZE .. offset + total], data); + self.publish(offset, offset + total); return offset; } + fn reserve(self: *WALSegment, total: u32) !u32 { + while (true) { + const current = self.pos.load(.monotonic); + if (current > BUF_SIZE or total > BUF_SIZE - current) return error.SegmentFull; + + if (self.pos.cmpxchgWeak(current, current + total, .monotonic, .monotonic) == null) { + return current; + } + + std.atomic.spinLoopHint(); + } + } + + fn tryPublish(self: *WALSegment, offset: u32, end: u32) bool { + return self.published_pos.cmpxchgStrong(offset, end, .release, .acquire) == null; + } + + fn publish(self: *WALSegment, offset: u32, end: u32) void { + while (!self.tryPublish(offset, end)) { + std.atomic.spinLoopHint(); + } + } + /// Flush dirty bytes to the underlying file. Called by the group-commit /// flusher — NOT per-transaction. pub fn flush(self: *WALSegment) !void { - const current = self.pos.load(.acquire); + const current = self.published_pos.load(.acquire); if (current <= self.flushed_pos) return; const dirty = self.buf[self.flushed_pos..current]; @@ -99,7 +122,7 @@ pub const WALSegment = struct { data: []const u8, next_offset: u32, } { - const end = self.pos.load(.acquire); + const end = self.published_pos.load(.acquire); if (offset + ENTRY_HEADER_SIZE > end) return error.InvalidOffset; const op: OpType = @enumFromInt(self.buf[offset]); @@ -229,7 +252,7 @@ pub const ParallelWAL = struct { fn flusherLoop(self: *ParallelWAL) void { while (self.running.load(.acquire) == 1) { self.groupCommit() catch {}; - std.Thread.sleep(1 * std.time.ns_per_ms); + compat.sleep(1 * std.time.ns_per_ms); } // Final flush on shutdown. self.groupCommit() catch {}; @@ -240,13 +263,17 @@ pub const ParallelWAL = struct { // Tests // --------------------------------------------------------------------------- +fn testingTmpPath(alloc: Allocator, tmp: *const std.testing.TmpDir) ![]u8 { + return std.fmt.allocPrint(alloc, ".zig-cache/tmp/{s}", .{tmp.sub_path}); +} + test "WALSegment single-threaded append and read" { const alloc = std.testing.allocator; // Use a temp directory. var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + const tmp_path = try testingTmpPath(alloc, &tmp); defer alloc.free(tmp_path); var seg = try WALSegment.init(alloc, tmp_path, 0); @@ -268,12 +295,88 @@ test "WALSegment single-threaded append and read" { try std.testing.expectEqualStrings("world", e2.data); } +test "WALSegment publish requires contiguous offsets" { + const alloc = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try testingTmpPath(alloc, &tmp); + defer alloc.free(tmp_path); + + var seg = try WALSegment.init(alloc, tmp_path, 0); + defer seg.deinit(alloc); + + const first_total: u32 = ENTRY_HEADER_SIZE + 5; + const second_total: u32 = ENTRY_HEADER_SIZE + 6; + + try std.testing.expect(!seg.tryPublish(first_total, first_total + second_total)); + try std.testing.expectEqual(@as(u32, 0), seg.published_pos.load(.acquire)); + + try std.testing.expect(seg.tryPublish(0, first_total)); + try std.testing.expectEqual(first_total, seg.published_pos.load(.acquire)); + + try std.testing.expect(seg.tryPublish(first_total, first_total + second_total)); + try std.testing.expectEqual(first_total + second_total, seg.published_pos.load(.acquire)); +} + +test "WALSegment flush ignores reserved unpublished bytes" { + const alloc = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try testingTmpPath(alloc, &tmp); + defer alloc.free(tmp_path); + + var seg = try WALSegment.init(alloc, tmp_path, 0); + defer seg.deinit(alloc); + + const payload = "complete"; + const data_len: u32 = payload.len; + const total: u32 = ENTRY_HEADER_SIZE + data_len; + const key_hash: u64 = 0xABCDEF; + + seg.pos.store(total, .release); + seg.buf[0] = @intFromEnum(OpType.put); + @memcpy(seg.buf[1..9], std.mem.asBytes(&key_hash)); + @memcpy(seg.buf[9..13], std.mem.asBytes(&data_len)); + @memcpy(seg.buf[ENTRY_HEADER_SIZE..@as(usize, total)], payload); + + try std.testing.expectError(error.InvalidOffset, seg.readEntry(0)); + + try seg.flush(); + try std.testing.expectEqual(@as(u32, 0), seg.flushed_pos); + var stat = try seg.fd.stat(); + try std.testing.expectEqual(@as(u64, 0), stat.size); + + seg.published_pos.store(total, .release); + + const entry = try seg.readEntry(0); + try std.testing.expectEqual(OpType.put, entry.op); + try std.testing.expectEqual(key_hash, entry.key_hash); + try std.testing.expectEqualStrings(payload, entry.data); + + try seg.flush(); + try std.testing.expectEqual(total, seg.flushed_pos); + stat = try seg.fd.stat(); + try std.testing.expectEqual(@as(u64, total), stat.size); + + var persisted: [ENTRY_HEADER_SIZE + payload.len]u8 = undefined; + var path_buf: [512]u8 = undefined; + const wal_path = try std.fmt.bufPrint(&path_buf, "{s}/wal_seg_{d:0>4}", .{ tmp_path, 0 }); + var read_file = try compat.cwd().openFile(wal_path, .{}); + defer read_file.close(); + + const read = try read_file.readAll(persisted[0..]); + try std.testing.expectEqual(@as(usize, total), read); + try std.testing.expectEqualStrings(payload, persisted[ENTRY_HEADER_SIZE..]); +} + test "ParallelWAL multi-threaded writes" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + const tmp_path = try testingTmpPath(alloc, &tmp); defer alloc.free(tmp_path); const n_segments: u32 = 4; @@ -309,13 +412,16 @@ test "ParallelWAL multi-threaded writes" { // Verify: total bytes written across all segments should account for // N_THREADS * WRITES_PER_THREAD entries. var total_bytes: u64 = 0; + var total_published_bytes: u64 = 0; var i: u32 = 0; while (i < n_segments) : (i += 1) { total_bytes += wal.segments[i].pos.load(.acquire); + total_published_bytes += wal.segments[i].published_pos.load(.acquire); } const expected_bytes: u64 = N_THREADS * WRITES_PER_THREAD * (ENTRY_HEADER_SIZE + 4); // "data" = 4 bytes try std.testing.expectEqual(expected_bytes, total_bytes); + try std.testing.expectEqual(expected_bytes, total_published_bytes); } test "ParallelWAL group commit advances epoch" { @@ -323,7 +429,7 @@ test "ParallelWAL group commit advances epoch" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + const tmp_path = try testingTmpPath(alloc, &tmp); defer alloc.free(tmp_path); var wal = try ParallelWAL.init(alloc, tmp_path, 2); @@ -345,7 +451,7 @@ test "ParallelWAL background flusher runs" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + const tmp_path = try testingTmpPath(alloc, &tmp); defer alloc.free(tmp_path); var wal = try ParallelWAL.init(alloc, tmp_path, 2); @@ -357,7 +463,7 @@ test "ParallelWAL background flusher runs" { _ = try wal.write(.put, 99, "flusher-test"); // Sleep a bit to let the flusher run at least once (~1 ms interval). - std.Thread.sleep(10 * std.time.ns_per_ms); + compat.sleep(10 * std.time.ns_per_ms); const epoch = wal.current_epoch.load(.acquire); try std.testing.expect(epoch >= 1); diff --git a/src/storage/wal.zig b/src/storage/wal.zig index f671037..9cb8979 100644 --- a/src/storage/wal.zig +++ b/src/storage/wal.zig @@ -27,58 +27,63 @@ //! 3. Replays only committed transactions via the caller-supplied apply_fn. //! 4. Truncates the tail of any partial (unfinished) entry at the end. const std = @import("std"); +const compat = @import("compat"); // ── Entry header (32 bytes, cache-line harmless) ────────────────────────────── pub const OpCode = enum(u8) { - nop = 0x00, + nop = 0x00, // Transaction control - txn_begin = 0x40, - txn_commit = 0x41, - txn_abort = 0x42, - checkpoint = 0xF0, + txn_begin = 0x40, + txn_commit = 0x41, + txn_abort = 0x42, + checkpoint = 0xF0, // Graph ops - node_insert = 0x01, - node_update = 0x02, - node_delete = 0x03, - edge_insert = 0x10, - edge_delete = 0x11, + node_insert = 0x01, + node_update = 0x02, + node_delete = 0x03, + edge_insert = 0x10, + edge_delete = 0x11, // Document ops - doc_insert = 0x20, - doc_update = 0x21, - doc_delete = 0x22, + doc_insert = 0x20, + doc_update = 0x21, + doc_delete = 0x22, // Schema ops - create_coll = 0x30, - drop_coll = 0x31, + create_coll = 0x30, + drop_coll = 0x31, _, }; pub const DB_TAG_GRAPH: u8 = 0x01; -pub const DB_TAG_DOC: u8 = 0x02; -pub const FLAG_COMMIT: u16 = 0x0001; +pub const DB_TAG_DOC: u8 = 0x02; +pub const FLAG_COMMIT: u16 = 0x0001; pub const EntryHeader = extern struct { - lsn: u64 align(1), - length: u32 align(1), - crc32: u32 align(1), - op_code: u8, - db_tag: u8, - flags: u16 align(1), - txn_id: u64 align(1), + lsn: u64 align(1), + length: u32 align(1), + crc32: u32 align(1), + op_code: u8, + db_tag: u8, + flags: u16 align(1), + txn_id: u64 align(1), reserved: u32 align(1), - comptime { std.debug.assert(@sizeOf(EntryHeader) == 32); } + comptime { + std.debug.assert(@sizeOf(EntryHeader) == 32); + } }; pub const HEADER_SIZE: usize = @sizeOf(EntryHeader); +pub const MAX_ENTRY_PAYLOAD: usize = 64 * 1024 * 1024; +const MAX_ENTRY_PAYLOAD_U32: u32 = @intCast(MAX_ENTRY_PAYLOAD); pub const Entry = struct { - lsn: u64, - txn_id: u64, + lsn: u64, + txn_id: u64, op_code: OpCode, - db_tag: u8, - flags: u16, - payload: []const u8, // slice into caller-owned buffer + db_tag: u8, + flags: u16, + payload: []const u8, // slice into caller-owned buffer }; // ── CRC-32 (IEEE polynomial) ────────────────────────────────────────────────── @@ -92,9 +97,9 @@ fn crc32(data: []const u8) u32 { fn entryChecksum(header_bytes: []const u8, payload: []const u8) u32 { // CRC over header (with crc field zeroed) ++ payload var h = std.hash.crc.Crc32.init(); - h.update(header_bytes[0..12]); // lsn + length - h.update(&[_]u8{0,0,0,0}); // zero-substitute crc32 field - h.update(header_bytes[16..]); // op..reserved + h.update(header_bytes[0..12]); // lsn + length + h.update(&[_]u8{ 0, 0, 0, 0 }); // zero-substitute crc32 field + h.update(header_bytes[16..]); // op..reserved h.update(payload); return h.final(); } @@ -102,17 +107,18 @@ fn entryChecksum(header_bytes: []const u8, payload: []const u8) u32 { // ── WAL ─────────────────────────────────────────────────────────────────────── pub const WAL = struct { - file: std.fs.File, - write_buf: std.ArrayList(u8), // pending (not yet flushed) - next_lsn: std.atomic.Value(u64), + file: compat.File, + write_buf: std.ArrayList(u8), // pending (not yet flushed) + next_lsn: std.atomic.Value(u64), checkpoint_lsn: u64, - allocator: std.mem.Allocator, + allocator: std.mem.Allocator, // Group commit state — guarded by mu - mu: std.Thread.Mutex, - cond: std.Thread.Condition, - synced_lsn: u64, - flushing: bool, + mu: compat.Mutex, + cond: compat.Condition, + synced_lsn: u64, + flushing: bool, + last_flush_error: ?anyerror, // Background flusher flush_thread: ?std.Thread, @@ -124,24 +130,27 @@ pub const WAL = struct { // ── Lifecycle ───────────────────────────────────────────────────────────── pub fn open(path: [:0]const u8, allocator: std.mem.Allocator) !WAL { - const f = try std.fs.createFileAbsolute(path, .{ - .read = true, .truncate = false, .exclusive = false, - }); - var wal = WAL{ - .file = f, - .write_buf = .empty, - .next_lsn = std.atomic.Value(u64).init(1), + const f = blk: { + const fd = std.c.open(path, .{ .ACCMODE = .RDWR, .CREAT = true }, @as(std.c.mode_t, 0o644)); + if (fd < 0) return error.CreateFileError; + break :blk compat.File{ .handle = fd }; + }; + const wal = WAL{ + .file = f, + .write_buf = .empty, + .next_lsn = std.atomic.Value(u64).init(1), .checkpoint_lsn = 0, - .allocator = allocator, - .mu = .{}, - .cond = .{}, - .synced_lsn = 0, - .flushing = false, - .flush_thread = null, + .allocator = allocator, + .mu = .{}, + .cond = .{}, + .synced_lsn = 0, + .flushing = false, + .last_flush_error = null, + .flush_thread = null, .flush_running = std.atomic.Value(bool).init(false), }; // Seek to end (append mode) - try wal.file.seekFromEnd(0); + _ = std.c.lseek(wal.file.handle, 0, std.c.SEEK.END); return wal; } @@ -154,7 +163,7 @@ pub const WAL = struct { fn flushLoop(self: *WAL) void { while (self.flush_running.load(.acquire)) { - std.Thread.sleep(1_000_000); // 1ms + compat.sleep(1_000_000); // 1ms self.flushPending(); } // Final flush on shutdown @@ -164,6 +173,10 @@ pub const WAL = struct { /// Flush any pending WAL entries to disk (non-blocking for callers). pub fn flushPending(self: *WAL) void { self.mu.lock(); + if (self.last_flush_error != null) { + self.mu.unlock(); + return; + } if (self.write_buf.items.len == 0) { self.mu.unlock(); return; @@ -178,17 +191,27 @@ pub const WAL = struct { self.write_buf = .empty; self.mu.unlock(); - self.file.writeAll(to_write.items) catch {}; + const io_err = self.writeAndSync(to_write.items); to_write.deinit(self.allocator); - self.file.sync() catch {}; self.mu.lock(); - self.synced_lsn = target; + if (io_err) |e| { + self.last_flush_error = e; + } else { + self.synced_lsn = target; + self.last_flush_error = null; + } self.flushing = false; self.cond.broadcast(); self.mu.unlock(); } + pub fn lastFlushError(self: *WAL) ?anyerror { + self.mu.lock(); + defer self.mu.unlock(); + return self.last_flush_error; + } + pub fn close(self: *WAL) void { if (self.flush_running.load(.acquire)) { self.flush_running.store(false, .release); @@ -203,36 +226,46 @@ pub const WAL = struct { // ── Write ───────────────────────────────────────────────────────────────── /// Encode an entry into the shared write buffer and return its LSN. - /// Thread-safe. Does NOT guarantee durability — call commit(lsn) for that. + /// Thread-safe. Does NOT guarantee durability — call commit(txn_id, db_tag) for that. /// Blocks if write buffer exceeds MAX_WRITE_BUF to apply backpressure. pub fn write( - self: *WAL, - txn_id: u64, - op: OpCode, - db_tag: u8, - flags: u16, + self: *WAL, + txn_id: u64, + op: OpCode, + db_tag: u8, + flags: u16, payload: []const u8, ) !u64 { + if (payload.len > MAX_ENTRY_PAYLOAD) return error.WALPayloadTooLarge; + const lsn = self.next_lsn.fetchAdd(1, .monotonic); const pad = paddingTo8(HEADER_SIZE + payload.len); var hdr = EntryHeader{ - .lsn = lsn, - .length = @intCast(payload.len), - .crc32 = 0, - .op_code = @intFromEnum(op), - .db_tag = db_tag, - .flags = flags, - .txn_id = txn_id, + .lsn = lsn, + .length = @intCast(payload.len), + .crc32 = 0, + .op_code = @intFromEnum(op), + .db_tag = db_tag, + .flags = flags, + .txn_id = txn_id, .reserved = 0, }; const hdr_bytes = std.mem.asBytes(&hdr); hdr.crc32 = entryChecksum(hdr_bytes, payload); self.mu.lock(); + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } // Backpressure: wait until flusher drains the buffer below threshold. // Use condition variable instead of yield-loop so the flusher can wake us. while (self.write_buf.items.len >= MAX_WRITE_BUF) { + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } // Release lock and wait for flusher to signal. self.cond.wait(&self.mu); } @@ -243,52 +276,78 @@ pub const WAL = struct { return lsn; } + /// Append a single-entry committed operation without forcing durability. + /// The background flusher or a later commit/checkpoint is responsible for fsync. + /// Use this only when the txn_id is not shared with a multi-entry transaction. + pub fn writeCommitted( + self: *WAL, + txn_id: u64, + op: OpCode, + db_tag: u8, + payload: []const u8, + ) !u64 { + return self.write(txn_id, op, db_tag, FLAG_COMMIT, payload); + } + /// Mark a transaction committed. Returns only after the entry is durable. /// Implements group commit: the first caller flushes for everyone. pub fn commit(self: *WAL, txn_id: u64, db_tag: u8) !void { // Write COMMIT entry to buffer var commit_payload: [16]u8 = undefined; - std.mem.writeInt(u64, commit_payload[0..8], txn_id, .little); - std.mem.writeInt(u64, commit_payload[8..16], @as(u64, @truncate(@as(u128, @bitCast(std.time.nanoTimestamp())))), .little); + std.mem.writeInt(u64, commit_payload[0..8], txn_id, .little); + std.mem.writeInt(u64, commit_payload[8..16], @as(u64, @truncate(@as(u128, @bitCast(compat.nanoTimestamp())))), .little); const lsn = try self.write(txn_id, .txn_commit, db_tag, FLAG_COMMIT, &commit_payload); // Group commit: become flusher or wait self.mu.lock(); - if (self.synced_lsn >= lsn) { - self.mu.unlock(); - return; - } + while (self.synced_lsn < lsn) { + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } - if (self.flushing) { - // Wait for current flusher - while (self.synced_lsn < lsn) self.cond.wait(&self.mu); - self.mu.unlock(); - return; - } + if (self.flushing) { + // Wait for the current flusher, then either observe its LSN + // advance or become the next flusher for entries it missed. + self.cond.wait(&self.mu); + continue; + } - // We are the flusher - self.flushing = true; - // Snapshot the buffer and target LSN under the lock - var to_write: std.ArrayList(u8) = self.write_buf; - const target = self.next_lsn.load(.monotonic) - 1; - self.write_buf = .empty; - self.mu.unlock(); + if (self.write_buf.items.len == 0) { + self.mu.unlock(); + return error.WALNotDurable; + } - // ── I/O outside lock ────────────────────────────────────────────────── - var io_err: ?anyerror = null; - self.file.writeAll(to_write.items) catch |e| { io_err = e; }; - to_write.deinit(self.allocator); - if (io_err == null) self.file.sync() catch |e| { io_err = e; }; - // ───────────────────────────────────────────────────────────────────── + // We are the flusher + self.flushing = true; + // Snapshot the buffer and target LSN under the lock + var to_write: std.ArrayList(u8) = self.write_buf; + const target = self.next_lsn.load(.monotonic) - 1; + self.write_buf = .empty; + self.mu.unlock(); - self.mu.lock(); - if (io_err == null) self.synced_lsn = target; - self.flushing = false; - self.cond.broadcast(); - self.mu.unlock(); + // ── I/O outside lock ────────────────────────────────────────────── + const io_err = self.writeAndSync(to_write.items); + to_write.deinit(self.allocator); + // ───────────────────────────────────────────────────────────────── + + self.mu.lock(); + if (io_err) |e| { + self.last_flush_error = e; + } else { + self.synced_lsn = target; + self.last_flush_error = null; + } + self.flushing = false; + self.cond.broadcast(); - if (io_err) |e| return e; + if (io_err) |e| { + self.mu.unlock(); + return e; + } + } + self.mu.unlock(); } // ── Checkpoint ──────────────────────────────────────────────────────────── @@ -303,13 +362,24 @@ pub const WAL = struct { self.checkpoint_lsn = lsn; // Force flush self.mu.lock(); + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } self.flushing = true; var to_write = self.write_buf; self.write_buf = .empty; self.mu.unlock(); - self.file.writeAll(to_write.items) catch {}; + const io_err = self.writeAndSync(to_write.items); to_write.deinit(self.allocator); - try self.file.sync(); + if (io_err) |e| { + self.mu.lock(); + self.last_flush_error = e; + self.flushing = false; + self.cond.broadcast(); + self.mu.unlock(); + return e; + } // Truncate WAL — all data is checkpointed to page files. self.file.seekTo(0) catch {}; @@ -317,6 +387,7 @@ pub const WAL = struct { self.mu.lock(); self.synced_lsn = lsn; + self.last_flush_error = null; self.flushing = false; self.cond.broadcast(); self.mu.unlock(); @@ -328,10 +399,10 @@ pub const WAL = struct { /// entry with lsn > skip_before_lsn. On return the file pointer is at EOF /// and ready for new appends. pub fn recover( - self: *WAL, + self: *WAL, skip_before_lsn: u64, - apply_fn: *const fn (entry: Entry) anyerror!void, - allocator: std.mem.Allocator, + apply_fn: *const fn (entry: Entry) anyerror!void, + allocator: std.mem.Allocator, ) !void { try self.file.seekTo(0); @@ -350,6 +421,7 @@ pub const WAL = struct { // ── Pass 2: replay committed entries ───────────────────────────────── try self.file.seekTo(0); var max_lsn: u64 = 0; + var valid_end: u64 = 0; { var it = EntryIterator.init(self.file, allocator); defer it.deinit(); @@ -358,15 +430,19 @@ pub const WAL = struct { if (e.lsn <= skip_before_lsn) continue; if (!committed.contains(e.txn_id)) continue; if (e.op_code == .txn_commit or e.op_code == .txn_begin or - e.op_code == .txn_abort or e.op_code == .checkpoint) continue; + e.op_code == .txn_abort or e.op_code == .checkpoint) continue; try apply_fn(e); } + valid_end = it.valid_end; } + const stat = try self.file.stat(); + if (valid_end < stat.size) try self.file.setEndPos(valid_end); + // ── Advance LSN counter and seek to EOF for new writes ──────────────── self.next_lsn.store(max_lsn + 1, .release); self.synced_lsn = max_lsn; - try self.file.seekFromEnd(0); + try self.file.seekTo(valid_end); std.log.info("WAL recovery: replayed up to lsn={d}", .{max_lsn}); } @@ -375,20 +451,30 @@ pub const WAL = struct { inline fn paddingTo8(n: usize) usize { return (8 - (n % 8)) % 8; } + + fn writeAndSync(self: *WAL, bytes: []const u8) ?anyerror { + self.file.writeAll(bytes) catch |e| return e; + self.file.sync() catch |e| return e; + return null; + } }; // ── Entry iterator (for recovery) ──────────────────────────────────────────── const EntryIterator = struct { - reader: std.io.BufferedReader(4096, std.fs.File.Reader), - buf: []u8, + reader: compat.GenericBufferedReader(4096, compat.File.Reader), + buf: []u8, allocator: std.mem.Allocator, + pos: u64, + valid_end: u64, - fn init(file: std.fs.File, allocator: std.mem.Allocator) EntryIterator { + fn init(file: compat.File, allocator: std.mem.Allocator) EntryIterator { return .{ - .reader = std.io.bufferedReader(file.reader()), - .buf = &.{}, + .reader = compat.bufferedReader(file.reader()), + .buf = &.{}, .allocator = allocator, + .pos = 0, + .valid_end = 0, }; } @@ -399,42 +485,280 @@ const EntryIterator = struct { /// Returns null at EOF or on a corrupt entry. fn next(self: *EntryIterator) !?Entry { var hdr_buf: [HEADER_SIZE]u8 = undefined; + const entry_start = self.pos; const n = self.reader.reader().readAll(&hdr_buf) catch return null; + self.pos += n; + if (n == 0) return null; if (n < HEADER_SIZE) return null; const hdr: *const EntryHeader = @ptrCast(&hdr_buf); if (hdr.lsn == 0) return null; // padding / sentinel + if (hdr.length > MAX_ENTRY_PAYLOAD_U32) return null; // absurd/corrupt length // Read payload - if (hdr.length > 0) { - if (self.buf.len < hdr.length) { + const payload_len: usize = @intCast(hdr.length); + if (payload_len > 0) { + if (self.buf.len < payload_len) { if (self.buf.len > 0) self.allocator.free(self.buf); - self.buf = try self.allocator.alloc(u8, hdr.length); + self.buf = try self.allocator.alloc(u8, payload_len); } - const p = self.reader.reader().readAll(self.buf[0..hdr.length]) catch return null; - if (p < hdr.length) return null; + const p = self.reader.reader().readAll(self.buf[0..payload_len]) catch return null; + self.pos += p; + if (p < payload_len) return null; } - const payload: []const u8 = if (hdr.length > 0) self.buf[0..hdr.length] else &.{}; + const payload: []const u8 = if (payload_len > 0) self.buf[0..payload_len] else &.{}; // Verify CRC const expected = entryChecksum(&hdr_buf, payload); if (expected != hdr.crc32) return null; // corrupt — stop recovery // Skip padding - const pad = WAL.paddingTo8(HEADER_SIZE + hdr.length); + const pad = WAL.paddingTo8(HEADER_SIZE + payload_len); if (pad > 0) { var skip: [7]u8 = undefined; - _ = self.reader.reader().readAll(skip[0..pad]) catch {}; + const skipped = self.reader.reader().readAll(skip[0..pad]) catch return null; + self.pos += skipped; + if (skipped < pad) return null; } + self.valid_end = entry_start + HEADER_SIZE + payload_len + pad; return Entry{ - .lsn = hdr.lsn, - .txn_id = hdr.txn_id, + .lsn = hdr.lsn, + .txn_id = hdr.txn_id, .op_code = @enumFromInt(hdr.op_code), - .db_tag = hdr.db_tag, - .flags = hdr.flags, + .db_tag = hdr.db_tag, + .flags = hdr.flags, .payload = payload, }; } }; + +// ── Tests ─────────────────────────────────────────────────────────────────── + +fn testingWalPath(allocator: std.mem.Allocator, tmp: *const std.testing.TmpDir, name: []const u8) ![:0]u8 { + return std.fmt.allocPrintSentinel(allocator, ".zig-cache/tmp/{s}/{s}", .{ tmp.sub_path, name }, 0); +} + +const ReplayProbe = struct { + var count: usize = 0; + var last_lsn: u64 = 0; + var last_txn_id: u64 = 0; + var last_op: OpCode = .nop; + var last_flags: u16 = 0; + var payload_buf: [128]u8 = undefined; + var payload_len: usize = 0; + + fn reset() void { + count = 0; + last_lsn = 0; + last_txn_id = 0; + last_op = .nop; + last_flags = 0; + payload_len = 0; + } + + fn apply(entry: Entry) !void { + if (entry.payload.len > payload_buf.len) return error.PayloadTooLarge; + count += 1; + last_lsn = entry.lsn; + last_txn_id = entry.txn_id; + last_op = entry.op_code; + last_flags = entry.flags; + payload_len = entry.payload.len; + @memcpy(payload_buf[0..payload_len], entry.payload); + } + + fn payload() []const u8 { + return payload_buf[0..payload_len]; + } +}; + +test "WAL recovery replays committed entries only" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "committed-only.wal"); + defer allocator.free(path); + + { + var wal = try WAL.open(path, allocator); + _ = try wal.write(10, .doc_insert, DB_TAG_DOC, 0, "committed"); + try wal.commit(10, DB_TAG_DOC); + _ = try wal.write(11, .doc_insert, DB_TAG_DOC, 0, "uncommitted"); + wal.close(); + } + + { + var wal = try WAL.open(path, allocator); + defer wal.close(); + + ReplayProbe.reset(); + try wal.recover(0, ReplayProbe.apply, allocator); + + try std.testing.expectEqual(@as(usize, 1), ReplayProbe.count); + try std.testing.expectEqual(OpCode.doc_insert, ReplayProbe.last_op); + try std.testing.expectEqual(@as(u64, 10), ReplayProbe.last_txn_id); + try std.testing.expectEqualStrings("committed", ReplayProbe.payload()); + } +} + +test "writeCommitted buffers committed entry without forcing fsync" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "write-committed.wal"); + defer allocator.free(path); + + { + var wal = try WAL.open(path, allocator); + const lsn = try wal.writeCommitted(40, .doc_insert, DB_TAG_DOC, "inline"); + + try std.testing.expectEqual(@as(u64, 1), lsn); + try std.testing.expectEqual(@as(u64, 0), wal.synced_lsn); + try std.testing.expect(wal.write_buf.items.len > 0); + + wal.flushPending(); + try std.testing.expectEqual(lsn, wal.synced_lsn); + wal.close(); + } + + { + var wal = try WAL.open(path, allocator); + defer wal.close(); + + ReplayProbe.reset(); + try wal.recover(0, ReplayProbe.apply, allocator); + + try std.testing.expectEqual(@as(usize, 1), ReplayProbe.count); + try std.testing.expectEqual(OpCode.doc_insert, ReplayProbe.last_op); + try std.testing.expectEqual(@as(u64, 40), ReplayProbe.last_txn_id); + try std.testing.expect((ReplayProbe.last_flags & FLAG_COMMIT) != 0); + try std.testing.expectEqualStrings("inline", ReplayProbe.payload()); + } +} + +test "commit still forces durable transaction commit record" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "commit-durable.wal"); + defer allocator.free(path); + + var wal = try WAL.open(path, allocator); + _ = try wal.write(50, .doc_insert, DB_TAG_DOC, 0, "durable"); + try wal.commit(50, DB_TAG_DOC); + + try std.testing.expectEqual(@as(u64, 2), wal.synced_lsn); + try std.testing.expectEqual(@as(usize, 0), wal.write_buf.items.len); + + wal.close(); +} + +test "WAL recovery truncates torn tail after committed entries" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "torn-tail.wal"); + defer allocator.free(path); + + { + var wal = try WAL.open(path, allocator); + _ = try wal.write(20, .doc_insert, DB_TAG_DOC, 0, "kept"); + try wal.commit(20, DB_TAG_DOC); + wal.close(); + } + + var before_size: u64 = 0; + { + var file = try compat.openFileAbsolute(path, .{}); + defer file.close(); + _ = std.c.lseek(file.handle, 0, std.c.SEEK.END); + try file.writeAll("torn-tail"); + try file.sync(); + before_size = (try file.stat()).size; + } + + { + var wal = try WAL.open(path, allocator); + defer wal.close(); + + ReplayProbe.reset(); + try wal.recover(0, ReplayProbe.apply, allocator); + + const after_size = (try wal.file.stat()).size; + try std.testing.expect(after_size < before_size); + try std.testing.expectEqual(@as(usize, 1), ReplayProbe.count); + try std.testing.expectEqualStrings("kept", ReplayProbe.payload()); + try std.testing.expectEqual(@as(u64, 3), wal.next_lsn.load(.acquire)); + try std.testing.expectEqual(@as(u64, 2), wal.synced_lsn); + } +} + +test "WAL recovery treats oversized payload length as corrupt tail" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "oversized-tail.wal"); + defer allocator.free(path); + + { + var file = try compat.createFileAbsolute(path, .{}); + defer file.close(); + + var hdr = EntryHeader{ + .lsn = 1, + .length = MAX_ENTRY_PAYLOAD_U32 + 1, + .crc32 = 0, + .op_code = @intFromEnum(OpCode.doc_insert), + .db_tag = DB_TAG_DOC, + .flags = FLAG_COMMIT, + .txn_id = 60, + .reserved = 0, + }; + hdr.crc32 = entryChecksum(std.mem.asBytes(&hdr), &.{}); + try file.writeAll(std.mem.asBytes(&hdr)); + try file.sync(); + } + + { + var wal = try WAL.open(path, allocator); + defer wal.close(); + + ReplayProbe.reset(); + try wal.recover(0, ReplayProbe.apply, allocator); + + const after_size = (try wal.file.stat()).size; + try std.testing.expectEqual(@as(usize, 0), ReplayProbe.count); + try std.testing.expectEqual(@as(u64, 0), after_size); + try std.testing.expectEqual(@as(u64, 1), wal.next_lsn.load(.acquire)); + try std.testing.expectEqual(@as(u64, 0), wal.synced_lsn); + } +} + +test "flushPending records write failure without advancing synced lsn" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "flush-error.wal"); + defer allocator.free(path); + + var wal = try WAL.open(path, allocator); + const lsn = try wal.write(30, .doc_insert, DB_TAG_DOC, 0, "not-durable"); + + wal.file.close(); + wal.file.handle = -1; + wal.flushPending(); + + try std.testing.expect(wal.synced_lsn < lsn); + try std.testing.expect(wal.lastFlushError() != null); + try std.testing.expect(wal.lastFlushError().? == error.WriteError); + + wal.write_buf.deinit(allocator); +} diff --git a/src/tdb.zig b/src/tdb.zig index f336637..7386d8c 100644 --- a/src/tdb.zig +++ b/src/tdb.zig @@ -11,6 +11,7 @@ /// tdb --data data directory (default: ./turbodb_data) /// const std = @import("std"); +const compat = @import("compat"); const collection_mod = @import("collection.zig"); const codeindex = @import("codeindex.zig"); const Database = collection_mod.Database; @@ -22,13 +23,13 @@ const EXTS = [_][]const u8{ ".json", ".toml", ".yaml", ".yml", ".md", }; -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + // GPA replaced by smp_allocator for Zig 0.16 - const args = try std.process.argsAlloc(alloc); - defer std.process.argsFree(alloc, args); + const alloc = std.heap.smp_allocator; + + const args = try compat.argsAlloc(alloc, init); + defer compat.argsFree(alloc, args); var data_dir: []const u8 = "./turbodb_data"; var col_name: []const u8 = "code"; @@ -62,7 +63,7 @@ pub fn main() !void { } // Ensure data dir - std.fs.cwd().makeDir(data_dir) catch |e| switch (e) { + compat.cwd().makeDir(data_dir) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return e, }; @@ -119,7 +120,7 @@ pub fn main() !void { fn cmdIndex(db: *Database, col_name: []const u8, dir_path: []const u8, alloc: std.mem.Allocator) !void { const col = try db.collection(col_name); - var dir = std.fs.cwd().openDir(dir_path, .{ .iterate = true }) catch |e| { + var dir = compat.cwd().openDir(dir_path, .{ .iterate = true }) catch |e| { std.debug.print("Cannot open directory: {any}\n", .{e}); return; }; @@ -127,17 +128,22 @@ fn cmdIndex(db: *Database, col_name: []const u8, dir_path: []const u8, alloc: st var indexed: u64 = 0; var skipped: u64 = 0; - const t0 = std.time.nanoTimestamp(); + const t0 = compat.nanoTimestamp(); - var walker = try dir.walk(alloc); - defer walker.deinit(); + var walker = dir.iterate(); + // iterate needs no deinit while (try walker.next()) |entry| { if (entry.kind != .file) continue; - if (!hasValidExt(entry.basename)) continue; + if (!hasValidExt(entry.name)) continue; // Read file content (first 8KB for indexing) - var file = dir.openFile(entry.path, .{}) catch { + var path_buf2: [4096]u8 = undefined; + const full_name = std.fmt.bufPrint(&path_buf2, "{s}/{s}", .{ dir_path, entry.name }) catch { + skipped += 1; + continue; + }; + var file = compat.cwd().openFile(full_name, .{}) catch { skipped += 1; continue; }; @@ -153,7 +159,7 @@ fn cmdIndex(db: *Database, col_name: []const u8, dir_path: []const u8, alloc: st continue; } // Dupe path — walker buffer is reused, but trigram index stores pointers - const stable_path = alloc.dupe(u8, entry.path) catch { + const stable_path = alloc.dupe(u8, entry.name) catch { skipped += 1; continue; }; @@ -164,14 +170,14 @@ fn cmdIndex(db: *Database, col_name: []const u8, dir_path: []const u8, alloc: st indexed += 1; if (indexed % 1000 == 0) { - const elapsed_ns = std.time.nanoTimestamp() - t0; + const elapsed_ns = compat.nanoTimestamp() - t0; const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / 1e9; const rate = @as(f64, @floatFromInt(indexed)) / elapsed_s; std.debug.print("\r Indexed {d} files ({d:.0} files/s)...", .{ indexed, rate }); } } - const elapsed_ns = std.time.nanoTimestamp() - t0; + const elapsed_ns = compat.nanoTimestamp() - t0; const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / 1e9; const rate = @as(f64, @floatFromInt(indexed)) / elapsed_s; @@ -183,10 +189,10 @@ fn cmdIndex(db: *Database, col_name: []const u8, dir_path: []const u8, alloc: st fn cmdSearch(db: *Database, col_name: []const u8, query: []const u8, alloc: std.mem.Allocator) !void { const col = try db.collection(col_name); - const t0 = std.time.nanoTimestamp(); + const t0 = compat.nanoTimestamp(); const result = try col.searchText(query, 20, alloc); defer result.deinit(); - const elapsed_ns = std.time.nanoTimestamp() - t0; + const elapsed_ns = compat.nanoTimestamp() - t0; const elapsed_us = @as(f64, @floatFromInt(elapsed_ns)) / 1e3; const n_cand = result.candidate_paths.len; @@ -250,9 +256,9 @@ fn cmdBench(db: *Database, col_name: []const u8, dir_path: []const u8, alloc: st var total_candidates: u64 = 0; for (queries) |q| { - const t0 = std.time.nanoTimestamp(); + const t0 = compat.nanoTimestamp(); const result = try col.searchText(q, 50, alloc); - const elapsed_ns = std.time.nanoTimestamp() - t0; + const elapsed_ns = compat.nanoTimestamp() - t0; const elapsed_us = @as(f64, @floatFromInt(elapsed_ns)) / 1e3; total_us += elapsed_us; total_hits += result.docs.len; @@ -330,7 +336,7 @@ fn findSnippet(value: []const u8, query: []const u8) []const u8 { if (value[ls] == '\n' and ls < vi) ls += 1; var le = end; while (le < value.len and value[le] != '\n') le += 1; - return std.mem.trimRight(u8, value[ls..le], " \t\r\n"); + return std.mem.trimEnd(u8, value[ls..le], " \t\r\n"); } } return ""; diff --git a/src/test_calvin_e2e.zig b/src/test_calvin_e2e.zig index 82445b0..c67c7c6 100644 --- a/src/test_calvin_e2e.zig +++ b/src/test_calvin_e2e.zig @@ -7,6 +7,7 @@ /// 4. Both nodes execute deterministically /// 5. Verify both databases have identical state const std = @import("std"); +const compat = @import("compat"); const collection_mod = @import("collection.zig"); const doc_mod = @import("doc.zig"); const sequencer = @import("replication/sequencer.zig"); @@ -89,17 +90,17 @@ fn makeTxn(alloc: std.mem.Allocator, txn_id: u64, txn_type: sequencer.TxnType, c } pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); + // GPA replaced by smp_allocator for Zig 0.16 + + const alloc = std.heap.smp_allocator; // Clean up any previous test data - std.fs.cwd().deleteTree("/tmp/calvin_test_leader") catch {}; - std.fs.cwd().deleteTree("/tmp/calvin_test_replica") catch {}; + compat.cwd().deleteTree("/tmp/calvin_test_leader") catch {}; + compat.cwd().deleteTree("/tmp/calvin_test_replica") catch {}; // Create data directories - std.fs.makeDirAbsolute("/tmp/calvin_test_leader") catch {}; - std.fs.makeDirAbsolute("/tmp/calvin_test_replica") catch {}; + compat.cwd().makeDir("/tmp/calvin_test_leader") catch {}; + compat.cwd().makeDir("/tmp/calvin_test_replica") catch {}; // ── Step 1: Open two separate databases ────────────────────────────── std.debug.print("\n=== TurboDB Calvin Replication E2E Test ===\n\n", .{}); @@ -236,6 +237,6 @@ pub fn main() !void { } // Cleanup - std.fs.cwd().deleteTree("/tmp/calvin_test_leader") catch {}; - std.fs.cwd().deleteTree("/tmp/calvin_test_replica") catch {}; + compat.cwd().deleteTree("/tmp/calvin_test_leader") catch {}; + compat.cwd().deleteTree("/tmp/calvin_test_replica") catch {}; } diff --git a/src/ttl.zig b/src/ttl.zig index f985251..78a9c40 100644 --- a/src/ttl.zig +++ b/src/ttl.zig @@ -8,6 +8,7 @@ /// separate hash map keyed by doc_id. This avoids modifying the 32-byte /// DocHeader format. const std = @import("std"); +const compat = @import("compat"); const Allocator = std.mem.Allocator; /// A TTL entry: document ID → expiry timestamp. @@ -19,7 +20,7 @@ const TTLEntry = struct { /// TTL index for a single collection. pub const TTLIndex = struct { entries: std.ArrayListUnmanaged(TTLEntry) = .empty, - lock: std.Thread.RwLock = .{}, + lock: compat.RwLock = .{}, pub fn deinit(self: *TTLIndex, alloc: Allocator) void { self.entries.deinit(alloc); @@ -27,7 +28,7 @@ pub const TTLIndex = struct { /// Set TTL for a document. `ttl_seconds` is relative to now. pub fn setTTL(self: *TTLIndex, alloc: Allocator, doc_id: u64, ttl_seconds: u64) !void { - const now: u64 = @intCast(@divFloor(std.time.milliTimestamp(), 1000)); + const now: u64 = @intCast(@divFloor(compat.milliTimestamp(), 1000)); const expires_at = now + ttl_seconds; self.lock.lock(); @@ -74,7 +75,7 @@ pub const TTLIndex = struct { /// Collect all expired doc_ids. Caller owns the returned slice. pub fn collectExpired(self: *TTLIndex, alloc: Allocator) ![]u64 { - const now: u64 = @intCast(@divFloor(std.time.milliTimestamp(), 1000)); + const now: u64 = @intCast(@divFloor(compat.milliTimestamp(), 1000)); var expired: std.ArrayListUnmanaged(u64) = .empty; self.lock.lockShared(); @@ -90,7 +91,7 @@ pub const TTLIndex = struct { /// Purge expired entries from the index (call after deleting the docs). pub fn purgeExpired(self: *TTLIndex) void { - const now: u64 = @intCast(@divFloor(std.time.milliTimestamp(), 1000)); + const now: u64 = @intCast(@divFloor(compat.milliTimestamp(), 1000)); self.lock.lock(); defer self.lock.unlock(); diff --git a/src/wire.zig b/src/wire.zig index 3e0553b..a6b433e 100644 --- a/src/wire.zig +++ b/src/wire.zig @@ -3,9 +3,13 @@ /// Frame: [len:u32 BE][op:u8][payload...] /// len includes the 5-byte header. /// -/// Ops: INSERT=0x01 GET=0x02 UPDATE=0x03 DELETE=0x04 SCAN=0x05 PING=0x06 +/// Ops: INSERT=0x01 GET=0x02 UPDATE=0x03 DELETE=0x04 SCAN=0x05 PING=0x06 BATCH=0x07 /// Status: OK=0x00 NOT_FOUND=0x01 ERROR=0x02 +/// +/// Batch v1 request payload: [count:u16 LE] repeated [op:u8][payload_len:u32 LE][payload]. +/// Batch v1 response payload: [status:u8][count:u16 LE] repeated [op:u8][status:u8][payload_len:u32 LE][payload]. const std = @import("std"); +const compat = @import("compat"); const activity = @import("activity.zig"); const collection_mod = @import("collection.zig"); const Database = collection_mod.Database; @@ -17,15 +21,21 @@ const OP_UPDATE: u8 = 0x03; const OP_DELETE: u8 = 0x04; const OP_SCAN: u8 = 0x05; const OP_PING: u8 = 0x06; +const OP_BATCH: u8 = 0x07; const STATUS_OK: u8 = 0x00; const STATUS_NOT_FOUND: u8 = 0x01; const STATUS_ERROR: u8 = 0x02; const HDR: usize = 5; -const MAX_FRAME: usize = 1048576; const RD_BUF: usize = 65536; const WR_BUF: usize = 131072; +const MAX_FRAME: usize = RD_BUF; +const BATCH_REQ_HDR: usize = 2; +const BATCH_ITEM_HDR: usize = 5; +const BATCH_RESP_HDR: usize = HDR + 1 + 2; +const BATCH_ITEM_RESP_HDR: usize = 1 + 1 + 4; +const CONN_THREAD_STACK_SIZE = 1024 * 1024; pub const WireServer = struct { db: *Database, @@ -38,44 +48,48 @@ pub const WireServer = struct { } pub fn run(self: *WireServer) !void { - const addr = try std.net.Address.parseIp("0.0.0.0", self.port); + const addr = try compat.net.Address.parseIp("0.0.0.0", self.port); var listener = try addr.listen(.{ .reuse_address = true, .kernel_backlog = 1024 }); defer listener.deinit(); self.running.store(true, .release); std.log.info("TurboDB wire protocol on :{d}", .{self.port}); while (self.running.load(.acquire)) { const conn = listener.accept() catch continue; - const t = std.Thread.spawn(.{}, handleConn, .{ self, conn }) catch continue; + const t = std.Thread.spawn(.{ .stack_size = CONN_THREAD_STACK_SIZE }, handleConn, .{ self, conn }) catch continue; t.detach(); } } pub fn runUnix(self: *WireServer, path: []const u8) !void { - // Remove any existing socket file - // Remove any existing socket file - std.posix.unlink(path) catch {}; - const fd = try std.posix.socket(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0); - defer std.posix.close(fd); + // Remove existing socket + { + var zbuf: [256]u8 = undefined; + @memcpy(zbuf[0..path.len], path); + zbuf[path.len] = 0; + _ = std.c.unlink(@ptrCast(&zbuf)); + } + const fd = std.c.socket(1, 1, 0); // AF_UNIX=1, SOCK_STREAM=1 + if (fd < 0) return error.SocketError; + defer _ = std.c.close(fd); // Construct sockaddr_un - var addr: std.posix.sockaddr.un = .{ .family = std.posix.AF.UNIX, .path = undefined }; + var addr: extern struct { family: u16, path: [104]u8 } = .{ .family = 1, .path = undefined }; @memset(&addr.path, 0); if (path.len >= addr.path.len) return error.PathTooLong; @memcpy(addr.path[0..path.len], path); - try std.posix.bind(fd, @ptrCast(&addr), @sizeOf(std.posix.sockaddr.un)); - try std.posix.listen(fd, 1024); + if (std.c.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return error.BindError; + if (std.c.listen(fd, 1024) != 0) return error.ListenError; self.running.store(true, .release); std.log.info("TurboDB wire protocol on unix:{s}", .{path}); while (self.running.load(.acquire)) { - var client_addr: std.posix.sockaddr.un = undefined; - var addr_len: std.posix.socklen_t = @sizeOf(std.posix.sockaddr.un); - const client_fd = std.posix.accept(fd, @ptrCast(&client_addr), &addr_len, 0) catch continue; - const stream = std.net.Stream{ .handle = client_fd }; - const conn = std.net.Server.Connection{ .stream = stream, .address = std.net.Address.initUnix(path) catch continue }; - const t = std.Thread.spawn(.{}, handleConn, .{ self, conn }) catch continue; + const client_fd = std.c.accept(fd, null, null); + if (client_fd < 0) continue; + const stream = compat.net.Stream{ .handle = client_fd }; + const conn = compat.net.Server.Connection{ .stream = stream, .address = compat.net.Address.initUnix(path) catch continue }; + const t = std.Thread.spawn(.{ .stack_size = CONN_THREAD_STACK_SIZE }, handleConn, .{ self, conn }) catch continue; t.detach(); } } @@ -87,7 +101,7 @@ pub const WireServer = struct { const Bufs = struct { rd: [RD_BUF]u8, wr: [WR_BUF]u8 }; -fn handleConn(srv: *WireServer, conn: std.net.Server.Connection) void { +fn handleConn(srv: *WireServer, conn: compat.net.Server.Connection) void { defer conn.stream.close(); const bufs = std.heap.page_allocator.create(Bufs) catch return; defer std.heap.page_allocator.destroy(bufs); @@ -120,7 +134,7 @@ fn handleConn(srv: *WireServer, conn: std.net.Server.Connection) void { conn.stream.writeAll(bufs.wr[0..wn]) catch return; } else { // Invalidate collection cache on writes - if (op == OP_INSERT or op == OP_UPDATE or op == OP_DELETE) { + if (op == OP_INSERT or op == OP_UPDATE or op == OP_DELETE or op == OP_BATCH) { cached_col = null; } srv.activity.recordQuery(); @@ -184,6 +198,7 @@ fn dispatch(srv: *WireServer, op: u8, p: []const u8, w: *[WR_BUF]u8) usize { OP_DELETE => doDelete(srv, p, w), OP_SCAN => doScan(srv, p, w), OP_PING => doPing(w), + OP_BATCH => doBatch(srv, p, w), else => errResp(w, 0xFF, STATUS_ERROR), }; } @@ -191,72 +206,173 @@ fn dispatch(srv: *WireServer, op: u8, p: []const u8, w: *[WR_BUF]u8) usize { // ── INSERT ────────────────────────────────────────────────────────────────── fn doInsert(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { - const a = parseKV(p) orelse return errResp(w, OP_INSERT, STATUS_ERROR); - const ref = resolveCollectionRef(a.col); - srv.db.recordTenantOperation(ref.tenant_id) catch return errResp(w, OP_INSERT, STATUS_ERROR); - srv.db.ensureTenantStorageAvailable(ref.tenant_id, a.val.len) catch return errResp(w, OP_INSERT, STATUS_ERROR); - const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return errResp(w, OP_INSERT, STATUS_ERROR); - const doc_id = col.insert(a.key, a.val) catch return errResp(w, OP_INSERT, STATUS_ERROR); - // [len:4][op:1][status:1][doc_id:8] = 14 - wrU32BE(w, 14); + const r = execInsertPayload(srv, p, w[6..]); + if (r.status != STATUS_OK) return errResp(w, OP_INSERT, r.status); + wrU32BE(w, @intCast(HDR + 1 + r.payload_len)); w[4] = OP_INSERT; w[5] = STATUS_OK; - wrU64LE(w[6..14], doc_id); - return 14; + return HDR + 1 + r.payload_len; +} + +fn execInsertPayload(srv: *WireServer, p: []const u8, out: []u8) ItemResult { + const a = parseKV(p) orelse return .{ .status = STATUS_ERROR, .payload_len = 0 }; + if (out.len < 8) return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const ref = resolveCollectionRef(a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + srv.db.ensureTenantStorageAvailable(ref.tenant_id, a.val.len) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const doc_id = col.insert(a.key, a.val) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + wrU64LE(out[0..8], doc_id); + return .{ .status = STATUS_OK, .payload_len = 8 }; } // ── GET ───────────────────────────────────────────────────────────────────── fn doGet(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { - const a = parseKey(p) orelse return errResp(w, OP_GET, STATUS_ERROR); - const ref = resolveCollectionRef(a.col); - srv.db.recordTenantOperation(ref.tenant_id) catch return errResp(w, OP_GET, STATUS_ERROR); - const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return errResp(w, OP_GET, STATUS_ERROR); - const d = col.get(a.key) orelse return errResp(w, OP_GET, STATUS_NOT_FOUND); - // [len:4][op:1][status:1][doc_id:8][ver:1][val_len:4][val:N] - const rlen = HDR + 1 + 8 + 1 + 4 + d.value.len; - if (rlen > WR_BUF) return errResp(w, OP_GET, STATUS_ERROR); - wrU32BE(w, @intCast(rlen)); + const r = execGetPayload(srv, p, w[6..]); + if (r.status != STATUS_OK) return errResp(w, OP_GET, r.status); + wrU32BE(w, @intCast(HDR + 1 + r.payload_len)); w[4] = OP_GET; w[5] = STATUS_OK; - wrU64LE(w[6..14], d.header.doc_id); - w[14] = d.header.version; - wrU32LE(w[15..19], @intCast(d.value.len)); - if (d.value.len > 0) @memcpy(w[19..][0..d.value.len], d.value); - return rlen; + return HDR + 1 + r.payload_len; +} + +fn execGetPayload(srv: *WireServer, p: []const u8, out: []u8) ItemResult { + const a = parseKey(p) orelse return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const ref = resolveCollectionRef(a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const d = col.get(a.key) orelse return .{ .status = STATUS_NOT_FOUND, .payload_len = 0 }; + // [len:4][op:1][status:1][doc_id:8][ver:1][val_len:4][val:N] + const payload_len = 8 + 1 + 4 + d.value.len; + if (payload_len > out.len) return .{ .status = STATUS_ERROR, .payload_len = 0 }; + wrU64LE(out[0..8], d.header.doc_id); + out[8] = d.header.version; + wrU32LE(out[9..13], @intCast(d.value.len)); + if (d.value.len > 0) @memcpy(out[13..][0..d.value.len], d.value); + return .{ .status = STATUS_OK, .payload_len = payload_len }; } // ── UPDATE ────────────────────────────────────────────────────────────────── fn doUpdate(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { - const a = parseKV(p) orelse return errResp(w, OP_UPDATE, STATUS_ERROR); - const ref = resolveCollectionRef(a.col); - srv.db.recordTenantOperation(ref.tenant_id) catch return errResp(w, OP_UPDATE, STATUS_ERROR); - srv.db.ensureTenantStorageAvailable(ref.tenant_id, a.val.len) catch return errResp(w, OP_UPDATE, STATUS_ERROR); - const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return errResp(w, OP_UPDATE, STATUS_ERROR); - const ok = col.update(a.key, a.val) catch return errResp(w, OP_UPDATE, STATUS_ERROR); - if (!ok) return errResp(w, OP_UPDATE, STATUS_NOT_FOUND); + const r = execUpdatePayload(srv, p); + if (r.status != STATUS_OK) return errResp(w, OP_UPDATE, r.status); wrU32BE(w, HDR + 1); w[4] = OP_UPDATE; w[5] = STATUS_OK; return HDR + 1; } +fn execUpdatePayload(srv: *WireServer, p: []const u8) ItemResult { + const a = parseKV(p) orelse return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const ref = resolveCollectionRef(a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + srv.db.ensureTenantStorageAvailable(ref.tenant_id, a.val.len) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const ok = col.update(a.key, a.val) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + if (!ok) return .{ .status = STATUS_NOT_FOUND, .payload_len = 0 }; + return .{ .status = STATUS_OK, .payload_len = 0 }; +} + // ── DELETE ─────────────────────────────────────────────────────────────────── fn doDelete(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { - const a = parseKey(p) orelse return errResp(w, OP_DELETE, STATUS_ERROR); - const ref = resolveCollectionRef(a.col); - srv.db.recordTenantOperation(ref.tenant_id) catch return errResp(w, OP_DELETE, STATUS_ERROR); - const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return errResp(w, OP_DELETE, STATUS_ERROR); - const ok = col.delete(a.key) catch return errResp(w, OP_DELETE, STATUS_ERROR); - if (!ok) return errResp(w, OP_DELETE, STATUS_NOT_FOUND); + const r = execDeletePayload(srv, p); + if (r.status != STATUS_OK) return errResp(w, OP_DELETE, r.status); wrU32BE(w, HDR + 1); w[4] = OP_DELETE; w[5] = STATUS_OK; return HDR + 1; } +fn execDeletePayload(srv: *WireServer, p: []const u8) ItemResult { + const a = parseKey(p) orelse return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const ref = resolveCollectionRef(a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const ok = col.delete(a.key) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + if (!ok) return .{ .status = STATUS_NOT_FOUND, .payload_len = 0 }; + return .{ .status = STATUS_OK, .payload_len = 0 }; +} + +// ── BATCH ────────────────────────────────────────────────────────────────── + +const ItemResult = struct { status: u8, payload_len: usize }; +const BatchEnvelope = struct { count: u16 }; + +fn doBatch(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { + const env = validateBatchEnvelope(p) orelse return errResp(w, OP_BATCH, STATUS_ERROR); + + var req_pos: usize = BATCH_REQ_HDR; + var resp_pos: usize = BATCH_RESP_HDR; + w[4] = OP_BATCH; + w[5] = STATUS_OK; + wrU16LE(w[6..8], env.count); + + for (0..env.count) |idx| { + const op = p[req_pos]; + const payload_len: usize = rdU32LE(p[req_pos + 1 ..][0..4]); + const payload = p[req_pos + BATCH_ITEM_HDR ..][0..payload_len]; + + const remaining_items = @as(usize, env.count) - idx - 1; + const reserved = remaining_items * BATCH_ITEM_RESP_HDR; + const item_payload_cap = WR_BUF - resp_pos - BATCH_ITEM_RESP_HDR - reserved; + const item_payload_out = w[resp_pos + BATCH_ITEM_RESP_HDR ..][0..item_payload_cap]; + + w[resp_pos] = op; + w[resp_pos + 1] = STATUS_ERROR; + wrU32LE(w[resp_pos + 2 ..][0..4], 0); + + const result = execBatchItem(srv, op, payload, item_payload_out); + w[resp_pos + 1] = result.status; + wrU32LE(w[resp_pos + 2 ..][0..4], @intCast(result.payload_len)); + resp_pos += BATCH_ITEM_RESP_HDR + result.payload_len; + req_pos += BATCH_ITEM_HDR + payload_len; + } + + wrU32BE(w, @intCast(resp_pos)); + return resp_pos; +} + +fn execBatchItem(srv: *WireServer, op: u8, p: []const u8, out: []u8) ItemResult { + return switch (op) { + OP_INSERT => execInsertPayload(srv, p, out), + OP_GET => execGetPayload(srv, p, out), + OP_UPDATE => execUpdatePayload(srv, p), + OP_DELETE => execDeletePayload(srv, p), + else => .{ .status = STATUS_ERROR, .payload_len = 0 }, + }; +} + +fn validateBatchEnvelope(p: []const u8) ?BatchEnvelope { + if (p.len < BATCH_REQ_HDR) return null; + const count = rdU16LE(p[0..2]); + const min_resp_len = BATCH_RESP_HDR + @as(usize, count) * BATCH_ITEM_RESP_HDR; + if (min_resp_len > WR_BUF) return null; + + var pos: usize = BATCH_REQ_HDR; + for (0..count) |_| { + if (pos + BATCH_ITEM_HDR > p.len) return null; + const op = p[pos]; + const payload_len: usize = rdU32LE(p[pos + 1 ..][0..4]); + if (payload_len > p.len - pos - BATCH_ITEM_HDR) return null; + const payload = p[pos + BATCH_ITEM_HDR ..][0..payload_len]; + if (!validBatchPayload(op, payload)) return null; + pos += BATCH_ITEM_HDR + payload_len; + } + if (pos != p.len) return null; + return .{ .count = count }; +} + +fn validBatchPayload(op: u8, p: []const u8) bool { + return switch (op) { + OP_INSERT, OP_UPDATE => parseKVExact(p), + OP_GET, OP_DELETE => parseKeyExact(p), + else => false, + }; +} + // ── SCAN ──────────────────────────────────────────────────────────────────── fn doScan(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { @@ -327,6 +443,18 @@ fn parseKV(p: []const u8) ?KV { return KV{ .col = p[2..][0..cl], .key = p[ko + 2 ..][0..kl], .val = p[vo + 4 ..][0..vl] }; } +fn parseKVExact(p: []const u8) bool { + if (p.len < 8) return false; + const cl: usize = rdU16LE(p[0..2]); + if (2 + cl + 2 > p.len) return false; + const ko = 2 + cl; + const kl: usize = rdU16LE(p[ko..][0..2]); + const vo = ko + 2 + kl; + if (vo + 4 > p.len) return false; + const vl: usize = rdU32LE(p[vo..][0..4]); + return vo + 4 + vl == p.len; +} + fn parseKey(p: []const u8) ?Key { if (p.len < 4) return null; const cl: usize = rdU16LE(p[0..2]); @@ -337,6 +465,15 @@ fn parseKey(p: []const u8) ?Key { return Key{ .col = p[2..][0..cl], .key = p[ko + 2 ..][0..kl] }; } +fn parseKeyExact(p: []const u8) bool { + if (p.len < 4) return false; + const cl: usize = rdU16LE(p[0..2]); + if (2 + cl + 2 > p.len) return false; + const ko = 2 + cl; + const kl: usize = rdU16LE(p[ko..][0..2]); + return ko + 2 + kl == p.len; +} + // ── Binary encoding ───────────────────────────────────────────────────────── fn rdU32BE(b: []const u8) u32 { @@ -376,3 +513,44 @@ fn resolveCollectionRef(full_name: []const u8) collection_mod.TenantCollectionRe .collection_name = full_name, }; } + +test "wire batch envelope accepts supported exact items" { + const get_payload = [_]u8{ 1, 0, 'c', 1, 0, 'k' }; + const insert_payload = [_]u8{ 1, 0, 'c', 1, 0, 'k', 1, 0, 0, 0, 'v' }; + + var buf: [128]u8 = undefined; + wrU16LE(buf[0..2], 2); + var pos: usize = BATCH_REQ_HDR; + + buf[pos] = OP_GET; + wrU32LE(buf[pos + 1 ..][0..4], get_payload.len); + @memcpy(buf[pos + BATCH_ITEM_HDR ..][0..get_payload.len], &get_payload); + pos += BATCH_ITEM_HDR + get_payload.len; + + buf[pos] = OP_INSERT; + wrU32LE(buf[pos + 1 ..][0..4], insert_payload.len); + @memcpy(buf[pos + BATCH_ITEM_HDR ..][0..insert_payload.len], &insert_payload); + pos += BATCH_ITEM_HDR + insert_payload.len; + + const env = validateBatchEnvelope(buf[0..pos]) orelse return error.TestExpectedBatchEnvelope; + try std.testing.expectEqual(@as(u16, 2), env.count); +} + +test "wire batch envelope rejects unsupported and trailing payloads" { + var unsupported: [BATCH_REQ_HDR + BATCH_ITEM_HDR]u8 = undefined; + wrU16LE(unsupported[0..2], 1); + unsupported[BATCH_REQ_HDR] = OP_SCAN; + wrU32LE(unsupported[BATCH_REQ_HDR + 1 ..][0..4], 0); + try std.testing.expect(validateBatchEnvelope(&unsupported) == null); + + const trailing = [_]u8{ 0, 0, 0 }; + try std.testing.expect(validateBatchEnvelope(&trailing) == null); + + const get_payload_with_trailing = [_]u8{ 1, 0, 'c', 1, 0, 'k', 0 }; + var bad_item: [BATCH_REQ_HDR + BATCH_ITEM_HDR + get_payload_with_trailing.len]u8 = undefined; + wrU16LE(bad_item[0..2], 1); + bad_item[BATCH_REQ_HDR] = OP_GET; + wrU32LE(bad_item[BATCH_REQ_HDR + 1 ..][0..4], get_payload_with_trailing.len); + @memcpy(bad_item[BATCH_REQ_HDR + BATCH_ITEM_HDR ..], &get_payload_with_trailing); + try std.testing.expect(validateBatchEnvelope(&bad_item) == null); +} From 35dfb9d7e52af654703d35ca10acb57e9c62d151 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:27:35 +0800 Subject: [PATCH 13/27] feat: add linux io_uring wal fast path --- .gitignore | 1 + build.zig | 1 + src/bench_wal.zig | 47 +++++++++++- src/compat.zig | 112 +++++++++++++++++++++++++++ src/storage/parallel_wal.zig | 143 ++++++++++++++++++++++++++++++++++- src/storage/wal.zig | 44 ++++++++++- 6 files changed, 339 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 08734d0..72bbd8c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ preprocessed_configs/ # Local agent/MCP workspaces optimizations/ MCP/ +architecutre/ diff --git a/build.zig b/build.zig index ef4e04c..3494519 100644 --- a/build.zig +++ b/build.zig @@ -245,6 +245,7 @@ pub fn build(b: *std.Build) void { .name = "bench-wal", .root_module = walbench_mod, }); + b.installArtifact(walbench_exe); const walbench_run = b.addRunArtifact(walbench_exe); const walbench_step = b.step("bench-wal", "Run WAL microbenchmark"); diff --git a/src/bench_wal.zig b/src/bench_wal.zig index e7afa3d..656c7ec 100644 --- a/src/bench_wal.zig +++ b/src/bench_wal.zig @@ -1,13 +1,17 @@ const std = @import("std"); const compat = @import("compat"); const wal_mod = @import("wal"); +const parallel_wal_mod = @import("storage/parallel_wal.zig"); const BASE_DIR = "/tmp/turbodb_wal_bench"; +const PARALLEL_DIR = "/tmp/turbodb_wal_bench/parallel"; const WRITE_ONLY_PATH: [:0]const u8 = "/tmp/turbodb_wal_bench/write_only.wal"; const COMMIT_PATH: [:0]const u8 = "/tmp/turbodb_wal_bench/commit.wal"; const WRITE_ONLY_ITERS: usize = 50_000; const COMMIT_ITERS: usize = 1_000; +const PARALLEL_SEGMENTS: u32 = 8; +const PARALLEL_ENTRIES_PER_SEGMENT: usize = 512; const PAYLOAD_SIZE: usize = 64; const Result = struct { @@ -15,6 +19,7 @@ const Result = struct { iterations: usize, bytes: usize, elapsed_ns: u64, + uring_active: bool, }; fn paddedEntrySize(payload_len: usize) usize { @@ -33,11 +38,12 @@ fn printResult(result: Result) void { const us_op = ns_f / ops_f / 1000.0; const mb_s = (@as(f64, @floatFromInt(result.bytes)) / (1024.0 * 1024.0)) / (ns_f / 1e9); - std.debug.print(" {s:<32} {d:>12.0} ops/s {d:>8.2} us/op {d:>8.1} MiB/s\n", .{ + std.debug.print(" {s:<32} {d:>12.0} ops/s {d:>8.2} us/op {d:>8.1} MiB/s io={s}\n", .{ result.name, ops_s, us_op, mb_s, + if (result.uring_active) "uring" else "sync", }); } @@ -56,12 +62,14 @@ fn benchWriteOnly(allocator: std.mem.Allocator, payload: []const u8) !Result { payload, ); } + wal.flushPending(); return .{ - .name = "buffered committed append", + .name = "buffered append plus flush", .iterations = WRITE_ONLY_ITERS, .bytes = WRITE_ONLY_ITERS * paddedEntrySize(payload.len), .elapsed_ns = elapsedSince(start_ns), + .uring_active = wal.usedLinuxUring(), }; } @@ -83,6 +91,39 @@ fn benchCommit(allocator: std.mem.Allocator, payload: []const u8) !Result { .iterations = COMMIT_ITERS, .bytes = COMMIT_ITERS * bytes_per_txn, .elapsed_ns = elapsedSince(start_ns), + .uring_active = wal.usedLinuxUring(), + }; +} + +fn benchParallelGroupCommit(allocator: std.mem.Allocator, payload: []const u8) !Result { + try compat.cwd().makePath(PARALLEL_DIR); + var wal = try parallel_wal_mod.ParallelWAL.init(allocator, PARALLEL_DIR, PARALLEL_SEGMENTS); + defer wal.deinit(); + + var seg_i: u32 = 0; + while (seg_i < PARALLEL_SEGMENTS) : (seg_i += 1) { + var entry_i: usize = 0; + while (entry_i < PARALLEL_ENTRIES_PER_SEGMENT) : (entry_i += 1) { + _ = try wal.segments[seg_i].append( + .put, + (@as(u64, seg_i) << 32) | @as(u64, @intCast(entry_i)), + payload, + ); + } + } + + const bytes = @as(usize, PARALLEL_SEGMENTS) * + PARALLEL_ENTRIES_PER_SEGMENT * + (13 + payload.len); + const start_ns = compat.nanoTimestamp(); + try wal.groupCommit(); + + return .{ + .name = "parallel segment group commit", + .iterations = @as(usize, PARALLEL_SEGMENTS) * PARALLEL_ENTRIES_PER_SEGMENT, + .bytes = bytes, + .elapsed_ns = elapsedSince(start_ns), + .uring_active = wal.usingLinuxUring(), }; } @@ -98,6 +139,7 @@ pub fn main() !void { const write_only = try benchWriteOnly(allocator, payload[0..]); const commit = try benchCommit(allocator, payload[0..]); + const parallel = try benchParallelGroupCommit(allocator, payload[0..]); std.debug.print("\nTurboDB WAL Microbenchmark\n", .{}); std.debug.print(" payload: {d} bytes\n", .{PAYLOAD_SIZE}); @@ -105,5 +147,6 @@ pub fn main() !void { std.debug.print(" commit iterations: {d}\n\n", .{COMMIT_ITERS}); printResult(write_only); printResult(commit); + printResult(parallel); std.debug.print("\n", .{}); } diff --git a/src/compat.zig b/src/compat.zig index 6190053..219200c 100644 --- a/src/compat.zig +++ b/src/compat.zig @@ -4,8 +4,10 @@ /// std.fs, std.Thread, std.io, and std.time. Uses POSIX/C functions directly /// to avoid threading std.Io through the entire codebase. const std = @import("std"); +const builtin = @import("builtin"); const c = std.c; const posix = std.posix; +const linux = std.os.linux; // ═══════════════════════════════════════════════════════════════════════════════ // File — drop-in replacement for std.fs.File (0.15) @@ -62,6 +64,16 @@ pub const File = struct { } } + pub fn pwriteAll(self: File, data: []const u8, offset: u64) !void { + var written: usize = 0; + while (written < data.len) { + const n = c.pwrite(self.handle, data[written..].ptr, data[written..].len, @intCast(offset + written)); + if (n < 0) return error.WriteError; + if (n == 0) return error.WriteError; + written += @intCast(n); + } + } + pub fn readAll(self: File, buf: []u8) !usize { var total: usize = 0; while (total < buf.len) { @@ -130,6 +142,89 @@ pub const File = struct { } }; +// ═══════════════════════════════════════════════════════════════════════════════ +// Linux io_uring helpers — optional fast path for WAL flush/sync +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const LinuxUring = linux.IoUring; + +pub fn initLinuxUring(entries: u16) ?LinuxUring { + if (comptime builtin.os.tag != .linux) return null; + if (ioUringDisabledByEnv()) return null; + return LinuxUring.init(entries, 0) catch null; +} + +fn ioUringDisabledByEnv() bool { + const value = getenv("TURBODB_DISABLE_IO_URING") orelse return false; + return value[0] != 0 and value[0] != '0'; +} + +pub fn deinitLinuxUring(ring: *LinuxUring) void { + if (comptime builtin.os.tag != .linux) return; + ring.deinit(); +} + +pub fn uringWriteAllAt(ring: *LinuxUring, file: File, data: []const u8, offset: u64) !void { + if (comptime builtin.os.tag != .linux) return error.Unsupported; + + var written: usize = 0; + while (written < data.len) { + const chunk = data[written..]; + _ = ring.write(1, file.handle, chunk, offset + written) catch return error.WriteError; + _ = ring.submit_and_wait(1) catch return error.WriteError; + const cqe = ring.copy_cqe() catch return error.WriteError; + if (cqe.user_data != 1) return error.WriteError; + if (cqe.res <= 0) return error.WriteError; + const n: usize = @intCast(cqe.res); + if (n > chunk.len) return error.WriteError; + written += n; + } +} + +pub fn uringFsync(ring: *LinuxUring, file: File) !void { + if (comptime builtin.os.tag != .linux) return error.Unsupported; + + _ = ring.fsync(2, file.handle, linux.IORING_FSYNC_DATASYNC) catch return error.SyncError; + _ = ring.submit_and_wait(1) catch return error.SyncError; + const cqe = ring.copy_cqe() catch return error.SyncError; + if (cqe.user_data != 2) return error.SyncError; + if (cqe.res < 0) return error.SyncError; +} + +pub fn uringWriteAndSyncAt(ring: *LinuxUring, file: File, data: []const u8, offset: u64) !void { + if (comptime builtin.os.tag != .linux) return error.Unsupported; + if (data.len == 0) return uringFsync(ring, file); + + const write_sqe = ring.write(1, file.handle, data, offset) catch return error.WriteError; + write_sqe.flags |= linux.IOSQE_IO_LINK; + _ = ring.fsync(2, file.handle, linux.IORING_FSYNC_DATASYNC) catch return error.SyncError; + _ = ring.submit_and_wait(2) catch return error.WriteError; + + var saw_write = false; + var saw_sync = false; + var write_res: i32 = 0; + var sync_res: i32 = 0; + + while (!saw_write or !saw_sync) { + const cqe = ring.copy_cqe() catch return error.WriteError; + switch (cqe.user_data) { + 1 => { + saw_write = true; + write_res = cqe.res; + }, + 2 => { + saw_sync = true; + sync_res = cqe.res; + }, + else => return error.WriteError, + } + } + + if (write_res < 0) return error.WriteError; + if (@as(usize, @intCast(write_res)) != data.len) return error.ShortWrite; + if (sync_res < 0) return error.SyncError; +} + // ═══════════════════════════════════════════════════════════════════════════════ // Dir / cwd() — drop-in for std.fs.cwd() // ═══════════════════════════════════════════════════════════════════════════════ @@ -587,9 +682,26 @@ pub fn bufferedReader(underlying: anytype) GenericBufferedReader(4096, @TypeOf(u // ═══════════════════════════════════════════════════════════════════════════════ extern "c" fn arc4random_buf(buf: *anyopaque, nbytes: usize) void; +extern "c" fn getenv(name: [*:0]const u8) ?[*:0]const u8; extern "c" fn system(command: [*:0]const u8) c_int; pub fn randomBytes(buf: []u8) void { + if (comptime builtin.os.tag == .linux) { + var filled: usize = 0; + while (filled < buf.len) { + const rc = linux.getrandom(buf[filled..].ptr, buf.len - filled, 0); + switch (linux.errno(rc)) { + .SUCCESS => { + const n: usize = @intCast(rc); + if (n == 0) @panic("getrandom returned zero bytes"); + filled += n; + }, + .INTR => continue, + else => @panic("getrandom failed"), + } + } + return; + } arc4random_buf(buf.ptr, buf.len); } diff --git a/src/storage/parallel_wal.zig b/src/storage/parallel_wal.zig index fa1851b..4c6da71 100644 --- a/src/storage/parallel_wal.zig +++ b/src/storage/parallel_wal.zig @@ -1,5 +1,7 @@ const std = @import("std"); +const builtin = @import("builtin"); const compat = @import("compat"); +const linux = std.os.linux; const Allocator = std.mem.Allocator; /// Operation types for WAL entries. @@ -20,6 +22,7 @@ const ENTRY_HEADER_SIZE = 13; pub const WALSegment = struct { segment_id: u32, fd: compat.File, + uring: ?compat.LinuxUring, buf: []align(4096) u8, pos: std.atomic.Value(u32), published_pos: std.atomic.Value(u32), @@ -40,6 +43,7 @@ pub const WALSegment = struct { return WALSegment{ .segment_id = id, .fd = fd, + .uring = compat.initLinuxUring(8), .buf = buf, .pos = std.atomic.Value(u32).init(0), .published_pos = std.atomic.Value(u32).init(0), @@ -48,6 +52,10 @@ pub const WALSegment = struct { } pub fn deinit(self: *WALSegment, alloc: Allocator) void { + if (self.uring) |*ring| { + compat.deinitLinuxUring(ring); + self.uring = null; + } self.fd.close(); alloc.free(self.buf); } @@ -103,15 +111,30 @@ pub const WALSegment = struct { if (current <= self.flushed_pos) return; const dirty = self.buf[self.flushed_pos..current]; - const written = try self.fd.write(dirty); - if (written != dirty.len) return error.ShortWrite; + if (self.uring) |*ring| { + compat.uringWriteAllAt(ring, self.fd, dirty, self.flushed_pos) catch { + compat.deinitLinuxUring(ring); + self.uring = null; + }; + if (self.uring == null) try self.fd.pwriteAll(dirty, self.flushed_pos); + } else { + try self.fd.pwriteAll(dirty, self.flushed_pos); + } self.flushed_pos = current; } /// Fsync the segment file to durable storage. pub fn sync(self: *WALSegment) !void { - try self.fd.sync(); + if (self.uring) |*ring| { + compat.uringFsync(ring, self.fd) catch { + compat.deinitLinuxUring(ring); + self.uring = null; + }; + if (self.uring == null) try self.fd.sync(); + } else { + try self.fd.sync(); + } } /// Read back an entry at the given buffer offset. Returns the fields and @@ -156,9 +179,20 @@ pub const ParallelWAL = struct { allocator: Allocator, flusher_thread: ?std.Thread, running: std.atomic.Value(u8), + uring: ?compat.LinuxUring, + dirty_ends: []u32, + dirty_lens: []usize, + + const MAX_URING_SEGMENTS: u32 = 256; pub fn init(alloc: Allocator, data_dir: []const u8, n_segments: u32) !*ParallelWAL { const segs = try alloc.alloc(*WALSegment, n_segments); + const dirty_ends = try alloc.alloc(u32, n_segments); + errdefer alloc.free(dirty_ends); + const dirty_lens = try alloc.alloc(usize, n_segments); + errdefer alloc.free(dirty_lens); + @memset(dirty_ends, 0); + @memset(dirty_lens, 0); var inited: u32 = 0; errdefer { var j: u32 = 0; @@ -185,17 +219,26 @@ pub const ParallelWAL = struct { .allocator = alloc, .flusher_thread = null, .running = std.atomic.Value(u8).init(0), + .uring = compat.initLinuxUring(MAX_URING_SEGMENTS), + .dirty_ends = dirty_ends, + .dirty_lens = dirty_lens, }; return self; } pub fn deinit(self: *ParallelWAL) void { self.stopFlusher(); + if (self.uring) |*ring| { + compat.deinitLinuxUring(ring); + self.uring = null; + } var i: u32 = 0; while (i < self.n_segments) : (i += 1) { self.segments[i].deinit(self.allocator); self.allocator.destroy(self.segments[i]); } + self.allocator.free(self.dirty_ends); + self.allocator.free(self.dirty_lens); self.allocator.free(self.segments); self.allocator.destroy(self); } @@ -203,7 +246,7 @@ pub const ParallelWAL = struct { /// Select a segment for the calling thread — thread ID modulo n_segments. pub fn getSegment(self: *ParallelWAL) *WALSegment { const tid = std.Thread.getCurrentId(); - const idx = @as(u32, @intCast(@as(u64, @bitCast(tid)) % @as(u64, self.n_segments))); + const idx = @as(u32, @intCast(@as(u64, @intCast(tid)) % @as(u64, self.n_segments))); return self.segments[idx]; } @@ -213,9 +256,18 @@ pub const ParallelWAL = struct { return seg.append(op, key_hash, data); } + pub fn usingLinuxUring(self: *const ParallelWAL) bool { + return self.uring != null; + } + /// Group commit: flush + fsync every segment and advance the epoch. /// Called by the background flusher or manually in tests. pub fn groupCommit(self: *ParallelWAL) !void { + if (self.groupCommitWithUring()) { + _ = self.current_epoch.fetchAdd(1, .release); + return; + } + // Phase 1: flush dirty buffers to OS page cache. var i: u32 = 0; while (i < self.n_segments) : (i += 1) { @@ -232,6 +284,89 @@ pub const ParallelWAL = struct { _ = self.current_epoch.fetchAdd(1, .release); } + fn groupCommitWithUring(self: *ParallelWAL) bool { + if (comptime builtin.os.tag != .linux) return false; + + if (self.n_segments == 0 or self.n_segments > MAX_URING_SEGMENTS) return false; + const ring = if (self.uring) |*r| r else return false; + + @memset(self.dirty_lens, 0); + var writes: u32 = 0; + var i: u32 = 0; + while (i < self.n_segments) : (i += 1) { + const seg = self.segments[i]; + const current = seg.published_pos.load(.acquire); + self.dirty_ends[i] = current; + if (current <= seg.flushed_pos) continue; + + const dirty = seg.buf[seg.flushed_pos..current]; + self.dirty_lens[i] = dirty.len; + _ = ring.write(@as(u64, i) + 1, seg.fd.handle, dirty, seg.flushed_pos) catch return self.disableUring(); + writes += 1; + } + + if (writes == 0) return true; + _ = ring.submit_and_wait(writes) catch return self.disableUring(); + if (!self.collectUringWrites(writes)) return self.disableUring(); + + var syncs: u32 = 0; + i = 0; + while (i < self.n_segments) : (i += 1) { + if (self.dirty_lens[i] == 0) continue; + _ = ring.fsync(@as(u64, i) + 1, self.segments[i].fd.handle, linux.IORING_FSYNC_DATASYNC) catch return self.disableUring(); + syncs += 1; + } + + _ = ring.submit_and_wait(syncs) catch return self.disableUring(); + if (!self.collectUringSyncs(syncs)) return self.disableUring(); + + i = 0; + while (i < self.n_segments) : (i += 1) { + if (self.dirty_lens[i] == 0) continue; + self.segments[i].flushed_pos = self.dirty_ends[i]; + } + return true; + } + + fn collectUringWrites(self: *ParallelWAL, expected: u32) bool { + const ring = if (self.uring) |*r| r else return false; + var completed: u32 = 0; + while (completed < expected) : (completed += 1) { + const cqe = ring.copy_cqe() catch return false; + const idx = self.cqeSegmentIndex(cqe.user_data) orelse return false; + if (cqe.res < 0) return false; + const n: usize = @intCast(cqe.res); + if (n != self.dirty_lens[idx]) return false; + } + return true; + } + + fn collectUringSyncs(self: *ParallelWAL, expected: u32) bool { + const ring = if (self.uring) |*r| r else return false; + var completed: u32 = 0; + while (completed < expected) : (completed += 1) { + const cqe = ring.copy_cqe() catch return false; + _ = self.cqeSegmentIndex(cqe.user_data) orelse return false; + if (cqe.res < 0) return false; + } + return true; + } + + fn cqeSegmentIndex(self: *const ParallelWAL, user_data: u64) ?usize { + if (user_data == 0) return null; + const idx: usize = @intCast(user_data - 1); + if (idx >= self.n_segments) return null; + return idx; + } + + fn disableUring(self: *ParallelWAL) bool { + if (self.uring) |*ring| { + compat.deinitLinuxUring(ring); + self.uring = null; + } + return false; + } + /// Start the background flusher thread (~1 ms group commit interval). pub fn startFlusher(self: *ParallelWAL) !void { if (self.running.load(.acquire) == 1) return; diff --git a/src/storage/wal.zig b/src/storage/wal.zig index 9cb8979..1285445 100644 --- a/src/storage/wal.zig +++ b/src/storage/wal.zig @@ -108,9 +108,13 @@ fn entryChecksum(header_bytes: []const u8, payload: []const u8) u32 { pub const WAL = struct { file: compat.File, + uring: ?compat.LinuxUring, write_buf: std.ArrayList(u8), // pending (not yet flushed) next_lsn: std.atomic.Value(u64), checkpoint_lsn: u64, + append_offset: u64, + uring_flushes: usize, + sync_flushes: usize, allocator: std.mem.Allocator, // Group commit state — guarded by mu @@ -126,6 +130,7 @@ pub const WAL = struct { /// Backpressure: block writers if pending buffer exceeds this. const MAX_WRITE_BUF: usize = 8 * 1024 * 1024; // 8 MiB + const URING_MIN_WRITE_AND_SYNC: usize = 8 * 1024 * 1024; // ── Lifecycle ───────────────────────────────────────────────────────────── @@ -135,11 +140,16 @@ pub const WAL = struct { if (fd < 0) return error.CreateFileError; break :blk compat.File{ .handle = fd }; }; + const end = std.c.lseek(f.handle, 0, std.c.SEEK.END); const wal = WAL{ .file = f, + .uring = compat.initLinuxUring(8), .write_buf = .empty, .next_lsn = std.atomic.Value(u64).init(1), .checkpoint_lsn = 0, + .append_offset = if (end >= 0) @intCast(end) else 0, + .uring_flushes = 0, + .sync_flushes = 0, .allocator = allocator, .mu = .{}, .cond = .{}, @@ -149,8 +159,6 @@ pub const WAL = struct { .flush_thread = null, .flush_running = std.atomic.Value(bool).init(false), }; - // Seek to end (append mode) - _ = std.c.lseek(wal.file.handle, 0, std.c.SEEK.END); return wal; } @@ -212,6 +220,14 @@ pub const WAL = struct { return self.last_flush_error; } + pub fn usingLinuxUring(self: *const WAL) bool { + return self.uring != null; + } + + pub fn usedLinuxUring(self: *const WAL) bool { + return self.uring_flushes > 0; + } + pub fn close(self: *WAL) void { if (self.flush_running.load(.acquire)) { self.flush_running.store(false, .release); @@ -220,6 +236,10 @@ pub const WAL = struct { // Flush any remaining entries self.flushPending(); self.write_buf.deinit(self.allocator); + if (self.uring) |*ring| { + compat.deinitLinuxUring(ring); + self.uring = null; + } self.file.close(); } @@ -384,6 +404,7 @@ pub const WAL = struct { // Truncate WAL — all data is checkpointed to page files. self.file.seekTo(0) catch {}; self.file.setEndPos(0) catch {}; + self.append_offset = 0; self.mu.lock(); self.synced_lsn = lsn; @@ -442,6 +463,7 @@ pub const WAL = struct { // ── Advance LSN counter and seek to EOF for new writes ──────────────── self.next_lsn.store(max_lsn + 1, .release); self.synced_lsn = max_lsn; + self.append_offset = valid_end; try self.file.seekTo(valid_end); std.log.info("WAL recovery: replayed up to lsn={d}", .{max_lsn}); } @@ -453,8 +475,24 @@ pub const WAL = struct { } fn writeAndSync(self: *WAL, bytes: []const u8) ?anyerror { - self.file.writeAll(bytes) catch |e| return e; + if (bytes.len >= URING_MIN_WRITE_AND_SYNC) { + if (self.uring) |*ring| { + compat.uringWriteAndSyncAt(ring, self.file, bytes, self.append_offset) catch { + compat.deinitLinuxUring(ring); + self.uring = null; + }; + if (self.uring != null) { + self.append_offset += bytes.len; + self.uring_flushes += 1; + return null; + } + } + } + + self.file.pwriteAll(bytes, self.append_offset) catch |e| return e; self.file.sync() catch |e| return e; + self.append_offset += bytes.len; + self.sync_flushes += 1; return null; } }; From 625705696ada0c5b00d49875d0c014e8eefbcd85 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:12:25 +0800 Subject: [PATCH 14/27] feat: add apple container engine benchmarks --- .gitignore | 2 + bench/README_apple_container_matrix.md | 67 +++ bench/container_shape_bench.py | 627 +++++++++++++++++++++++++ bench/run_apple_container_bench.py | 418 +++++++++++++++++ 4 files changed, 1114 insertions(+) create mode 100644 bench/README_apple_container_matrix.md create mode 100644 bench/container_shape_bench.py create mode 100644 bench/run_apple_container_bench.py diff --git a/.gitignore b/.gitignore index 72bbd8c..e1c8e40 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ preprocessed_configs/ optimizations/ MCP/ architecutre/ +LOCAL_README.md +benchmark-results/ diff --git a/bench/README_apple_container_matrix.md b/bench/README_apple_container_matrix.md new file mode 100644 index 0000000..9750444 --- /dev/null +++ b/bench/README_apple_container_matrix.md @@ -0,0 +1,67 @@ +# Apple Container Benchmark Matrix + +This harness compares TurboDB against PostgreSQL 18, MySQL, and optionally +TigerBeetle using the same generated application shape. + +It must be run through Apple `container`. The runner creates a private container +network, starts each database in a container, runs the Python benchmark client in +another container, and does not publish database ports to macOS. + +## Workload Shape + +- `users`: keyed user records with an email and JSON profile. +- `orders`: keyed order records with `user_id`, amount, status, and JSON payload. +- TurboDB stores `bench_users`, `bench_orders`, and a materialized + `bench_user_orders` edge collection. +- PostgreSQL 18 and MySQL use normalized `users` and `orders` tables with a + secondary index on `orders.user_id`. +- TigerBeetle uses accounts and transfers, so relationship lookups are modeled + as account transfer queries rather than SQL/document joins. Updates and + deletes are reported as not applicable because transfers are immutable. + +## Workloads + +- `ingest`: create all users and orders. +- `point_get`: read users by primary key. +- `relationship_lookup`: fetch all orders/transfers for a user. +- `join_or_join_like`: SQL join for PostgreSQL/MySQL, materialized edge fetches + for TurboDB, account transfer query for TigerBeetle. +- `update_orders`: update order status where the engine supports mutation. +- `delete_orders`: delete orders where the engine supports deletion. + +## Run + +Small smoke run: + +```sh +python3 bench/run_apple_container_bench.py \ + --users 50 \ + --orders-per-user 3 \ + --samples 25 \ + --skip-tigerbeetle +``` + +Larger run with all engines: + +```sh +python3 bench/run_apple_container_bench.py \ + --users 10000 \ + --orders-per-user 5 \ + --samples 2000 +``` + +Useful flags: + +- `--skip-turbodb`, `--skip-postgres`, `--skip-mysql`, `--skip-tigerbeetle` +- `--mysql-image mysql:8.4` +- `--postgres-image postgres:18` +- `--tigerbeetle-image ghcr.io/tigerbeetle/tigerbeetle:latest` +- `--tigerbeetle-memory 2G` because the replica journal needs more than the + Apple container 1 GiB default in practice. +- `--keep` to leave containers, network, and volumes behind for inspection. + +Results are written under ignored `benchmark-results/`. + +PostgreSQL 18 is mounted at `/var/lib/postgresql`, not +`/var/lib/postgresql/data`, because the official image stores data in +versioned subdirectories for upgrade compatibility. diff --git a/bench/container_shape_bench.py b/bench/container_shape_bench.py new file mode 100644 index 0000000..6aea99f --- /dev/null +++ b/bench/container_shape_bench.py @@ -0,0 +1,627 @@ +#!/usr/bin/env python3 +""" +Container runner for keyed document / relational / transfer-shaped benchmarks. + +This script is intended to run inside an Apple container network created by +run_apple_container_bench.py. It connects only to private container IPs. +""" +from __future__ import annotations + +import argparse +import json +import math +import random +import statistics +import sys +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import Any, Callable + + +def now() -> float: + return time.perf_counter() + + +def percentile(values: list[float], p: float) -> float: + if not values: + return 0.0 + values = sorted(values) + k = (len(values) - 1) * (p / 100.0) + lo = int(math.floor(k)) + hi = min(lo + 1, len(values) - 1) + return values[lo] + (values[hi] - values[lo]) * (k - lo) + + +def metric(elapsed: float, ops: int, latencies: list[float] | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]: + latencies = latencies or [] + result = { + "ops": ops, + "seconds": elapsed, + "ops_sec": ops / elapsed if elapsed > 0 else 0, + } + if latencies: + result.update({ + "p50_ms": percentile(latencies, 50), + "p95_ms": percentile(latencies, 95), + "p99_ms": percentile(latencies, 99), + }) + if extra: + result.update(extra) + return result + + +def timed(fn: Callable[[], Any], ops: int, extra: dict[str, Any] | None = None) -> dict[str, Any]: + start = now() + fn() + return metric(now() - start, ops, extra=extra) + + +def timed_loop(items: list[Any], fn: Callable[[Any], Any], extra: dict[str, Any] | None = None) -> dict[str, Any]: + latencies: list[float] = [] + start = now() + for item in items: + op_start = now() + fn(item) + latencies.append((now() - op_start) * 1000.0) + return metric(now() - start, len(items), latencies, extra) + + +def user_doc(user_id: int) -> dict[str, Any]: + return { + "id": user_id, + "email": f"user{user_id}@example.test", + "profile": { + "tier": user_id % 7, + "region": f"r{user_id % 16}", + "active": True, + }, + } + + +def order_doc(order_id: int, user_id: int) -> dict[str, Any]: + return { + "id": order_id, + "user_id": user_id, + "amount": 100 + (order_id % 10_000), + "status": "open" if order_id % 3 else "settled", + "payload": { + "sku": f"sku-{order_id % 97}", + "memo": f"order memo {order_id}", + }, + } + + +@dataclass +class Shape: + users: int + orders_per_user: int + + @property + def orders(self) -> int: + return self.users * self.orders_per_user + + def user_ids(self) -> range: + return range(1, self.users + 1) + + def order_id(self, user_id: int, ordinal: int) -> int: + return (user_id - 1) * self.orders_per_user + ordinal + 1 + + def order_ids_for_user(self, user_id: int) -> list[int]: + return [self.order_id(user_id, i) for i in range(self.orders_per_user)] + + +class TurboDBBench: + name = "turbodb" + + def __init__(self, host: str, port: int, shape: Shape, batch_size: int): + self.base = f"http://{host}:{port}" + self.shape = shape + self.batch_size = batch_size + + def request(self, method: str, path: str, body: bytes | None = None) -> bytes: + req = urllib.request.Request( + self.base + path, + data=body, + method=method, + headers={"content-type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + return resp.read() + + def wait(self) -> None: + deadline = time.time() + 30 + while time.time() < deadline: + try: + self.request("GET", "/health") + return + except Exception: + time.sleep(0.25) + raise RuntimeError("TurboDB did not become healthy") + + def drop(self) -> None: + for col in ("bench_users", "bench_orders", "bench_user_orders"): + try: + self.request("DELETE", f"/db/{col}") + except Exception: + pass + + def bulk(self, collection: str, rows: list[tuple[str, dict[str, Any]]]) -> None: + lines = [] + for key, value in rows: + lines.append(json.dumps({"key": key, "value": value}, separators=(",", ":"))) + self.request("POST", f"/db/{collection}/bulk", ("\n".join(lines) + "\n").encode()) + + def ingest(self) -> dict[str, Any]: + self.drop() + + def run() -> None: + rows: list[tuple[str, dict[str, Any]]] = [] + for uid in self.shape.user_ids(): + rows.append((str(uid), user_doc(uid))) + if len(rows) >= self.batch_size: + self.bulk("bench_users", rows) + rows.clear() + if rows: + self.bulk("bench_users", rows) + + rows.clear() + for uid in self.shape.user_ids(): + for oid in self.shape.order_ids_for_user(uid): + rows.append((str(oid), order_doc(oid, uid))) + if len(rows) >= self.batch_size: + self.bulk("bench_orders", rows) + rows.clear() + if rows: + self.bulk("bench_orders", rows) + + rows.clear() + for uid in self.shape.user_ids(): + rows.append((str(uid), {"user_id": uid, "order_ids": self.shape.order_ids_for_user(uid)})) + if len(rows) >= self.batch_size: + self.bulk("bench_user_orders", rows) + rows.clear() + if rows: + self.bulk("bench_user_orders", rows) + + logical = self.shape.users + self.shape.orders + physical = logical + self.shape.users + return timed(run, logical, {"physical_writes": physical}) + + def get_doc(self, collection: str, key: int) -> dict[str, Any]: + return json.loads(self.request("GET", f"/db/{collection}/{key}").decode()) + + def point_get(self, samples: list[int]) -> dict[str, Any]: + return timed_loop(samples, lambda uid: self.get_doc("bench_users", uid)) + + def order_lookup(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + edge = self.get_doc("bench_user_orders", uid)["value"] + for oid in edge["order_ids"]: + self.get_doc("bench_orders", oid) + + return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user}) + + def join_like(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + user = self.get_doc("bench_users", uid)["value"] + edge = self.get_doc("bench_user_orders", uid)["value"] + orders = [self.get_doc("bench_orders", oid)["value"] for oid in edge["order_ids"]] + _ = (user["email"], len(orders)) + + return timed_loop(samples, op, {"mode": "materialized_edge_point_gets"}) + + def update_orders(self, samples: list[int]) -> dict[str, Any]: + def op(oid: int) -> None: + value = order_doc(oid, ((oid - 1) // self.shape.orders_per_user) + 1) + value["status"] = "updated" + self.request("PUT", f"/db/bench_orders/{oid}", json.dumps(value).encode()) + + return timed_loop(samples, op) + + def delete_orders(self, samples: list[int]) -> dict[str, Any]: + return timed_loop(samples, lambda oid: self.request("DELETE", f"/db/bench_orders/{oid}")) + + +class PostgresBench: + name = "postgresql18" + + def __init__(self, host: str, shape: Shape, batch_size: int): + import psycopg + + self.conn = psycopg.connect(host=host, port=5432, dbname="bench", user="postgres", password="postgres", autocommit=True) + self.shape = shape + self.batch_size = batch_size + + def wait(self) -> None: + with self.conn.cursor() as cur: + cur.execute("select 1") + + def drop(self) -> None: + with self.conn.cursor() as cur: + cur.execute("drop table if exists orders") + cur.execute("drop table if exists users") + cur.execute("create table users (id bigint primary key, email text not null, profile jsonb not null)") + cur.execute("create table orders (id bigint primary key, user_id bigint not null references users(id), amount bigint not null, status text not null, payload jsonb not null)") + cur.execute("create index orders_user_id_idx on orders(user_id)") + + def ingest(self) -> dict[str, Any]: + self.drop() + + def run() -> None: + with self.conn.cursor() as cur: + cur.executemany( + "insert into users (id,email,profile) values (%s,%s,%s::jsonb)", + [(uid, user_doc(uid)["email"], json.dumps(user_doc(uid)["profile"])) for uid in self.shape.user_ids()], + ) + rows = [] + for uid in self.shape.user_ids(): + for oid in self.shape.order_ids_for_user(uid): + doc = order_doc(oid, uid) + rows.append((oid, uid, doc["amount"], doc["status"], json.dumps(doc["payload"]))) + cur.executemany( + "insert into orders (id,user_id,amount,status,payload) values (%s,%s,%s,%s,%s::jsonb)", + rows, + ) + + return timed(run, self.shape.users + self.shape.orders) + + def point_get(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("select id,email,profile from users where id=%s", (uid,)) + cur.fetchone() + + return timed_loop(samples, op) + + def order_lookup(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("select id,amount,status,payload from orders where user_id=%s", (uid,)) + cur.fetchall() + + return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user}) + + def join_like(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + with self.conn.cursor() as cur: + cur.execute( + "select u.email,o.id,o.amount,o.payload from users u join orders o on o.user_id=u.id where u.id=%s", + (uid,), + ) + cur.fetchall() + + return timed_loop(samples, op, {"mode": "sql_join"}) + + def update_orders(self, samples: list[int]) -> dict[str, Any]: + def op(oid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("update orders set status='updated' where id=%s", (oid,)) + + return timed_loop(samples, op) + + def delete_orders(self, samples: list[int]) -> dict[str, Any]: + def op(oid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("delete from orders where id=%s", (oid,)) + + return timed_loop(samples, op) + + +class MySQLBench: + name = "mysql" + + def __init__(self, host: str, shape: Shape, batch_size: int): + import pymysql + + self.conn = pymysql.connect(host=host, port=3306, user="root", password="mysql", database="bench", autocommit=True) + self.shape = shape + self.batch_size = batch_size + + def wait(self) -> None: + with self.conn.cursor() as cur: + cur.execute("select 1") + + def drop(self) -> None: + with self.conn.cursor() as cur: + cur.execute("drop table if exists orders") + cur.execute("drop table if exists users") + cur.execute("create table users (id bigint primary key, email varchar(255) not null, profile json not null)") + cur.execute("create table orders (id bigint primary key, user_id bigint not null, amount bigint not null, status varchar(32) not null, payload json not null, index orders_user_id_idx(user_id))") + + def ingest(self) -> dict[str, Any]: + self.drop() + + def run() -> None: + with self.conn.cursor() as cur: + cur.executemany( + "insert into users (id,email,profile) values (%s,%s,%s)", + [(uid, user_doc(uid)["email"], json.dumps(user_doc(uid)["profile"])) for uid in self.shape.user_ids()], + ) + rows = [] + for uid in self.shape.user_ids(): + for oid in self.shape.order_ids_for_user(uid): + doc = order_doc(oid, uid) + rows.append((oid, uid, doc["amount"], doc["status"], json.dumps(doc["payload"]))) + cur.executemany( + "insert into orders (id,user_id,amount,status,payload) values (%s,%s,%s,%s,%s)", + rows, + ) + + return timed(run, self.shape.users + self.shape.orders) + + def point_get(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("select id,email,profile from users where id=%s", (uid,)) + cur.fetchone() + + return timed_loop(samples, op) + + def order_lookup(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("select id,amount,status,payload from orders where user_id=%s", (uid,)) + cur.fetchall() + + return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user}) + + def join_like(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + with self.conn.cursor() as cur: + cur.execute( + "select u.email,o.id,o.amount,o.payload from users u join orders o on o.user_id=u.id where u.id=%s", + (uid,), + ) + cur.fetchall() + + return timed_loop(samples, op, {"mode": "sql_join"}) + + def update_orders(self, samples: list[int]) -> dict[str, Any]: + def op(oid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("update orders set status='updated' where id=%s", (oid,)) + + return timed_loop(samples, op) + + def delete_orders(self, samples: list[int]) -> dict[str, Any]: + def op(oid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("delete from orders where id=%s", (oid,)) + + return timed_loop(samples, op) + + +class TigerBeetleBench: + name = "tigerbeetle" + + def __init__(self, host: str, shape: Shape, batch_size: int): + import tigerbeetle as tb + + self.tb = tb + self.client = tb.ClientSync(cluster_id=0, replica_addresses=f"{host}:3000") + self.shape = shape + # Keep TigerBeetle client batches conservative; the wire limit is based + # on encoded message size, not just object count. + self.batch_size = min(batch_size, 128) + self.merchant_id = 9_000_000_000_000 + + def wait(self) -> None: + deadline = time.time() + 30 + while time.time() < deadline: + try: + self.client.lookup_accounts([1]) + return + except Exception: + time.sleep(0.25) + raise RuntimeError("TigerBeetle did not become ready") + + def batches(self, values: list[Any]) -> list[list[Any]]: + return [values[i:i + self.batch_size] for i in range(0, len(values), self.batch_size)] + + def ingest(self) -> dict[str, Any]: + tb = self.tb + + def run() -> None: + accounts = [ + tb.Account( + id=uid, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=uid, + user_data_64=0, + user_data_32=0, + ledger=1, + code=100, + flags=0, + timestamp=0, + ) + for uid in self.shape.user_ids() + ] + accounts.append(tb.Account( + id=self.merchant_id, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=200, + flags=0, + timestamp=0, + )) + for batch in self.batches(accounts): + self.client.create_accounts(batch) + + transfers = [] + for uid in self.shape.user_ids(): + for oid in self.shape.order_ids_for_user(uid): + transfers.append(tb.Transfer( + id=oid, + debit_account_id=uid, + credit_account_id=self.merchant_id, + amount=100 + (oid % 10_000), + pending_id=0, + user_data_128=uid, + user_data_64=oid, + user_data_32=0, + timeout=0, + ledger=1, + code=300, + flags=0, + timestamp=0, + )) + for batch in self.batches(transfers): + self.client.create_transfers(batch) + + return timed(run, self.shape.users + self.shape.orders, {"model": "accounts_and_transfers"}) + + def point_get(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + self.client.lookup_accounts([uid]) + + return timed_loop(samples, op) + + def order_lookup(self, samples: list[int]) -> dict[str, Any]: + tb = self.tb + flags = tb.AccountFilterFlags.DEBITS | tb.AccountFilterFlags.CREDITS + + def op(uid: int) -> None: + account_filter = tb.AccountFilter( + account_id=uid, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=max(self.shape.orders_per_user, 1), + flags=flags, + ) + self.client.get_account_transfers(account_filter) + + return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user, "model": "get_account_transfers"}) + + def join_like(self, samples: list[int]) -> dict[str, Any]: + result = self.order_lookup(samples) + result["mode"] = "account_transfer_query_not_sql_join" + return result + + def update_orders(self, samples: list[int]) -> dict[str, Any]: + return {"not_applicable": True, "reason": "TigerBeetle accounts/transfers are immutable after creation"} + + def delete_orders(self, samples: list[int]) -> dict[str, Any]: + return {"not_applicable": True, "reason": "TigerBeetle does not delete accounts/transfers"} + + +def run_engine(engine: Any, shape: Shape, samples: int) -> dict[str, Any]: + rng = random.Random(42) + user_samples = [rng.randint(1, shape.users) for _ in range(samples)] + order_samples = [rng.randint(1, shape.orders) for _ in range(samples)] + delete_order_samples = rng.sample(range(1, shape.orders + 1), min(samples, shape.orders)) + return { + "ingest": engine.ingest(), + "point_get": engine.point_get(user_samples), + "relationship_lookup": engine.order_lookup(user_samples), + "join_or_join_like": engine.join_like(user_samples), + "update_orders": engine.update_orders(order_samples), + "delete_orders": engine.delete_orders(delete_order_samples), + } + + +def print_summary(results: dict[str, Any]) -> None: + workloads = [ + "ingest", + "point_get", + "relationship_lookup", + "join_or_join_like", + "update_orders", + "delete_orders", + ] + print("\nContainer Shape Benchmark") + print("=" * 96) + print(f"{'workload':<24} {'engine':<14} {'ops/sec':>12} {'p50 ms':>10} {'p95 ms':>10} {'notes'}") + print("-" * 96) + for workload in workloads: + for engine, engine_results in results["engines"].items(): + row = engine_results.get(workload) + if not row: + continue + if row.get("not_applicable"): + print(f"{workload:<24} {engine:<14} {'n/a':>12} {'':>10} {'':>10} {row.get('reason', '')}") + continue + notes = row.get("mode") or row.get("model") or "" + print( + f"{workload:<24} {engine:<14} {row.get('ops_sec', 0):>12.0f} " + f"{row.get('p50_ms', 0):>10.3f} {row.get('p95_ms', 0):>10.3f} {notes}" + ) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--users", type=int, default=1000) + ap.add_argument("--orders-per-user", type=int, default=3) + ap.add_argument("--samples", type=int, default=500) + ap.add_argument("--batch-size", type=int, default=500) + ap.add_argument("--output", default="/work/benchmark-results/container-shape-bench.json") + ap.add_argument("--turbodb-host") + ap.add_argument("--turbodb-port", type=int, default=27017) + ap.add_argument("--postgres-host") + ap.add_argument("--mysql-host") + ap.add_argument("--tigerbeetle-host") + args = ap.parse_args() + if args.users < 1: + raise SystemExit("--users must be at least 1") + if args.orders_per_user < 1: + raise SystemExit("--orders-per-user must be at least 1") + if args.samples < 1: + raise SystemExit("--samples must be at least 1") + + shape = Shape(args.users, args.orders_per_user) + results: dict[str, Any] = { + "config": { + "users": shape.users, + "orders": shape.orders, + "orders_per_user": shape.orders_per_user, + "samples": args.samples, + "batch_size": args.batch_size, + }, + "engines": {}, + "errors": {}, + } + + engines: list[Any] = [] + if args.turbodb_host: + engines.append(TurboDBBench(args.turbodb_host, args.turbodb_port, shape, args.batch_size)) + if args.postgres_host: + engines.append(PostgresBench(args.postgres_host, shape, args.batch_size)) + if args.mysql_host: + engines.append(MySQLBench(args.mysql_host, shape, args.batch_size)) + if args.tigerbeetle_host: + engines.append(TigerBeetleBench(args.tigerbeetle_host, shape, args.batch_size)) + + for engine in engines: + try: + engine.wait() + results["engines"][engine.name] = run_engine(engine, shape, min(args.samples, shape.users, max(shape.orders, 1))) + except Exception as exc: + results["errors"][engine.name] = repr(exc) + print(f"{engine.name} failed: {exc}", file=sys.stderr) + + print_summary(results) + + import os + os.makedirs(os.path.dirname(args.output), exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + json.dump(results, f, indent=2) + print(f"\nwrote {args.output}") + return 0 if results["engines"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/run_apple_container_bench.py b/bench/run_apple_container_bench.py new file mode 100644 index 0000000..f6361db --- /dev/null +++ b/bench/run_apple_container_bench.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +""" +Run the document/relational/transfer benchmark matrix inside Apple containers. + +The script keeps every database service on a private Apple container network and +runs the benchmark client from a Python container. It does not publish database +ports to the macOS host. +""" +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] + + +def shlex_join(argv: list[str]) -> str: + import shlex + + return " ".join(shlex.quote(part) for part in argv) + + +def run(argv: list[str], *, check: bool = True, capture: bool = False, cwd: Path = ROOT, timeout: int | None = None) -> subprocess.CompletedProcess[str]: + print(f"+ {shlex_join(argv)}", flush=True) + return subprocess.run( + argv, + cwd=cwd, + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.STDOUT if capture else None, + check=check, + timeout=timeout, + ) + + +def container_exists(name: str) -> bool: + proc = run(["container", "inspect", name], check=False, capture=True) + return proc.returncode == 0 + + +def stop_container(name: str) -> None: + if not container_exists(name): + return + run(["container", "stop", name], check=False, capture=True) + + +def delete_container(name: str) -> None: + if not container_exists(name): + return + run(["container", "stop", name], check=False, capture=True) + run(["container", "rm", name], check=False, capture=True) + + +def network_exists(name: str) -> bool: + proc = run(["container", "network", "list"], check=False, capture=True) + if proc.returncode != 0 or proc.stdout is None: + return False + return any(line.split(maxsplit=1)[0] == name for line in proc.stdout.splitlines()[1:]) + + +def create_network(name: str) -> None: + if not network_exists(name): + run(["container", "network", "create", name]) + + +def delete_network(name: str) -> None: + if network_exists(name): + run(["container", "network", "delete", name], check=False, capture=True) + + +def volume_exists(name: str) -> bool: + proc = run(["container", "volume", "list"], check=False, capture=True) + if proc.returncode != 0 or proc.stdout is None: + return False + return any(line.split(maxsplit=1)[0] == name for line in proc.stdout.splitlines()[1:]) + + +def create_volume(name: str) -> None: + if not volume_exists(name): + run(["container", "volume", "create", name]) + + +def delete_volume(name: str) -> None: + if volume_exists(name): + run(["container", "volume", "delete", name], check=False, capture=True) + + +def inspect_json(name: str) -> Any: + proc = run(["container", "inspect", name], capture=True) + assert proc.stdout is not None + return json.loads(proc.stdout) + + +def container_ip(name: str) -> str: + data = inspect_json(name) + candidates: list[Any] + if isinstance(data, list): + candidates = data + else: + candidates = [data] + + for item in candidates: + networks = item.get("networks") if isinstance(item, dict) else None + if not networks: + continue + for net in networks: + address = net.get("ipv4Address") or net.get("address") + if address: + return str(address).split("/", 1)[0] + raise RuntimeError(f"could not find IPv4 address for container {name}") + + +def wait_http(network: str, url: str, timeout_s: int = 45) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + proc = run( + ["container", "run", "--rm", "--network", network, "--entrypoint", "wget", "alpine:3.20", "-qO-", url], + check=False, + capture=True, + timeout=20, + ) + if proc.returncode == 0: + return + time.sleep(0.5) + raise RuntimeError(f"timed out waiting for {url}") + + +def wait_exec(name: str, command: list[str], timeout_s: int = 60) -> None: + deadline = time.time() + timeout_s + last_output = "" + while time.time() < deadline: + proc = run(["container", "exec", name, *command], check=False, capture=True, timeout=20) + last_output = (proc.stdout or "").strip() + if proc.returncode == 0: + return + time.sleep(0.75) + suffix = f": {last_output}" if last_output else "" + raise RuntimeError(f"timed out waiting for {name}: {' '.join(command)}{suffix}") + + +def logs_tail(name: str, lines: int = 120) -> str: + proc = run(["container", "logs", "-n", str(lines), name], check=False, capture=True) + return (proc.stdout or "").strip() + + +def build_turbodb(target: str) -> None: + run(["zig", "build", f"-Dtarget={target}"]) + + +@dataclass +class Service: + name: str + host: str + error: str | None = None + + +class BenchRun: + def __init__(self, args: argparse.Namespace): + self.args = args + self.run_id = args.run_id or f"shape-{uuid.uuid4().hex[:10]}" + self.network = args.network or f"tdb-bench-{self.run_id}" + self.output = Path(args.output or ROOT / "benchmark-results" / f"{self.run_id}.json").resolve() + self.containers: list[str] = [] + self.volumes: list[str] = [] + + def name(self, suffix: str) -> str: + return f"tdb-{self.run_id}-{suffix}" + + def volume(self, suffix: str) -> str: + name = self.name(suffix) + delete_volume(name) + create_volume(name) + self.volumes.append(name) + return name + + def start(self) -> dict[str, Service]: + create_network(self.network) + services: dict[str, Service] = {} + + if not self.args.skip_turbodb: + build_turbodb(self.args.turbodb_target) + services["turbodb"] = self.start_turbodb() + if not self.args.skip_postgres: + services["postgres"] = self.start_postgres() + if not self.args.skip_mysql: + services["mysql"] = self.start_mysql() + if not self.args.skip_tigerbeetle: + services["tigerbeetle"] = self.start_tigerbeetle() + return services + + def start_turbodb(self) -> Service: + name = self.name("turbodb") + data_volume = self.volume("turbodb-data") + delete_container(name) + run([ + "container", "run", "-d", + "--name", name, + "--network", self.network, + "--mount", f"type=bind,source={ROOT},target=/work", + "--volume", f"{data_volume}:/data", + "--entrypoint", "/work/zig-out/bin/turbodb", + "alpine:3.20", + "--data", "/data", + "--port", "27017", + "--http", + ]) + self.containers.append(name) + host = container_ip(name) + wait_http(self.network, f"http://{host}:27017/health", timeout_s=60) + return Service(name=name, host=host) + + def start_postgres(self) -> Service: + name = self.name("postgres") + data_volume = self.volume("postgres-data") + delete_container(name) + run([ + "container", "run", "-d", + "--name", name, + "--network", self.network, + "-e", "POSTGRES_PASSWORD=postgres", + "-e", "POSTGRES_DB=bench", + "--volume", f"{data_volume}:/var/lib/postgresql", + self.args.postgres_image, + ]) + self.containers.append(name) + try: + wait_exec(name, ["pg_isready", "-U", "postgres", "-d", "bench"], timeout_s=90) + except Exception as exc: + raise RuntimeError(f"{exc}\npostgres logs:\n{logs_tail(name)}") from exc + return Service(name=name, host=container_ip(name)) + + def start_mysql(self) -> Service: + name = self.name("mysql") + data_volume = self.volume("mysql-data") + delete_container(name) + run([ + "container", "run", "-d", + "--name", name, + "--network", self.network, + "-e", "MYSQL_ROOT_PASSWORD=mysql", + "-e", "MYSQL_DATABASE=bench", + "--volume", f"{data_volume}:/var/lib/mysql", + self.args.mysql_image, + ]) + self.containers.append(name) + try: + wait_exec(name, ["mysqladmin", "ping", "-uroot", "-pmysql", "--silent"], timeout_s=120) + except Exception as exc: + raise RuntimeError(f"{exc}\nmysql logs:\n{logs_tail(name)}") from exc + return Service(name=name, host=container_ip(name)) + + def start_tigerbeetle(self) -> Service: + name = self.name("tigerbeetle") + data_volume = self.volume("tigerbeetle-data") + data_file = "/data/0_0.tigerbeetle" + delete_container(name) + + format_proc = run([ + "container", "run", "--rm", + "--volume", f"{data_volume}:/data", + self.args.tigerbeetle_image, + "format", + "--cluster=0", + "--replica=0", + "--replica-count=1", + "--development", + data_file, + ], check=False, capture=True, timeout=120) + if format_proc.returncode != 0: + message = format_proc.stdout.strip() if format_proc.stdout else "format failed" + if self.args.require_tigerbeetle: + raise RuntimeError(message) + return Service(name=name, host="", error=message) + + start_proc = run([ + "container", "run", "-d", + "--name", name, + "--network", self.network, + "--memory", self.args.tigerbeetle_memory, + "--ulimit", "memlock=-1:-1", + "--volume", f"{data_volume}:/data", + self.args.tigerbeetle_image, + "start", + "--addresses=0.0.0.0:3000", + "--development", + data_file, + ], check=False, capture=True, timeout=120) + if start_proc.returncode != 0: + message = start_proc.stdout.strip() if start_proc.stdout else "start failed" + if self.args.require_tigerbeetle: + raise RuntimeError(message) + return Service(name=name, host="", error=message) + + self.containers.append(name) + time.sleep(2.0) + try: + host = container_ip(name) + except Exception as exc: + message = logs_tail(name) or str(exc) + if self.args.require_tigerbeetle: + raise RuntimeError(f"TigerBeetle did not stay running:\n{message}") from exc + return Service(name=name, host="", error=message) + return Service(name=name, host=host) + + def run_client(self, services: dict[str, Service]) -> int: + self.output.parent.mkdir(parents=True, exist_ok=True) + deps: list[str] = [] + args = [ + "python", "bench/container_shape_bench.py", + "--users", str(self.args.users), + "--orders-per-user", str(self.args.orders_per_user), + "--samples", str(self.args.samples), + "--batch-size", str(self.args.batch_size), + "--output", f"/work/{self.output.relative_to(ROOT)}", + ] + + if (svc := services.get("turbodb")) and not svc.error: + args.extend(["--turbodb-host", svc.host]) + if (svc := services.get("postgres")) and not svc.error: + deps.append("psycopg[binary]") + args.extend(["--postgres-host", svc.host]) + if (svc := services.get("mysql")) and not svc.error: + deps.append("pymysql") + deps.append("cryptography") + args.extend(["--mysql-host", svc.host]) + if (svc := services.get("tigerbeetle")) and not svc.error: + deps.append("tigerbeetle") + args.extend(["--tigerbeetle-host", svc.host]) + + if not any(services.get(engine) and not services[engine].error for engine in services): + raise RuntimeError("no benchmark services started successfully") + + client_name = self.name("client") + delete_container(client_name) + pip_cmd = "python -m pip install --quiet --disable-pip-version-check --root-user-action=ignore " + " ".join(deps) if deps else "true" + bench_cmd = shlex_join(args) + command = f"{pip_cmd} && {bench_cmd}" + proc = run([ + "container", "run", "--rm", + "--name", client_name, + "--network", self.network, + "--mount", f"type=bind,source={ROOT},target=/work", + "-w", "/work", + "--entrypoint", "/bin/sh", + self.args.python_image, + "-lc", command, + ], check=False) + return proc.returncode + + def cleanup(self) -> None: + if self.args.keep: + print(f"keeping containers/network/volumes for run {self.run_id}", flush=True) + return + for name in reversed(self.containers): + stop_container(name) + delete_container(name) + delete_network(self.network) + for name in reversed(self.volumes): + delete_volume(name) + + +def parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser(description="Run TurboDB/PostgreSQL/MySQL/TigerBeetle benchmarks in Apple containers") + ap.add_argument("--users", type=int, default=1000) + ap.add_argument("--orders-per-user", type=int, default=3) + ap.add_argument("--samples", type=int, default=500) + ap.add_argument("--batch-size", type=int, default=500) + ap.add_argument("--output") + ap.add_argument("--run-id") + ap.add_argument("--network") + ap.add_argument("--turbodb-target", default="aarch64-linux-musl") + ap.add_argument("--python-image", default="python:3.12-slim") + ap.add_argument("--postgres-image", default="postgres:18") + ap.add_argument("--mysql-image", default="mysql:8.4") + ap.add_argument("--tigerbeetle-image", default="ghcr.io/tigerbeetle/tigerbeetle:latest") + ap.add_argument("--tigerbeetle-memory", default="2G") + ap.add_argument("--skip-turbodb", action="store_true") + ap.add_argument("--skip-postgres", action="store_true") + ap.add_argument("--skip-mysql", action="store_true") + ap.add_argument("--skip-tigerbeetle", action="store_true") + ap.add_argument("--require-tigerbeetle", action="store_true") + ap.add_argument("--keep", action="store_true", help="leave containers, network, and volumes in place") + return ap.parse_args() + + +def main() -> int: + if not shutil.which("container"): + print("Apple container CLI is required: https://github.com/apple/container", file=sys.stderr) + return 2 + + args = parse_args() + bench = BenchRun(args) + services: dict[str, Service] = {} + try: + services = bench.start() + errors = {name: svc.error for name, svc in services.items() if svc.error} + if errors: + print("optional services failed:") + for name, error in errors.items(): + print(f" {name}: {error}") + rc = bench.run_client(services) + print(f"benchmark output: {bench.output}") + return rc + finally: + bench.cleanup() + + +if __name__ == "__main__": + raise SystemExit(main()) From c255190a6d23fb1209bcf6fbcdec617fd415fbce Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:41:05 +0800 Subject: [PATCH 15/27] chore: clean benchmark imports --- bench/container_shape_bench.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bench/container_shape_bench.py b/bench/container_shape_bench.py index 6aea99f..39af121 100644 --- a/bench/container_shape_bench.py +++ b/bench/container_shape_bench.py @@ -11,10 +11,8 @@ import json import math import random -import statistics import sys import time -import urllib.error import urllib.request from dataclasses import dataclass from typing import Any, Callable From cc92d6b933d0df4ffc3add205d2e745288cfb6ab Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:57:29 +0800 Subject: [PATCH 16/27] feat: batch document reads over http --- bench/README_apple_container_matrix.md | 4 +- bench/container_shape_bench.py | 44 ++++--- src/server.zig | 161 ++++++++++++++++++++++++- 3 files changed, 190 insertions(+), 19 deletions(-) diff --git a/bench/README_apple_container_matrix.md b/bench/README_apple_container_matrix.md index 9750444..491b3cd 100644 --- a/bench/README_apple_container_matrix.md +++ b/bench/README_apple_container_matrix.md @@ -12,7 +12,9 @@ another container, and does not publish database ports to macOS. - `users`: keyed user records with an email and JSON profile. - `orders`: keyed order records with `user_id`, amount, status, and JSON payload. - TurboDB stores `bench_users`, `bench_orders`, and a materialized - `bench_user_orders` edge collection. + `bench_user_orders` edge collection. The benchmark client uses persistent + HTTP plus `POST /db/:col/batch_get` for relationship fetches, so it does not + inflate TurboDB latency with one TCP/HTTP setup per document. - PostgreSQL 18 and MySQL use normalized `users` and `orders` tables with a secondary index on `orders.user_id`. - TigerBeetle uses accounts and transfers, so relationship lookups are modeled diff --git a/bench/container_shape_bench.py b/bench/container_shape_bench.py index 39af121..25faf4d 100644 --- a/bench/container_shape_bench.py +++ b/bench/container_shape_bench.py @@ -8,12 +8,12 @@ from __future__ import annotations import argparse +import http.client import json import math import random import sys import time -import urllib.request from dataclasses import dataclass from typing import Any, Callable @@ -114,19 +114,30 @@ class TurboDBBench: name = "turbodb" def __init__(self, host: str, port: int, shape: Shape, batch_size: int): - self.base = f"http://{host}:{port}" + self.host = host + self.port = port self.shape = shape self.batch_size = batch_size + self.conn = http.client.HTTPConnection(host, port, timeout=10) def request(self, method: str, path: str, body: bytes | None = None) -> bytes: - req = urllib.request.Request( - self.base + path, - data=body, - method=method, - headers={"content-type": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=10) as resp: - return resp.read() + headers = {"content-type": "application/json"} + try: + self.conn.request(method, path, body=body, headers=headers) + resp = self.conn.getresponse() + data = resp.read() + if resp.status >= 400: + raise RuntimeError(f"{method} {path} failed: {resp.status} {data[:200]!r}") + return data + except (http.client.HTTPException, OSError): + self.conn.close() + self.conn = http.client.HTTPConnection(self.host, self.port, timeout=10) + self.conn.request(method, path, body=body, headers=headers) + resp = self.conn.getresponse() + data = resp.read() + if resp.status >= 400: + raise RuntimeError(f"{method} {path} failed: {resp.status} {data[:200]!r}") + return data def wait(self) -> None: deadline = time.time() + 30 @@ -190,25 +201,28 @@ def run() -> None: def get_doc(self, collection: str, key: int) -> dict[str, Any]: return json.loads(self.request("GET", f"/db/{collection}/{key}").decode()) + def batch_get_docs(self, collection: str, keys: list[int]) -> list[dict[str, Any]]: + body = json.dumps({"keys": [str(key) for key in keys]}, separators=(",", ":")).encode() + return json.loads(self.request("POST", f"/db/{collection}/batch_get", body).decode())["docs"] + def point_get(self, samples: list[int]) -> dict[str, Any]: return timed_loop(samples, lambda uid: self.get_doc("bench_users", uid)) def order_lookup(self, samples: list[int]) -> dict[str, Any]: def op(uid: int) -> None: edge = self.get_doc("bench_user_orders", uid)["value"] - for oid in edge["order_ids"]: - self.get_doc("bench_orders", oid) + self.batch_get_docs("bench_orders", edge["order_ids"]) - return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user}) + return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user, "mode": "materialized_edge_batch_get"}) def join_like(self, samples: list[int]) -> dict[str, Any]: def op(uid: int) -> None: user = self.get_doc("bench_users", uid)["value"] edge = self.get_doc("bench_user_orders", uid)["value"] - orders = [self.get_doc("bench_orders", oid)["value"] for oid in edge["order_ids"]] + orders = self.batch_get_docs("bench_orders", edge["order_ids"]) _ = (user["email"], len(orders)) - return timed_loop(samples, op, {"mode": "materialized_edge_point_gets"}) + return timed_loop(samples, op, {"mode": "materialized_edge_batch_get"}) def update_orders(self, samples: list[int]) -> dict[str, Any]: def op(oid: int) -> None: diff --git a/src/server.zig b/src/server.zig index 394ff99..603a1ac 100644 --- a/src/server.zig +++ b/src/server.zig @@ -1,6 +1,7 @@ /// TurboDB HTTP server — MongoDB-compatible-ish JSON REST API /// Routes: /// POST /db/:col insert document +/// POST /db/:col/batch_get get multiple documents by key /// GET /db/:col/:key get document by key /// PUT /db/:col/:key upsert document /// DELETE /db/:col/:key delete document @@ -446,15 +447,17 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { // Routes under /db/:col if (std.mem.startsWith(u8, path, "/db/")) { - if (isBasicDbWriteMethod(method) and !canWriteDb(auth_ctx_ptr)) - return err(403, "forbidden: API key is read-only"); - const rest = path[4..]; // /db/:col/:key if (std.mem.indexOfScalar(u8, rest, '/')) |sep| { const col_name = rest[0..sep]; const key = rest[sep + 1 ..]; const tenant_id = requestTenant(raw, query, auth_ctx_ptr); + // POST /db/:col/batch_get — read-only bulk point lookup. + if (std.mem.eql(u8, key, "batch_get") and std.mem.eql(u8, method, "POST")) + return handleBatchGet(srv, tenant_id, col_name, body, requestAsOf(raw, query)); + if (isBasicDbWriteMethod(method) and !canWriteDb(auth_ctx_ptr)) + return err(403, "forbidden: API key is read-only"); // POST /db/:col/bulk — bulk insert if (std.mem.eql(u8, key, "bulk") and std.mem.eql(u8, method, "POST")) return handleBulkInsert(srv, tenant_id, col_name, body, alloc); @@ -464,6 +467,8 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { } else { const col_name = rest; const tenant_id = requestTenant(raw, query, auth_ctx_ptr); + if (isBasicDbWriteMethod(method) and !canWriteDb(auth_ctx_ptr)) + return err(403, "forbidden: API key is read-only"); if (std.mem.eql(u8, method, "POST")) return handleInsert(srv, tenant_id, col_name, body, alloc); if (std.mem.eql(u8, method, "GET")) return handleScan(srv, tenant_id, col_name, query, requestAsOf(raw, query), alloc); if (std.mem.eql(u8, method, "DELETE")) return handleDrop(srv, tenant_id, col_name); @@ -551,6 +556,69 @@ fn handleBulkInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, b .{ inserted, errors, col_name, tenant_id }) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } + +/// POST /db/:col/batch_get — read multiple documents in one request. +/// Body can be a JSON string array, {"keys":[...]}, or one key per line. +fn handleBatchGet(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, as_of: ?i64) usize { + const start_ns = compat.nanoTimestamp(); + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + + const HEADER_RESERVE = 256; + var resp = getRespBuf(); + var fbs = compat.fixedBufferStream(resp[HEADER_RESERVE..]); + const w = fbs.writer(); + compat.format(w, "{{\"tenant\":\"{s}\",\"collection\":\"{s}\",\"docs\":[", + .{ tenant_id, col_name }) catch return err(500, "response too large"); + + var iter = KeyIter.init(body); + var found: usize = 0; + var missing: usize = 0; + var bytes_read: usize = 0; + var truncated = false; + while (iter.next()) |key| { + if (key.len == 0) continue; + const d = if (as_of) |ts_ms| + (col.getAsOfTimestamp(key, ts_ms) orelse { + missing += 1; + continue; + }) + else + (col.get(key) orelse { + missing += 1; + continue; + }); + + const val = if (d.value.len > 0) d.value else "{}"; + const is_json = isJsonValue(val); + const next_len = (if (found > 0) @as(usize, 1) else 0) + docJsonLen(d, val, is_json); + if (fbs.pos + next_len + 80 >= resp[HEADER_RESERVE..].len) { + truncated = true; + break; + } + if (found > 0) w.writeByte(',') catch return err(500, "response too large"); + writeDocJson(w, d, val, is_json) catch return err(500, "response too large"); + found += 1; + bytes_read += d.key.len + d.value.len; + } + + compat.format(w, "],\"count\":{d},\"missing\":{d},\"truncated\":{}}}", + .{ found, missing, truncated }) catch return err(500, "response too large"); + const body_len = fbs.pos; + + srv.recordQueryCost(tenant_id, "batch_get", found + missing, bytes_read, start_ns); + + var hdr_fbs = compat.fixedBufferStream(resp[0..HEADER_RESERVE]); + compat.format(hdr_fbs.writer(), + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", + .{body_len}) catch {}; + const hdr_len = hdr_fbs.pos; + if (hdr_len < HEADER_RESERVE) { + std.mem.copyForwards(u8, resp[hdr_len .. hdr_len + body_len], resp[HEADER_RESERVE .. HEADER_RESERVE + body_len]); + } + return hdr_len + body_len; +} + fn handleGet(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []const u8, as_of: ?i64) usize { const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); @@ -1169,6 +1237,79 @@ fn extractContentLength(raw: []const u8) usize { return 0; } +const KeyIter = struct { + body: []const u8, + pos: usize = 0, + array_mode: bool = false, + + fn init(body: []const u8) KeyIter { + const trimmed = std.mem.trim(u8, body, " \t\r\n"); + if (trimmed.len == 0) return .{ .body = trimmed }; + if (trimmed[0] == '{') { + if (jsonValue(trimmed, "keys")) |keys| { + if (keys.len > 0 and keys[0] == '[') { + return .{ .body = keys, .pos = 1, .array_mode = true }; + } + } + } + if (trimmed[0] == '[') return .{ .body = trimmed, .pos = 1, .array_mode = true }; + return .{ .body = trimmed }; + } + + fn next(self: *KeyIter) ?[]const u8 { + if (self.array_mode) return self.nextArray(); + return self.nextLine(); + } + + fn nextLine(self: *KeyIter) ?[]const u8 { + while (self.pos < self.body.len) { + const line_end = std.mem.indexOfScalarPos(u8, self.body, self.pos, '\n') orelse self.body.len; + const raw_line = std.mem.trim(u8, self.body[self.pos..line_end], " \t\r"); + self.pos = if (line_end < self.body.len) line_end + 1 else line_end; + if (raw_line.len == 0) continue; + if (raw_line[0] == '{') return jsonStr(raw_line, "key") orelse continue; + if (raw_line.len >= 2 and raw_line[0] == '"' and raw_line[raw_line.len - 1] == '"') + return raw_line[1 .. raw_line.len - 1]; + return raw_line; + } + return null; + } + + fn nextArray(self: *KeyIter) ?[]const u8 { + while (self.pos < self.body.len) { + while (self.pos < self.body.len and + (self.body[self.pos] == ' ' or self.body[self.pos] == '\t' or + self.body[self.pos] == '\r' or self.body[self.pos] == '\n' or + self.body[self.pos] == ',')) : (self.pos += 1) {} + if (self.pos >= self.body.len or self.body[self.pos] == ']') return null; + + if (self.body[self.pos] == '"') { + const start = self.pos + 1; + var i = start; + while (i < self.body.len) : (i += 1) { + if (self.body[i] == '\\' and i + 1 < self.body.len) { + i += 1; + continue; + } + if (self.body[i] == '"') { + self.pos = i + 1; + return self.body[start..i]; + } + } + self.pos = self.body.len; + return null; + } + + const start = self.pos; + while (self.pos < self.body.len and self.body[self.pos] != ',' and self.body[self.pos] != ']') : (self.pos += 1) {} + const raw_key = std.mem.trim(u8, self.body[start..self.pos], " \t\r\n"); + if (raw_key.len == 0) continue; + return raw_key; + } + return null; + } +}; + fn jsonStr(json: []const u8, key: []const u8) ?[]const u8 { var kbuf: [64]u8 = undefined; const needle = std.fmt.bufPrint(&kbuf, "\"{s}\":", .{key}) catch return null; @@ -1576,3 +1717,17 @@ test "read-only auth cannot write basic db methods" { ctx.perm = .read_write; try std.testing.expect(canWriteDb(&ctx)); } + +test "KeyIter reads JSON arrays and keyed newline bodies" { + var array_iter = KeyIter.init("{\"keys\":[\"1\",\"2\",\"3\"]}"); + try std.testing.expectEqualStrings("1", array_iter.next().?); + try std.testing.expectEqualStrings("2", array_iter.next().?); + try std.testing.expectEqualStrings("3", array_iter.next().?); + try std.testing.expect(array_iter.next() == null); + + var line_iter = KeyIter.init("{\"key\":\"a\"}\nplain\n\"quoted\"\n"); + try std.testing.expectEqualStrings("a", line_iter.next().?); + try std.testing.expectEqualStrings("plain", line_iter.next().?); + try std.testing.expectEqualStrings("quoted", line_iter.next().?); + try std.testing.expect(line_iter.next() == null); +} From b028bb9551ed624a5e096098f623676e4571faa2 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:58:48 +0800 Subject: [PATCH 17/27] chore: ignore local agent notes --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e1c8e40..6a3293c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ optimizations/ MCP/ architecutre/ LOCAL_README.md +local-agent-readme.md benchmark-results/ From 0f4ee1e6b784419a50548a9c4559012a4f0dd68c Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:09:43 +0800 Subject: [PATCH 18/27] feat: add ffi benchmark path --- .gitignore | 1 + bench/README_apple_container_matrix.md | 10 +- bench/container_shape_bench.py | 208 +++++++++++++++++++++++++ bench/run_apple_container_bench.py | 24 ++- src/ffi.zig | 203 ++++++++++++++++++++++++ 5 files changed, 442 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 6a3293c..66027d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ zig-out/ +zig-out-ffi/ .zig-cache/ python/dist/ *.egg-info/ diff --git a/bench/README_apple_container_matrix.md b/bench/README_apple_container_matrix.md index 491b3cd..89bbea9 100644 --- a/bench/README_apple_container_matrix.md +++ b/bench/README_apple_container_matrix.md @@ -1,7 +1,8 @@ # Apple Container Benchmark Matrix This harness compares TurboDB against PostgreSQL 18, MySQL, and optionally -TigerBeetle using the same generated application shape. +TigerBeetle using the same generated application shape. TurboDB is measured two +ways: over HTTP and through the embedded C ABI/FFI bridge. It must be run through Apple `container`. The runner creates a private container network, starts each database in a container, runs the Python benchmark client in @@ -15,6 +16,9 @@ another container, and does not publish database ports to macOS. `bench_user_orders` edge collection. The benchmark client uses persistent HTTP plus `POST /db/:col/batch_get` for relationship fetches, so it does not inflate TurboDB latency with one TCP/HTTP setup per document. +- `turbodb_ffi` loads `/work/zig-out-ffi/lib/libturbodb.so` with Python `ctypes`. + This is an embedded/raw-engine path, not a network database path, and exists + to show the cost of HTTP/client transport separately from storage operations. - PostgreSQL 18 and MySQL use normalized `users` and `orders` tables with a secondary index on `orders.user_id`. - TigerBeetle uses accounts and transfers, so relationship lookups are modeled @@ -55,6 +59,10 @@ python3 bench/run_apple_container_bench.py \ Useful flags: - `--skip-turbodb`, `--skip-postgres`, `--skip-mysql`, `--skip-tigerbeetle` +- `--skip-turbodb-ffi` +- `--turbodb-ffi-target aarch64-linux-gnu` +- `--turbodb-ffi-prefix zig-out-ffi` +- `--turbodb-ffi-lib /work/zig-out-ffi/lib/libturbodb.so` - `--mysql-image mysql:8.4` - `--postgres-image postgres:18` - `--tigerbeetle-image ghcr.io/tigerbeetle/tigerbeetle:latest` diff --git a/bench/container_shape_bench.py b/bench/container_shape_bench.py index 25faf4d..3eddbd9 100644 --- a/bench/container_shape_bench.py +++ b/bench/container_shape_bench.py @@ -8,10 +8,13 @@ from __future__ import annotations import argparse +import ctypes import http.client import json import math +import os import random +import shutil import sys import time from dataclasses import dataclass @@ -236,6 +239,203 @@ def delete_orders(self, samples: list[int]) -> dict[str, Any]: return timed_loop(samples, lambda oid: self.request("DELETE", f"/db/bench_orders/{oid}")) +class TurboDocResult(ctypes.Structure): + _fields_ = [ + ("key_ptr", ctypes.c_void_p), + ("key_len", ctypes.c_size_t), + ("val_ptr", ctypes.c_void_p), + ("val_len", ctypes.c_size_t), + ("doc_id", ctypes.c_uint64), + ("version", ctypes.c_uint8), + ("_pad", ctypes.c_uint8 * 7), + ] + + +class TurboDBFFIBench: + name = "turbodb_ffi" + + def __init__(self, lib_path: str, data_dir: str, shape: Shape, batch_size: int): + self.shape = shape + self.batch_size = batch_size + self.data_dir = data_dir + shutil.rmtree(data_dir, ignore_errors=True) + os.makedirs(data_dir, exist_ok=True) + + self.lib = ctypes.CDLL(lib_path) + self._bind() + data_dir_b = data_dir.encode() + self.db = self.lib.turbodb_open(data_dir_b, len(data_dir_b)) + if not self.db: + raise RuntimeError(f"failed to open TurboDB FFI database at {data_dir}") + self.collections: dict[str, ctypes.c_void_p] = {} + + def _bind(self) -> None: + lib = self.lib + lib.turbodb_open.argtypes = [ctypes.c_char_p, ctypes.c_size_t] + lib.turbodb_open.restype = ctypes.c_void_p + lib.turbodb_close.argtypes = [ctypes.c_void_p] + lib.turbodb_close.restype = None + lib.turbodb_collection.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t] + lib.turbodb_collection.restype = ctypes.c_void_p + lib.turbodb_drop_collection.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t] + lib.turbodb_drop_collection.restype = None + lib.turbodb_insert.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, + ctypes.c_char_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_uint64), + ] + lib.turbodb_insert.restype = ctypes.c_int + lib.turbodb_insert_bulk_ndjson.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, + ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), + ] + lib.turbodb_insert_bulk_ndjson.restype = ctypes.c_int + lib.turbodb_get.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, ctypes.POINTER(TurboDocResult)] + lib.turbodb_get.restype = ctypes.c_int + lib.turbodb_get_many_keys.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, + ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_size_t), + ] + lib.turbodb_get_many_keys.restype = ctypes.c_int + lib.turbodb_update.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, ctypes.c_char_p, ctypes.c_size_t] + lib.turbodb_update.restype = ctypes.c_int + lib.turbodb_delete.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t] + lib.turbodb_delete.restype = ctypes.c_int + + def close(self) -> None: + if getattr(self, "db", None): + self.lib.turbodb_close(self.db) + self.db = None + + def wait(self) -> None: + pass + + def collection(self, name: str) -> ctypes.c_void_p: + if name in self.collections: + return self.collections[name] + name_b = name.encode() + handle = self.lib.turbodb_collection(self.db, name_b, len(name_b)) + if not handle: + raise RuntimeError(f"failed to open FFI collection {name}") + self.collections[name] = handle + return handle + + def drop(self) -> None: + self.collections.clear() + for col in ("bench_users", "bench_orders", "bench_user_orders"): + col_b = col.encode() + self.lib.turbodb_drop_collection(self.db, col_b, len(col_b)) + + def bulk(self, collection: str, rows: list[tuple[str, dict[str, Any]]]) -> tuple[int, int]: + lines = [] + for key, value in rows: + lines.append(json.dumps({"key": key, "value": value}, separators=(",", ":"))) + body = ("\n".join(lines) + "\n").encode() + inserted = ctypes.c_uint32(0) + errors = ctypes.c_uint32(0) + rc = self.lib.turbodb_insert_bulk_ndjson( + self.collection(collection), body, len(body), ctypes.byref(inserted), ctypes.byref(errors) + ) + if rc != 0: + raise RuntimeError(f"FFI bulk insert failed for {collection}") + return int(inserted.value), int(errors.value) + + def ingest(self) -> dict[str, Any]: + self.drop() + + def run() -> None: + rows: list[tuple[str, dict[str, Any]]] = [] + for uid in self.shape.user_ids(): + rows.append((str(uid), user_doc(uid))) + if len(rows) >= self.batch_size: + self.bulk("bench_users", rows) + rows.clear() + if rows: + self.bulk("bench_users", rows) + + rows.clear() + for uid in self.shape.user_ids(): + for oid in self.shape.order_ids_for_user(uid): + rows.append((str(oid), order_doc(oid, uid))) + if len(rows) >= self.batch_size: + self.bulk("bench_orders", rows) + rows.clear() + if rows: + self.bulk("bench_orders", rows) + + rows.clear() + for uid in self.shape.user_ids(): + rows.append((str(uid), {"user_id": uid, "order_ids": self.shape.order_ids_for_user(uid)})) + if len(rows) >= self.batch_size: + self.bulk("bench_user_orders", rows) + rows.clear() + if rows: + self.bulk("bench_user_orders", rows) + + logical = self.shape.users + self.shape.orders + physical = logical + self.shape.users + return timed(run, logical, {"physical_writes": physical, "mode": "embedded_c_abi"}) + + def get_key(self, collection: str, key: int) -> TurboDocResult: + key_b = str(key).encode() + out = TurboDocResult() + rc = self.lib.turbodb_get(self.collection(collection), key_b, len(key_b), ctypes.byref(out)) + if rc != 0: + raise RuntimeError(f"FFI get failed for {collection}/{key}") + return out + + def batch_get_keys(self, collection: str, keys: list[int]) -> tuple[int, int, int]: + body = ("\n".join(str(key) for key in keys) + "\n").encode() + found = ctypes.c_uint32(0) + missing = ctypes.c_uint32(0) + bytes_read = ctypes.c_size_t(0) + rc = self.lib.turbodb_get_many_keys( + self.collection(collection), body, len(body), + ctypes.byref(found), ctypes.byref(missing), ctypes.byref(bytes_read), + ) + if rc != 0: + raise RuntimeError(f"FFI batch get failed for {collection}") + return int(found.value), int(missing.value), int(bytes_read.value) + + def point_get(self, samples: list[int]) -> dict[str, Any]: + return timed_loop(samples, lambda uid: self.get_key("bench_users", uid), {"mode": "embedded_c_abi"}) + + def order_lookup(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + self.get_key("bench_user_orders", uid) + self.batch_get_keys("bench_orders", self.shape.order_ids_for_user(uid)) + + return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user, "mode": "embedded_c_abi_batch_get"}) + + def join_like(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + self.get_key("bench_users", uid) + self.get_key("bench_user_orders", uid) + self.batch_get_keys("bench_orders", self.shape.order_ids_for_user(uid)) + + return timed_loop(samples, op, {"mode": "embedded_c_abi_materialized_edge_batch_get"}) + + def update_orders(self, samples: list[int]) -> dict[str, Any]: + def op(oid: int) -> None: + key_b = str(oid).encode() + value = order_doc(oid, ((oid - 1) // self.shape.orders_per_user) + 1) + value["status"] = "updated" + value_b = json.dumps(value, separators=(",", ":")).encode() + rc = self.lib.turbodb_update(self.collection("bench_orders"), key_b, len(key_b), value_b, len(value_b)) + if rc != 0: + raise RuntimeError(f"FFI update failed for order {oid}") + + return timed_loop(samples, op, {"mode": "embedded_c_abi"}) + + def delete_orders(self, samples: list[int]) -> dict[str, Any]: + def op(oid: int) -> None: + key_b = str(oid).encode() + rc = self.lib.turbodb_delete(self.collection("bench_orders"), key_b, len(key_b)) + if rc != 0: + raise RuntimeError(f"FFI delete failed for order {oid}") + + return timed_loop(samples, op, {"mode": "embedded_c_abi"}) + + class PostgresBench: name = "postgresql18" @@ -583,6 +783,8 @@ def main() -> int: ap.add_argument("--output", default="/work/benchmark-results/container-shape-bench.json") ap.add_argument("--turbodb-host") ap.add_argument("--turbodb-port", type=int, default=27017) + ap.add_argument("--turbodb-ffi-lib") + ap.add_argument("--turbodb-ffi-dir", default="/tmp/turbodb_ffi_shape_bench") ap.add_argument("--postgres-host") ap.add_argument("--mysql-host") ap.add_argument("--tigerbeetle-host") @@ -610,6 +812,8 @@ def main() -> int: engines: list[Any] = [] if args.turbodb_host: engines.append(TurboDBBench(args.turbodb_host, args.turbodb_port, shape, args.batch_size)) + if args.turbodb_ffi_lib: + engines.append(TurboDBFFIBench(args.turbodb_ffi_lib, args.turbodb_ffi_dir, shape, args.batch_size)) if args.postgres_host: engines.append(PostgresBench(args.postgres_host, shape, args.batch_size)) if args.mysql_host: @@ -624,6 +828,10 @@ def main() -> int: except Exception as exc: results["errors"][engine.name] = repr(exc) print(f"{engine.name} failed: {exc}", file=sys.stderr) + finally: + close = getattr(engine, "close", None) + if callable(close): + close() print_summary(results) diff --git a/bench/run_apple_container_bench.py b/bench/run_apple_container_bench.py index f6361db..4f1ad86 100644 --- a/bench/run_apple_container_bench.py +++ b/bench/run_apple_container_bench.py @@ -152,8 +152,12 @@ def logs_tail(name: str, lines: int = 120) -> str: return (proc.stdout or "").strip() -def build_turbodb(target: str) -> None: - run(["zig", "build", f"-Dtarget={target}"]) +def build_turbodb(target: str, prefix: str | None = None) -> None: + argv = ["zig", "build"] + if prefix: + argv.extend(["--prefix", prefix]) + argv.append(f"-Dtarget={target}") + run(argv) @dataclass @@ -188,6 +192,9 @@ def start(self) -> dict[str, Service]: if not self.args.skip_turbodb: build_turbodb(self.args.turbodb_target) + if not self.args.skip_turbodb_ffi: + build_turbodb(self.args.turbodb_ffi_target, self.args.turbodb_ffi_prefix) + if not self.args.skip_turbodb: services["turbodb"] = self.start_turbodb() if not self.args.skip_postgres: services["postgres"] = self.start_postgres() @@ -325,6 +332,11 @@ def run_client(self, services: dict[str, Service]) -> int: if (svc := services.get("turbodb")) and not svc.error: args.extend(["--turbodb-host", svc.host]) + if not self.args.skip_turbodb_ffi: + args.extend([ + "--turbodb-ffi-lib", self.args.turbodb_ffi_lib, + "--turbodb-ffi-dir", self.args.turbodb_ffi_dir, + ]) if (svc := services.get("postgres")) and not svc.error: deps.append("psycopg[binary]") args.extend(["--postgres-host", svc.host]) @@ -336,7 +348,8 @@ def run_client(self, services: dict[str, Service]) -> int: deps.append("tigerbeetle") args.extend(["--tigerbeetle-host", svc.host]) - if not any(services.get(engine) and not services[engine].error for engine in services): + has_service_engine = any(services.get(engine) and not services[engine].error for engine in services) + if not has_service_engine and self.args.skip_turbodb_ffi: raise RuntimeError("no benchmark services started successfully") client_name = self.name("client") @@ -378,12 +391,17 @@ def parse_args() -> argparse.Namespace: ap.add_argument("--run-id") ap.add_argument("--network") ap.add_argument("--turbodb-target", default="aarch64-linux-musl") + ap.add_argument("--turbodb-ffi-target", default="aarch64-linux-gnu") + ap.add_argument("--turbodb-ffi-prefix", default="zig-out-ffi") + ap.add_argument("--turbodb-ffi-lib", default="/work/zig-out-ffi/lib/libturbodb.so") + ap.add_argument("--turbodb-ffi-dir", default="/tmp/turbodb_ffi_shape_bench") ap.add_argument("--python-image", default="python:3.12-slim") ap.add_argument("--postgres-image", default="postgres:18") ap.add_argument("--mysql-image", default="mysql:8.4") ap.add_argument("--tigerbeetle-image", default="ghcr.io/tigerbeetle/tigerbeetle:latest") ap.add_argument("--tigerbeetle-memory", default="2G") ap.add_argument("--skip-turbodb", action="store_true") + ap.add_argument("--skip-turbodb-ffi", action="store_true") ap.add_argument("--skip-postgres", action="store_true") ap.add_argument("--skip-mysql", action="store_true") ap.add_argument("--skip-tigerbeetle", action="store_true") diff --git a/src/ffi.zig b/src/ffi.zig index 8252705..25afea9 100644 --- a/src/ffi.zig +++ b/src/ffi.zig @@ -160,6 +160,44 @@ export fn turbodb_insert( return 0; } +/// Bulk insert NDJSON lines shaped as {"key":"...","value":...}. +/// Returns 0 if the request was processed; per-row failures are counted. +export fn turbodb_insert_bulk_ndjson( + col_handle: *anyopaque, + body: [*]const u8, + body_len: usize, + out_inserted: *u32, + out_errors: *u32, +) c_int { + const col = validateColHandle(col_handle) orelse return -1; + var inserted: u32 = 0; + var errors: u32 = 0; + const input = body[0..body_len]; + + var pos: usize = 0; + while (pos < input.len) { + const line_end = std.mem.indexOfScalarPos(u8, input, pos, '\n') orelse input.len; + const line = std.mem.trim(u8, input[pos..line_end], " \t\r"); + pos = if (line_end < input.len) line_end + 1 else line_end; + if (line.len == 0) continue; + + const key = jsonStr(line, "key") orelse { + errors += 1; + continue; + }; + const value = jsonValue(line, "value") orelse line; + _ = col.insert(key, value) catch { + errors += 1; + continue; + }; + inserted += 1; + } + + out_inserted.* = inserted; + out_errors.* = errors; + return 0; +} + /// Insert with a pre-computed embedding (no JSON parsing). Fast path for vector inserts. export fn turbodb_insert_with_embedding( col_handle: *anyopaque, @@ -189,6 +227,38 @@ export fn turbodb_get( return 0; } +/// Read many newline-delimited or JSON-array keys in one FFI call. +/// The output reports found/missing counts and total key+value bytes read. +export fn turbodb_get_many_keys( + col_handle: *anyopaque, + keys_body: [*]const u8, + keys_len: usize, + out_found: *u32, + out_missing: *u32, + out_bytes: *usize, +) c_int { + const col = validateColHandle(col_handle) orelse return -1; + var iter = KeyIter.init(keys_body[0..keys_len]); + var found: u32 = 0; + var missing: u32 = 0; + var bytes_read: usize = 0; + + while (iter.next()) |key| { + if (key.len == 0) continue; + if (col.get(key)) |d| { + found += 1; + bytes_read += d.key.len + d.value.len; + } else { + missing += 1; + } + } + + out_found.* = found; + out_missing.* = missing; + out_bytes.* = bytes_read; + return 0; +} + /// Update a document by key. Returns 0 on success, -1 if not found. export fn turbodb_update( col_handle: *anyopaque, @@ -270,6 +340,139 @@ fn docToResult(d: Doc) TurboDocResult { }; } +const KeyIter = struct { + body: []const u8, + pos: usize = 0, + array_mode: bool = false, + + fn init(body: []const u8) KeyIter { + const trimmed = std.mem.trim(u8, body, " \t\r\n"); + if (trimmed.len == 0) return .{ .body = trimmed }; + if (trimmed[0] == '{') { + if (jsonValue(trimmed, "keys")) |keys| { + if (keys.len > 0 and keys[0] == '[') { + return .{ .body = keys, .pos = 1, .array_mode = true }; + } + } + } + if (trimmed[0] == '[') return .{ .body = trimmed, .pos = 1, .array_mode = true }; + return .{ .body = trimmed }; + } + + fn next(self: *KeyIter) ?[]const u8 { + if (self.array_mode) return self.nextArray(); + return self.nextLine(); + } + + fn nextLine(self: *KeyIter) ?[]const u8 { + while (self.pos < self.body.len) { + const line_end = std.mem.indexOfScalarPos(u8, self.body, self.pos, '\n') orelse self.body.len; + const raw_line = std.mem.trim(u8, self.body[self.pos..line_end], " \t\r"); + self.pos = if (line_end < self.body.len) line_end + 1 else line_end; + if (raw_line.len == 0) continue; + if (raw_line[0] == '{') return jsonStr(raw_line, "key") orelse continue; + if (raw_line.len >= 2 and raw_line[0] == '"' and raw_line[raw_line.len - 1] == '"') + return raw_line[1 .. raw_line.len - 1]; + return raw_line; + } + return null; + } + + fn nextArray(self: *KeyIter) ?[]const u8 { + while (self.pos < self.body.len) { + while (self.pos < self.body.len and + (self.body[self.pos] == ' ' or self.body[self.pos] == '\t' or + self.body[self.pos] == '\r' or self.body[self.pos] == '\n' or + self.body[self.pos] == ',')) : (self.pos += 1) {} + if (self.pos >= self.body.len or self.body[self.pos] == ']') return null; + + if (self.body[self.pos] == '"') { + const start = self.pos + 1; + var i = start; + while (i < self.body.len) : (i += 1) { + if (self.body[i] == '\\' and i + 1 < self.body.len) { + i += 1; + continue; + } + if (self.body[i] == '"') { + self.pos = i + 1; + return self.body[start..i]; + } + } + self.pos = self.body.len; + return null; + } + + const start = self.pos; + while (self.pos < self.body.len and self.body[self.pos] != ',' and self.body[self.pos] != ']') : (self.pos += 1) {} + const raw_key = std.mem.trim(u8, self.body[start..self.pos], " \t\r\n"); + if (raw_key.len == 0) continue; + return raw_key; + } + return null; + } +}; + +fn jsonStr(json: []const u8, key: []const u8) ?[]const u8 { + var kbuf: [64]u8 = undefined; + const needle = std.fmt.bufPrint(&kbuf, "\"{s}\":", .{key}) catch return null; + const pos = std.mem.indexOf(u8, json, needle) orelse return null; + var start = pos + needle.len; + while (start < json.len and (json[start] == ' ' or json[start] == '\t')) start += 1; + if (start >= json.len or json[start] != '"') return null; + start += 1; + const end = std.mem.indexOfScalarPos(u8, json, start, '"') orelse return null; + return json[start..end]; +} + +fn jsonValue(json: []const u8, key: []const u8) ?[]const u8 { + var kbuf: [64]u8 = undefined; + const needle = std.fmt.bufPrint(&kbuf, "\"{s}\":", .{key}) catch return null; + const pos = std.mem.indexOf(u8, json, needle) orelse return null; + var start = pos + needle.len; + while (start < json.len and (json[start] == ' ' or json[start] == '\t')) start += 1; + if (start >= json.len) return null; + + const ch = json[start]; + if (ch == '"') { + var i = start + 1; + while (i < json.len) : (i += 1) { + if (json[i] == '\\' and i + 1 < json.len) { + i += 1; + continue; + } + if (json[i] == '"') return json[start .. i + 1]; + } + return null; + } else if (ch == '{' or ch == '[') { + const close: u8 = if (ch == '{') '}' else ']'; + var depth: u32 = 1; + var i = start + 1; + var in_str = false; + while (i < json.len and depth > 0) : (i += 1) { + if (json[i] == '\\' and in_str) { + i += 1; + continue; + } + if (json[i] == '"') { + in_str = !in_str; + continue; + } + if (in_str) continue; + if (json[i] == ch) depth += 1; + if (json[i] == close) { + depth -= 1; + if (depth == 0) return json[start .. i + 1]; + } + } + return null; + } else { + var end = start; + while (end < json.len and json[end] != ',' and json[end] != '}' and json[end] != '\n' and json[end] != '\r') : (end += 1) {} + return std.mem.trim(u8, json[start..end], " \t"); + } +} + // ── Search (trigram-indexed) ───────────────────────────────────────────────── /// Search documents by substring using trigram index. From f377aca832b888be8587c4edb5015db91ca54f7b Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:35:54 +0800 Subject: [PATCH 19/27] feat: batch http writes and joins --- bench/README_apple_container_matrix.md | 17 +- bench/container_shape_bench.py | 69 +++- src/server.zig | 482 ++++++++++++++++++------- 3 files changed, 428 insertions(+), 140 deletions(-) diff --git a/bench/README_apple_container_matrix.md b/bench/README_apple_container_matrix.md index 89bbea9..2f5ff25 100644 --- a/bench/README_apple_container_matrix.md +++ b/bench/README_apple_container_matrix.md @@ -14,8 +14,11 @@ another container, and does not publish database ports to macOS. - `orders`: keyed order records with `user_id`, amount, status, and JSON payload. - TurboDB stores `bench_users`, `bench_orders`, and a materialized `bench_user_orders` edge collection. The benchmark client uses persistent - HTTP plus `POST /db/:col/batch_get` for relationship fetches, so it does not - inflate TurboDB latency with one TCP/HTTP setup per document. + HTTP plus `POST /db/:col/batch_get?mode=count` for relationship fetches, so + lookup-heavy workloads do not pay to serialize document bodies they do not + consume. For the join-like, update, and delete workloads it assumes the + faster HTTP shapes exist and uses `POST /db/:edge_col/join?mode=count`, + `POST /db/:col/batch_update`, and `POST /db/:col/batch_delete`. - `turbodb_ffi` loads `/work/zig-out-ffi/lib/libturbodb.so` with Python `ctypes`. This is an embedded/raw-engine path, not a network database path, and exists to show the cost of HTTP/client transport separately from storage operations. @@ -29,12 +32,18 @@ another container, and does not publish database ports to macOS. - `ingest`: create all users and orders. - `point_get`: read users by primary key. -- `relationship_lookup`: fetch all orders/transfers for a user. +- `relationship_lookup`: fetch all orders/transfers for a user. TurboDB HTTP + keeps this as a materialized edge read plus compact `batch_get`. - `join_or_join_like`: SQL join for PostgreSQL/MySQL, materialized edge fetches - for TurboDB, account transfer query for TigerBeetle. + for TurboDB FFI, compact `POST /db/:edge_col/join` for TurboDB HTTP, account + transfer query for TigerBeetle. - `update_orders`: update order status where the engine supports mutation. - `delete_orders`: delete orders where the engine supports deletion. +TurboDB HTTP update/delete latency is reported as amortized row latency when +the benchmark uses the batch endpoints; throughput remains row operations per +second. + ## Run Small smoke run: diff --git a/bench/container_shape_bench.py b/bench/container_shape_bench.py index 3eddbd9..2bb2096 100644 --- a/bench/container_shape_bench.py +++ b/bench/container_shape_bench.py @@ -69,6 +69,18 @@ def timed_loop(items: list[Any], fn: Callable[[Any], Any], extra: dict[str, Any] return metric(now() - start, len(items), latencies, extra) +def timed_batch_loop(batches: list[list[Any]], fn: Callable[[list[Any]], Any], extra: dict[str, Any] | None = None) -> dict[str, Any]: + latencies: list[float] = [] + ops = sum(len(batch) for batch in batches) + start = now() + for batch in batches: + op_start = now() + fn(batch) + batch_elapsed_ms = (now() - op_start) * 1000.0 + latencies.extend([batch_elapsed_ms / len(batch)] * len(batch)) + return metric(now() - start, ops, latencies, extra) + + def user_doc(user_id: int) -> dict[str, Any]: return { "id": user_id, @@ -123,8 +135,8 @@ def __init__(self, host: str, port: int, shape: Shape, batch_size: int): self.batch_size = batch_size self.conn = http.client.HTTPConnection(host, port, timeout=10) - def request(self, method: str, path: str, body: bytes | None = None) -> bytes: - headers = {"content-type": "application/json"} + def request(self, method: str, path: str, body: bytes | None = None, content_type: str = "application/json") -> bytes: + headers = {"content-type": content_type} try: self.conn.request(method, path, body=body, headers=headers) resp = self.conn.getresponse() @@ -165,6 +177,20 @@ def bulk(self, collection: str, rows: list[tuple[str, dict[str, Any]]]) -> None: lines.append(json.dumps({"key": key, "value": value}, separators=(",", ":"))) self.request("POST", f"/db/{collection}/bulk", ("\n".join(lines) + "\n").encode()) + def batches(self, values: list[Any]) -> list[list[Any]]: + batch_size = max(self.batch_size, 1) + return [values[i:i + batch_size] for i in range(0, len(values), batch_size)] + + def batch_update(self, collection: str, rows: list[tuple[str, dict[str, Any]]]) -> None: + lines = [] + for key, value in rows: + lines.append(json.dumps({"key": key, "value": value}, separators=(",", ":"))) + self.request("POST", f"/db/{collection}/batch_update", ("\n".join(lines) + "\n").encode(), "application/x-ndjson") + + def batch_delete(self, collection: str, keys: list[int]) -> None: + body = ("\n".join(str(key) for key in keys) + "\n").encode() + self.request("POST", f"/db/{collection}/batch_delete", body, "text/plain") + def ingest(self) -> dict[str, Any]: self.drop() @@ -208,35 +234,48 @@ def batch_get_docs(self, collection: str, keys: list[int]) -> list[dict[str, Any body = json.dumps({"keys": [str(key) for key in keys]}, separators=(",", ":")).encode() return json.loads(self.request("POST", f"/db/{collection}/batch_get", body).decode())["docs"] + def batch_get_count(self, collection: str, keys: list[int]) -> None: + body = json.dumps({"keys": [str(key) for key in keys]}, separators=(",", ":")).encode() + self.request("POST", f"/db/{collection}/batch_get?mode=count", body) + def point_get(self, samples: list[int]) -> dict[str, Any]: return timed_loop(samples, lambda uid: self.get_doc("bench_users", uid)) def order_lookup(self, samples: list[int]) -> dict[str, Any]: def op(uid: int) -> None: edge = self.get_doc("bench_user_orders", uid)["value"] - self.batch_get_docs("bench_orders", edge["order_ids"]) + self.batch_get_count("bench_orders", edge["order_ids"]) - return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user, "mode": "materialized_edge_batch_get"}) + return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user, "mode": "materialized_edge_batch_get_count"}) def join_like(self, samples: list[int]) -> dict[str, Any]: def op(uid: int) -> None: - user = self.get_doc("bench_users", uid)["value"] - edge = self.get_doc("bench_user_orders", uid)["value"] - orders = self.batch_get_docs("bench_orders", edge["order_ids"]) - _ = (user["email"], len(orders)) + body = json.dumps({ + "key": str(uid), + "target_collection": "bench_orders", + "field": "order_ids", + }, separators=(",", ":")).encode() + self.request("POST", "/db/bench_user_orders/join?mode=count", body) - return timed_loop(samples, op, {"mode": "materialized_edge_batch_get"}) + return timed_loop(samples, op, {"mode": "server_join_edge_field_count"}) def update_orders(self, samples: list[int]) -> dict[str, Any]: - def op(oid: int) -> None: - value = order_doc(oid, ((oid - 1) // self.shape.orders_per_user) + 1) - value["status"] = "updated" - self.request("PUT", f"/db/bench_orders/{oid}", json.dumps(value).encode()) + def op(batch: list[int]) -> None: + rows = [] + for oid in batch: + value = order_doc(oid, ((oid - 1) // self.shape.orders_per_user) + 1) + value["status"] = "updated" + rows.append((str(oid), value)) + self.batch_update("bench_orders", rows) - return timed_loop(samples, op) + return timed_batch_loop(self.batches(samples), op, {"mode": "http_ndjson_batch_update", "latency_unit": "amortized_row"}) def delete_orders(self, samples: list[int]) -> dict[str, Any]: - return timed_loop(samples, lambda oid: self.request("DELETE", f"/db/bench_orders/{oid}")) + return timed_batch_loop( + self.batches(samples), + lambda keys: self.batch_delete("bench_orders", keys), + {"mode": "http_newline_batch_delete", "latency_unit": "amortized_row"}, + ) class TurboDocResult(ctypes.Structure): diff --git a/src/server.zig b/src/server.zig index 603a1ac..7123d2c 100644 --- a/src/server.zig +++ b/src/server.zig @@ -2,6 +2,9 @@ /// Routes: /// POST /db/:col insert document /// POST /db/:col/batch_get get multiple documents by key +/// POST /db/:col/batch_update upsert multiple documents by key +/// POST /db/:col/batch_delete delete multiple documents by key +/// POST /db/:edge_col/join read edge keys and batch-get target docs /// GET /db/:col/:key get document by key /// PUT /db/:col/:key upsert document /// DELETE /db/:col/:key delete document @@ -20,15 +23,15 @@ const doc_mod = @import("doc.zig"); const page_mod = @import("page.zig"); const Database = collection.Database; -const MAX_REQ = 65536; // 64 KiB (initial read) +const MAX_REQ = 65536; // 64 KiB (initial read) const MAX_RESP = 131072; // 128 KiB -const MAX_BODY = 65536; // 64 KiB +const MAX_BODY = 65536; // 64 KiB const MAX_BULK = 16 * 1024 * 1024; // 16 MiB for bulk inserts // Heap-allocated per-connection buffers (threadlocal pointers set in handleConn). // This avoids large threadlocal TLS segments that break in Release mode on macOS. const ConnBufs = struct { - req: [MAX_REQ]u8, + req: [MAX_REQ]u8, resp: [MAX_RESP]u8, body: [MAX_BODY]u8, }; @@ -36,8 +39,12 @@ const ConnBufs = struct { const CONN_THREAD_STACK_SIZE = 1024 * 1024; threadlocal var tl_bufs: ?*ConnBufs = null; -fn getRespBuf() *[MAX_RESP]u8 { return &tl_bufs.?.resp; } -fn getBodyBuf() *[MAX_BODY]u8 { return &tl_bufs.?.body; } +fn getRespBuf() *[MAX_RESP]u8 { + return &tl_bufs.?.resp; +} +fn getBodyBuf() *[MAX_BODY]u8 { + return &tl_bufs.?.body; +} pub const Server = struct { pub const QueryCost = struct { @@ -69,7 +76,7 @@ pub const Server = struct { query_cost_nanos_usd: std.atomic.Value(u64), // Ring buffer for billing log — avoids O(n) orderedRemove(0). billing_ring: [BILLING_LOG_CAP]QueryCost, - billing_ring_head: usize, // next write position + billing_ring_head: usize, // next write position billing_ring_count: usize, // entries currently stored billing_mu: compat.Mutex, activity: activity.ActivityTracker, @@ -78,10 +85,10 @@ pub const Server = struct { pub fn init(alloc: std.mem.Allocator, db: *Database, port: u16) Server { return .{ - .db = db, - .port = port, + .db = db, + .port = port, .running = std.atomic.Value(bool).init(false), - .alloc = alloc, + .alloc = alloc, .req_count = std.atomic.Value(u64).init(0), .err_count = std.atomic.Value(u64).init(0), .query_count = std.atomic.Value(u64).init(0), @@ -124,7 +131,7 @@ pub const Server = struct { _ = self.err_count.fetchAdd(1, .monotonic); continue; } - const t = std.Thread.spawn(.{ .stack_size = CONN_THREAD_STACK_SIZE }, handleConnWrapped, .{self, conn}) catch { + const t = std.Thread.spawn(.{ .stack_size = CONN_THREAD_STACK_SIZE }, handleConnWrapped, .{ self, conn }) catch { conn.stream.close(); _ = self.err_count.fetchAdd(1, .monotonic); continue; @@ -252,9 +259,7 @@ fn handleConn(srv: *Server, conn: compat.net.Server.Connection) void { // contain part of a large body. const content_length = extractContentLength(initial); if (content_length > 0) { - const header_end = if (std.mem.indexOf(u8, initial, "\r\n\r\n")) |p| p + 4 - else if (std.mem.indexOf(u8, initial, "\n\n")) |p| p + 2 - else n; + const header_end = if (std.mem.indexOf(u8, initial, "\r\n\r\n")) |p| p + 4 else if (std.mem.indexOf(u8, initial, "\n\n")) |p| p + 2 else n; const total_size = header_end + content_length; if (total_size > MAX_BULK) { const resp_len = err(413, "request too large"); @@ -312,9 +317,7 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { } // Body. - const body = if (std.mem.indexOf(u8, raw, "\r\n\r\n")) |p| raw[p + 4 ..] - else if (std.mem.indexOf(u8, raw, "\n\n")) |p| raw[p + 2 ..] - else @as([]const u8, ""); + const body = if (std.mem.indexOf(u8, raw, "\r\n\r\n")) |p| raw[p + 4 ..] else if (std.mem.indexOf(u8, raw, "\n\n")) |p| raw[p + 2 ..] else @as([]const u8, ""); // Route: /health if (std.mem.eql(u8, path, "/health")) { @@ -326,17 +329,15 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { // Route: /metrics if (std.mem.eql(u8, path, "/metrics")) { var fbs = compat.fixedBufferStream(getBodyBuf()); - compat.format(fbs.writer(), - "{{\"requests\":{d},\"errors\":{d},\"queries\":{d},\"rows_scanned\":{d},\"bytes_read\":{d},\"cpu_us\":{d},\"cost_nanos_usd\":{d}}}", - .{ - srv.req_count.load(.acquire), - srv.err_count.load(.acquire), - srv.query_count.load(.acquire), - srv.query_rows_scanned.load(.acquire), - srv.query_bytes_read.load(.acquire), - srv.query_cpu_us.load(.acquire), - srv.query_cost_nanos_usd.load(.acquire), - }) catch {}; + compat.format(fbs.writer(), "{{\"requests\":{d},\"errors\":{d},\"queries\":{d},\"rows_scanned\":{d},\"bytes_read\":{d},\"cpu_us\":{d},\"cost_nanos_usd\":{d}}}", .{ + srv.req_count.load(.acquire), + srv.err_count.load(.acquire), + srv.query_count.load(.acquire), + srv.query_rows_scanned.load(.acquire), + srv.query_bytes_read.load(.acquire), + srv.query_cpu_us.load(.acquire), + srv.query_cost_nanos_usd.load(.acquire), + }) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } @@ -455,22 +456,31 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { const tenant_id = requestTenant(raw, query, auth_ctx_ptr); // POST /db/:col/batch_get — read-only bulk point lookup. if (std.mem.eql(u8, key, "batch_get") and std.mem.eql(u8, method, "POST")) - return handleBatchGet(srv, tenant_id, col_name, body, requestAsOf(raw, query)); + return handleBatchGet(srv, tenant_id, col_name, body, requestAsOf(raw, query), batchDocsCompact(query)); + // POST /db/:edge_col/join — read-only edge doc lookup + target batch lookup. + if (std.mem.eql(u8, key, "join") and std.mem.eql(u8, method, "POST")) + return handleJoin(srv, tenant_id, col_name, body, batchDocsCompact(query)); if (isBasicDbWriteMethod(method) and !canWriteDb(auth_ctx_ptr)) return err(403, "forbidden: API key is read-only"); + // POST /db/:col/batch_update — write-gated bulk upsert. + if (std.mem.eql(u8, key, "batch_update") and std.mem.eql(u8, method, "POST")) + return handleBatchUpdate(srv, tenant_id, col_name, body, alloc); + // POST /db/:col/batch_delete — write-gated bulk delete. + if (std.mem.eql(u8, key, "batch_delete") and std.mem.eql(u8, method, "POST")) + return handleBatchDelete(srv, tenant_id, col_name, body); // POST /db/:col/bulk — bulk insert if (std.mem.eql(u8, key, "bulk") and std.mem.eql(u8, method, "POST")) return handleBulkInsert(srv, tenant_id, col_name, body, alloc); - if (std.mem.eql(u8, method, "GET")) return handleGet(srv, tenant_id, col_name, key, requestAsOf(raw, query)); - if (std.mem.eql(u8, method, "PUT")) return handleUpdate(srv, tenant_id, col_name, key, body, alloc); + if (std.mem.eql(u8, method, "GET")) return handleGet(srv, tenant_id, col_name, key, requestAsOf(raw, query)); + if (std.mem.eql(u8, method, "PUT")) return handleUpdate(srv, tenant_id, col_name, key, body, alloc); if (std.mem.eql(u8, method, "DELETE")) return handleDelete(srv, tenant_id, col_name, key); } else { const col_name = rest; const tenant_id = requestTenant(raw, query, auth_ctx_ptr); if (isBasicDbWriteMethod(method) and !canWriteDb(auth_ctx_ptr)) return err(403, "forbidden: API key is read-only"); - if (std.mem.eql(u8, method, "POST")) return handleInsert(srv, tenant_id, col_name, body, alloc); - if (std.mem.eql(u8, method, "GET")) return handleScan(srv, tenant_id, col_name, query, requestAsOf(raw, query), alloc); + if (std.mem.eql(u8, method, "POST")) return handleInsert(srv, tenant_id, col_name, body, alloc); + if (std.mem.eql(u8, method, "GET")) return handleScan(srv, tenant_id, col_name, query, requestAsOf(raw, query), alloc); if (std.mem.eql(u8, method, "DELETE")) return handleDrop(srv, tenant_id, col_name); } } @@ -505,9 +515,7 @@ fn doInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []co const doc_id = col.insert(key, value) catch return err(500, "insert failed"); srv.recordQueryCost(tenant_id, "insert", 1, value.len, start_ns); var fbs = compat.fixedBufferStream(getBodyBuf()); - compat.format(fbs.writer(), - "{{\"doc_id\":{d},\"key\":\"{s}\",\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", - .{ doc_id, key, col_name, tenant_id }) catch {}; + compat.format(fbs.writer(), "{{\"doc_id\":{d},\"key\":\"{s}\",\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", .{ doc_id, key, col_name, tenant_id }) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } @@ -551,30 +559,146 @@ fn handleBulkInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, b srv.recordQueryCost(tenant_id, "bulk_insert", inserted, total_bytes, start_ns); var fbs = compat.fixedBufferStream(getBodyBuf()); - compat.format(fbs.writer(), - "{{\"inserted\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", - .{ inserted, errors, col_name, tenant_id }) catch {}; + compat.format(fbs.writer(), "{{\"inserted\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", .{ inserted, errors, col_name, tenant_id }) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } /// POST /db/:col/batch_get — read multiple documents in one request. /// Body can be a JSON string array, {"keys":[...]}, or one key per line. -fn handleBatchGet(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, as_of: ?i64) usize { +fn handleBatchGet(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, as_of: ?i64, compact: bool) usize { + const start_ns = compat.nanoTimestamp(); + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + var iter = KeyIter.init(body); + return writeBatchDocsResponse(srv, tenant_id, col_name, col, &iter, as_of, "batch_get", start_ns, 0, compact); +} + +/// POST /db/:col/batch_update — upsert multiple documents in one request. +/// Body: NDJSON — one {"key":"...","value":...} per line. +fn handleBatchUpdate(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, alloc: std.mem.Allocator) usize { + _ = alloc; + const start_ns = compat.nanoTimestamp(); + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + srv.db.ensureTenantStorageAvailable(tenant_id, body.len) catch return err(429, "tenant storage quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + + var iter = BatchUpdateIter.init(body); + var updated: u32 = 0; + var inserted: u32 = 0; + var errors: u32 = 0; + var total_bytes: u64 = 0; + + while (iter.next()) |entry| { + if (!entry.valid) { + errors += 1; + continue; + } + if (!documentFitsLeaf(entry.key, entry.value)) { + errors += 1; + continue; + } + + const did_update = col.update(entry.key, entry.value) catch { + errors += 1; + continue; + }; + if (did_update) { + updated += 1; + } else { + _ = col.insert(entry.key, entry.value) catch { + errors += 1; + continue; + }; + inserted += 1; + } + total_bytes += entry.line_len; + } + + srv.recordQueryCost(tenant_id, "batch_update", updated + inserted + errors, total_bytes, start_ns); + + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"updated\":{d},\"inserted\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", .{ updated, inserted, errors, col_name, tenant_id }) catch {}; + return ok(getBodyBuf()[0..fbs.pos]); +} + +/// POST /db/:col/batch_delete — delete multiple documents in one request. +/// Body can be a JSON string array, {"keys":[...]}, or one key per line. +fn handleBatchDelete(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8) usize { const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + var iter = KeyIter.init(body); + var deleted: u32 = 0; + var missing: u32 = 0; + var errors: u32 = 0; + while (iter.next()) |key| { + if (key.len == 0) continue; + const did_delete = col.delete(key) catch { + errors += 1; + continue; + }; + if (did_delete) deleted += 1 else missing += 1; + } + + srv.recordQueryCost(tenant_id, "batch_delete", deleted + missing + errors, 0, start_ns); + + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"deleted\":{d},\"missing\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", .{ deleted, missing, errors, col_name, tenant_id }) catch {}; + return ok(getBodyBuf()[0..fbs.pos]); +} + +/// POST /db/:edge_col/join — read edge.value[field] as target keys and batch-get targets. +fn handleJoin(srv: *Server, tenant_id: []const u8, edge_col_name: []const u8, body: []const u8, compact: bool) usize { + const req = JoinRequest.parse(body) orelse return err(400, "missing key, target_collection, or field"); + const start_ns = compat.nanoTimestamp(); + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + const edge_col = srv.db.collectionForTenant(tenant_id, edge_col_name) catch return err(500, "open collection failed"); + const target_col = srv.db.collectionForTenant(tenant_id, req.target_collection) catch return err(500, "open collection failed"); + + const edge = edge_col.get(req.key) orelse return err(404, "not found"); + const raw_keys = jsonValue(edge.value, req.field) orelse return err(400, "missing join field"); + const keys = std.mem.trim(u8, raw_keys, " \t\r\n"); + if (keys.len == 0 or keys[0] != '[') return err(400, "join field must be an array"); + + var iter = KeyIter.init(keys); + return writeBatchDocsResponse( + srv, + tenant_id, + req.target_collection, + target_col, + &iter, + null, + "join", + start_ns, + edge.key.len + edge.value.len, + compact, + ); +} + +fn writeBatchDocsResponse( + srv: *Server, + tenant_id: []const u8, + col_name: []const u8, + col: *collection.Collection, + iter: *KeyIter, + as_of: ?i64, + op_name: []const u8, + start_ns: i128, + initial_bytes_read: usize, + compact: bool, +) usize { const HEADER_RESERVE = 256; var resp = getRespBuf(); var fbs = compat.fixedBufferStream(resp[HEADER_RESERVE..]); const w = fbs.writer(); - compat.format(w, "{{\"tenant\":\"{s}\",\"collection\":\"{s}\",\"docs\":[", - .{ tenant_id, col_name }) catch return err(500, "response too large"); + if (!compact) { + compat.format(w, "{{\"tenant\":\"{s}\",\"collection\":\"{s}\",\"docs\":[", .{ tenant_id, col_name }) catch return err(500, "response too large"); + } - var iter = KeyIter.init(body); var found: usize = 0; var missing: usize = 0; - var bytes_read: usize = 0; + var bytes_read: usize = initial_bytes_read; var truncated = false; while (iter.next()) |key| { if (key.len == 0) continue; @@ -589,29 +713,32 @@ fn handleBatchGet(srv: *Server, tenant_id: []const u8, col_name: []const u8, bod continue; }); - const val = if (d.value.len > 0) d.value else "{}"; - const is_json = isJsonValue(val); - const next_len = (if (found > 0) @as(usize, 1) else 0) + docJsonLen(d, val, is_json); - if (fbs.pos + next_len + 80 >= resp[HEADER_RESERVE..].len) { - truncated = true; - break; + if (!compact) { + const val = if (d.value.len > 0) d.value else "{}"; + const is_json = isJsonValue(val); + const next_len = (if (found > 0) @as(usize, 1) else 0) + docJsonLen(d, val, is_json); + if (fbs.pos + next_len + 80 >= resp[HEADER_RESERVE..].len) { + truncated = true; + break; + } + if (found > 0) w.writeByte(',') catch return err(500, "response too large"); + writeDocJson(w, d, val, is_json) catch return err(500, "response too large"); } - if (found > 0) w.writeByte(',') catch return err(500, "response too large"); - writeDocJson(w, d, val, is_json) catch return err(500, "response too large"); found += 1; bytes_read += d.key.len + d.value.len; } - compat.format(w, "],\"count\":{d},\"missing\":{d},\"truncated\":{}}}", - .{ found, missing, truncated }) catch return err(500, "response too large"); + if (compact) { + compat.format(w, "{{\"tenant\":\"{s}\",\"collection\":\"{s}\",\"count\":{d},\"missing\":{d},\"bytes_read\":{d},\"truncated\":{},\"mode\":\"count\"}}", .{ tenant_id, col_name, found, missing, bytes_read, truncated }) catch return err(500, "response too large"); + } else { + compat.format(w, "],\"count\":{d},\"missing\":{d},\"truncated\":{}}}", .{ found, missing, truncated }) catch return err(500, "response too large"); + } const body_len = fbs.pos; - srv.recordQueryCost(tenant_id, "batch_get", found + missing, bytes_read, start_ns); + srv.recordQueryCost(tenant_id, op_name, found + missing, bytes_read, start_ns); var hdr_fbs = compat.fixedBufferStream(resp[0..HEADER_RESERVE]); - compat.format(hdr_fbs.writer(), - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", - .{body_len}) catch {}; + compat.format(hdr_fbs.writer(), "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", .{body_len}) catch {}; const hdr_len = hdr_fbs.pos; if (hdr_len < HEADER_RESERVE) { std.mem.copyForwards(u8, resp[hdr_len .. hdr_len + body_len], resp[HEADER_RESERVE .. HEADER_RESERVE + body_len]); @@ -620,38 +747,36 @@ fn handleBatchGet(srv: *Server, tenant_id: []const u8, col_name: []const u8, bod } fn handleGet(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []const u8, as_of: ?i64) usize { - const start_ns = compat.nanoTimestamp(); - srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); - const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); - const d = if (as_of) |ts_ms| - (col.getAsOfTimestamp(key, ts_ms) orelse return err(404, "not found")) - else - (col.get(key) orelse return err(404, "not found")); - srv.recordQueryCost(tenant_id, "get", 1, d.key.len + d.value.len, start_ns); + const start_ns = compat.nanoTimestamp(); + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + const d = if (as_of) |ts_ms| + (col.getAsOfTimestamp(key, ts_ms) orelse return err(404, "not found")) + else + (col.get(key) orelse return err(404, "not found")); + srv.recordQueryCost(tenant_id, "get", 1, d.key.len + d.value.len, start_ns); - // Write JSON body directly into resp_buf at offset 256 (reserve space for headers) - const HEADER_RESERVE = 256; - var resp = getRespBuf(); - var fbs = compat.fixedBufferStream(resp[HEADER_RESERVE..]); - const val = if (d.value.len > 0) d.value else "{}"; - const is_json = isJsonValue(val); - const w = fbs.writer(); - writeDocJson(w, d, val, is_json) catch return err(500, "response too large"); - const body_len = fbs.pos; - - // Now write headers into the reserved space at the front - var hdr_fbs = compat.fixedBufferStream(resp[0..HEADER_RESERVE]); - compat.format(hdr_fbs.writer(), - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", - .{body_len}) catch {}; - const hdr_len = hdr_fbs.pos; - - // Move body right after headers (memmove if needed) - if (hdr_len < HEADER_RESERVE) { - std.mem.copyForwards(u8, resp[hdr_len .. hdr_len + body_len], resp[HEADER_RESERVE .. HEADER_RESERVE + body_len]); - } - return hdr_len + body_len; + // Write JSON body directly into resp_buf at offset 256 (reserve space for headers) + const HEADER_RESERVE = 256; + var resp = getRespBuf(); + var fbs = compat.fixedBufferStream(resp[HEADER_RESERVE..]); + const val = if (d.value.len > 0) d.value else "{}"; + const is_json = isJsonValue(val); + const w = fbs.writer(); + writeDocJson(w, d, val, is_json) catch return err(500, "response too large"); + const body_len = fbs.pos; + + // Now write headers into the reserved space at the front + var hdr_fbs = compat.fixedBufferStream(resp[0..HEADER_RESERVE]); + compat.format(hdr_fbs.writer(), "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", .{body_len}) catch {}; + const hdr_len = hdr_fbs.pos; + + // Move body right after headers (memmove if needed) + if (hdr_len < HEADER_RESERVE) { + std.mem.copyForwards(u8, resp[hdr_len .. hdr_len + body_len], resp[HEADER_RESERVE .. HEADER_RESERVE + body_len]); } + return hdr_len + body_len; +} fn handleUpdate(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []const u8, body: []const u8, alloc: std.mem.Allocator) usize { const start_ns = compat.nanoTimestamp(); @@ -670,9 +795,7 @@ fn handleUpdate(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: srv.recordQueryCost(tenant_id, "upsert", 1, body.len, start_ns); var buf: [128]u8 = undefined; - const response = std.fmt.bufPrint(&buf, - "{{\"updated\":false,\"inserted\":true,\"doc_id\":{d}}}", - .{doc_id}) catch return err(500, "format failed"); + const response = std.fmt.bufPrint(&buf, "{{\"updated\":false,\"inserted\":true,\"doc_id\":{d}}}", .{doc_id}) catch return err(500, "format failed"); return ok(response); } @@ -709,8 +832,7 @@ fn handleScan(srv: *Server, tenant_id: []const u8, col_name: []const u8, query_s var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); - compat.format(w, "{{\"tenant\":\"{s}\",\"collection\":\"{s}\",\"count\":{d},\"docs\":[", - .{ tenant_id, col_name, emit_count }) catch {}; + compat.format(w, "{{\"tenant\":\"{s}\",\"collection\":\"{s}\",\"count\":{d},\"docs\":[", .{ tenant_id, col_name, emit_count }) catch {}; for (result.docs[0..emit_count], 0..) |d, i| { if (i > 0) w.writeByte(',') catch {}; const val = if (d.value.len > 0) d.value else "{}"; @@ -723,7 +845,6 @@ fn handleScan(srv: *Server, tenant_id: []const u8, col_name: []const u8, query_s } w.writeAll("]}") catch {}; return ok(getBodyBuf()[0..fbs.pos]); - } fn scanEmitCount(tenant_id: []const u8, col_name: []const u8, docs: []const doc_mod.Doc) usize { @@ -834,12 +955,12 @@ fn handleSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, query // Write JSON header with escaped query string w.writeAll("{\"query\":\"") catch {}; for (query) |ch| { - if (ch == '"' or ch == '\\') { w.writeByte('\\') catch {}; } + if (ch == '"' or ch == '\\') { + w.writeByte('\\') catch {}; + } w.writeByte(ch) catch {}; } - compat.format(w, - "\",\"hits\":{d},\"candidates\":{d},\"total_docs\":{d},\"total_files\":{d},\"results\":[", - .{ result.docs.len, result.candidate_paths.len, col.docCount(), result.total_files }) catch {}; + compat.format(w, "\",\"hits\":{d},\"candidates\":{d},\"total_docs\":{d},\"total_files\":{d},\"results\":[", .{ result.docs.len, result.candidate_paths.len, col.docCount(), result.total_files }) catch {}; for (result.docs, 0..) |d, i| { if (fbs.pos + 256 >= MAX_BODY) break; if (i > 0) w.writeByte(',') catch {}; @@ -853,7 +974,10 @@ fn handleSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, query compat.format(w, "{d},\"key\":\"{s}\",\"value\":\"", .{ d.header.doc_id, d.key }) catch {}; for (val) |ch| { if (ch == '"' or ch == '\\') w.writeByte('\\') catch {}; - if (ch == '\n') { w.writeAll("\\n") catch {}; continue; } + if (ch == '\n') { + w.writeAll("\\n") catch {}; + continue; + } w.writeByte(ch) catch {}; } w.writeAll("\"}") catch {}; @@ -879,7 +1003,9 @@ fn handleDiscoverContext(srv: *Server, tenant_id: []const u8, col_name: []const // Write JSON: matching_files w.writeAll("{\"query\":\"") catch {}; for (query) |ch| { - if (ch == '"' or ch == '\\') { w.writeByte('\\') catch {}; } + if (ch == '"' or ch == '\\') { + w.writeByte('\\') catch {}; + } w.writeByte(ch) catch {}; } w.writeAll("\",\"matching_files\":[") catch {}; @@ -940,7 +1066,8 @@ fn handleBillingLog(srv: *Server) usize { const idx = (start_idx + i) % cap; const entry = srv.billing_ring[idx]; if (i > 0) w.writeByte(',') catch {}; - compat.format(w, + compat.format( + w, "{{\"tenant\":\"{s}\",\"op\":\"{s}\",\"rows_scanned\":{d},\"bytes_read\":{d},\"cpu_us\":{d},\"cost_nanos_usd\":{d}}}", .{ entry.tenant_id[0..entry.tenant_id_len], @@ -985,7 +1112,8 @@ fn handleCdcEvents(srv: *Server, tenant_filter: ?[]const u8, alloc: std.mem.Allo w.writeAll("{\"events\":[") catch {}; for (deliveries, 0..) |entry, i| { if (i > 0) w.writeByte(',') catch {}; - compat.format(w, + compat.format( + w, "{{\"seq\":{d},\"tenant\":\"{s}\",\"collection\":\"{s}\",\"webhook_url\":\"{s}\",\"signature\":\"{s}\",\"payload\":{s}}}", .{ entry.seq, @@ -1062,10 +1190,7 @@ fn handleBranchSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, compat.format(w, "{{\"branch\":\"{s}\",\"hits\":{d},\"results\":[", .{ branch_name, result.docs.len }) catch {}; for (result.docs, 0..) |d, i| { if (i > 0) w.writeByte(',') catch {}; - compat.format(w, - "{{\"doc_id\":{d},\"key\":\"{s}\",\"value\":{s}}}", - .{ d.header.doc_id, d.key, - if (d.value.len > 0) d.value else "{}" }) catch {}; + compat.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"value\":{s}}}", .{ d.header.doc_id, d.key, if (d.value.len > 0) d.value else "{}" }) catch {}; } w.writeAll("]}") catch {}; return ok(getBodyBuf()[0..fbs.pos]); @@ -1130,6 +1255,14 @@ fn requestAsOf(raw: []const u8, query: []const u8) ?i64 { return parseAsOfTimestamp(value); } +fn batchDocsCompact(query: []const u8) bool { + if (qparam(query, "mode")) |mode| + return std.mem.eql(u8, mode, "count") or std.mem.eql(u8, mode, "compact"); + if (qparam(query, "docs")) |docs| + return std.mem.eql(u8, docs, "0") or std.mem.eql(u8, docs, "false"); + return false; +} + fn header(raw: []const u8, needle: []const u8) ?[]const u8 { const pos = std.mem.indexOf(u8, raw, needle) orelse return null; const start = pos + needle.len; @@ -1279,8 +1412,9 @@ const KeyIter = struct { while (self.pos < self.body.len) { while (self.pos < self.body.len and (self.body[self.pos] == ' ' or self.body[self.pos] == '\t' or - self.body[self.pos] == '\r' or self.body[self.pos] == '\n' or - self.body[self.pos] == ',')) : (self.pos += 1) {} + self.body[self.pos] == '\r' or self.body[self.pos] == '\n' or + self.body[self.pos] == ',')) : (self.pos += 1) + {} if (self.pos >= self.body.len or self.body[self.pos] == ']') return null; if (self.body[self.pos] == '"') { @@ -1310,6 +1444,59 @@ const KeyIter = struct { } }; +const BatchUpdateLine = struct { + key: []const u8 = "", + value: []const u8 = "", + line_len: usize = 0, + valid: bool = false, +}; + +const BatchUpdateIter = struct { + body: []const u8, + pos: usize = 0, + + fn init(body: []const u8) BatchUpdateIter { + return .{ .body = body }; + } + + fn next(self: *BatchUpdateIter) ?BatchUpdateLine { + while (self.pos < self.body.len) { + const line_end = std.mem.indexOfScalarPos(u8, self.body, self.pos, '\n') orelse self.body.len; + const line = std.mem.trim(u8, self.body[self.pos..line_end], " \t\r"); + self.pos = if (line_end < self.body.len) line_end + 1 else line_end; + if (line.len == 0) continue; + + const key = jsonStr(line, "key") orelse return .{ .line_len = line.len }; + const value = jsonValue(line, "value") orelse return .{ .line_len = line.len }; + return .{ + .key = key, + .value = value, + .line_len = line.len, + .valid = true, + }; + } + return null; + } +}; + +const JoinRequest = struct { + key: []const u8, + target_collection: []const u8, + field: []const u8, + + fn parse(body: []const u8) ?JoinRequest { + const key = jsonStr(body, "key") orelse return null; + const target_collection = jsonStr(body, "target_collection") orelse return null; + const field = jsonStr(body, "field") orelse return null; + if (key.len == 0 or target_collection.len == 0 or field.len == 0) return null; + return .{ + .key = key, + .target_collection = target_collection, + .field = field, + }; + } +}; + fn jsonStr(json: []const u8, key: []const u8) ?[]const u8 { var kbuf: [64]u8 = undefined; const needle = std.fmt.bufPrint(&kbuf, "\"{s}\":", .{key}) catch return null; @@ -1342,7 +1529,10 @@ fn jsonValue(json: []const u8, key: []const u8) ?[]const u8 { // and re-emitted verbatim by handleGet. var i = start + 1; while (i < json.len) : (i += 1) { - if (json[i] == '\\' and i + 1 < json.len) { i += 1; continue; } + if (json[i] == '\\' and i + 1 < json.len) { + i += 1; + continue; + } if (json[i] == '"') return json[start .. i + 1]; // include both quotes } return null; // unterminated string — don't read past buffer @@ -1353,8 +1543,14 @@ fn jsonValue(json: []const u8, key: []const u8) ?[]const u8 { var i = start + 1; var in_str = false; while (i < json.len and depth > 0) : (i += 1) { - if (json[i] == '\\' and in_str) { i += 1; continue; } - if (json[i] == '"') { in_str = !in_str; continue; } + if (json[i] == '\\' and in_str) { + i += 1; + continue; + } + if (json[i] == '"') { + in_str = !in_str; + continue; + } if (in_str) continue; if (json[i] == ch) depth += 1; if (json[i] == close) depth -= 1; @@ -1456,7 +1652,10 @@ fn headerContains(raw: []const u8, name: []const u8, value: []const u8) bool { const b = name[j]; const al = if (a >= 'A' and a <= 'Z') a + 32 else a; const bl = if (b >= 'A' and b <= 'Z') b + 32 else b; - if (al != bl) { match = false; break; } + if (al != bl) { + match = false; + break; + } } if (!match) continue; if (headers[line_start + name.len] != ':') continue; @@ -1473,7 +1672,10 @@ fn headerContains(raw: []const u8, name: []const u8, value: []const u8) bool { const b = value[j]; const al = if (a >= 'A' and a <= 'Z') a + 32 else a; const bl = if (b >= 'A' and b <= 'Z') b + 32 else b; - if (al != bl) { vmatch = false; break; } + if (al != bl) { + vmatch = false; + break; + } } if (vmatch) return true; } @@ -1495,7 +1697,10 @@ fn headerValue(raw: []const u8, name: []const u8) ?[]const u8 { const b = name[j]; const al = if (a >= 'A' and a <= 'Z') a + 32 else a; const bl = if (b >= 'A' and b <= 'Z') b + 32 else b; - if (al != bl) { match = false; break; } + if (al != bl) { + match = false; + break; + } } if (!match) continue; if (headers[ls + name.len] != ':') continue; @@ -1528,9 +1733,7 @@ fn handleWebSocket(srv: *Server, conn: compat.net.Server.Connection, initial: [] // Send upgrade response var resp_buf: [256]u8 = undefined; - const resp_len = std.fmt.bufPrint(&resp_buf, - "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {s}\r\n\r\n", - .{accept}) catch return error.FormatFailed; + const resp_len = std.fmt.bufPrint(&resp_buf, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {s}\r\n\r\n", .{accept}) catch return error.FormatFailed; conn.stream.writeAll(resp_len) catch return error.WriteFailed; // WebSocket frame loop — heap-allocate frame buffer (too large for stack). @@ -1567,7 +1770,7 @@ fn handleWebSocket(srv: *Server, conn: compat.net.Server.Connection, initial: [] } // Read mask key (4 bytes if masked) - var mask: [4]u8 = .{0, 0, 0, 0}; + var mask: [4]u8 = .{ 0, 0, 0, 0 }; if (masked) wsReadExact(conn, &mask) catch return; // Read payload @@ -1610,9 +1813,7 @@ fn wsDispatch(srv: *Server, msg: []const u8) []const u8 { const http_buf = std.heap.page_allocator.alloc(u8, needed) catch return "{\"error\":\"request too large\"}"; defer std.heap.page_allocator.free(http_buf); - const http_len = std.fmt.bufPrint(http_buf, - "{s} HTTP/1.1\r\nContent-Length: {d}\r\n\r\n{s}", - .{ req_line, body.len, body }) catch return "{\"error\":\"request too large\"}"; + const http_len = std.fmt.bufPrint(http_buf, "{s} HTTP/1.1\r\nContent-Length: {d}\r\n\r\n{s}", .{ req_line, body.len, body }) catch return "{\"error\":\"request too large\"}"; const resp_len = dispatch(srv, http_len, std.heap.page_allocator); const resp = getRespBuf()[0..resp_len]; @@ -1644,7 +1845,7 @@ fn wsWriteText(conn: compat.net.Server.Connection, payload: []const u8) !void { fn wsWriteClose(conn: compat.net.Server.Connection, code: u16) void { var frame: [4]u8 = undefined; frame[0] = 0x88; // FIN + close - frame[1] = 2; // payload = 2 bytes (status code) + frame[1] = 2; // payload = 2 bytes (status code) std.mem.writeInt(u16, frame[2..4], code, .big); conn.stream.writeAll(&frame) catch {}; } @@ -1731,3 +1932,42 @@ test "KeyIter reads JSON arrays and keyed newline bodies" { try std.testing.expectEqualStrings("quoted", line_iter.next().?); try std.testing.expect(line_iter.next() == null); } + +test "BatchUpdateIter reads NDJSON key value lines and flags invalid lines" { + var iter = BatchUpdateIter.init( + "{\"key\":\"a\",\"value\":{\"n\":1}}\n" ++ + "{\"key\":\"b\",\"value\":\"text\"}\n" ++ + "{\"key\":\"missing-value\"}\n", + ); + + const first = iter.next().?; + try std.testing.expect(first.valid); + try std.testing.expectEqualStrings("a", first.key); + try std.testing.expectEqualStrings("{\"n\":1}", first.value); + + const second = iter.next().?; + try std.testing.expect(second.valid); + try std.testing.expectEqualStrings("b", second.key); + try std.testing.expectEqualStrings("\"text\"", second.value); + + const third = iter.next().?; + try std.testing.expect(!third.valid); + try std.testing.expect(iter.next() == null); +} + +test "JoinRequest parses required join fields" { + const req = JoinRequest.parse("{\"key\":\"customer-1\",\"target_collection\":\"orders\",\"field\":\"order_ids\"}").?; + try std.testing.expectEqualStrings("customer-1", req.key); + try std.testing.expectEqualStrings("orders", req.target_collection); + try std.testing.expectEqualStrings("order_ids", req.field); + + try std.testing.expect(JoinRequest.parse("{\"key\":\"customer-1\",\"field\":\"order_ids\"}") == null); +} + +test "batchDocsCompact detects compact response query options" { + try std.testing.expect(batchDocsCompact("mode=count")); + try std.testing.expect(batchDocsCompact("mode=compact&tenant=t1")); + try std.testing.expect(batchDocsCompact("tenant=t1&docs=0")); + try std.testing.expect(!batchDocsCompact("mode=full")); + try std.testing.expect(!batchDocsCompact("")); +} From 0013e16d29ab6639c2fe84ff71ca26fdafabed79 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:54:19 +0800 Subject: [PATCH 20/27] perf: tune linux wal io_uring commits --- src/compat.zig | 17 +++++++- src/storage/parallel_wal.zig | 78 +++++++++++++++++++----------------- src/storage/wal.zig | 8 ++-- 3 files changed, 62 insertions(+), 41 deletions(-) diff --git a/src/compat.zig b/src/compat.zig index 219200c..bed24c3 100644 --- a/src/compat.zig +++ b/src/compat.zig @@ -124,6 +124,14 @@ pub const File = struct { if (c.fsync(self.handle) != 0) return error.SyncError; } + pub fn syncData(self: File) !void { + if (comptime builtin.os.tag == .linux) { + if (c.fdatasync(self.handle) != 0) return error.SyncError; + } else { + try self.sync(); + } + } + pub fn pread(self: File, buf: []u8, offset: u64) !usize { const n = c.pread(self.handle, buf.ptr, buf.len, @intCast(offset)); if (n < 0) return error.ReadError; @@ -159,6 +167,13 @@ fn ioUringDisabledByEnv() bool { return value[0] != 0 and value[0] != '0'; } +pub fn envUsize(comptime name: [:0]const u8, default_value: usize) usize { + const value_ptr = getenv(name.ptr) orelse return default_value; + const value = std.mem.sliceTo(value_ptr, 0); + if (value.len == 0) return default_value; + return std.fmt.parseInt(usize, value, 10) catch default_value; +} + pub fn deinitLinuxUring(ring: *LinuxUring) void { if (comptime builtin.os.tag != .linux) return; ring.deinit(); @@ -715,8 +730,6 @@ pub fn format(w: anytype, comptime fmt_str: []const u8, args: anytype) !void { try w.writeAll(result); } - - // ═══════════════════════════════════════════════════════════════════════════════ // TCP Networking — drop-in for std.net (removed in 0.16) // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/src/storage/parallel_wal.zig b/src/storage/parallel_wal.zig index 4c6da71..3469c11 100644 --- a/src/storage/parallel_wal.zig +++ b/src/storage/parallel_wal.zig @@ -131,9 +131,9 @@ pub const WALSegment = struct { compat.deinitLinuxUring(ring); self.uring = null; }; - if (self.uring == null) try self.fd.sync(); + if (self.uring == null) try self.fd.syncData(); } else { - try self.fd.sync(); + try self.fd.syncData(); } } @@ -184,6 +184,8 @@ pub const ParallelWAL = struct { dirty_lens: []usize, const MAX_URING_SEGMENTS: u32 = 256; + const URING_QUEUE_ENTRIES: u16 = 512; + const SYNC_USER_DATA_BASE: u64 = MAX_URING_SEGMENTS + 1; pub fn init(alloc: Allocator, data_dir: []const u8, n_segments: u32) !*ParallelWAL { const segs = try alloc.alloc(*WALSegment, n_segments); @@ -219,7 +221,7 @@ pub const ParallelWAL = struct { .allocator = alloc, .flusher_thread = null, .running = std.atomic.Value(u8).init(0), - .uring = compat.initLinuxUring(MAX_URING_SEGMENTS), + .uring = compat.initLinuxUring(URING_QUEUE_ENTRIES), .dirty_ends = dirty_ends, .dirty_lens = dirty_lens, }; @@ -291,7 +293,7 @@ pub const ParallelWAL = struct { const ring = if (self.uring) |*r| r else return false; @memset(self.dirty_lens, 0); - var writes: u32 = 0; + var dirty_segments: u32 = 0; var i: u32 = 0; while (i < self.n_segments) : (i += 1) { const seg = self.segments[i]; @@ -301,24 +303,15 @@ pub const ParallelWAL = struct { const dirty = seg.buf[seg.flushed_pos..current]; self.dirty_lens[i] = dirty.len; - _ = ring.write(@as(u64, i) + 1, seg.fd.handle, dirty, seg.flushed_pos) catch return self.disableUring(); - writes += 1; + const write_sqe = ring.write(writeUserData(i), seg.fd.handle, dirty, seg.flushed_pos) catch return self.disableUring(); + write_sqe.flags |= linux.IOSQE_IO_LINK; + _ = ring.fsync(syncUserData(i), seg.fd.handle, linux.IORING_FSYNC_DATASYNC) catch return self.disableUring(); + dirty_segments += 1; } - if (writes == 0) return true; - _ = ring.submit_and_wait(writes) catch return self.disableUring(); - if (!self.collectUringWrites(writes)) return self.disableUring(); - - var syncs: u32 = 0; - i = 0; - while (i < self.n_segments) : (i += 1) { - if (self.dirty_lens[i] == 0) continue; - _ = ring.fsync(@as(u64, i) + 1, self.segments[i].fd.handle, linux.IORING_FSYNC_DATASYNC) catch return self.disableUring(); - syncs += 1; - } - - _ = ring.submit_and_wait(syncs) catch return self.disableUring(); - if (!self.collectUringSyncs(syncs)) return self.disableUring(); + if (dirty_segments == 0) return true; + _ = ring.submit_and_wait(dirty_segments * 2) catch return self.disableUring(); + if (!self.collectLinkedUringCompletions(dirty_segments * 2)) return self.disableUring(); i = 0; while (i < self.n_segments) : (i += 1) { @@ -328,37 +321,50 @@ pub const ParallelWAL = struct { return true; } - fn collectUringWrites(self: *ParallelWAL, expected: u32) bool { + fn collectLinkedUringCompletions(self: *ParallelWAL, expected: u32) bool { const ring = if (self.uring) |*r| r else return false; var completed: u32 = 0; while (completed < expected) : (completed += 1) { const cqe = ring.copy_cqe() catch return false; - const idx = self.cqeSegmentIndex(cqe.user_data) orelse return false; - if (cqe.res < 0) return false; - const n: usize = @intCast(cqe.res); - if (n != self.dirty_lens[idx]) return false; + if (isSyncUserData(cqe.user_data)) { + _ = self.cqeSyncSegmentIndex(cqe.user_data) orelse return false; + if (cqe.res < 0) return false; + } else { + const idx = self.cqeWriteSegmentIndex(cqe.user_data) orelse return false; + if (cqe.res < 0) return false; + const n: usize = @intCast(cqe.res); + if (n != self.dirty_lens[idx]) return false; + } } return true; } - fn collectUringSyncs(self: *ParallelWAL, expected: u32) bool { - const ring = if (self.uring) |*r| r else return false; - var completed: u32 = 0; - while (completed < expected) : (completed += 1) { - const cqe = ring.copy_cqe() catch return false; - _ = self.cqeSegmentIndex(cqe.user_data) orelse return false; - if (cqe.res < 0) return false; - } - return true; + fn writeUserData(segment_index: u32) u64 { + return @as(u64, segment_index) + 1; + } + + fn syncUserData(segment_index: u32) u64 { + return SYNC_USER_DATA_BASE + @as(u64, segment_index); + } + + fn isSyncUserData(user_data: u64) bool { + return user_data >= SYNC_USER_DATA_BASE; } - fn cqeSegmentIndex(self: *const ParallelWAL, user_data: u64) ?usize { - if (user_data == 0) return null; + fn cqeWriteSegmentIndex(self: *const ParallelWAL, user_data: u64) ?usize { + if (user_data == 0 or isSyncUserData(user_data)) return null; const idx: usize = @intCast(user_data - 1); if (idx >= self.n_segments) return null; return idx; } + fn cqeSyncSegmentIndex(self: *const ParallelWAL, user_data: u64) ?usize { + if (!isSyncUserData(user_data)) return null; + const idx: usize = @intCast(user_data - SYNC_USER_DATA_BASE); + if (idx >= self.n_segments) return null; + return idx; + } + fn disableUring(self: *ParallelWAL) bool { if (self.uring) |*ring| { compat.deinitLinuxUring(ring); diff --git a/src/storage/wal.zig b/src/storage/wal.zig index 1285445..c15f0e5 100644 --- a/src/storage/wal.zig +++ b/src/storage/wal.zig @@ -115,6 +115,7 @@ pub const WAL = struct { append_offset: u64, uring_flushes: usize, sync_flushes: usize, + uring_min_write_and_sync: usize, allocator: std.mem.Allocator, // Group commit state — guarded by mu @@ -130,7 +131,7 @@ pub const WAL = struct { /// Backpressure: block writers if pending buffer exceeds this. const MAX_WRITE_BUF: usize = 8 * 1024 * 1024; // 8 MiB - const URING_MIN_WRITE_AND_SYNC: usize = 8 * 1024 * 1024; + const DEFAULT_URING_MIN_WRITE_AND_SYNC: usize = 32 * 1024; // ── Lifecycle ───────────────────────────────────────────────────────────── @@ -150,6 +151,7 @@ pub const WAL = struct { .append_offset = if (end >= 0) @intCast(end) else 0, .uring_flushes = 0, .sync_flushes = 0, + .uring_min_write_and_sync = compat.envUsize("TURBODB_IO_URING_MIN_BYTES", DEFAULT_URING_MIN_WRITE_AND_SYNC), .allocator = allocator, .mu = .{}, .cond = .{}, @@ -475,7 +477,7 @@ pub const WAL = struct { } fn writeAndSync(self: *WAL, bytes: []const u8) ?anyerror { - if (bytes.len >= URING_MIN_WRITE_AND_SYNC) { + if (bytes.len >= self.uring_min_write_and_sync) { if (self.uring) |*ring| { compat.uringWriteAndSyncAt(ring, self.file, bytes, self.append_offset) catch { compat.deinitLinuxUring(ring); @@ -490,7 +492,7 @@ pub const WAL = struct { } self.file.pwriteAll(bytes, self.append_offset) catch |e| return e; - self.file.sync() catch |e| return e; + self.file.syncData() catch |e| return e; self.append_offset += bytes.len; self.sync_flushes += 1; return null; From 3060e9461db632a9c64c987f5cb54f425113ad02 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:21:55 +0800 Subject: [PATCH 21/27] perf: bound durable bulk ingest memory --- bench/README_apple_container_matrix.md | 7 +- src/collection.zig | 351 ++++++++++++++++++++---- src/compat.zig | 47 ++++ src/server.zig | 357 ++++++++++++++++++++++++- src/storage/wal.zig | 140 ++++++++++ 5 files changed, 841 insertions(+), 61 deletions(-) diff --git a/bench/README_apple_container_matrix.md b/bench/README_apple_container_matrix.md index 2f5ff25..ed365e3 100644 --- a/bench/README_apple_container_matrix.md +++ b/bench/README_apple_container_matrix.md @@ -14,7 +14,8 @@ another container, and does not publish database ports to macOS. - `orders`: keyed order records with `user_id`, amount, status, and JSON payload. - TurboDB stores `bench_users`, `bench_orders`, and a materialized `bench_user_orders` edge collection. The benchmark client uses persistent - HTTP plus `POST /db/:col/batch_get?mode=count` for relationship fetches, so + HTTP plus durable `POST /db/:col/bulk` ingestion and + `POST /db/:col/batch_get?mode=count` for relationship fetches, so lookup-heavy workloads do not pay to serialize document bodies they do not consume. For the join-like, update, and delete workloads it assumes the faster HTTP shapes exist and uses `POST /db/:edge_col/join?mode=count`, @@ -30,7 +31,9 @@ another container, and does not publish database ports to macOS. ## Workloads -- `ingest`: create all users and orders. +- `ingest`: create all users and orders. TurboDB HTTP uses a collection-level + bulk insert path that writes committed WAL entries as a batch and flushes the + WAL before acknowledging the bulk request. - `point_get`: read users by primary key. - `relationship_lookup`: fetch all orders/transfers for a user. TurboDB HTTP keeps this as a materialized edge read plus compact `batch_get`. diff --git a/src/collection.zig b/src/collection.zig index 354b796..e57bf98 100644 --- a/src/collection.zig +++ b/src/collection.zig @@ -1,14 +1,14 @@ /// TurboDB — Collection (MVCC document store + query engine) const std = @import("std"); const compat = @import("compat"); -const doc_mod = @import("doc.zig"); -const page_mod = @import("page.zig"); +const doc_mod = @import("doc.zig"); +const page_mod = @import("page.zig"); const btree_mod = @import("btree.zig"); const codeindex = @import("codeindex.zig"); -const wal_mod = @import("wal"); +const wal_mod = @import("wal"); const epoch_mod = @import("epoch"); const hot_cache = @import("hot_cache.zig"); -const mvcc_mod = @import("mvcc.zig"); +const mvcc_mod = @import("mvcc.zig"); const cdc_mod = @import("cdc.zig"); const vector = @import("vector.zig"); const branch_mod = @import("branch.zig"); @@ -26,6 +26,8 @@ const WordIndex = codeindex.WordIndex; pub const DEFAULT_TENANT = "default"; pub const MAX_TENANT_ID_LEN: usize = 64; pub const MAX_COLLECTION_NAME_LEN: usize = 64; +const DEFAULT_INDEX_QUEUE_MEMORY_LIMIT: usize = 64 * 1024 * 1024; +const INDEX_QUEUE_MEMORY_FRACTION: usize = 16; fn writeCommittedDocumentWal(wal_log: *WAL, op: wal_mod.OpCode, payload: []const u8) !void { const txn = wal_log.next_lsn.load(.monotonic); @@ -36,6 +38,19 @@ fn writeCommittedDocumentWal(wal_log: *WAL, op: wal_mod.OpCode, payload: []const } } +fn indexQueueMemoryLimit() usize { + const explicit = compat.envUsize("TURBODB_INDEX_QUEUE_MEMORY_LIMIT_BYTES", 0); + if (explicit > 0) return explicit; + if (compat.availableMemoryBytes()) |available| { + return @min(available / INDEX_QUEUE_MEMORY_FRACTION, DEFAULT_INDEX_QUEUE_MEMORY_LIMIT); + } + return DEFAULT_INDEX_QUEUE_MEMORY_LIMIT; +} + +fn indexEntryBytes(key: []const u8, value: []const u8) usize { + return std.math.add(usize, key.len, value.len) catch std.math.maxInt(usize); +} + // ─── IndexQueue ────────────────────────────────────────────────────────── // Lock-free MPSC queue for deferring trigram+word index builds to a background thread. // Insert path pushes owned (key, value) pairs; background thread pops and indexes. @@ -107,7 +122,10 @@ fn fastParseFloat(s: []const u8) ?f32 { if (s.len == 0) return null; var i: usize = 0; var neg: bool = false; - if (s[0] == '-') { neg = true; i = 1; } + if (s[0] == '-') { + neg = true; + i = 1; + } var int_part: i32 = 0; while (i < s.len and s[i] >= '0' and s[i] <= '9') : (i += 1) { int_part = int_part * 10 + @as(i32, s[i] - '0'); @@ -176,6 +194,28 @@ pub const Collection = struct { // MVCC GC: trigger version chain cleanup every GC_INTERVAL inserts. const GC_INTERVAL: u64 = 500; + pub const BulkInsertRow = struct { + key: []const u8, + value: []const u8, + line_len: usize = 0, + }; + + pub const BulkInsertResult = struct { + inserted: u32 = 0, + errors: u32 = 0, + bytes: u64 = 0, + }; + + const BulkPreparedDoc = struct { + key: []const u8, + value: []const u8, + doc_id: u64, + key_hash: u64, + enc_off: usize, + enc_len: usize, + line_len: usize, + }; + name_buf: [128]u8, name_len: u8, pf: PageFile, @@ -197,6 +237,7 @@ pub const Collection = struct { /// Per-key modification epoch — tracks when each key was last written on main. /// Used by mergeBranch to detect conflicts (key modified after branch was created). key_epochs: std.AutoHashMap(u64, u64), + append_leaf_hint: u32, alloc: std.mem.Allocator, cache: hot_cache.HotCache, // MVCC version chain — append-only, vacuumed periodically via gc_counter. @@ -210,6 +251,7 @@ pub const Collection = struct { index_stop: std.atomic.Value(bool), queue_toggle: std.atomic.Value(u32), indexing_count: std.atomic.Value(u32), + index_queue_bytes: std.atomic.Value(usize), // Vector embedding column (optional) vectors: ?*vector.VectorColumn = null, @@ -258,6 +300,7 @@ pub const Collection = struct { col.hash_idx = std.AutoHashMap(u64, BTreeEntry).init(alloc); col.key_doc_ids = std.AutoHashMap(u64, u64).init(alloc); col.key_epochs = std.AutoHashMap(u64, u64).init(alloc); + col.append_leaf_hint = 0; col.alloc = alloc; col.cache = hot_cache.HotCache.init(); col.versions = mvcc_mod.VersionChain.init(alloc); @@ -266,6 +309,7 @@ pub const Collection = struct { col.index_stop = std.atomic.Value(bool).init(false); col.queue_toggle = std.atomic.Value(u32).init(0); col.indexing_count = std.atomic.Value(u32).init(0); + col.index_queue_bytes = std.atomic.Value(usize).init(0); col.vectors = null; col.vector_field_len = 0; col.vec_entries = .empty; @@ -410,8 +454,8 @@ pub const Collection = struct { // Index the document. const entry = BTreeEntry{ .key_hash = hdr.key_hash, - .doc_id = doc_id, - .page_no = pno, + .doc_id = doc_id, + .page_no = pno, .page_off = page_off, }; try self.idx.insert(entry); @@ -423,33 +467,7 @@ pub const Collection = struct { self.key_doc_ids.put(hdr.key_hash, doc_id) catch {}; self.key_epochs.put(hdr.key_hash, doc_id) catch {}; // epoch = doc_id (monotonic) - // Async trigram + word indexing — push to background queue, sync fallback if full or no worker. - if (value.len >= 3) { - if (self.index_thread == null) { - // No background worker — index synchronously to avoid losing data. - self.tri.indexFile(key, value) catch {}; - self.words.indexFile(key, value) catch {}; - } else { - const owned_key = self.alloc.dupe(u8, key) catch null; - const owned_val = self.alloc.dupe(u8, value) catch null; - if (owned_key != null and owned_val != null) { - const q = &self.index_queue; - var retries: u32 = 0; - while (!q.push(owned_key.?, owned_val.?)) { - retries += 1; - if (retries > 1000) { - // Queue full — fall back to synchronous indexing. - self.tri.indexFile(owned_key.?, owned_val.?) catch {}; - self.words.indexFile(owned_key.?, owned_val.?) catch {}; - self.alloc.free(owned_key.?); - self.alloc.free(owned_val.?); - break; - } - std.Thread.yield() catch {}; - } - } - } - } + self.enqueueTextIndex(key, value); // Extract and store vector embedding if configured. // Uses stack buffer to avoid heap allocation per insert. @@ -482,6 +500,127 @@ pub const Collection = struct { return doc_id; } + fn bulkEncodedLen(row: BulkInsertRow) ?usize { + if (row.key.len == 0 or row.key.len > 1024 or row.value.len > 64 * 1024 * 1024) return null; + const total_size = DocHeader.size + row.key.len + row.value.len; + if (total_size > page_mod.PAGE_USABLE) return null; + return total_size; + } + + /// Insert many documents as one durable ingestion batch. The WAL is flushed + /// before page/index updates are applied and before this call returns. + pub fn insertBulk(self: *Collection, rows: []const BulkInsertRow) !BulkInsertResult { + var result = BulkInsertResult{}; + if (rows.len == 0) return result; + + var encoded: std.ArrayList(u8) = .empty; + defer encoded.deinit(self.alloc); + var prepared: std.ArrayList(BulkPreparedDoc) = .empty; + defer prepared.deinit(self.alloc); + var payloads: std.ArrayList(wal_mod.BatchPayload) = .empty; + defer payloads.deinit(self.alloc); + + var valid_rows: usize = 0; + var estimated_bytes: usize = 0; + for (rows) |row| { + const total_size = bulkEncodedLen(row) orelse { + result.errors += 1; + continue; + }; + estimated_bytes = std.math.add(usize, estimated_bytes, total_size) catch return error.OutOfMemory; + valid_rows += 1; + } + + if (valid_rows == 0) return result; + + try prepared.ensureTotalCapacity(self.alloc, valid_rows); + try payloads.ensureTotalCapacity(self.alloc, valid_rows); + try encoded.ensureTotalCapacity(self.alloc, estimated_bytes); + + for (rows) |row| { + const total_size = bulkEncodedLen(row) orelse continue; + + const doc_id = self.next_doc_id.fetchAdd(1, .monotonic); + const hdr = doc_mod.newHeader(doc_id, row.key, row.value); + const d = Doc{ .header = hdr, .key = row.key, .value = row.value }; + const enc_off = encoded.items.len; + try encoded.appendNTimes(self.alloc, 0, total_size); + const enc = encoded.items[enc_off .. enc_off + total_size]; + _ = try d.encodeBuf(enc); + + prepared.appendAssumeCapacity(.{ + .key = row.key, + .value = row.value, + .doc_id = doc_id, + .key_hash = hdr.key_hash, + .enc_off = enc_off, + .enc_len = total_size, + .line_len = row.line_len, + }); + payloads.appendAssumeCapacity(enc); + } + + if (prepared.items.len == 0) return result; + + const last_lsn = try self.wal_log.writeCommittedBatch(.doc_insert, wal_mod.DB_TAG_DOC, payloads.items); + try self.wal_log.flushUpTo(last_lsn); + + self.meta_mu.lock(); + { + defer self.meta_mu.unlock(); + for (prepared.items) |item| { + const enc = encoded.items[item.enc_off .. item.enc_off + item.enc_len]; + const pno = try self.findOrAllocLeaf(enc.len); + const page_off = self.pf.leafAppend(pno, enc) orelse return error.PageFull; + const entry = BTreeEntry{ + .key_hash = item.key_hash, + .doc_id = item.doc_id, + .page_no = pno, + .page_off = page_off, + }; + try self.idx.insert(entry); + self.hash_idx.put(item.key_hash, entry) catch {}; + + const epoch = self.epochs.advance(); + self.versions.appendVersion(self.alloc, item.doc_id, pno, page_off, epoch) catch {}; + self.key_doc_ids.put(item.key_hash, item.doc_id) catch {}; + self.key_epochs.put(item.key_hash, item.doc_id) catch {}; + + if (self.vectors) |vc| { + const field = self.vector_field[0..self.vector_field_len]; + const dims: usize = vc.dims; + var embed_stack: [4096]f32 = undefined; + const emb = if (dims <= 4096) embed_stack[0..dims] else blk: { + break :blk self.alloc.alloc(f32, dims) catch null; + }; + if (emb) |e| { + defer if (dims > 4096) self.alloc.free(e); + if (extractJsonFloatArray(item.value, field, e)) |count| { + if (count == dims) { + vc.append(self.alloc, e) catch {}; + self.vec_entries.append(self.alloc, entry) catch {}; + } + } + } + } + + if (self.gc_counter.fetchAdd(1, .monotonic) % GC_INTERVAL == GC_INTERVAL - 1) { + _ = self.versions.gc(self.alloc); + } + + result.inserted += 1; + result.bytes += item.line_len; + } + } + + for (prepared.items) |item| { + self.enqueueTextIndex(item.key, item.value); + emitChange(self, .insert, item.key, item.value, item.doc_id); + } + + return result; + } + /// Insert a document with a pre-computed embedding (no JSON parsing needed). /// This is the fast path — embedding is passed directly as f32 slice. pub fn insertWithEmbedding(self: *Collection, key: []const u8, value: []const u8, embedding: []const f32) !u64 { @@ -615,8 +754,8 @@ pub const Collection = struct { const new_entry = BTreeEntry{ .key_hash = key_hash, - .doc_id = doc_id, - .page_no = pno, + .doc_id = doc_id, + .page_no = pno, .page_off = page_off, }; self.hash_idx.put(key_hash, new_entry) catch {}; @@ -803,7 +942,10 @@ pub const Collection = struct { const nc = needle[j]; const hl = if (hc >= 'A' and hc <= 'Z') hc + 32 else hc; const nl = if (nc >= 'A' and nc <= 'Z') nc + 32 else nc; - if (hl != nl) { match = false; break; } + if (hl != nl) { + match = false; + break; + } } if (match) return true; } @@ -846,7 +988,9 @@ pub const Collection = struct { pub const ScanResult = struct { docs: []Doc, alloc: std.mem.Allocator, - pub fn deinit(self: ScanResult) void { self.alloc.free(self.docs); } + pub fn deinit(self: ScanResult) void { + self.alloc.free(self.docs); + } }; /// Linear scan of all live documents, with optional limit/offset. @@ -880,7 +1024,10 @@ pub const Collection = struct { const d = decoded.doc; pos += decoded.consumed; if (d.header.flags & DocHeader.DELETED != 0) continue; - if (skipped < offset) { skipped += 1; continue; } + if (skipped < offset) { + skipped += 1; + continue; + } try results.append(alloc, d); if (results.items.len >= limit) break :outer; } @@ -938,8 +1085,7 @@ pub const Collection = struct { } fn readEntry(self: *Collection, entry: BTreeEntry) ?Doc { - const raw = self.pf.leafRead(entry.page_no, entry.page_off, - DocHeader.size + 1024 + 65536); + const raw = self.pf.leafRead(entry.page_no, entry.page_off, DocHeader.size + 1024 + 65536); const decoded = doc_mod.decode(raw) catch return null; if (decoded.doc.header.flags & DocHeader.DELETED != 0) return null; return decoded.doc; @@ -952,15 +1098,32 @@ pub const Collection = struct { } fn findOrAllocLeaf(self: *Collection, needed: usize) !u32 { + if (self.append_leaf_hint != 0) { + const ph = self.pf.pageHeader(self.append_leaf_hint); + if (@as(page_mod.PageType, @enumFromInt(ph.page_type)) == .leaf and + page_mod.PAGE_USABLE - ph.used_bytes >= needed) + { + return self.append_leaf_hint; + } + } + const total = self.pf.next_alloc.load(.acquire); var pno = if (total > 0) total - 1 else 0; var checked: u32 = 0; - while (checked < 32 and pno > 0) : ({ pno -= 1; checked += 1; }) { + while (checked < 32 and pno > 0) : ({ + pno -= 1; + checked += 1; + }) { const ph = self.pf.pageHeader(pno); if (@as(page_mod.PageType, @enumFromInt(ph.page_type)) != .leaf) continue; - if (page_mod.PAGE_USABLE - ph.used_bytes >= needed) return pno; + if (page_mod.PAGE_USABLE - ph.used_bytes >= needed) { + self.append_leaf_hint = pno; + return pno; + } } - return self.pf.allocPage(.leaf); + const allocated = try self.pf.allocPage(.leaf); + self.append_leaf_hint = allocated; + return allocated; } fn rebuildIndexes(self: *Collection) !void { @@ -1006,6 +1169,71 @@ pub const Collection = struct { self.cdc.emit(ref.tenant_id, ref.collection_name, key, value, doc_id, op); } + fn enqueueTextIndex(self: *Collection, key: []const u8, value: []const u8) void { + if (value.len < 3) return; + if (self.index_thread == null and self.index_thread2 == null) { + self.tri.indexFile(key, value) catch {}; + self.words.indexFile(key, value) catch {}; + return; + } + + const queued_bytes = indexEntryBytes(key, value); + if (!self.tryReserveIndexQueueBytes(queued_bytes)) { + self.tri.indexFile(key, value) catch {}; + self.words.indexFile(key, value) catch {}; + return; + } + + const owned_key = self.alloc.dupe(u8, key) catch { + self.releaseIndexQueueBytes(queued_bytes); + return; + }; + const owned_val = self.alloc.dupe(u8, value) catch { + self.alloc.free(owned_key); + self.releaseIndexQueueBytes(queued_bytes); + return; + }; + + const q = if (self.index_thread != null and self.index_thread2 != null) + if (self.queue_toggle.fetchAdd(1, .monotonic) % 2 == 0) &self.index_queue else &self.index_queue2 + else if (self.index_thread != null) + &self.index_queue + else + &self.index_queue2; + + var retries: u32 = 0; + while (!q.push(owned_key, owned_val)) { + retries += 1; + if (retries > 1000) { + self.releaseIndexQueueBytes(queued_bytes); + self.tri.indexFile(owned_key, owned_val) catch {}; + self.words.indexFile(owned_key, owned_val) catch {}; + self.alloc.free(owned_val); + self.alloc.free(owned_key); + return; + } + std.Thread.yield() catch {}; + } + } + + fn tryReserveIndexQueueBytes(self: *Collection, bytes: usize) bool { + const limit = indexQueueMemoryLimit(); + while (true) { + const current = self.index_queue_bytes.load(.acquire); + const next = std.math.add(usize, current, bytes) catch return false; + if (next > limit) return false; + if (self.index_queue_bytes.cmpxchgWeak(current, next, .acq_rel, .monotonic)) |_| { + continue; + } + return true; + } + } + + fn releaseIndexQueueBytes(self: *Collection, bytes: usize) void { + if (bytes == 0) return; + _ = self.index_queue_bytes.fetchSub(bytes, .release); + } + // ─── Vector search ──────────────────────────────────────────────────── pub const VectorSearchResult = struct { @@ -1405,9 +1633,9 @@ pub const Collection = struct { var name_end = name_start; while (name_end < doc.value.len and ((doc.value[name_end] >= 'a' and doc.value[name_end] <= 'z') or - (doc.value[name_end] >= 'A' and doc.value[name_end] <= 'Z') or - (doc.value[name_end] >= '0' and doc.value[name_end] <= '9') or - doc.value[name_end] == '_')) + (doc.value[name_end] >= 'A' and doc.value[name_end] <= 'Z') or + (doc.value[name_end] >= '0' and doc.value[name_end] <= '9') or + doc.value[name_end] == '_')) { name_end += 1; } @@ -1516,6 +1744,7 @@ fn indexWorkerQ(col: *Collection, queue: *IndexQueue) void { col.words.indexFile(batch_keys[i], batch_vals[i]) catch {}; } for (0..n) |i| { + col.releaseIndexQueueBytes(indexEntryBytes(batch_keys[i], batch_vals[i])); col.alloc.free(batch_vals[i]); col.alloc.free(batch_keys[i]); } @@ -1525,6 +1754,7 @@ fn indexWorkerQ(col: *Collection, queue: *IndexQueue) void { while (queue.pop()) |entry| { col.tri.indexFile(entry.key, entry.value) catch {}; col.words.indexFile(entry.key, entry.value) catch {}; + col.releaseIndexQueueBytes(indexEntryBytes(entry.key, entry.value)); col.alloc.free(entry.value); col.alloc.free(entry.key); } @@ -2004,6 +2234,33 @@ test "collection WAL replays committed document writes and full tombstone" { try std.testing.expectEqual(DocHeader.size + "u1".len, decoded.consumed); } +test "collection bulk insert is durable and queryable" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_collection_bulk_insert"; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + + const col = try db.collection("users"); + const rows = [_]Collection.BulkInsertRow{ + .{ .key = "u1", .value = "{\"name\":\"alice\"}", .line_len = 25 }, + .{ .key = "u2", .value = "{\"name\":\"bob\"}", .line_len = 23 }, + .{ .key = "u3", .value = "{\"name\":\"carol\"}", .line_len = 25 }, + }; + + const result = try col.insertBulk(rows[0..]); + try std.testing.expectEqual(@as(u32, 3), result.inserted); + try std.testing.expectEqual(@as(u32, 0), result.errors); + try std.testing.expect(db.wal_log.synced_lsn >= 3); + + try std.testing.expectEqualStrings("{\"name\":\"alice\"}", col.get("u1").?.value); + try std.testing.expectEqualStrings("{\"name\":\"bob\"}", col.get("u2").?.value); + try std.testing.expectEqualStrings("{\"name\":\"carol\"}", col.get("u3").?.value); +} + test "collection concurrent inserts keep shared indexes stable" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_collection_concurrent_insert"; diff --git a/src/compat.zig b/src/compat.zig index bed24c3..56f3d01 100644 --- a/src/compat.zig +++ b/src/compat.zig @@ -174,6 +174,53 @@ pub fn envUsize(comptime name: [:0]const u8, default_value: usize) usize { return std.fmt.parseInt(usize, value, 10) catch default_value; } +pub fn availableMemoryBytes() ?usize { + if (comptime builtin.os.tag != .linux) return null; + if (cgroupAvailableMemoryBytes()) |bytes| return bytes; + return procMemAvailableBytes(); +} + +fn cgroupAvailableMemoryBytes() ?usize { + var max_buf: [64]u8 = undefined; + const max_raw = readSmallAbsolute("/sys/fs/cgroup/memory.max", &max_buf) orelse return null; + const max_bytes = parseUsizePrefix(max_raw) orelse return null; + + var current_buf: [64]u8 = undefined; + const current_raw = readSmallAbsolute("/sys/fs/cgroup/memory.current", ¤t_buf) orelse return null; + const current_bytes = parseUsizePrefix(current_raw) orelse return null; + + if (current_bytes >= max_bytes) return 0; + return max_bytes - current_bytes; +} + +fn procMemAvailableBytes() ?usize { + var buf: [4096]u8 = undefined; + const data = readSmallAbsolute("/proc/meminfo", &buf) orelse return null; + const labels = [_][]const u8{ "MemAvailable:", "MemFree:" }; + for (labels) |label| { + const idx = std.mem.indexOf(u8, data, label) orelse continue; + const kb = parseUsizePrefix(data[idx + label.len ..]) orelse continue; + return std.math.mul(usize, kb, 1024) catch null; + } + return null; +} + +fn readSmallAbsolute(path: []const u8, buf: []u8) ?[]const u8 { + var file = openFileAbsolute(path, .{}) catch return null; + defer file.close(); + const n = file.read(buf) catch return null; + return buf[0..n]; +} + +fn parseUsizePrefix(raw: []const u8) ?usize { + const trimmed = std.mem.trim(u8, raw, " \t\r\n"); + if (trimmed.len == 0 or std.mem.startsWith(u8, trimmed, "max")) return null; + var end: usize = 0; + while (end < trimmed.len and trimmed[end] >= '0' and trimmed[end] <= '9') : (end += 1) {} + if (end == 0) return null; + return std.fmt.parseInt(usize, trimmed[0..end], 10) catch null; +} + pub fn deinitLinuxUring(ring: *LinuxUring) void { if (comptime builtin.os.tag != .linux) return; ring.deinit(); diff --git a/src/server.zig b/src/server.zig index 7123d2c..20d5c14 100644 --- a/src/server.zig +++ b/src/server.zig @@ -27,6 +27,13 @@ const MAX_REQ = 65536; // 64 KiB (initial read) const MAX_RESP = 131072; // 128 KiB const MAX_BODY = 65536; // 64 KiB const MAX_BULK = 16 * 1024 * 1024; // 16 MiB for bulk inserts +const BULK_INSERT_CHUNK_ROWS: usize = 4096; +const BULK_INSERT_CHUNK_BYTES: usize = 4 * 1024 * 1024; +const DEFAULT_BULK_MEMORY_LIMIT: usize = 256 * 1024 * 1024; +const MAX_AUTO_BULK_MEMORY_LIMIT: usize = 512 * 1024 * 1024; +const BULK_MEMORY_AVAILABLE_FRACTION: usize = 4; +const BULK_TENANT_MEMORY_FRACTION: usize = 2; +const BULK_MEMORY_BASE_OVERHEAD: usize = 1024 * 1024; // Heap-allocated per-connection buffers (threadlocal pointers set in handleConn). // This avoids large threadlocal TLS segments that break in Release mode on macOS. @@ -58,6 +65,25 @@ pub const Server = struct { cost_nanos_usd: u64, }; + const BulkTenantUsage = struct { + bytes: usize = 0, + requests: u32 = 0, + }; + + pub const BulkMemoryReservation = struct { + srv: *Server, + tenant_id: [collection.MAX_TENANT_ID_LEN]u8, + tenant_id_len: u8, + bytes: usize, + active: bool = true, + + pub fn release(self: *BulkMemoryReservation) void { + if (!self.active) return; + self.srv.releaseBulkMemory(self.tenant_id[0..self.tenant_id_len], self.bytes); + self.active = false; + } + }; + const MAX_CONNECTIONS: u32 = 512; const BILLING_LOG_CAP: usize = 1024; @@ -82,6 +108,11 @@ pub const Server = struct { activity: activity.ActivityTracker, // Connection limiter — prevents unbounded thread spawning under flood. active_conns: std.atomic.Value(u32), + bulk_mu: compat.Mutex, + bulk_tenant_usage: std.StringHashMap(BulkTenantUsage), + bulk_inflight_bytes: usize, + bulk_inflight_requests: u32, + bulk_rejected: std.atomic.Value(u64), pub fn init(alloc: std.mem.Allocator, db: *Database, port: u16) Server { return .{ @@ -102,9 +133,105 @@ pub const Server = struct { .billing_mu = .{}, .activity = activity.ActivityTracker.init(), .active_conns = std.atomic.Value(u32).init(0), + .bulk_mu = .{}, + .bulk_tenant_usage = std.StringHashMap(BulkTenantUsage).init(alloc), + .bulk_inflight_bytes = 0, + .bulk_inflight_requests = 0, + .bulk_rejected = std.atomic.Value(u64).init(0), + }; + } + + pub fn deinit(self: *Server) void { + self.bulk_mu.lock(); + defer self.bulk_mu.unlock(); + var it = self.bulk_tenant_usage.keyIterator(); + while (it.next()) |key| self.alloc.free(key.*); + self.bulk_tenant_usage.deinit(); + } + + fn acquireBulkMemory(self: *Server, tenant_id: []const u8, estimated_bytes: usize) !BulkMemoryReservation { + const global_limit = bulkGlobalMemoryLimit(); + const tenant_limit = bulkTenantMemoryLimit(global_limit); + return self.acquireBulkMemoryWithLimits(tenant_id, estimated_bytes, global_limit, tenant_limit); + } + + fn acquireBulkMemoryWithLimits( + self: *Server, + tenant_id: []const u8, + estimated_bytes: usize, + global_limit: usize, + tenant_limit: usize, + ) !BulkMemoryReservation { + if (tenant_id.len == 0 or tenant_id.len > collection.MAX_TENANT_ID_LEN) return error.BulkMemoryLimitExceeded; + if (estimated_bytes == 0 or estimated_bytes > global_limit or estimated_bytes > tenant_limit) { + _ = self.bulk_rejected.fetchAdd(1, .monotonic); + return error.BulkMemoryLimitExceeded; + } + + self.bulk_mu.lock(); + defer self.bulk_mu.unlock(); + + const global_next = std.math.add(usize, self.bulk_inflight_bytes, estimated_bytes) catch { + _ = self.bulk_rejected.fetchAdd(1, .monotonic); + return error.BulkMemoryLimitExceeded; + }; + if (global_next > global_limit) { + _ = self.bulk_rejected.fetchAdd(1, .monotonic); + return error.BulkMemoryLimitExceeded; + } + + const current_tenant_usage = self.bulk_tenant_usage.get(tenant_id) orelse BulkTenantUsage{}; + const tenant_next = std.math.add(usize, current_tenant_usage.bytes, estimated_bytes) catch { + _ = self.bulk_rejected.fetchAdd(1, .monotonic); + return error.BulkMemoryLimitExceeded; + }; + if (tenant_next > tenant_limit) { + _ = self.bulk_rejected.fetchAdd(1, .monotonic); + return error.BulkMemoryLimitExceeded; + } + + if (self.bulk_tenant_usage.getPtr(tenant_id)) |usage| { + usage.bytes = tenant_next; + usage.requests += 1; + } else { + const owned_key = try self.alloc.dupe(u8, tenant_id); + errdefer self.alloc.free(owned_key); + try self.bulk_tenant_usage.put(owned_key, .{ + .bytes = tenant_next, + .requests = 1, + }); + } + self.bulk_inflight_bytes = global_next; + self.bulk_inflight_requests += 1; + + var tenant_copy = [_]u8{0} ** collection.MAX_TENANT_ID_LEN; + @memcpy(tenant_copy[0..tenant_id.len], tenant_id); + return .{ + .srv = self, + .tenant_id = tenant_copy, + .tenant_id_len = @intCast(tenant_id.len), + .bytes = estimated_bytes, }; } + fn releaseBulkMemory(self: *Server, tenant_id: []const u8, bytes: usize) void { + self.bulk_mu.lock(); + defer self.bulk_mu.unlock(); + + self.bulk_inflight_bytes = if (self.bulk_inflight_bytes >= bytes) self.bulk_inflight_bytes - bytes else 0; + if (self.bulk_inflight_requests > 0) self.bulk_inflight_requests -= 1; + + if (self.bulk_tenant_usage.getPtr(tenant_id)) |usage| { + usage.bytes = if (usage.bytes >= bytes) usage.bytes - bytes else 0; + if (usage.requests > 0) usage.requests -= 1; + if (usage.bytes == 0 and usage.requests == 0) { + if (self.bulk_tenant_usage.fetchRemove(tenant_id)) |kv| { + self.alloc.free(kv.key); + } + } + } + } + pub fn run(self: *Server) !void { const addr = try compat.net.Address.parseIp("0.0.0.0", self.port); var listener = try addr.listen(.{ @@ -225,6 +352,110 @@ pub const Server = struct { } }; +threadlocal var tl_bulk_reservation: ?Server.BulkMemoryReservation = null; + +const ParsedRequestTarget = struct { + method: []const u8, + path: []const u8, + query: []const u8, +}; + +const BulkPreflight = union(enum) { + not_bulk, + reserved: Server.BulkMemoryReservation, + reject: struct { + code: u16, + message: []const u8, + }, +}; + +fn bulkGlobalMemoryLimit() usize { + const explicit = compat.envUsize("TURBODB_BULK_MEMORY_LIMIT_BYTES", 0); + if (explicit > 0) return explicit; + if (compat.availableMemoryBytes()) |available| { + return @min(available / BULK_MEMORY_AVAILABLE_FRACTION, MAX_AUTO_BULK_MEMORY_LIMIT); + } + return DEFAULT_BULK_MEMORY_LIMIT; +} + +fn bulkTenantMemoryLimit(global_limit: usize) usize { + const explicit = compat.envUsize("TURBODB_BULK_TENANT_MEMORY_LIMIT_BYTES", 0); + if (explicit > 0) return @min(explicit, global_limit); + return global_limit / BULK_TENANT_MEMORY_FRACTION; +} + +fn estimateBulkMemoryBytes(body_len: usize) usize { + const chunk_bytes = @min(body_len, BULK_INSERT_CHUNK_BYTES); + const row_meta = @min(body_len / 4 + 64 * 1024, 2 * 1024 * 1024); + var total = std.math.add(usize, body_len, BULK_MEMORY_BASE_OVERHEAD) catch return std.math.maxInt(usize); + total = std.math.add(usize, total, row_meta) catch return std.math.maxInt(usize); + total = std.math.add(usize, total, chunk_bytes) catch return std.math.maxInt(usize); + total = std.math.add(usize, total, chunk_bytes) catch return std.math.maxInt(usize); + return total; +} + +fn parseRequestTarget(raw: []const u8) ?ParsedRequestTarget { + const nl = std.mem.indexOfScalar(u8, raw, '\n') orelse return null; + const req_line = std.mem.trimEnd(u8, raw[0..nl], "\r"); + var parts = std.mem.splitScalar(u8, req_line, ' '); + const method = parts.next() orelse return null; + const full_path = parts.next() orelse return null; + + var path = full_path; + var query: []const u8 = ""; + if (std.mem.indexOfScalar(u8, full_path, '?')) |qi| { + path = full_path[0..qi]; + query = full_path[qi + 1 ..]; + } + + return .{ .method = method, .path = path, .query = query }; +} + +fn isBulkInsertTarget(method: []const u8, path: []const u8) bool { + if (!std.mem.eql(u8, method, "POST")) return false; + if (!std.mem.startsWith(u8, path, "/db/")) return false; + const rest = path[4..]; + const sep = std.mem.indexOfScalar(u8, rest, '/') orelse return false; + const key = rest[sep + 1 ..]; + return std.mem.eql(u8, key, "bulk"); +} + +fn preflightBulkAdmission(srv: *Server, initial: []const u8, body_len: usize) BulkPreflight { + const target = parseRequestTarget(initial) orelse return .not_bulk; + if (!isBulkInsertTarget(target.method, target.path)) return .not_bulk; + + var auth_ctx = auth.AuthContext{ + .perm = .admin, + .tenant_id = [_]u8{0} ** 64, + .tenant_id_len = 0, + }; + var auth_ctx_bound = false; + if (srv.db.auth.isEnabled()) { + const api_key = auth.AuthStore.extractHttpKey(initial) orelse + return .{ .reject = .{ .code = 401, .message = "unauthorized — missing X-Api-Key header" } }; + auth_ctx = srv.db.auth.resolve(api_key) orelse + return .{ .reject = .{ .code = 401, .message = "unauthorized — invalid API key" } }; + auth_ctx_bound = true; + } + + const auth_ctx_ptr: ?*const auth.AuthContext = if (auth_ctx_bound) &auth_ctx else null; + if (!canWriteDb(auth_ctx_ptr)) { + return .{ .reject = .{ .code = 403, .message = "forbidden: API key is read-only" } }; + } + + const tenant_id = requestTenant(initial, target.query, auth_ctx_ptr); + const estimated_bytes = estimateBulkMemoryBytes(body_len); + const reservation = srv.acquireBulkMemory(tenant_id, estimated_bytes) catch { + return .{ .reject = .{ .code = 429, .message = "bulk memory limit exceeded" } }; + }; + return .{ .reserved = reservation }; +} + +fn releaseThreadBulkReservation() void { + if (tl_bulk_reservation) |*reservation| reservation.release(); + tl_bulk_reservation = null; +} + /// Wrapper that tracks active connection count around handleConn. fn handleConnWrapped(srv: *Server, conn: compat.net.Server.Connection) void { _ = srv.active_conns.fetchAdd(1, .monotonic); @@ -264,14 +495,25 @@ fn handleConn(srv: *Server, conn: compat.net.Server.Connection) void { if (total_size > MAX_BULK) { const resp_len = err(413, "request too large"); conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; - continue; + return; + } + + switch (preflightBulkAdmission(srv, initial, content_length)) { + .not_bulk => {}, + .reserved => |reservation| tl_bulk_reservation = reservation, + .reject => |rejection| { + const resp_len = err(rejection.code, rejection.message); + conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + return; + }, } if (total_size > bufs.req.len) { const big_buf = std.heap.page_allocator.alloc(u8, total_size) catch { - const resp_len = dispatch(srv, initial, std.heap.page_allocator); + releaseThreadBulkReservation(); + const resp_len = err(500, "request allocation failed"); conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; - continue; + return; }; defer std.heap.page_allocator.free(big_buf); @memcpy(big_buf[0..n], initial); @@ -282,6 +524,7 @@ fn handleConn(srv: *Server, conn: compat.net.Server.Connection) void { n += r; } const resp_len = dispatch(srv, big_buf[0..n], std.heap.page_allocator); + releaseThreadBulkReservation(); conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; continue; } else { @@ -291,6 +534,7 @@ fn handleConn(srv: *Server, conn: compat.net.Server.Connection) void { n += r; } const resp_len = dispatch(srv, bufs.req[0..n], std.heap.page_allocator); + releaseThreadBulkReservation(); conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; continue; } @@ -329,7 +573,12 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { // Route: /metrics if (std.mem.eql(u8, path, "/metrics")) { var fbs = compat.fixedBufferStream(getBodyBuf()); - compat.format(fbs.writer(), "{{\"requests\":{d},\"errors\":{d},\"queries\":{d},\"rows_scanned\":{d},\"bytes_read\":{d},\"cpu_us\":{d},\"cost_nanos_usd\":{d}}}", .{ + srv.bulk_mu.lock(); + const bulk_inflight_bytes = srv.bulk_inflight_bytes; + const bulk_inflight_requests = srv.bulk_inflight_requests; + srv.bulk_mu.unlock(); + const bulk_global_limit = bulkGlobalMemoryLimit(); + compat.format(fbs.writer(), "{{\"requests\":{d},\"errors\":{d},\"queries\":{d},\"rows_scanned\":{d},\"bytes_read\":{d},\"cpu_us\":{d},\"cost_nanos_usd\":{d},\"bulk_inflight_bytes\":{d},\"bulk_inflight_requests\":{d},\"bulk_rejected\":{d},\"bulk_memory_limit_bytes\":{d},\"bulk_tenant_memory_limit_bytes\":{d}}}", .{ srv.req_count.load(.acquire), srv.err_count.load(.acquire), srv.query_count.load(.acquire), @@ -337,6 +586,11 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { srv.query_bytes_read.load(.acquire), srv.query_cpu_us.load(.acquire), srv.query_cost_nanos_usd.load(.acquire), + bulk_inflight_bytes, + bulk_inflight_requests, + srv.bulk_rejected.load(.acquire), + bulk_global_limit, + bulkTenantMemoryLimit(bulk_global_limit), }) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } @@ -523,8 +777,14 @@ fn doInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []co /// Body: NDJSON — one {"key":"...","value":"..."} per line. /// Response: {"inserted":N,"errors":M,"collection":"...","tenant":"..."} fn handleBulkInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, alloc: std.mem.Allocator) usize { - _ = alloc; const start_ns = compat.nanoTimestamp(); + var local_bulk_reservation: ?Server.BulkMemoryReservation = null; + if (tl_bulk_reservation == null) { + local_bulk_reservation = srv.acquireBulkMemory(tenant_id, estimateBulkMemoryBytes(body.len)) catch + return err(429, "bulk memory limit exceeded"); + } + defer if (local_bulk_reservation) |*reservation| reservation.release(); + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); srv.db.ensureTenantStorageAvailable(tenant_id, body.len) catch return err(429, "tenant storage quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); @@ -532,6 +792,28 @@ fn handleBulkInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, b var inserted: u32 = 0; var errors: u32 = 0; var total_bytes: u64 = 0; + var chunk_bytes: usize = 0; + var rows: std.ArrayList(collection.Collection.BulkInsertRow) = .empty; + defer rows.deinit(alloc); + + const BulkFlush = struct { + fn run( + col_arg: *collection.Collection, + rows_arg: *std.ArrayList(collection.Collection.BulkInsertRow), + chunk_bytes_arg: *usize, + inserted_arg: *u32, + errors_arg: *u32, + bytes_arg: *u64, + ) !void { + if (rows_arg.items.len == 0) return; + const bulk = try col_arg.insertBulk(rows_arg.items); + inserted_arg.* += bulk.inserted; + errors_arg.* += bulk.errors; + bytes_arg.* += bulk.bytes; + rows_arg.clearRetainingCapacity(); + chunk_bytes_arg.* = 0; + } + }; // Parse NDJSON: iterate lines, each is a {"key":"...","value":...} object var pos: usize = 0; @@ -544,17 +826,28 @@ fn handleBulkInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, b if (line.len < 2) continue; // skip empty lines // Extract key from this JSON line - const key = jsonStr(line, "key") orelse continue; - // Extract value field; fall back to full line for backwards compat. - const value = jsonValue(line, "value") orelse line; - - _ = col.insert(key, value) catch { + const key = jsonStr(line, "key") orelse { errors += 1; continue; }; - inserted += 1; - total_bytes += line.len; + // Extract value field; fall back to full line for backwards compat. + const value = jsonValue(line, "value") orelse line; + const row_bytes = key.len + value.len + 128; + if (rows.items.len > 0 and chunk_bytes + row_bytes > BULK_INSERT_CHUNK_BYTES) { + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors, &total_bytes) catch + return err(500, "bulk insert failed"); + } + rows.append(alloc, .{ .key = key, .value = value, .line_len = line.len }) catch + return err(500, "bulk request too large"); + chunk_bytes += row_bytes; + + if (rows.items.len >= BULK_INSERT_CHUNK_ROWS) { + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors, &total_bytes) catch + return err(500, "bulk insert failed"); + } } + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors, &total_bytes) catch + return err(500, "bulk insert failed"); srv.recordQueryCost(tenant_id, "bulk_insert", inserted, total_bytes, start_ns); @@ -1919,6 +2212,46 @@ test "read-only auth cannot write basic db methods" { try std.testing.expect(canWriteDb(&ctx)); } +test "bulk memory estimate includes body and bounded chunk workspace" { + const small = estimateBulkMemoryBytes(1024); + const large = estimateBulkMemoryBytes(MAX_BULK); + try std.testing.expect(small > 1024); + try std.testing.expect(large > MAX_BULK); + try std.testing.expect(large < MAX_BULK + 16 * 1024 * 1024); +} + +test "bulk memory admission enforces tenant and global limits" { + const alloc = std.testing.allocator; + var fake_db: Database = undefined; + var srv = Server.init(alloc, &fake_db, 0); + defer srv.deinit(); + + const request_bytes = estimateBulkMemoryBytes(1024 * 1024); + const global_limit = request_bytes * 2 + 1024; + const tenant_limit = request_bytes + 512; + + var r1 = try srv.acquireBulkMemoryWithLimits("tenant-a", request_bytes, global_limit, tenant_limit); + try std.testing.expectError( + error.BulkMemoryLimitExceeded, + srv.acquireBulkMemoryWithLimits("tenant-a", request_bytes, global_limit, tenant_limit), + ); + + var r2 = try srv.acquireBulkMemoryWithLimits("tenant-b", request_bytes, global_limit, tenant_limit); + try std.testing.expectError( + error.BulkMemoryLimitExceeded, + srv.acquireBulkMemoryWithLimits("tenant-c", request_bytes, global_limit, tenant_limit), + ); + + r1.release(); + var r3 = try srv.acquireBulkMemoryWithLimits("tenant-c", request_bytes, global_limit, tenant_limit); + r2.release(); + r3.release(); + + try std.testing.expectEqual(@as(usize, 0), srv.bulk_inflight_bytes); + try std.testing.expectEqual(@as(u32, 0), srv.bulk_inflight_requests); + try std.testing.expectEqual(@as(u32, 0), srv.bulk_tenant_usage.count()); +} + test "KeyIter reads JSON arrays and keyed newline bodies" { var array_iter = KeyIter.init("{\"keys\":[\"1\",\"2\",\"3\"]}"); try std.testing.expectEqualStrings("1", array_iter.next().?); diff --git a/src/storage/wal.zig b/src/storage/wal.zig index c15f0e5..e7e2254 100644 --- a/src/storage/wal.zig +++ b/src/storage/wal.zig @@ -86,6 +86,8 @@ pub const Entry = struct { payload: []const u8, // slice into caller-owned buffer }; +pub const BatchPayload = []const u8; + // ── CRC-32 (IEEE polynomial) ────────────────────────────────────────────────── fn crc32(data: []const u8) u32 { @@ -311,6 +313,112 @@ pub const WAL = struct { return self.write(txn_id, op, db_tag, FLAG_COMMIT, payload); } + /// Append many self-committed entries while holding the WAL mutex once. + /// Returns the last LSN appended. Call flushUpTo(last_lsn) before + /// acknowledging if the caller requires durable ingestion. + pub fn writeCommittedBatch( + self: *WAL, + op: OpCode, + db_tag: u8, + payloads: []const BatchPayload, + ) !u64 { + if (payloads.len == 0) return self.synced_lsn; + + var bytes_needed: usize = 0; + for (payloads) |payload| { + if (payload.len > MAX_ENTRY_PAYLOAD) return error.WALPayloadTooLarge; + const raw_len = HEADER_SIZE + payload.len; + bytes_needed += raw_len + paddingTo8(raw_len); + } + + self.mu.lock(); + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } + while (self.write_buf.items.len >= MAX_WRITE_BUF) { + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } + self.cond.wait(&self.mu); + } + self.write_buf.ensureUnusedCapacity(self.allocator, bytes_needed) catch |e| { + self.mu.unlock(); + return e; + }; + + const start_lsn = self.next_lsn.fetchAdd(@intCast(payloads.len), .monotonic); + for (payloads, 0..) |payload, i| { + const lsn = start_lsn + @as(u64, @intCast(i)); + const pad = paddingTo8(HEADER_SIZE + payload.len); + var hdr = EntryHeader{ + .lsn = lsn, + .length = @intCast(payload.len), + .crc32 = 0, + .op_code = @intFromEnum(op), + .db_tag = db_tag, + .flags = FLAG_COMMIT, + .txn_id = lsn, + .reserved = 0, + }; + const hdr_bytes = std.mem.asBytes(&hdr); + hdr.crc32 = entryChecksum(hdr_bytes, payload); + self.write_buf.appendSliceAssumeCapacity(std.mem.asBytes(&hdr)); + self.write_buf.appendSliceAssumeCapacity(payload); + if (pad > 0) self.write_buf.appendNTimesAssumeCapacity(0, pad); + } + self.mu.unlock(); + + return start_lsn + @as(u64, @intCast(payloads.len - 1)); + } + + /// Ensure every entry up to `target_lsn` is durably flushed. + pub fn flushUpTo(self: *WAL, target_lsn: u64) !void { + self.mu.lock(); + while (self.synced_lsn < target_lsn) { + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } + + if (self.flushing) { + self.cond.wait(&self.mu); + continue; + } + + if (self.write_buf.items.len == 0) { + self.mu.unlock(); + return error.WALNotDurable; + } + + self.flushing = true; + var to_write: std.ArrayList(u8) = self.write_buf; + const flushed_lsn = self.next_lsn.load(.monotonic) - 1; + self.write_buf = .empty; + self.mu.unlock(); + + const io_err = self.writeAndSync(to_write.items); + to_write.deinit(self.allocator); + + self.mu.lock(); + if (io_err) |e| { + self.last_flush_error = e; + } else { + self.synced_lsn = flushed_lsn; + self.last_flush_error = null; + } + self.flushing = false; + self.cond.broadcast(); + + if (io_err) |e| { + self.mu.unlock(); + return e; + } + } + self.mu.unlock(); + } + /// Mark a transaction committed. Returns only after the entry is durable. /// Implements group commit: the first caller flushes for everyone. pub fn commit(self: *WAL, txn_id: u64, db_tag: u8) !void { @@ -680,6 +788,38 @@ test "writeCommitted buffers committed entry without forcing fsync" { } } +test "writeCommittedBatch plus flushUpTo durably replays all entries" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "batch-committed.wal"); + defer allocator.free(path); + + { + var wal = try WAL.open(path, allocator); + defer wal.close(); + + const payloads = [_]BatchPayload{ "one", "two", "three" }; + const last_lsn = try wal.writeCommittedBatch(.doc_insert, DB_TAG_DOC, payloads[0..]); + try wal.flushUpTo(last_lsn); + try std.testing.expect(wal.synced_lsn >= last_lsn); + } + + { + var wal = try WAL.open(path, allocator); + defer wal.close(); + + ReplayProbe.reset(); + try wal.recover(0, ReplayProbe.apply, allocator); + try std.testing.expectEqual(@as(usize, 3), ReplayProbe.count); + try std.testing.expectEqual(OpCode.doc_insert, ReplayProbe.last_op); + try std.testing.expectEqual(@as(u64, 3), ReplayProbe.last_txn_id); + try std.testing.expect((ReplayProbe.last_flags & FLAG_COMMIT) != 0); + try std.testing.expectEqualStrings("three", ReplayProbe.payload()); + } +} + test "commit still forces durable transaction commit record" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); From e76d7dd9f6622635a9d973dc6fbaf9615867e580 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:35:24 +0800 Subject: [PATCH 22/27] perf: speed durable bulk ingestion --- src/codeindex.zig | 377 +++++++++++++++++++++++++-------------------- src/collection.zig | 116 +++++++++++--- src/ffi.zig | 52 +++++-- 3 files changed, 351 insertions(+), 194 deletions(-) diff --git a/src/codeindex.zig b/src/codeindex.zig index f8a9847..bbd4e77 100644 --- a/src/codeindex.zig +++ b/src/codeindex.zig @@ -82,7 +82,6 @@ pub const WordIndex = struct { } } - /// Index a file's content — tokenizes into words and records hits. pub fn indexFile(self: *WordIndex, path: []const u8, content: []const u8) !void { self.mu.lock(); @@ -154,33 +153,33 @@ pub const WordIndex = struct { /// Look up hits, returning results allocated by the caller. /// Deduplicates by (path, line_num). -pub fn searchDeduped(self: *WordIndex, word: []const u8, allocator: std.mem.Allocator) ![]const WordHit { - const hits = self.search(word); - if (hits.len == 0) return try allocator.alloc(WordHit, 0); - if (hits.len == 1) { - var out = try allocator.alloc(WordHit, 1); - out[0] = hits[0]; - return out; - } + pub fn searchDeduped(self: *WordIndex, word: []const u8, allocator: std.mem.Allocator) ![]const WordHit { + const hits = self.search(word); + if (hits.len == 0) return try allocator.alloc(WordHit, 0); + if (hits.len == 1) { + var out = try allocator.alloc(WordHit, 1); + out[0] = hits[0]; + return out; + } - const DedupKey = struct { path_ptr: usize, line_num: u32 }; - var seen = std.AutoHashMap(DedupKey, void).init(allocator); - defer seen.deinit(); - try seen.ensureTotalCapacity(@intCast(hits.len)); + const DedupKey = struct { path_ptr: usize, line_num: u32 }; + var seen = std.AutoHashMap(DedupKey, void).init(allocator); + defer seen.deinit(); + try seen.ensureTotalCapacity(@intCast(hits.len)); - var result: std.ArrayList(WordHit) = .{}; - errdefer result.deinit(allocator); - try result.ensureTotalCapacity(allocator, hits.len); + var result: std.ArrayList(WordHit) = .{}; + errdefer result.deinit(allocator); + try result.ensureTotalCapacity(allocator, hits.len); - for (hits) |hit| { - const key = DedupKey{ .path_ptr = @intFromPtr(hit.path.ptr), .line_num = hit.line_num }; - const gop = try seen.getOrPut(key); - if (!gop.found_existing) { - result.appendAssumeCapacity(hit); + for (hits) |hit| { + const key = DedupKey{ .path_ptr = @intFromPtr(hit.path.ptr), .line_num = hit.line_num }; + const gop = try seen.getOrPut(key); + if (!gop.found_existing) { + result.appendAssumeCapacity(hit); + } } + return result.toOwnedSlice(allocator); } - return result.toOwnedSlice(allocator); -} }; // ── Trigram index ─────────────────────────────────────────── @@ -194,7 +193,6 @@ pub fn packTrigram(a: u8, b: u8, c: u8) Trigram { return @as(Trigram, a) << 16 | @as(Trigram, b) << 8 | @as(Trigram, c); } - pub const PostingMask = struct { next_mask: u8 = 0, // bloom filter of chars following this trigram loc_mask: u8 = 0, // bit mask of (position % 8) where trigram appears @@ -264,7 +262,6 @@ fn postingSortedInsert(list: *PostingList, alloc: std.mem.Allocator, entry: Post }; } - pub const TrigramIndex = struct { /// trigram → dense sorted posting list index: std.AutoHashMap(Trigram, PostingList), @@ -412,9 +409,15 @@ pub const TrigramIndex = struct { ) !void { if (paths.len == 0) return; - // Per-doc trigram sets collected outside the lock + // Per-doc trigram sets collected outside the lock. Keep the common + // small case on the stack, but accept larger caller batches too. const BatchEntry = struct { tris: []Trigram }; - var batch: [64]BatchEntry = undefined; + var stack_batch: [64]BatchEntry = undefined; + const batch = if (paths.len <= stack_batch.len) + stack_batch[0..paths.len] + else + try self.allocator.alloc(BatchEntry, paths.len); + defer if (paths.len > stack_batch.len) self.allocator.free(batch); for (paths, contents, 0..) |path, content, di| { _ = path; @@ -477,119 +480,118 @@ pub const TrigramIndex = struct { self.file_trigrams.put(file_id, tri_list) catch {}; } } -pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.Allocator) ?[]const []const u8 { - self.mu.lock(); - defer self.mu.unlock(); - - if (query.len < 3) return null; - const tri_count = query.len - 2; - - // Deduplicate query trigrams first so repeated trigrams don't do repeated work. - var unique = std.AutoHashMap(Trigram, void).init(allocator); - defer unique.deinit(); - unique.ensureTotalCapacity(@intCast(tri_count)) catch return null; - for (0..tri_count) |i| { - const tri = packTrigram( - normalizeChar(query[i]), - normalizeChar(query[i + 1]), - normalizeChar(query[i + 2]), - ); - _ = unique.getOrPut(tri) catch return null; - } + pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.Allocator) ?[]const []const u8 { + self.mu.lock(); + defer self.mu.unlock(); - var sets: std.ArrayList(*PostingList) = .empty; - defer sets.deinit(allocator); - sets.ensureTotalCapacity(allocator, unique.count()) catch return null; + if (query.len < 3) return null; + const tri_count = query.len - 2; - var tri_iter = unique.keyIterator(); - while (tri_iter.next()) |tri_ptr| { - const file_set = self.index.getPtr(tri_ptr.*) orelse { - return allocator.alloc([]const u8, 0) catch null; - }; - sets.appendAssumeCapacity(file_set); - } + // Deduplicate query trigrams first so repeated trigrams don't do repeated work. + var unique = std.AutoHashMap(Trigram, void).init(allocator); + defer unique.deinit(); + unique.ensureTotalCapacity(@intCast(tri_count)) catch return null; + for (0..tri_count) |i| { + const tri = packTrigram( + normalizeChar(query[i]), + normalizeChar(query[i + 1]), + normalizeChar(query[i + 2]), + ); + _ = unique.getOrPut(tri) catch return null; + } - if (sets.items.len == 0) { - return allocator.alloc([]const u8, 0) catch null; - } + var sets: std.ArrayList(*PostingList) = .empty; + defer sets.deinit(allocator); + sets.ensureTotalCapacity(allocator, unique.count()) catch return null; - // Iterate the smallest set and check membership in all others. - var min_idx: usize = 0; - var min_count = sets.items[0].items.len; - for (sets.items[1..], 1..) |set, i| { - const count = set.items.len; - if (count < min_count) { - min_count = count; - min_idx = i; + var tri_iter = unique.keyIterator(); + while (tri_iter.next()) |tri_ptr| { + const file_set = self.index.getPtr(tri_ptr.*) orelse { + return allocator.alloc([]const u8, 0) catch null; + }; + sets.appendAssumeCapacity(file_set); } - } - var result: std.ArrayList([]const u8) = .empty; - errdefer result.deinit(allocator); - result.ensureTotalCapacity(allocator, min_count) catch return null; - - for (sets.items[min_idx].items) |entry| { - const file_id = entry.file_id; - - // Intersection check: candidate must be in all sets - var in_all = true; - for (sets.items, 0..) |set, i| { - if (i == min_idx) continue; - if (!postingContains(set, file_id)) { - in_all = false; - break; + if (sets.items.len == 0) { + return allocator.alloc([]const u8, 0) catch null; + } + + // Iterate the smallest set and check membership in all others. + var min_idx: usize = 0; + var min_count = sets.items[0].items.len; + for (sets.items[1..], 1..) |set, i| { + const count = set.items.len; + if (count < min_count) { + min_count = count; + min_idx = i; } } - if (!in_all) continue; - // Bloom-filter check for consecutive trigram pairs - var bloom_pass = true; - if (tri_count >= 2) { - for (0..tri_count - 1) |j| { - const tri_a = packTrigram( - normalizeChar(query[j]), - normalizeChar(query[j + 1]), - normalizeChar(query[j + 2]), - ); - const tri_b = packTrigram( - normalizeChar(query[j + 1]), - normalizeChar(query[j + 2]), - normalizeChar(query[j + 3]), - ); - const set_a = self.index.getPtr(tri_a) orelse continue; - const set_b = self.index.getPtr(tri_b) orelse continue; - const mask_a = postingGet(set_a, file_id) orelse continue; - const mask_b = postingGet(set_b, file_id) orelse continue; - - // next_mask: bit for query[j+3] must be set in tri_a's next_mask - const next_bit: u8 = @as(u8, 1) << @intCast(normalizeChar(query[j + 3]) % 8); - if ((mask_a.next_mask & next_bit) == 0) { - bloom_pass = false; + var result: std.ArrayList([]const u8) = .empty; + errdefer result.deinit(allocator); + result.ensureTotalCapacity(allocator, min_count) catch return null; + + for (sets.items[min_idx].items) |entry| { + const file_id = entry.file_id; + + // Intersection check: candidate must be in all sets + var in_all = true; + for (sets.items, 0..) |set, i| { + if (i == min_idx) continue; + if (!postingContains(set, file_id)) { + in_all = false; break; } + } + if (!in_all) continue; + + // Bloom-filter check for consecutive trigram pairs + var bloom_pass = true; + if (tri_count >= 2) { + for (0..tri_count - 1) |j| { + const tri_a = packTrigram( + normalizeChar(query[j]), + normalizeChar(query[j + 1]), + normalizeChar(query[j + 2]), + ); + const tri_b = packTrigram( + normalizeChar(query[j + 1]), + normalizeChar(query[j + 2]), + normalizeChar(query[j + 3]), + ); + const set_a = self.index.getPtr(tri_a) orelse continue; + const set_b = self.index.getPtr(tri_b) orelse continue; + const mask_a = postingGet(set_a, file_id) orelse continue; + const mask_b = postingGet(set_b, file_id) orelse continue; + + // next_mask: bit for query[j+3] must be set in tri_a's next_mask + const next_bit: u8 = @as(u8, 1) << @intCast(normalizeChar(query[j + 3]) % 8); + if ((mask_a.next_mask & next_bit) == 0) { + bloom_pass = false; + break; + } - // loc_mask adjacency: use circular shift to handle position wrap-around - const rotated = (mask_a.loc_mask << 1) | (mask_a.loc_mask >> 7); - if ((rotated & mask_b.loc_mask) == 0) { - bloom_pass = false; - break; + // loc_mask adjacency: use circular shift to handle position wrap-around + const rotated = (mask_a.loc_mask << 1) | (mask_a.loc_mask >> 7); + if ((rotated & mask_b.loc_mask) == 0) { + bloom_pass = false; + break; + } } } - } - if (!bloom_pass) continue; + if (!bloom_pass) continue; - // Translate file_id -> path - if (file_id < self.id_to_path.items.len) { - result.appendAssumeCapacity(self.id_to_path.items[file_id]); + // Translate file_id -> path + if (file_id < self.id_to_path.items.len) { + result.appendAssumeCapacity(self.id_to_path.items[file_id]); + } } - } - - return result.toOwnedSlice(allocator) catch { - result.deinit(allocator); - return null; - }; -} + return result.toOwnedSlice(allocator) catch { + result.deinit(allocator); + return null; + }; + } /// Find candidate files matching a RegexQuery. /// Intersects AND trigrams, then for each OR group unions posting lists @@ -951,7 +953,10 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All if (result.file_trigrams.getPtr(file_id)) |tri_list| { var found = false; for (tri_list.items) |existing| { - if (existing == tri) { found = true; break; } + if (existing == tri) { + found = true; + break; + } } if (!found) try tri_list.append(allocator, tri); } @@ -1019,10 +1024,8 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All const header = try readDiskHeader(dir_path, allocator) orelse return null; return header.git_head; } - }; - // ── Regex decomposition ───────────────────────────────────── pub const RegexQuery = struct { @@ -1059,11 +1062,30 @@ pub fn decomposeRegex(pattern: []const u8, allocator: std.mem.Allocator) !RegexQ i += 2; continue; } - if (c == '[') { in_bracket = true; i += 1; continue; } - if (c == ']') { in_bracket = false; i += 1; continue; } - if (in_bracket) { i += 1; continue; } - if (c == '(') { depth += 1; i += 1; continue; } - if (c == ')') { if (depth > 0) depth -= 1; i += 1; continue; } + if (c == '[') { + in_bracket = true; + i += 1; + continue; + } + if (c == ']') { + in_bracket = false; + i += 1; + continue; + } + if (in_bracket) { + i += 1; + continue; + } + if (c == '(') { + depth += 1; + i += 1; + continue; + } + if (c == ')') { + if (depth > 0) depth -= 1; + i += 1; + continue; + } if (c == '|' and depth == 0) { try top_pipes.append(allocator, i); } @@ -1256,7 +1278,6 @@ fn flushLiterals( literals.clearRetainingCapacity(); } - // ── Tokenizer ─────────────────────────────────────────────── pub const WordTokenizer = struct { @@ -1296,42 +1317,70 @@ pub const MAX_NGRAM_LEN: usize = 16; /// Rare pairs → HIGH weight (they become n-gram boundaries). /// All unspecified pairs default to 0xFE00 (rare = high weight). pub const default_pair_freq: [256][256]u16 = blk: { - var table: [256][256]u16 = .{.{0xFE00} ** 256} ** 256; // English bigrams (lowercase) — common in identifiers and prose - table['t']['h'] = 0x1000; table['h']['e'] = 0x1000; - table['i']['n'] = 0x1000; table['e']['r'] = 0x1000; - table['a']['n'] = 0x1000; table['r']['e'] = 0x1000; - table['o']['n'] = 0x1000; table['e']['n'] = 0x1000; - table['s']['t'] = 0x1000; table['e']['s'] = 0x1000; - table['a']['t'] = 0x1000; table['i']['o'] = 0x1000; - table['t']['e'] = 0x1000; table['o']['r'] = 0x1000; - table['t']['i'] = 0x1000; table['a']['r'] = 0x1000; - table['a']['l'] = 0x1000; table['l']['e'] = 0x1000; - table['n']['t'] = 0x1000; table['e']['d'] = 0x1000; - table['n']['d'] = 0x1000; table['o']['u'] = 0x1000; - table['e']['a'] = 0x1000; table['f']['o'] = 0x1000; + table['t']['h'] = 0x1000; + table['h']['e'] = 0x1000; + table['i']['n'] = 0x1000; + table['e']['r'] = 0x1000; + table['a']['n'] = 0x1000; + table['r']['e'] = 0x1000; + table['o']['n'] = 0x1000; + table['e']['n'] = 0x1000; + table['s']['t'] = 0x1000; + table['e']['s'] = 0x1000; + table['a']['t'] = 0x1000; + table['i']['o'] = 0x1000; + table['t']['e'] = 0x1000; + table['o']['r'] = 0x1000; + table['t']['i'] = 0x1000; + table['a']['r'] = 0x1000; + table['a']['l'] = 0x1000; + table['l']['e'] = 0x1000; + table['n']['t'] = 0x1000; + table['e']['d'] = 0x1000; + table['n']['d'] = 0x1000; + table['o']['u'] = 0x1000; + table['e']['a'] = 0x1000; + table['f']['o'] = 0x1000; // Common code keyword fragments - table['f']['n'] = 0x1000; table['i']['f'] = 0x1000; - table['r']['n'] = 0x1000; table['t']['u'] = 0x1000; - table['p']['u'] = 0x1000; table['b']['l'] = 0x1000; - table['c']['o'] = 0x1000; table['n']['s'] = 0x1000; - table['t']['r'] = 0x1000; table['u']['e'] = 0x1000; + table['f']['n'] = 0x1000; + table['i']['f'] = 0x1000; + table['r']['n'] = 0x1000; + table['t']['u'] = 0x1000; + table['p']['u'] = 0x1000; + table['b']['l'] = 0x1000; + table['c']['o'] = 0x1000; + table['n']['s'] = 0x1000; + table['t']['r'] = 0x1000; + table['u']['e'] = 0x1000; // Common operator / punctuation pairs - table['('][')'] = 0x0800; table['{']['}'] = 0x0800; - table['['][']'] = 0x0800; table['/']['/'] = 0x0800; - table['-']['>'] = 0x0800; table['=']['>'] = 0x0800; - table[':'][':'] = 0x0800; table['!']['='] = 0x0800; - table['=']['='] = 0x0800; table['<']['='] = 0x0800; - table['>']['='] = 0x0800; table['&']['&'] = 0x0800; + table['('][')'] = 0x0800; + table['{']['}'] = 0x0800; + table['['][']'] = 0x0800; + table['/']['/'] = 0x0800; + table['-']['>'] = 0x0800; + table['=']['>'] = 0x0800; + table[':'][':'] = 0x0800; + table['!']['='] = 0x0800; + table['=']['='] = 0x0800; + table['<']['='] = 0x0800; + table['>']['='] = 0x0800; + table['&']['&'] = 0x0800; table['|']['|'] = 0x0800; // Whitespace / structural pairs - table[' '][' '] = 0x0800; table['\t'][' '] = 0x0800; - table[' ']['('] = 0x0800; table[' ']['{'] = 0x0800; - table[';'][' '] = 0x0800; table[':'][' '] = 0x0800; - table['='][' '] = 0x0800; table[' ']['='] = 0x0800; - table[','][' '] = 0x0800; table['.']['.'] = 0x0800; - table['\n'][' '] = 0x0800; table['\n']['\t'] = 0x0800; + table[' '][' '] = 0x0800; + table['\t'][' '] = 0x0800; + table[' ']['('] = 0x0800; + table[' ']['{'] = 0x0800; + table[';'][' '] = 0x0800; + table[':'][' '] = 0x0800; + table['='][' '] = 0x0800; + table[' ']['='] = 0x0800; + table[','][' '] = 0x0800; + table['.']['.'] = 0x0800; + table['\n'][' '] = 0x0800; + table['\n']['\t'] = 0x0800; break :blk table; }; @@ -1340,7 +1389,6 @@ pub const default_pair_freq: [256][256]u16 = blk: { pub var active_pair_freq: *const [256][256]u16 = &default_pair_freq; var loaded_freq_table: [256][256]u16 = undefined; - /// Deterministic weight for a character pair, used to place content-defined /// boundaries between n-grams. Frequency-weighted: common source-code pairs /// get LOW weight (they stay interior to n-grams); rare pairs get HIGH weight @@ -1477,7 +1525,7 @@ pub fn readFrequencyTable(dir_path: []const u8, allocator: std.mem.Allocator) !? /// A single sparse n-gram extracted from a string. pub const SparseNgram = struct { - hash: u64, // Wyhash of the normalized (lowercased) n-gram bytes + hash: u64, // Wyhash of the normalized (lowercased) n-gram bytes pos: usize, // byte offset in the source string len: usize, // byte length of the n-gram }; @@ -1561,7 +1609,6 @@ pub fn extractSparseNgrams(content: []const u8, allocator: std.mem.Allocator) ![ // every byte in the span is covered. try result.append(allocator, makeNgram(content, ngram_end - MIN_LEN, MIN_LEN)); } - } } diff --git a/src/collection.zig b/src/collection.zig index e57bf98..04b956b 100644 --- a/src/collection.zig +++ b/src/collection.zig @@ -58,8 +58,11 @@ pub const IndexQueue = struct { const CAPACITY = 131072; // 128K entries — sized for 100+ concurrent writers const Entry = struct { - key: []const u8, - value: []const u8, + key: []const u8 = "", + value: []const u8 = "", + loc: BTreeEntry = undefined, + stored: bool = false, + queued_bytes: usize = 0, }; buf: []Entry, @@ -81,7 +84,22 @@ pub const IndexQueue = struct { } /// Push a (key, value) pair. Lock-free MPSC via CAS + per-slot ready flag. - pub fn push(self: *IndexQueue, key: []const u8, value: []const u8) bool { + pub fn pushOwned(self: *IndexQueue, key: []const u8, value: []const u8, queued_bytes: usize) bool { + return self.push(.{ + .key = key, + .value = value, + .queued_bytes = queued_bytes, + }); + } + + pub fn pushStored(self: *IndexQueue, loc: BTreeEntry) bool { + return self.push(.{ + .loc = loc, + .stored = true, + }); + } + + fn push(self: *IndexQueue, entry: Entry) bool { while (true) { const h = self.head.load(.acquire); const next = (h + 1) % CAPACITY; @@ -91,7 +109,7 @@ pub const IndexQueue = struct { continue; // CAS failed — another producer won, retry } // We own slot h — write data then signal readiness. - self.buf[h] = .{ .key = key, .value = value }; + self.buf[h] = entry; self.ready[h].store(1, .release); return true; } @@ -214,6 +232,8 @@ pub const Collection = struct { enc_off: usize, enc_len: usize, line_len: usize, + page_no: u32 = 0, + page_off: u16 = 0, }; name_buf: [128]u8, @@ -442,6 +462,7 @@ pub const Collection = struct { // Single-entry document writes are self-committed for recovery. try writeCommittedDocumentWal(self.wal_log, .doc_insert, enc); + var inserted_entry: BTreeEntry = undefined; self.meta_mu.lock(); { defer self.meta_mu.unlock(); @@ -460,6 +481,7 @@ pub const Collection = struct { }; try self.idx.insert(entry); self.hash_idx.put(hdr.key_hash, entry) catch {}; + inserted_entry = entry; // MVCC: register version in the version chain. const epoch = self.epochs.advance(); @@ -467,8 +489,6 @@ pub const Collection = struct { self.key_doc_ids.put(hdr.key_hash, doc_id) catch {}; self.key_epochs.put(hdr.key_hash, doc_id) catch {}; // epoch = doc_id (monotonic) - self.enqueueTextIndex(key, value); - // Extract and store vector embedding if configured. // Uses stack buffer to avoid heap allocation per insert. if (self.vectors) |vc| { @@ -495,6 +515,7 @@ pub const Collection = struct { } } + self.enqueueStoredTextIndex(inserted_entry, key, value); emitChange(self, .insert, key, value, doc_id); return doc_id; @@ -568,7 +589,7 @@ pub const Collection = struct { self.meta_mu.lock(); { defer self.meta_mu.unlock(); - for (prepared.items) |item| { + for (prepared.items) |*item| { const enc = encoded.items[item.enc_off .. item.enc_off + item.enc_len]; const pno = try self.findOrAllocLeaf(enc.len); const page_off = self.pf.leafAppend(pno, enc) orelse return error.PageFull; @@ -580,6 +601,8 @@ pub const Collection = struct { }; try self.idx.insert(entry); self.hash_idx.put(item.key_hash, entry) catch {}; + item.page_no = pno; + item.page_off = page_off; const epoch = self.epochs.advance(); self.versions.appendVersion(self.alloc, item.doc_id, pno, page_off, epoch) catch {}; @@ -614,7 +637,16 @@ pub const Collection = struct { } for (prepared.items) |item| { - self.enqueueTextIndex(item.key, item.value); + self.enqueueStoredTextIndex( + .{ + .key_hash = item.key_hash, + .doc_id = item.doc_id, + .page_no = item.page_no, + .page_off = item.page_off, + }, + item.key, + item.value, + ); emitChange(self, .insert, item.key, item.value, item.doc_id); } @@ -1202,7 +1234,7 @@ pub const Collection = struct { &self.index_queue2; var retries: u32 = 0; - while (!q.push(owned_key, owned_val)) { + while (!q.pushOwned(owned_key, owned_val, queued_bytes)) { retries += 1; if (retries > 1000) { self.releaseIndexQueueBytes(queued_bytes); @@ -1216,6 +1248,33 @@ pub const Collection = struct { } } + fn enqueueStoredTextIndex(self: *Collection, entry: BTreeEntry, key: []const u8, value: []const u8) void { + if (value.len < 3) return; + if (self.index_thread == null and self.index_thread2 == null) { + self.tri.indexFile(key, value) catch {}; + self.words.indexFile(key, value) catch {}; + return; + } + + const q = if (self.index_thread != null and self.index_thread2 != null) + if (self.queue_toggle.fetchAdd(1, .monotonic) % 2 == 0) &self.index_queue else &self.index_queue2 + else if (self.index_thread != null) + &self.index_queue + else + &self.index_queue2; + + var retries: u32 = 0; + while (!q.pushStored(entry)) { + retries += 1; + if (retries > 1000) { + self.tri.indexFile(key, value) catch {}; + self.words.indexFile(key, value) catch {}; + return; + } + std.Thread.yield() catch {}; + } + } + fn tryReserveIndexQueueBytes(self: *Collection, bytes: usize) bool { const limit = indexQueueMemoryLimit(); while (true) { @@ -1718,6 +1777,7 @@ fn indexWorkerQ(col: *Collection, queue: *IndexQueue) void { const BATCH = 64; var batch_keys: [BATCH][]const u8 = undefined; var batch_vals: [BATCH][]const u8 = undefined; + var batch_entries: [BATCH]IndexQueue.Entry = undefined; var reusable_tris = std.AutoHashMap(codeindex.Trigram, void).init(col.alloc); defer reusable_tris.deinit(); @@ -1728,8 +1788,16 @@ fn indexWorkerQ(col: *Collection, queue: *IndexQueue) void { var n: usize = 0; while (n < BATCH) { const entry = queue.pop() orelse break; - batch_keys[n] = entry.key; - batch_vals[n] = entry.value; + if (entry.stored) { + const doc = col.readEntry(entry.loc) orelse continue; + if (doc.value.len < 3) continue; + batch_keys[n] = doc.key; + batch_vals[n] = doc.value; + } else { + batch_keys[n] = entry.key; + batch_vals[n] = entry.value; + } + batch_entries[n] = entry; n += 1; } if (n == 0) { @@ -1744,19 +1812,29 @@ fn indexWorkerQ(col: *Collection, queue: *IndexQueue) void { col.words.indexFile(batch_keys[i], batch_vals[i]) catch {}; } for (0..n) |i| { - col.releaseIndexQueueBytes(indexEntryBytes(batch_keys[i], batch_vals[i])); - col.alloc.free(batch_vals[i]); - col.alloc.free(batch_keys[i]); + const entry = batch_entries[i]; + if (!entry.stored) { + col.releaseIndexQueueBytes(entry.queued_bytes); + col.alloc.free(entry.value); + col.alloc.free(entry.key); + } } _ = col.indexing_count.fetchSub(1, .release); } // Final drain on shutdown. while (queue.pop()) |entry| { - col.tri.indexFile(entry.key, entry.value) catch {}; - col.words.indexFile(entry.key, entry.value) catch {}; - col.releaseIndexQueueBytes(indexEntryBytes(entry.key, entry.value)); - col.alloc.free(entry.value); - col.alloc.free(entry.key); + if (entry.stored) { + if (col.readEntry(entry.loc)) |doc| { + col.tri.indexFile(doc.key, doc.value) catch {}; + col.words.indexFile(doc.key, doc.value) catch {}; + } + } else { + col.tri.indexFile(entry.key, entry.value) catch {}; + col.words.indexFile(entry.key, entry.value) catch {}; + col.releaseIndexQueueBytes(entry.queued_bytes); + col.alloc.free(entry.value); + col.alloc.free(entry.key); + } } } diff --git a/src/ffi.zig b/src/ffi.zig index 25afea9..d77a603 100644 --- a/src/ffi.zig +++ b/src/ffi.zig @@ -17,6 +17,8 @@ const Collection = collection.Collection; const Doc = doc_mod.Doc; const alloc = std.heap.c_allocator; +const BULK_INSERT_CHUNK_ROWS: usize = 4096; +const BULK_INSERT_CHUNK_BYTES: usize = 4 * 1024 * 1024; // ── Handle wrappers with magic sentinel for validation ────────────────────── @@ -173,6 +175,26 @@ export fn turbodb_insert_bulk_ndjson( var inserted: u32 = 0; var errors: u32 = 0; const input = body[0..body_len]; + var rows: std.ArrayList(Collection.BulkInsertRow) = .empty; + defer rows.deinit(alloc); + var chunk_bytes: usize = 0; + + const BulkFlush = struct { + fn run( + col_arg: *Collection, + rows_arg: *std.ArrayList(Collection.BulkInsertRow), + chunk_bytes_arg: *usize, + inserted_arg: *u32, + errors_arg: *u32, + ) !void { + if (rows_arg.items.len == 0) return; + const result = try col_arg.insertBulk(rows_arg.items); + inserted_arg.* += result.inserted; + errors_arg.* += result.errors; + rows_arg.clearRetainingCapacity(); + chunk_bytes_arg.* = 0; + } + }; var pos: usize = 0; while (pos < input.len) { @@ -186,12 +208,18 @@ export fn turbodb_insert_bulk_ndjson( continue; }; const value = jsonValue(line, "value") orelse line; - _ = col.insert(key, value) catch { - errors += 1; - continue; - }; - inserted += 1; + const row_bytes = key.len + value.len + 128; + if (rows.items.len > 0 and chunk_bytes + row_bytes > BULK_INSERT_CHUNK_BYTES) { + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors) catch return -1; + } + rows.append(alloc, .{ .key = key, .value = value, .line_len = line.len }) catch return -1; + chunk_bytes += row_bytes; + + if (rows.items.len >= BULK_INSERT_CHUNK_ROWS) { + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors) catch return -1; + } } + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors) catch return -1; out_inserted.* = inserted; out_errors.* = errors; @@ -201,9 +229,12 @@ export fn turbodb_insert_bulk_ndjson( /// Insert with a pre-computed embedding (no JSON parsing). Fast path for vector inserts. export fn turbodb_insert_with_embedding( col_handle: *anyopaque, - key: [*]const u8, key_len: usize, - val: [*]const u8, val_len: usize, - embedding: [*]const f32, dims: u32, + key: [*]const u8, + key_len: usize, + val: [*]const u8, + val_len: usize, + embedding: [*]const f32, + dims: u32, out_id: *u64, ) c_int { const col = validateColHandle(col_handle) orelse return -1; @@ -382,8 +413,9 @@ const KeyIter = struct { while (self.pos < self.body.len) { while (self.pos < self.body.len and (self.body[self.pos] == ' ' or self.body[self.pos] == '\t' or - self.body[self.pos] == '\r' or self.body[self.pos] == '\n' or - self.body[self.pos] == ',')) : (self.pos += 1) {} + self.body[self.pos] == '\r' or self.body[self.pos] == '\n' or + self.body[self.pos] == ',')) : (self.pos += 1) + {} if (self.pos >= self.body.len or self.body[self.pos] == ']') return null; if (self.body[self.pos] == '"') { From cceeba16572bb9136570a5c2e44472fe574b1bb4 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:47:11 +0800 Subject: [PATCH 23/27] perf: fast path bulk ingest parsing --- src/cdc.zig | 5 +++ src/ffi.zig | 73 +++++++++++++++++++++++++++++++++++++++++-- src/server.zig | 85 +++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 157 insertions(+), 6 deletions(-) diff --git a/src/cdc.zig b/src/cdc.zig index 57f6a24..853c6b7 100644 --- a/src/cdc.zig +++ b/src/cdc.zig @@ -103,6 +103,7 @@ pub const CDCManager = struct { deliveries: std.ArrayList(Delivery), next_subscription_id: std.atomic.Value(u64), next_seq: std.atomic.Value(u64), + subscription_count: std.atomic.Value(u32), mu: compat.Mutex, cond: compat.Condition, running: std.atomic.Value(bool), @@ -116,6 +117,7 @@ pub const CDCManager = struct { .deliveries = .empty, .next_subscription_id = std.atomic.Value(u64).init(1), .next_seq = std.atomic.Value(u64).init(1), + .subscription_count = std.atomic.Value(u32).init(0), .mu = .{}, .cond = .{}, .running = std.atomic.Value(bool).init(false), @@ -155,10 +157,13 @@ pub const CDCManager = struct { self.mu.lock(); defer self.mu.unlock(); try self.subscriptions.append(self.allocator, sub); + _ = self.subscription_count.fetchAdd(1, .release); return sub.id; } pub fn emit(self: *CDCManager, tenant_id: []const u8, collection: []const u8, key: []const u8, value: []const u8, doc_id: u64, op: Op) void { + if (self.subscription_count.load(.acquire) == 0) return; + var ev = std.mem.zeroes(Event); ev.seq = self.next_seq.fetchAdd(1, .monotonic); ev.doc_id = doc_id; diff --git a/src/ffi.zig b/src/ffi.zig index d77a603..bbdbbb1 100644 --- a/src/ffi.zig +++ b/src/ffi.zig @@ -203,11 +203,12 @@ export fn turbodb_insert_bulk_ndjson( pos = if (line_end < input.len) line_end + 1 else line_end; if (line.len == 0) continue; - const key = jsonStr(line, "key") orelse { + const parsed = parseBulkLine(line) orelse { errors += 1; continue; }; - const value = jsonValue(line, "value") orelse line; + const key = parsed.key; + const value = parsed.value; const row_bytes = key.len + value.len + 128; if (rows.items.len > 0 and chunk_bytes + row_bytes > BULK_INSERT_CHUNK_BYTES) { BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors) catch return -1; @@ -445,6 +446,74 @@ const KeyIter = struct { } }; +const BulkLine = struct { + key: []const u8, + value: []const u8, +}; + +fn parseBulkLine(line: []const u8) ?BulkLine { + if (parseBulkLineFast(line)) |parsed| return parsed; + const key = jsonStr(line, "key") orelse return null; + const value = jsonValue(line, "value") orelse line; + return .{ .key = key, .value = value }; +} + +fn parseBulkLineFast(line: []const u8) ?BulkLine { + if (line.len < "{\"key\":\"\",\"value\":}".len or line[0] != '{') return null; + var row_end = line.len; + while (row_end > 0 and (line[row_end - 1] == ' ' or line[row_end - 1] == '\t' or line[row_end - 1] == '\r')) row_end -= 1; + if (row_end == 0 or line[row_end - 1] != '}') return null; + row_end -= 1; + + var i: usize = 1; + skipJsonSpaces(line, &i, row_end); + if (!std.mem.startsWith(u8, line[i..row_end], "\"key\"")) return null; + i += "\"key\"".len; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end or line[i] != ':') return null; + i += 1; + skipJsonSpaces(line, &i, row_end); + const key = parseJsonStringToken(line, &i, row_end) orelse return null; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end or line[i] != ',') return null; + i += 1; + skipJsonSpaces(line, &i, row_end); + if (!std.mem.startsWith(u8, line[i..row_end], "\"value\"")) return null; + i += "\"value\"".len; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end or line[i] != ':') return null; + i += 1; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end) return null; + + var value_end = row_end; + while (value_end > i and (line[value_end - 1] == ' ' or line[value_end - 1] == '\t')) value_end -= 1; + if (value_end <= i) return null; + return .{ .key = key, .value = line[i..value_end] }; +} + +fn skipJsonSpaces(data: []const u8, pos: *usize, limit: usize) void { + while (pos.* < limit and (data[pos.*] == ' ' or data[pos.*] == '\t')) pos.* += 1; +} + +fn parseJsonStringToken(data: []const u8, pos: *usize, limit: usize) ?[]const u8 { + if (pos.* >= limit or data[pos.*] != '"') return null; + pos.* += 1; + const start = pos.*; + while (pos.* < limit) : (pos.* += 1) { + if (data[pos.*] == '\\' and pos.* + 1 < limit) { + pos.* += 1; + continue; + } + if (data[pos.*] == '"') { + const end = pos.*; + pos.* += 1; + return data[start..end]; + } + } + return null; +} + fn jsonStr(json: []const u8, key: []const u8) ?[]const u8 { var kbuf: [64]u8 = undefined; const needle = std.fmt.bufPrint(&kbuf, "\"{s}\":", .{key}) catch return null; diff --git a/src/server.zig b/src/server.zig index 20d5c14..7ef4afc 100644 --- a/src/server.zig +++ b/src/server.zig @@ -825,13 +825,12 @@ fn handleBulkInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, b if (line.len < 2) continue; // skip empty lines - // Extract key from this JSON line - const key = jsonStr(line, "key") orelse { + const parsed = parseBulkLine(line) orelse { errors += 1; continue; }; - // Extract value field; fall back to full line for backwards compat. - const value = jsonValue(line, "value") orelse line; + const key = parsed.key; + const value = parsed.value; const row_bytes = key.len + value.len + 128; if (rows.items.len > 0 and chunk_bytes + row_bytes > BULK_INSERT_CHUNK_BYTES) { BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors, &total_bytes) catch @@ -1737,6 +1736,74 @@ const KeyIter = struct { } }; +const BulkLine = struct { + key: []const u8, + value: []const u8, +}; + +fn parseBulkLine(line: []const u8) ?BulkLine { + if (parseBulkLineFast(line)) |parsed| return parsed; + const key = jsonStr(line, "key") orelse return null; + const value = jsonValue(line, "value") orelse line; + return .{ .key = key, .value = value }; +} + +fn parseBulkLineFast(line: []const u8) ?BulkLine { + if (line.len < "{\"key\":\"\",\"value\":}".len or line[0] != '{') return null; + var row_end = line.len; + while (row_end > 0 and (line[row_end - 1] == ' ' or line[row_end - 1] == '\t' or line[row_end - 1] == '\r')) row_end -= 1; + if (row_end == 0 or line[row_end - 1] != '}') return null; + row_end -= 1; + + var i: usize = 1; + skipJsonSpaces(line, &i, row_end); + if (!std.mem.startsWith(u8, line[i..row_end], "\"key\"")) return null; + i += "\"key\"".len; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end or line[i] != ':') return null; + i += 1; + skipJsonSpaces(line, &i, row_end); + const key = parseJsonStringToken(line, &i, row_end) orelse return null; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end or line[i] != ',') return null; + i += 1; + skipJsonSpaces(line, &i, row_end); + if (!std.mem.startsWith(u8, line[i..row_end], "\"value\"")) return null; + i += "\"value\"".len; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end or line[i] != ':') return null; + i += 1; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end) return null; + + var value_end = row_end; + while (value_end > i and (line[value_end - 1] == ' ' or line[value_end - 1] == '\t')) value_end -= 1; + if (value_end <= i) return null; + return .{ .key = key, .value = line[i..value_end] }; +} + +fn skipJsonSpaces(data: []const u8, pos: *usize, limit: usize) void { + while (pos.* < limit and (data[pos.*] == ' ' or data[pos.*] == '\t')) pos.* += 1; +} + +fn parseJsonStringToken(data: []const u8, pos: *usize, limit: usize) ?[]const u8 { + if (pos.* >= limit or data[pos.*] != '"') return null; + pos.* += 1; + const start = pos.*; + while (pos.* < limit) : (pos.* += 1) { + if (data[pos.*] == '\\' and pos.* + 1 < limit) { + pos.* += 1; + continue; + } + if (data[pos.*] == '"') { + const end = pos.*; + pos.* += 1; + return data[start..end]; + } + } + return null; +} + const BatchUpdateLine = struct { key: []const u8 = "", value: []const u8 = "", @@ -2212,6 +2279,16 @@ test "read-only auth cannot write basic db methods" { try std.testing.expect(canWriteDb(&ctx)); } +test "bulk line parser fast path and generic fallback" { + const fast = parseBulkLine("{\"key\":\"u1\",\"value\":{\"name\":\"alice\",\"tags\":[1,2]}}").?; + try std.testing.expectEqualStrings("u1", fast.key); + try std.testing.expectEqualStrings("{\"name\":\"alice\",\"tags\":[1,2]}", fast.value); + + const fallback = parseBulkLine("{\"value\":{\"name\":\"bob\"},\"key\":\"u2\"}").?; + try std.testing.expectEqualStrings("u2", fallback.key); + try std.testing.expectEqualStrings("{\"name\":\"bob\"}", fallback.value); +} + test "bulk memory estimate includes body and bounded chunk workspace" { const small = estimateBulkMemoryBytes(1024); const large = estimateBulkMemoryBytes(MAX_BULK); From a69fd7f6a8ebdabc172349bc4f0e442478ae993e Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:02:47 +0800 Subject: [PATCH 24/27] Add durable bulk paths and database benchmarks --- .gitignore | 7 + bench/README_apple_container_matrix.md | 22 +- bench/container_shape_bench.py | 75 ++++- bench/run_apple_container_bench.py | 2 + src/cdc.zig | 6 +- src/collection.zig | 40 ++- src/ffi.zig | 109 ++++++- src/mvcc.zig | 41 +++ src/server.zig | 397 ++++++++++++++++++++++++- src/storage/epoch.zig | 42 ++- 10 files changed, 692 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index 66027d9..cb386a7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,18 @@ zig-out/ zig-out-ffi/ +zig-out-*/ .zig-cache/ python/dist/ *.egg-info/ codedb.snapshot __pycache__/ *.pyc +.DS_Store +.claude/ +.zigrep_archive +run_status.txt +turbodb_data/ +charts.png # ClickHouse local data access/ diff --git a/bench/README_apple_container_matrix.md b/bench/README_apple_container_matrix.md index ed365e3..81c64a9 100644 --- a/bench/README_apple_container_matrix.md +++ b/bench/README_apple_container_matrix.md @@ -14,7 +14,9 @@ another container, and does not publish database ports to macOS. - `orders`: keyed order records with `user_id`, amount, status, and JSON payload. - TurboDB stores `bench_users`, `bench_orders`, and a materialized `bench_user_orders` edge collection. The benchmark client uses persistent - HTTP plus durable `POST /db/:col/bulk` ingestion and + HTTP plus durable `POST /db/:col/bulk` ingestion by default. Use + `--turbodb-bulk-mode binary` to benchmark length-prefixed + `POST /db/:col/bulk_binary` ingestion. It uses `POST /db/:col/batch_get?mode=count` for relationship fetches, so lookup-heavy workloads do not pay to serialize document bodies they do not consume. For the join-like, update, and delete workloads it assumes the @@ -23,6 +25,9 @@ another container, and does not publish database ports to macOS. - `turbodb_ffi` loads `/work/zig-out-ffi/lib/libturbodb.so` with Python `ctypes`. This is an embedded/raw-engine path, not a network database path, and exists to show the cost of HTTP/client transport separately from storage operations. + Bulk inserts, updates, deletes, and batched reads use C ABI batch exports so + the benchmark does not hide storage behavior behind one Python FFI call per + row. - PostgreSQL 18 and MySQL use normalized `users` and `orders` tables with a secondary index on `orders.user_id`. - TigerBeetle uses accounts and transfers, so relationship lookups are modeled @@ -47,6 +52,18 @@ TurboDB HTTP update/delete latency is reported as amortized row latency when the benchmark uses the batch endpoints; throughput remains row operations per second. +TurboDB also exposes a gRPC-compatible bridge for binary bulk mutations on the +same server: + +- `POST /grpc/:col/BulkInsert` +- `POST /grpc/:col/BulkUpdate` / `BulkUpsert` +- `POST /grpc/:col/BulkDelete` + +The bridge accepts one uncompressed gRPC message frame for `application/grpc` +requests, or the raw binary payload directly. It is not a full HTTP/2/protobuf +server yet; it is a fast route shape for clients/gateways that already speak in +gRPC-style frames. + ## Run Small smoke run: @@ -72,6 +89,9 @@ Useful flags: - `--skip-turbodb`, `--skip-postgres`, `--skip-mysql`, `--skip-tigerbeetle` - `--skip-turbodb-ffi` +- `--turbodb-bulk-mode ndjson|binary` +- `--batch-size 16384` to align with TurboDB's larger default durable bulk + chunk and reduce per-request/WAL-flush overhead on large ingests - `--turbodb-ffi-target aarch64-linux-gnu` - `--turbodb-ffi-prefix zig-out-ffi` - `--turbodb-ffi-lib /work/zig-out-ffi/lib/libturbodb.so` diff --git a/bench/container_shape_bench.py b/bench/container_shape_bench.py index 2bb2096..d05b3df 100644 --- a/bench/container_shape_bench.py +++ b/bench/container_shape_bench.py @@ -15,6 +15,7 @@ import os import random import shutil +import struct import sys import time from dataclasses import dataclass @@ -128,11 +129,12 @@ def order_ids_for_user(self, user_id: int) -> list[int]: class TurboDBBench: name = "turbodb" - def __init__(self, host: str, port: int, shape: Shape, batch_size: int): + def __init__(self, host: str, port: int, shape: Shape, batch_size: int, bulk_mode: str = "ndjson"): self.host = host self.port = port self.shape = shape self.batch_size = batch_size + self.bulk_mode = bulk_mode self.conn = http.client.HTTPConnection(host, port, timeout=10) def request(self, method: str, path: str, body: bytes | None = None, content_type: str = "application/json") -> bytes: @@ -172,6 +174,19 @@ def drop(self) -> None: pass def bulk(self, collection: str, rows: list[tuple[str, dict[str, Any]]]) -> None: + if self.bulk_mode == "binary": + body = bytearray() + for key, value in rows: + key_b = key.encode() + value_b = json.dumps(value, separators=(",", ":")).encode() + if len(key_b) > 0xFFFF: + raise ValueError("key too large for binary bulk format") + body += struct.pack(" None: lib.turbodb_get_many_keys.restype = ctypes.c_int lib.turbodb_update.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, ctypes.c_char_p, ctypes.c_size_t] lib.turbodb_update.restype = ctypes.c_int + lib.turbodb_update_bulk_ndjson.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, + ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), + ] + lib.turbodb_update_bulk_ndjson.restype = ctypes.c_int lib.turbodb_delete.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t] lib.turbodb_delete.restype = ctypes.c_int + lib.turbodb_delete_many_keys.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, + ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), + ] + lib.turbodb_delete_many_keys.restype = ctypes.c_int def close(self) -> None: if getattr(self, "db", None): @@ -454,25 +479,43 @@ def op(uid: int) -> None: return timed_loop(samples, op, {"mode": "embedded_c_abi_materialized_edge_batch_get"}) def update_orders(self, samples: list[int]) -> dict[str, Any]: - def op(oid: int) -> None: - key_b = str(oid).encode() - value = order_doc(oid, ((oid - 1) // self.shape.orders_per_user) + 1) - value["status"] = "updated" - value_b = json.dumps(value, separators=(",", ":")).encode() - rc = self.lib.turbodb_update(self.collection("bench_orders"), key_b, len(key_b), value_b, len(value_b)) + def op(batch: list[int]) -> None: + lines = [] + for oid in batch: + value = order_doc(oid, ((oid - 1) // self.shape.orders_per_user) + 1) + value["status"] = "updated" + lines.append(json.dumps({"key": str(oid), "value": value}, separators=(",", ":"))) + body = ("\n".join(lines) + "\n").encode() + updated = ctypes.c_uint32(0) + inserted = ctypes.c_uint32(0) + errors = ctypes.c_uint32(0) + rc = self.lib.turbodb_update_bulk_ndjson( + self.collection("bench_orders"), body, len(body), + ctypes.byref(updated), ctypes.byref(inserted), ctypes.byref(errors), + ) if rc != 0: - raise RuntimeError(f"FFI update failed for order {oid}") + raise RuntimeError("FFI bulk update failed for orders") + if errors.value: + raise RuntimeError(f"FFI bulk update had {errors.value} row errors") - return timed_loop(samples, op, {"mode": "embedded_c_abi"}) + return timed_batch_loop(self.batches(samples), op, {"mode": "embedded_c_abi_bulk_update", "latency_unit": "amortized_row"}) def delete_orders(self, samples: list[int]) -> dict[str, Any]: - def op(oid: int) -> None: - key_b = str(oid).encode() - rc = self.lib.turbodb_delete(self.collection("bench_orders"), key_b, len(key_b)) + def op(batch: list[int]) -> None: + body = ("\n".join(str(oid) for oid in batch) + "\n").encode() + deleted = ctypes.c_uint32(0) + missing = ctypes.c_uint32(0) + errors = ctypes.c_uint32(0) + rc = self.lib.turbodb_delete_many_keys( + self.collection("bench_orders"), body, len(body), + ctypes.byref(deleted), ctypes.byref(missing), ctypes.byref(errors), + ) if rc != 0: - raise RuntimeError(f"FFI delete failed for order {oid}") + raise RuntimeError("FFI bulk delete failed for orders") + if errors.value: + raise RuntimeError(f"FFI bulk delete had {errors.value} row errors") - return timed_loop(samples, op, {"mode": "embedded_c_abi"}) + return timed_batch_loop(self.batches(samples), op, {"mode": "embedded_c_abi_bulk_delete", "latency_unit": "amortized_row"}) class PostgresBench: @@ -822,6 +865,7 @@ def main() -> int: ap.add_argument("--output", default="/work/benchmark-results/container-shape-bench.json") ap.add_argument("--turbodb-host") ap.add_argument("--turbodb-port", type=int, default=27017) + ap.add_argument("--turbodb-bulk-mode", choices=("ndjson", "binary"), default="ndjson") ap.add_argument("--turbodb-ffi-lib") ap.add_argument("--turbodb-ffi-dir", default="/tmp/turbodb_ffi_shape_bench") ap.add_argument("--postgres-host") @@ -843,6 +887,7 @@ def main() -> int: "orders_per_user": shape.orders_per_user, "samples": args.samples, "batch_size": args.batch_size, + "turbodb_bulk_mode": args.turbodb_bulk_mode, }, "engines": {}, "errors": {}, @@ -850,7 +895,7 @@ def main() -> int: engines: list[Any] = [] if args.turbodb_host: - engines.append(TurboDBBench(args.turbodb_host, args.turbodb_port, shape, args.batch_size)) + engines.append(TurboDBBench(args.turbodb_host, args.turbodb_port, shape, args.batch_size, args.turbodb_bulk_mode)) if args.turbodb_ffi_lib: engines.append(TurboDBFFIBench(args.turbodb_ffi_lib, args.turbodb_ffi_dir, shape, args.batch_size)) if args.postgres_host: diff --git a/bench/run_apple_container_bench.py b/bench/run_apple_container_bench.py index 4f1ad86..7f6e57e 100644 --- a/bench/run_apple_container_bench.py +++ b/bench/run_apple_container_bench.py @@ -328,6 +328,7 @@ def run_client(self, services: dict[str, Service]) -> int: "--samples", str(self.args.samples), "--batch-size", str(self.args.batch_size), "--output", f"/work/{self.output.relative_to(ROOT)}", + "--turbodb-bulk-mode", self.args.turbodb_bulk_mode, ] if (svc := services.get("turbodb")) and not svc.error: @@ -395,6 +396,7 @@ def parse_args() -> argparse.Namespace: ap.add_argument("--turbodb-ffi-prefix", default="zig-out-ffi") ap.add_argument("--turbodb-ffi-lib", default="/work/zig-out-ffi/lib/libturbodb.so") ap.add_argument("--turbodb-ffi-dir", default="/tmp/turbodb_ffi_shape_bench") + ap.add_argument("--turbodb-bulk-mode", choices=("ndjson", "binary"), default="ndjson") ap.add_argument("--python-image", default="python:3.12-slim") ap.add_argument("--postgres-image", default="postgres:18") ap.add_argument("--mysql-image", default="mysql:8.4") diff --git a/src/cdc.zig b/src/cdc.zig index 853c6b7..0faea3c 100644 --- a/src/cdc.zig +++ b/src/cdc.zig @@ -161,8 +161,12 @@ pub const CDCManager = struct { return sub.id; } + pub fn hasSubscriptions(self: *const CDCManager) bool { + return self.subscription_count.load(.acquire) != 0; + } + pub fn emit(self: *CDCManager, tenant_id: []const u8, collection: []const u8, key: []const u8, value: []const u8, doc_id: u64, op: Op) void { - if (self.subscription_count.load(.acquire) == 0) return; + if (!self.hasSubscriptions()) return; var ev = std.mem.zeroes(Event); ev.seq = self.next_seq.fetchAdd(1, .monotonic); diff --git a/src/collection.zig b/src/collection.zig index 04b956b..36ade87 100644 --- a/src/collection.zig +++ b/src/collection.zig @@ -558,15 +558,17 @@ pub const Collection = struct { try payloads.ensureTotalCapacity(self.alloc, valid_rows); try encoded.ensureTotalCapacity(self.alloc, estimated_bytes); + const first_doc_id = self.next_doc_id.fetchAdd(@intCast(valid_rows), .monotonic); + var doc_id_offset: u64 = 0; for (rows) |row| { const total_size = bulkEncodedLen(row) orelse continue; - const doc_id = self.next_doc_id.fetchAdd(1, .monotonic); + const doc_id = first_doc_id + doc_id_offset; + doc_id_offset += 1; const hdr = doc_mod.newHeader(doc_id, row.key, row.value); const d = Doc{ .header = hdr, .key = row.key, .value = row.value }; const enc_off = encoded.items.len; - try encoded.appendNTimes(self.alloc, 0, total_size); - const enc = encoded.items[enc_off .. enc_off + total_size]; + const enc = encoded.addManyAsSliceAssumeCapacity(total_size); _ = try d.encodeBuf(enc); prepared.appendAssumeCapacity(.{ @@ -586,10 +588,16 @@ pub const Collection = struct { const last_lsn = try self.wal_log.writeCommittedBatch(.doc_insert, wal_mod.DB_TAG_DOC, payloads.items); try self.wal_log.flushUpTo(last_lsn); + const first_epoch = self.epochs.advanceMany(prepared.items.len); self.meta_mu.lock(); { defer self.meta_mu.unlock(); - for (prepared.items) |*item| { + try self.hash_idx.ensureUnusedCapacity(@intCast(prepared.items.len)); + try self.key_doc_ids.ensureUnusedCapacity(@intCast(prepared.items.len)); + try self.key_epochs.ensureUnusedCapacity(@intCast(prepared.items.len)); + try self.versions.ensureUnusedCapacity(self.alloc, prepared.items.len); + + for (prepared.items, 0..) |*item, i| { const enc = encoded.items[item.enc_off .. item.enc_off + item.enc_len]; const pno = try self.findOrAllocLeaf(enc.len); const page_off = self.pf.leafAppend(pno, enc) orelse return error.PageFull; @@ -600,14 +608,14 @@ pub const Collection = struct { .page_off = page_off, }; try self.idx.insert(entry); - self.hash_idx.put(item.key_hash, entry) catch {}; + self.hash_idx.putAssumeCapacity(item.key_hash, entry); item.page_no = pno; item.page_off = page_off; - const epoch = self.epochs.advance(); - self.versions.appendVersion(self.alloc, item.doc_id, pno, page_off, epoch) catch {}; - self.key_doc_ids.put(item.key_hash, item.doc_id) catch {}; - self.key_epochs.put(item.key_hash, item.doc_id) catch {}; + const epoch = first_epoch + @as(u64, @intCast(i)); + self.versions.appendFreshVersionAssumeCapacity(item.doc_id, pno, page_off, epoch); + self.key_doc_ids.putAssumeCapacity(item.key_hash, item.doc_id); + self.key_epochs.putAssumeCapacity(item.key_hash, item.doc_id); if (self.vectors) |vc| { const field = self.vector_field[0..self.vector_field_len]; @@ -627,15 +635,18 @@ pub const Collection = struct { } } - if (self.gc_counter.fetchAdd(1, .monotonic) % GC_INTERVAL == GC_INTERVAL - 1) { - _ = self.versions.gc(self.alloc); - } - result.inserted += 1; result.bytes += item.line_len; } + + const inserted_count: u64 = @intCast(prepared.items.len); + const old_gc_counter = self.gc_counter.fetchAdd(inserted_count, .monotonic); + if (old_gc_counter / GC_INTERVAL != (old_gc_counter + inserted_count) / GC_INTERVAL) { + _ = self.versions.gc(self.alloc); + } } + const emit_cdc = self.cdc.hasSubscriptions(); for (prepared.items) |item| { self.enqueueStoredTextIndex( .{ @@ -647,7 +658,7 @@ pub const Collection = struct { item.key, item.value, ); - emitChange(self, .insert, item.key, item.value, item.doc_id); + if (emit_cdc) emitChange(self, .insert, item.key, item.value, item.doc_id); } return result; @@ -1193,6 +1204,7 @@ pub const Collection = struct { } fn emitChange(self: *Collection, op: cdc_mod.Op, key: []const u8, value: []const u8, doc_id: u64) void { + if (!self.cdc.hasSubscriptions()) return; const full_name = self.name(); const ref: TenantCollectionRef = splitTenantCollectionKey(full_name) orelse TenantCollectionRef{ .tenant_id = DEFAULT_TENANT, diff --git a/src/ffi.zig b/src/ffi.zig index bbdbbb1..ee128fe 100644 --- a/src/ffi.zig +++ b/src/ffi.zig @@ -17,8 +17,8 @@ const Collection = collection.Collection; const Doc = doc_mod.Doc; const alloc = std.heap.c_allocator; -const BULK_INSERT_CHUNK_ROWS: usize = 4096; -const BULK_INSERT_CHUNK_BYTES: usize = 4 * 1024 * 1024; +const BULK_INSERT_CHUNK_ROWS: usize = 16384; +const BULK_INSERT_CHUNK_BYTES: usize = 16 * 1024 * 1024; // ── Handle wrappers with magic sentinel for validation ────────────────────── @@ -177,6 +177,7 @@ export fn turbodb_insert_bulk_ndjson( const input = body[0..body_len]; var rows: std.ArrayList(Collection.BulkInsertRow) = .empty; defer rows.deinit(alloc); + rows.ensureTotalCapacity(alloc, @min(BULK_INSERT_CHUNK_ROWS, @max(@as(usize, 1), input.len / 96))) catch return -1; var chunk_bytes: usize = 0; const BulkFlush = struct { @@ -304,6 +305,54 @@ export fn turbodb_update( return if (updated) 0 else -1; } +/// Bulk upsert NDJSON lines shaped as {"key":"...","value":...}. +/// Returns 0 if the request was processed; per-row failures are counted. +export fn turbodb_update_bulk_ndjson( + col_handle: *anyopaque, + body: [*]const u8, + body_len: usize, + out_updated: *u32, + out_inserted: *u32, + out_errors: *u32, +) c_int { + const col = validateColHandle(col_handle) orelse return -1; + const input = body[0..body_len]; + var updated: u32 = 0; + var inserted: u32 = 0; + var errors: u32 = 0; + + var pos: usize = 0; + while (pos < input.len) { + const line_end = std.mem.indexOfScalarPos(u8, input, pos, '\n') orelse input.len; + const line = std.mem.trim(u8, input[pos..line_end], " \t\r"); + pos = if (line_end < input.len) line_end + 1 else line_end; + if (line.len == 0) continue; + + const parsed = parseStrictBulkLine(line) orelse { + errors += 1; + continue; + }; + const did_update = col.update(parsed.key, parsed.value) catch { + errors += 1; + continue; + }; + if (did_update) { + updated += 1; + } else { + _ = col.insert(parsed.key, parsed.value) catch { + errors += 1; + continue; + }; + inserted += 1; + } + } + + out_updated.* = updated; + out_inserted.* = inserted; + out_errors.* = errors; + return 0; +} + /// Delete a document by key. Returns 0 on success, -1 if not found. export fn turbodb_delete( col_handle: *anyopaque, @@ -315,6 +364,36 @@ export fn turbodb_delete( return if (deleted) 0 else -1; } +/// Delete many newline-delimited or JSON-array keys in one FFI call. +export fn turbodb_delete_many_keys( + col_handle: *anyopaque, + keys_body: [*]const u8, + keys_len: usize, + out_deleted: *u32, + out_missing: *u32, + out_errors: *u32, +) c_int { + const col = validateColHandle(col_handle) orelse return -1; + var iter = KeyIter.init(keys_body[0..keys_len]); + var deleted: u32 = 0; + var missing: u32 = 0; + var errors: u32 = 0; + + while (iter.next()) |key| { + if (key.len == 0) continue; + const did_delete = col.delete(key) catch { + errors += 1; + continue; + }; + if (did_delete) deleted += 1 else missing += 1; + } + + out_deleted.* = deleted; + out_missing.* = missing; + out_errors.* = errors; + return 0; +} + // ── Scan ──────────────────────────────────────────────────────────────────── /// Scan a collection. Returns 0 on success, -1 on error. @@ -452,12 +531,38 @@ const BulkLine = struct { }; fn parseBulkLine(line: []const u8) ?BulkLine { + if (parseBulkLineExact(line)) |parsed| return parsed; if (parseBulkLineFast(line)) |parsed| return parsed; const key = jsonStr(line, "key") orelse return null; const value = jsonValue(line, "value") orelse line; return .{ .key = key, .value = value }; } +fn parseStrictBulkLine(line: []const u8) ?BulkLine { + if (parseBulkLineExact(line)) |parsed| return parsed; + if (parseBulkLineFast(line)) |parsed| return parsed; + const key = jsonStr(line, "key") orelse return null; + const value = jsonValue(line, "value") orelse return null; + return .{ .key = key, .value = value }; +} + +fn parseBulkLineExact(line: []const u8) ?BulkLine { + const prefix = "{\"key\":\""; + const middle = "\",\"value\":"; + if (line.len < prefix.len + middle.len + 1 or !std.mem.startsWith(u8, line, prefix)) return null; + + var key_end = prefix.len; + while (key_end < line.len and line[key_end] != '"') : (key_end += 1) { + if (line[key_end] == '\\') return null; + } + if (key_end >= line.len) return null; + if (!std.mem.startsWith(u8, line[key_end..], middle)) return null; + + const value_start = key_end + middle.len; + if (value_start >= line.len or line[line.len - 1] != '}') return null; + return .{ .key = line[prefix.len..key_end], .value = line[value_start .. line.len - 1] }; +} + fn parseBulkLineFast(line: []const u8) ?BulkLine { if (line.len < "{\"key\":\"\",\"value\":}".len or line[0] != '{') return null; var row_end = line.len; diff --git a/src/mvcc.zig b/src/mvcc.zig index 8f359da..88225c6 100644 --- a/src/mvcc.zig +++ b/src/mvcc.zig @@ -113,6 +113,26 @@ pub const VersionChain = struct { try self.all_versions.append(alloc, .{ .doc_id = doc_id, .ver = ver }); } + pub fn ensureUnusedCapacity(self: *VersionChain, alloc: Allocator, additional: usize) !void { + try self.latest.ensureUnusedCapacity(@intCast(additional)); + try self.history.ensureUnusedCapacity(@intCast(additional)); + try self.all_versions.ensureUnusedCapacity(alloc, additional); + } + + pub fn appendFreshVersionAssumeCapacity(self: *VersionChain, doc_id: u64, page_no: u32, page_off: u16, epoch: u64) void { + const ver = VersionPtr{ + .page_no = page_no, + .page_off = page_off, + .epoch = epoch, + .prev_page_no = NO_PREV_PAGE_NO, + .prev_page_off = NO_PREV_PAGE_OFF, + }; + + self.latest.putAssumeCapacityNoClobber(doc_id, ver); + self.history.putAssumeCapacityNoClobber(chainKey(page_no, page_off), ver); + self.all_versions.appendAssumeCapacity(.{ .doc_id = doc_id, .ver = ver }); + } + /// Get the latest version location for a document. pub fn getLatest(self: *VersionChain, doc_id: u64) ?VersionPtr { return self.latest.get(doc_id); @@ -221,6 +241,27 @@ test "version chain traversal" { try testing.expectEqual(@as(u16, 0), latest.prev_page_off); } +test "bulk fresh append uses reserved capacity" { + const alloc = testing.allocator; + var chain = VersionChain.init(alloc); + defer chain.deinit(alloc); + + try chain.ensureUnusedCapacity(alloc, 3); + chain.appendFreshVersionAssumeCapacity(1, 10, 20, 2); + chain.appendFreshVersionAssumeCapacity(2, 10, 40, 3); + chain.appendFreshVersionAssumeCapacity(3, 11, 0, 4); + + const first = chain.getLatest(1).?; + try testing.expectEqual(@as(u32, 10), first.page_no); + try testing.expectEqual(@as(u16, 20), first.page_off); + try testing.expectEqual(@as(u64, 2), first.epoch); + try testing.expectEqual(NO_PREV_PAGE_NO, first.prev_page_no); + try testing.expectEqual(NO_PREV_PAGE_OFF, first.prev_page_off); + try testing.expect(chain.getAtEpoch(3, 3) == null); + try testing.expectEqual(@as(usize, 3), chain.latest.count()); + try testing.expectEqual(@as(usize, 3), chain.all_versions.items.len); +} + test "read transaction sees correct epoch" { const alloc = testing.allocator; var chain = VersionChain.init(alloc); diff --git a/src/server.zig b/src/server.zig index 7ef4afc..b267c9d 100644 --- a/src/server.zig +++ b/src/server.zig @@ -4,6 +4,9 @@ /// POST /db/:col/batch_get get multiple documents by key /// POST /db/:col/batch_update upsert multiple documents by key /// POST /db/:col/batch_delete delete multiple documents by key +/// POST /grpc/:col/BulkInsert gRPC-framed binary bulk insert bridge +/// POST /grpc/:col/BulkUpdate gRPC-framed binary bulk update/upsert bridge +/// POST /grpc/:col/BulkDelete gRPC-framed binary bulk delete bridge /// POST /db/:edge_col/join read edge keys and batch-get target docs /// GET /db/:col/:key get document by key /// PUT /db/:col/:key upsert document @@ -26,9 +29,9 @@ const Database = collection.Database; const MAX_REQ = 65536; // 64 KiB (initial read) const MAX_RESP = 131072; // 128 KiB const MAX_BODY = 65536; // 64 KiB -const MAX_BULK = 16 * 1024 * 1024; // 16 MiB for bulk inserts -const BULK_INSERT_CHUNK_ROWS: usize = 4096; -const BULK_INSERT_CHUNK_BYTES: usize = 4 * 1024 * 1024; +const MAX_BULK = 64 * 1024 * 1024; // 64 MiB for large bulk inserts +const BULK_INSERT_CHUNK_ROWS: usize = 16384; +const BULK_INSERT_CHUNK_BYTES: usize = 16 * 1024 * 1024; const DEFAULT_BULK_MEMORY_LIMIT: usize = 256 * 1024 * 1024; const MAX_AUTO_BULK_MEMORY_LIMIT: usize = 512 * 1024 * 1024; const BULK_MEMORY_AVAILABLE_FRACTION: usize = 4; @@ -360,6 +363,11 @@ const ParsedRequestTarget = struct { query: []const u8, }; +const GrpcTarget = struct { + col_name: []const u8, + rpc: []const u8, +}; + const BulkPreflight = union(enum) { not_bulk, reserved: Server.BulkMemoryReservation, @@ -417,12 +425,36 @@ fn isBulkInsertTarget(method: []const u8, path: []const u8) bool { const rest = path[4..]; const sep = std.mem.indexOfScalar(u8, rest, '/') orelse return false; const key = rest[sep + 1 ..]; - return std.mem.eql(u8, key, "bulk"); + return std.mem.eql(u8, key, "bulk") or std.mem.eql(u8, key, "bulk_binary"); +} + +fn parseGrpcTarget(path: []const u8) ?GrpcTarget { + if (!std.mem.startsWith(u8, path, "/grpc/")) return null; + const rest = path["/grpc/".len..]; + const sep = std.mem.indexOfScalar(u8, rest, '/') orelse return null; + if (sep == 0 or sep + 1 >= rest.len) return null; + return .{ + .col_name = rest[0..sep], + .rpc = rest[sep + 1 ..], + }; +} + +fn isGrpcBulkMutation(method: []const u8, path: []const u8) bool { + if (!std.mem.eql(u8, method, "POST")) return false; + const target = parseGrpcTarget(path) orelse return false; + return std.mem.eql(u8, target.rpc, "BulkInsert") or + std.mem.eql(u8, target.rpc, "BulkUpdate") or + std.mem.eql(u8, target.rpc, "BulkUpsert") or + std.mem.eql(u8, target.rpc, "BulkDelete"); +} + +fn isBulkMemoryTarget(method: []const u8, path: []const u8) bool { + return isBulkInsertTarget(method, path) or isGrpcBulkMutation(method, path); } fn preflightBulkAdmission(srv: *Server, initial: []const u8, body_len: usize) BulkPreflight { const target = parseRequestTarget(initial) orelse return .not_bulk; - if (!isBulkInsertTarget(target.method, target.path)) return .not_bulk; + if (!isBulkMemoryTarget(target.method, target.path)) return .not_bulk; var auth_ctx = auth.AuthContext{ .perm = .admin, @@ -492,7 +524,7 @@ fn handleConn(srv: *Server, conn: compat.net.Server.Connection) void { if (content_length > 0) { const header_end = if (std.mem.indexOf(u8, initial, "\r\n\r\n")) |p| p + 4 else if (std.mem.indexOf(u8, initial, "\n\n")) |p| p + 2 else n; const total_size = header_end + content_length; - if (total_size > MAX_BULK) { + if (content_length > MAX_BULK) { const resp_len = err(413, "request too large"); conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; return; @@ -700,6 +732,24 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { } } + // Routes under /grpc/:col/:rpc. + // This is a bridge for gRPC-style clients on the current HTTP/1 transport: + // if Content-Type is application/grpc, the body must be a single uncompressed + // gRPC frame; otherwise the same raw binary payload is accepted directly. + if (parseGrpcTarget(path)) |target| { + if (!std.mem.eql(u8, method, "POST")) return err(404, "not found"); + if (!canWriteDb(auth_ctx_ptr)) return err(403, "forbidden: API key is read-only"); + const payload = grpcPayload(raw, body) orelse return err(400, "bad grpc frame"); + const tenant_id = requestTenant(raw, query, auth_ctx_ptr); + if (std.mem.eql(u8, target.rpc, "BulkInsert")) + return handleBulkInsertBinaryWithMode(srv, tenant_id, target.col_name, payload, alloc, "grpc_bulk_insert", "grpc_bridge_binary"); + if (std.mem.eql(u8, target.rpc, "BulkUpdate") or std.mem.eql(u8, target.rpc, "BulkUpsert")) + return handleBulkUpdateBinary(srv, tenant_id, target.col_name, payload, "grpc_bulk_update", "grpc_bridge_binary"); + if (std.mem.eql(u8, target.rpc, "BulkDelete")) + return handleBulkDeleteBinary(srv, tenant_id, target.col_name, payload, "grpc_bulk_delete", "grpc_bridge_binary"); + return err(404, "not found"); + } + // Routes under /db/:col if (std.mem.startsWith(u8, path, "/db/")) { const rest = path[4..]; @@ -725,6 +775,9 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { // POST /db/:col/bulk — bulk insert if (std.mem.eql(u8, key, "bulk") and std.mem.eql(u8, method, "POST")) return handleBulkInsert(srv, tenant_id, col_name, body, alloc); + // POST /db/:col/bulk_binary — length-prefixed bulk insert. + if (std.mem.eql(u8, key, "bulk_binary") and std.mem.eql(u8, method, "POST")) + return handleBulkInsertBinaryWithMode(srv, tenant_id, col_name, body, alloc, "bulk_insert_bin", "binary"); if (std.mem.eql(u8, method, "GET")) return handleGet(srv, tenant_id, col_name, key, requestAsOf(raw, query)); if (std.mem.eql(u8, method, "PUT")) return handleUpdate(srv, tenant_id, col_name, key, body, alloc); if (std.mem.eql(u8, method, "DELETE")) return handleDelete(srv, tenant_id, col_name, key); @@ -795,6 +848,8 @@ fn handleBulkInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, b var chunk_bytes: usize = 0; var rows: std.ArrayList(collection.Collection.BulkInsertRow) = .empty; defer rows.deinit(alloc); + rows.ensureTotalCapacity(alloc, @min(BULK_INSERT_CHUNK_ROWS, @max(@as(usize, 1), body.len / 96))) catch + return err(500, "bulk request too large"); const BulkFlush = struct { fn run( @@ -855,6 +910,199 @@ fn handleBulkInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, b return ok(getBodyBuf()[0..fbs.pos]); } +/// POST /db/:col/bulk_binary — insert multiple documents in one request. +/// Body: repeated little-endian records: +/// [key_len:u16][value_len:u32][key bytes][raw value bytes] +fn handleBulkInsertBinaryWithMode( + srv: *Server, + tenant_id: []const u8, + col_name: []const u8, + body: []const u8, + alloc: std.mem.Allocator, + op_name: []const u8, + mode: []const u8, +) usize { + const start_ns = compat.nanoTimestamp(); + var local_bulk_reservation: ?Server.BulkMemoryReservation = null; + if (tl_bulk_reservation == null) { + local_bulk_reservation = srv.acquireBulkMemory(tenant_id, estimateBulkMemoryBytes(body.len)) catch + return err(429, "bulk memory limit exceeded"); + } + defer if (local_bulk_reservation) |*reservation| reservation.release(); + + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + srv.db.ensureTenantStorageAvailable(tenant_id, body.len) catch return err(429, "tenant storage quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + + var inserted: u32 = 0; + var errors: u32 = 0; + var total_bytes: u64 = 0; + var chunk_bytes: usize = 0; + var rows: std.ArrayList(collection.Collection.BulkInsertRow) = .empty; + defer rows.deinit(alloc); + rows.ensureTotalCapacity(alloc, @min(BULK_INSERT_CHUNK_ROWS, @max(@as(usize, 1), body.len / 80))) catch + return err(500, "bulk request too large"); + + const BulkFlush = struct { + fn run( + col_arg: *collection.Collection, + rows_arg: *std.ArrayList(collection.Collection.BulkInsertRow), + chunk_bytes_arg: *usize, + inserted_arg: *u32, + errors_arg: *u32, + bytes_arg: *u64, + ) !void { + if (rows_arg.items.len == 0) return; + const bulk = try col_arg.insertBulk(rows_arg.items); + inserted_arg.* += bulk.inserted; + errors_arg.* += bulk.errors; + bytes_arg.* += bulk.bytes; + rows_arg.clearRetainingCapacity(); + chunk_bytes_arg.* = 0; + } + }; + + var pos: usize = 0; + while (pos < body.len) { + if (pos + 6 > body.len) { + errors += 1; + break; + } + const key_len: usize = std.mem.readInt(u16, body[pos..][0..2], .little); + const value_len: usize = std.mem.readInt(u32, body[pos + 2 ..][0..4], .little); + pos += 6; + const end = std.math.add(usize, pos, key_len) catch { + errors += 1; + break; + }; + const value_end = std.math.add(usize, end, value_len) catch { + errors += 1; + break; + }; + if (key_len == 0 or end > body.len or value_end > body.len) { + errors += 1; + break; + } + + const key = body[pos..end]; + const value = body[end..value_end]; + pos = value_end; + + const row_bytes = key.len + value.len + 128; + if (rows.items.len > 0 and chunk_bytes + row_bytes > BULK_INSERT_CHUNK_BYTES) { + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors, &total_bytes) catch + return err(500, "bulk insert failed"); + } + rows.append(alloc, .{ .key = key, .value = value, .line_len = 6 + key.len + value.len }) catch + return err(500, "bulk request too large"); + chunk_bytes += row_bytes; + + if (rows.items.len >= BULK_INSERT_CHUNK_ROWS) { + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors, &total_bytes) catch + return err(500, "bulk insert failed"); + } + } + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors, &total_bytes) catch + return err(500, "bulk insert failed"); + + srv.recordQueryCost(tenant_id, op_name, inserted, total_bytes, start_ns); + + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"inserted\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\",\"mode\":\"{s}\"}}", .{ inserted, errors, col_name, tenant_id, mode }) catch {}; + return ok(getBodyBuf()[0..fbs.pos]); +} + +/// Binary bulk update/upsert used by the gRPC bridge. +/// Body: repeated little-endian records: +/// [key_len:u16][value_len:u32][key bytes][raw value bytes] +fn handleBulkUpdateBinary(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, op_name: []const u8, mode: []const u8) usize { + const start_ns = compat.nanoTimestamp(); + var local_bulk_reservation: ?Server.BulkMemoryReservation = null; + if (tl_bulk_reservation == null) { + local_bulk_reservation = srv.acquireBulkMemory(tenant_id, estimateBulkMemoryBytes(body.len)) catch + return err(429, "bulk memory limit exceeded"); + } + defer if (local_bulk_reservation) |*reservation| reservation.release(); + + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + srv.db.ensureTenantStorageAvailable(tenant_id, body.len) catch return err(429, "tenant storage quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + + var updated: u32 = 0; + var inserted: u32 = 0; + var errors: u32 = 0; + var total_bytes: u64 = 0; + var pos: usize = 0; + while (pos < body.len) { + const record = nextBinaryKeyValue(body, &pos) orelse { + errors += 1; + break; + }; + if (!documentFitsLeaf(record.key, record.value)) { + errors += 1; + continue; + } + const did_update = col.update(record.key, record.value) catch { + errors += 1; + continue; + }; + if (did_update) { + updated += 1; + } else { + _ = col.insert(record.key, record.value) catch { + errors += 1; + continue; + }; + inserted += 1; + } + total_bytes += record.key.len + record.value.len; + } + + srv.recordQueryCost(tenant_id, op_name, updated + inserted + errors, total_bytes, start_ns); + + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"updated\":{d},\"inserted\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\",\"mode\":\"{s}\"}}", .{ updated, inserted, errors, col_name, tenant_id, mode }) catch {}; + return ok(getBodyBuf()[0..fbs.pos]); +} + +/// Binary bulk delete used by the gRPC bridge. +/// Body: repeated little-endian records: +/// [key_len:u16][key bytes] +fn handleBulkDeleteBinary(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, op_name: []const u8, mode: []const u8) usize { + const start_ns = compat.nanoTimestamp(); + var local_bulk_reservation: ?Server.BulkMemoryReservation = null; + if (tl_bulk_reservation == null) { + local_bulk_reservation = srv.acquireBulkMemory(tenant_id, estimateBulkMemoryBytes(body.len)) catch + return err(429, "bulk memory limit exceeded"); + } + defer if (local_bulk_reservation) |*reservation| reservation.release(); + + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + + var deleted: u32 = 0; + var missing: u32 = 0; + var errors: u32 = 0; + var pos: usize = 0; + while (pos < body.len) { + const record = nextBinaryKey(body, &pos) orelse { + errors += 1; + break; + }; + const did_delete = col.delete(record.key) catch { + errors += 1; + continue; + }; + if (did_delete) deleted += 1 else missing += 1; + } + + srv.recordQueryCost(tenant_id, op_name, deleted + missing + errors, body.len, start_ns); + + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"deleted\":{d},\"missing\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\",\"mode\":\"{s}\"}}", .{ deleted, missing, errors, col_name, tenant_id, mode }) catch {}; + return ok(getBodyBuf()[0..fbs.pos]); +} + /// POST /db/:col/batch_get — read multiple documents in one request. /// Body can be a JSON string array, {"keys":[...]}, or one key per line. fn handleBatchGet(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, as_of: ?i64, compact: bool) usize { @@ -1662,6 +1910,52 @@ fn extractContentLength(raw: []const u8) usize { return 0; } +fn grpcPayload(raw: []const u8, body: []const u8) ?[]const u8 { + const requires_frame = headerContains(raw, "content-type", "application/grpc"); + if (body.len >= 5 and body[0] == 0) { + const frame_len: usize = @intCast(std.mem.readInt(u32, body[1..][0..4], .big)); + if (frame_len == body.len - 5) return body[5..]; + if (requires_frame) return null; + } else if (requires_frame) { + return null; + } + return body; +} + +const BinaryKeyValueRecord = struct { + key: []const u8, + value: []const u8, +}; + +fn nextBinaryKeyValue(body: []const u8, pos: *usize) ?BinaryKeyValueRecord { + if (pos.* + 6 > body.len) return null; + const key_len: usize = std.mem.readInt(u16, body[pos.*..][0..2], .little); + const value_len: usize = std.mem.readInt(u32, body[pos.* + 2 ..][0..4], .little); + pos.* += 6; + const key_end = std.math.add(usize, pos.*, key_len) catch return null; + const value_end = std.math.add(usize, key_end, value_len) catch return null; + if (key_len == 0 or key_end > body.len or value_end > body.len) return null; + const key = body[pos.*..key_end]; + const value = body[key_end..value_end]; + pos.* = value_end; + return .{ .key = key, .value = value }; +} + +const BinaryKeyRecord = struct { + key: []const u8, +}; + +fn nextBinaryKey(body: []const u8, pos: *usize) ?BinaryKeyRecord { + if (pos.* + 2 > body.len) return null; + const key_len: usize = std.mem.readInt(u16, body[pos.*..][0..2], .little); + pos.* += 2; + const key_end = std.math.add(usize, pos.*, key_len) catch return null; + if (key_len == 0 or key_end > body.len) return null; + const key = body[pos.*..key_end]; + pos.* = key_end; + return .{ .key = key }; +} + const KeyIter = struct { body: []const u8, pos: usize = 0, @@ -1742,12 +2036,38 @@ const BulkLine = struct { }; fn parseBulkLine(line: []const u8) ?BulkLine { + if (parseBulkLineExact(line)) |parsed| return parsed; if (parseBulkLineFast(line)) |parsed| return parsed; const key = jsonStr(line, "key") orelse return null; const value = jsonValue(line, "value") orelse line; return .{ .key = key, .value = value }; } +fn parseStrictBulkLine(line: []const u8) ?BulkLine { + if (parseBulkLineExact(line)) |parsed| return parsed; + if (parseBulkLineFast(line)) |parsed| return parsed; + const key = jsonStr(line, "key") orelse return null; + const value = jsonValue(line, "value") orelse return null; + return .{ .key = key, .value = value }; +} + +fn parseBulkLineExact(line: []const u8) ?BulkLine { + const prefix = "{\"key\":\""; + const middle = "\",\"value\":"; + if (line.len < prefix.len + middle.len + 1 or !std.mem.startsWith(u8, line, prefix)) return null; + + var key_end = prefix.len; + while (key_end < line.len and line[key_end] != '"') : (key_end += 1) { + if (line[key_end] == '\\') return null; + } + if (key_end >= line.len) return null; + if (!std.mem.startsWith(u8, line[key_end..], middle)) return null; + + const value_start = key_end + middle.len; + if (value_start >= line.len or line[line.len - 1] != '}') return null; + return .{ .key = line[prefix.len..key_end], .value = line[value_start .. line.len - 1] }; +} + fn parseBulkLineFast(line: []const u8) ?BulkLine { if (line.len < "{\"key\":\"\",\"value\":}".len or line[0] != '{') return null; var row_end = line.len; @@ -1826,11 +2146,10 @@ const BatchUpdateIter = struct { self.pos = if (line_end < self.body.len) line_end + 1 else line_end; if (line.len == 0) continue; - const key = jsonStr(line, "key") orelse return .{ .line_len = line.len }; - const value = jsonValue(line, "value") orelse return .{ .line_len = line.len }; + const parsed = parseStrictBulkLine(line) orelse return .{ .line_len = line.len }; return .{ - .key = key, - .value = value, + .key = parsed.key, + .value = parsed.value, .line_len = line.len, .valid = true, }; @@ -2289,12 +2608,68 @@ test "bulk line parser fast path and generic fallback" { try std.testing.expectEqualStrings("{\"name\":\"bob\"}", fallback.value); } +test "bulk insert target recognizes binary bulk endpoint" { + try std.testing.expect(isBulkInsertTarget("POST", "/db/users/bulk")); + try std.testing.expect(isBulkInsertTarget("POST", "/db/users/bulk_binary")); + try std.testing.expect(!isBulkInsertTarget("GET", "/db/users/bulk_binary")); + try std.testing.expect(!isBulkInsertTarget("POST", "/db/users/batch_update")); +} + +test "grpc bridge target parser and admission detection" { + const target = parseGrpcTarget("/grpc/users/BulkInsert").?; + try std.testing.expectEqualStrings("users", target.col_name); + try std.testing.expectEqualStrings("BulkInsert", target.rpc); + + try std.testing.expect(isGrpcBulkMutation("POST", "/grpc/users/BulkInsert")); + try std.testing.expect(isGrpcBulkMutation("POST", "/grpc/users/BulkUpdate")); + try std.testing.expect(isGrpcBulkMutation("POST", "/grpc/users/BulkUpsert")); + try std.testing.expect(isGrpcBulkMutation("POST", "/grpc/users/BulkDelete")); + try std.testing.expect(!isGrpcBulkMutation("GET", "/grpc/users/BulkInsert")); + try std.testing.expect(!isGrpcBulkMutation("POST", "/grpc/users/PointGet")); + try std.testing.expect(!isGrpcBulkMutation("POST", "/grpc/users")); +} + +test "grpc payload accepts raw binary or one uncompressed grpc frame" { + const raw_plain = "POST /grpc/users/BulkInsert HTTP/1.1\r\nContent-Length: 3\r\n\r\nabc"; + try std.testing.expectEqualStrings("abc", grpcPayload(raw_plain, "abc").?); + + const raw_grpc = "POST /grpc/users/BulkInsert HTTP/1.1\r\nContent-Type: application/grpc\r\nContent-Length: 8\r\n\r\n"; + const framed = [_]u8{ 0, 0, 0, 0, 3, 'a', 'b', 'c' }; + try std.testing.expectEqualStrings("abc", grpcPayload(raw_grpc, framed[0..]).?); + + const bad_framed = [_]u8{ 0, 0, 0, 0, 4, 'a', 'b', 'c' }; + try std.testing.expect(grpcPayload(raw_grpc, bad_framed[0..]) == null); + try std.testing.expect(grpcPayload(raw_grpc, "abc") == null); +} + +test "binary bulk record parsers walk key value and key-only bodies" { + const kv = [_]u8{ + 2, 0, 7, 0, 0, 0, 'k', '1', '{', '"', 'n', '"', ':', '1', '}', + 2, 0, 7, 0, 0, 0, 'k', '2', '{', '"', 'n', '"', ':', '2', '}', + }; + var kv_pos: usize = 0; + const first = nextBinaryKeyValue(kv[0..], &kv_pos).?; + try std.testing.expectEqualStrings("k1", first.key); + try std.testing.expectEqualStrings("{\"n\":1}", first.value); + const second = nextBinaryKeyValue(kv[0..], &kv_pos).?; + try std.testing.expectEqualStrings("k2", second.key); + try std.testing.expectEqualStrings("{\"n\":2}", second.value); + try std.testing.expect(kv_pos == kv.len); + try std.testing.expect(nextBinaryKeyValue(kv[0 .. kv.len - 1], &kv_pos) == null); + + const keys = [_]u8{ 2, 0, 'k', '1', 2, 0, 'k', '2' }; + var key_pos: usize = 0; + try std.testing.expectEqualStrings("k1", nextBinaryKey(keys[0..], &key_pos).?.key); + try std.testing.expectEqualStrings("k2", nextBinaryKey(keys[0..], &key_pos).?.key); + try std.testing.expect(key_pos == keys.len); +} + test "bulk memory estimate includes body and bounded chunk workspace" { const small = estimateBulkMemoryBytes(1024); const large = estimateBulkMemoryBytes(MAX_BULK); try std.testing.expect(small > 1024); try std.testing.expect(large > MAX_BULK); - try std.testing.expect(large < MAX_BULK + 16 * 1024 * 1024); + try std.testing.expect(large < MAX_BULK + 40 * 1024 * 1024); } test "bulk memory admission enforces tenant and global limits" { diff --git a/src/storage/epoch.zig b/src/storage/epoch.zig index a3e6882..2ffbf94 100644 --- a/src/storage/epoch.zig +++ b/src/storage/epoch.zig @@ -16,14 +16,16 @@ const std = @import("std"); const compat = @import("compat"); -pub const MAX_READERS: usize = 1024; -pub const EPOCH_INACTIVE: u64 = std.math.maxInt(u64); +pub const MAX_READERS: usize = 1024; +pub const EPOCH_INACTIVE: u64 = std.math.maxInt(u64); /// One slot per reader thread. Padded to 64 bytes to avoid false sharing. const Slot = struct { epoch: std.atomic.Value(u64) = std.atomic.Value(u64).init(EPOCH_INACTIVE), - _pad: [56]u8 = [_]u8{0} ** 56, - comptime { std.debug.assert(@sizeOf(@This()) == 64); } + _pad: [56]u8 = [_]u8{0} ** 56, + comptime { + std.debug.assert(@sizeOf(@This()) == 64); + } }; pub const EpochManager = struct { @@ -45,7 +47,7 @@ pub const EpochManager = struct { for (slots) |*s| s.epoch.store(EPOCH_INACTIVE, .release); return .{ .global_ts = std.atomic.Value(u64).init(1), - .slots = slots, + .slots = slots, .allocator = allocator, .timeline = .empty, .timeline_mu = .{}, @@ -72,6 +74,24 @@ pub const EpochManager = struct { return epoch; } + /// Reserve a contiguous epoch range for a single batched commit. + /// Returns the first epoch in the range. Timestamp lookups map to the + /// batch's final epoch so the whole batch becomes visible together. + pub fn advanceMany(self: *EpochManager, count: usize) u64 { + return self.advanceManyAt(count, compat.milliTimestamp()); + } + + pub fn advanceManyAt(self: *EpochManager, count: usize, ts_ms: i64) u64 { + if (count == 0) return self.now(); + const count_u64: u64 = @intCast(count); + const first_epoch = self.global_ts.fetchAdd(count_u64, .acq_rel) + 1; + const last_epoch = first_epoch + count_u64 - 1; + self.timeline_mu.lock(); + defer self.timeline_mu.unlock(); + self.timeline.append(self.allocator, .{ .epoch = last_epoch, .ts_ms = ts_ms }) catch {}; + return first_epoch; + } + /// Current global timestamp (snapshot for reads). pub fn now(self: *const EpochManager) u64 { return self.global_ts.load(.acquire); @@ -140,3 +160,15 @@ test "epoch manager maps timestamps to epochs" { try std.testing.expectEqual(@as(?u64, @as(u64, 2)), mgr.epochForTimestamp(1_500)); try std.testing.expectEqual(@as(?u64, @as(u64, 3)), mgr.epochForTimestamp(2_000)); } + +test "epoch manager reserves batched epoch ranges" { + const alloc = std.testing.allocator; + var mgr = try EpochManager.init(alloc); + defer mgr.deinit(); + + try std.testing.expectEqual(@as(u64, 2), mgr.advanceManyAt(4, 1_000)); + try std.testing.expectEqual(@as(u64, 5), mgr.now()); + try std.testing.expectEqual(@as(?u64, @as(u64, 5)), mgr.epochForTimestamp(1_000)); + try std.testing.expectEqual(@as(u64, 6), mgr.advanceAt(2_000)); + try std.testing.expectEqual(@as(?u64, @as(u64, 6)), mgr.epochForTimestamp(2_000)); +} From f8ff479ba65e46e0dc70a85e8f988b10c3e65ddc Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:22:25 +0800 Subject: [PATCH 25/27] add wire2 perf path and bound text indexing --- bench/agentic_nanoapi_client.py | 250 +++++++ bench/nanoapi_agent_proxy.zig | 292 ++++++++ bench/run_apple_container_bench.py | 8 +- bench/run_container_agentic_nanoapi_bench.py | 228 ++++++ bench/wire2_perf_client.zig | 690 +++++++++++++++++++ bench/wire2_smoke_client.py | 278 ++++++++ build.zig | 50 +- build.zig.zon | 6 +- src/codeindex.zig | 24 + src/collection.zig | 76 +- src/main.zig | 25 +- src/server.zig | 322 +++++++-- src/storage/wal.zig | 137 +++- src/wire.zig | 672 +++++++++++++++++- 14 files changed, 2938 insertions(+), 120 deletions(-) create mode 100644 bench/agentic_nanoapi_client.py create mode 100644 bench/nanoapi_agent_proxy.zig create mode 100644 bench/run_container_agentic_nanoapi_bench.py create mode 100644 bench/wire2_perf_client.zig create mode 100644 bench/wire2_smoke_client.py diff --git a/bench/agentic_nanoapi_client.py b/bench/agentic_nanoapi_client.py new file mode 100644 index 0000000..504092f --- /dev/null +++ b/bench/agentic_nanoapi_client.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Concurrent agent-shaped client workload for the nanoapi TurboDB proxy.""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import http.client +import json +import random +import statistics +import string +import time +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class OpStats: + latencies_ms: list[float] = field(default_factory=list) + ok: int = 0 + errors: int = 0 + statuses: dict[int, int] = field(default_factory=dict) + + def add(self, latency_ms: float, status: int, ok: bool) -> None: + self.latencies_ms.append(latency_ms) + self.statuses[status] = self.statuses.get(status, 0) + 1 + if ok: + self.ok += 1 + else: + self.errors += 1 + + +def parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser(description="Pound the nanoapi agent proxy through agent-shaped calls") + ap.add_argument("--host", required=True) + ap.add_argument("--port", type=int, default=28080) + ap.add_argument("--agents", type=int, default=32) + ap.add_argument("--rounds", type=int, default=20) + ap.add_argument("--events-per-round", type=int, default=3) + ap.add_argument("--context-limit", type=int, default=20) + ap.add_argument("--timeout", type=float, default=10.0) + ap.add_argument("--output", required=True) + ap.add_argument("--run-id", default=f"agentic-nanoapi-{int(time.time())}") + ap.add_argument("--seed", type=int, default=7) + return ap.parse_args() + + +class AgentClient: + def __init__(self, host: str, port: int, timeout: float): + self.host = host + self.port = port + self.timeout = timeout + self.conn = http.client.HTTPConnection(host, port, timeout=timeout) + + def close(self) -> None: + self.conn.close() + + def request(self, method: str, path: str, body: str = "") -> tuple[int, bytes]: + headers = {"Content-Type": "application/json"} + try: + self.conn.request(method, path, body=body.encode("utf-8"), headers=headers) + resp = self.conn.getresponse() + data = resp.read() + return resp.status, data + except (http.client.HTTPException, OSError): + self.conn.close() + self.conn = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout) + raise + + +def random_text(rng: random.Random, words: int = 24) -> str: + parts: list[str] = [] + alphabet = string.ascii_lowercase + for _ in range(words): + parts.append("".join(rng.choice(alphabet) for _ in range(rng.randint(3, 10)))) + return " ".join(parts) + + +def timed(stats: dict[str, OpStats], name: str, fn) -> None: + started = time.perf_counter() + status = 0 + ok = False + try: + status, body = fn() + ok = 200 <= status < 300 and body + except Exception: + status = 0 + finally: + stats.setdefault(name, OpStats()).add((time.perf_counter() - started) * 1000.0, status, ok) + + +def run_agent(agent_id: int, args: argparse.Namespace) -> dict[str, OpStats]: + rng = random.Random(args.seed + agent_id) + client = AgentClient(args.host, args.port, args.timeout) + stats: dict[str, OpStats] = {} + try: + for round_id in range(args.rounds): + base_key = f"{args.run_id}-a{agent_id:03d}-r{round_id:03d}" + message = { + "run_id": args.run_id, + "agent": agent_id, + "round": round_id, + "kind": "message", + "thought_summary": random_text(rng, 12), + "message": random_text(rng, 32), + "tool_calls": [ + {"name": "ziggrep", "args": {"pattern": "agent", "path": "MCP/harness"}}, + {"name": "chat_post", "args": {"topic": "agentic-load", "role": f"agent-{agent_id:03d}"}}, + ], + } + + timed(stats, "write_event", lambda m=message, k=base_key: client.request("POST", f"/agent/event/{k}", json.dumps(m, separators=(",", ":")))) + + tool = { + "run_id": args.run_id, + "agent": agent_id, + "round": round_id, + "kind": "tool_result", + "status": "ok", + "latency_ms": rng.randint(10, 400), + "preview": random_text(rng, 18), + } + timed(stats, "write_tool", lambda t=tool, k=base_key: client.request("POST", f"/agent/tool/{k}", json.dumps(t, separators=(",", ":")))) + + batch_lines: list[str] = [] + for event_id in range(args.events_per_round): + key = f"{base_key}-batch-{event_id:02d}" + value = { + "run_id": args.run_id, + "agent": agent_id, + "round": round_id, + "event": event_id, + "kind": "trace", + "content": random_text(rng, 16), + } + batch_lines.append(json.dumps({"key": key, "value": value}, separators=(",", ":"))) + timed(stats, "batch_trace", lambda body="\n".join(batch_lines) + "\n": client.request("POST", "/agent/batch", body)) + + if round_id > 0: + prev_key = f"{args.run_id}-a{agent_id:03d}-r{round_id - 1:03d}" + timed(stats, "read_previous", lambda k=prev_key: client.request("GET", f"/agent/event/{k}")) + + if round_id % 3 == 0: + timed(stats, "context_scan", lambda: client.request("GET", f"/agent/context?limit={args.context_limit}")) + finally: + client.close() + return stats + + +def merge_stats(results: list[dict[str, OpStats]]) -> dict[str, OpStats]: + merged: dict[str, OpStats] = {} + for result in results: + for name, stats in result.items(): + dst = merged.setdefault(name, OpStats()) + dst.latencies_ms.extend(stats.latencies_ms) + dst.ok += stats.ok + dst.errors += stats.errors + for status, count in stats.statuses.items(): + dst.statuses[status] = dst.statuses.get(status, 0) + count + return merged + + +def percentile(values: list[float], pct: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + idx = min(len(ordered) - 1, int((len(ordered) - 1) * pct)) + return ordered[idx] + + +def summarize(stats: dict[str, OpStats], elapsed_s: float, args: argparse.Namespace, health: dict[str, Any]) -> dict[str, Any]: + ops: dict[str, Any] = {} + total_ok = 0 + total_errors = 0 + total_ops = 0 + for name, item in sorted(stats.items()): + values = item.latencies_ms + total_ok += item.ok + total_errors += item.errors + total_ops += len(values) + ops[name] = { + "ops": len(values), + "ok": item.ok, + "errors": item.errors, + "statuses": item.statuses, + "p50_ms": statistics.median(values) if values else 0.0, + "p95_ms": percentile(values, 0.95), + "p99_ms": percentile(values, 0.99), + } + return { + "run_id": args.run_id, + "config": { + "agents": args.agents, + "rounds": args.rounds, + "events_per_round": args.events_per_round, + "context_limit": args.context_limit, + }, + "elapsed_s": elapsed_s, + "ops_sec": total_ops / elapsed_s if elapsed_s > 0 else 0.0, + "total_ops": total_ops, + "ok": total_ok, + "errors": total_errors, + "operations": ops, + "post_health": health, + } + + +def gateway_health(host: str, port: int, timeout: float) -> dict[str, Any]: + client = AgentClient(host, port, timeout) + try: + status, body = client.request("GET", "/health") + turbo_status, turbo_body = client.request("GET", "/turbodb/health") + return { + "gateway_status": status, + "gateway_body": body.decode("utf-8", "replace")[:500], + "turbodb_status": turbo_status, + "turbodb_body": turbo_body.decode("utf-8", "replace")[:500], + } + except Exception as exc: + return {"error": repr(exc)} + finally: + client.close() + + +def main() -> int: + args = parse_args() + started = time.perf_counter() + with concurrent.futures.ThreadPoolExecutor(max_workers=args.agents) as pool: + futures = [pool.submit(run_agent, i, args) for i in range(args.agents)] + results = [future.result() for future in concurrent.futures.as_completed(futures)] + elapsed = time.perf_counter() - started + health = gateway_health(args.host, args.port, args.timeout) + summary = summarize(merge_stats(results), elapsed, args, health) + + with open(args.output, "w", encoding="utf-8") as f: + json.dump(summary, f, indent=2, sort_keys=True) + + print(json.dumps({ + "run_id": args.run_id, + "ops_sec": round(summary["ops_sec"], 2), + "total_ops": summary["total_ops"], + "errors": summary["errors"], + "post_health": summary["post_health"], + }, indent=2)) + return 0 if summary["errors"] == 0 and "error" not in summary["post_health"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/nanoapi_agent_proxy.zig b/bench/nanoapi_agent_proxy.zig new file mode 100644 index 0000000..dc594b6 --- /dev/null +++ b/bench/nanoapi_agent_proxy.zig @@ -0,0 +1,292 @@ +const std = @import("std"); +const nano = @import("nanoapi"); + +extern "c" fn socket(domain: c_uint, sock_type: c_uint, protocol: c_uint) c_int; +extern "c" fn close(fd: std.c.fd_t) c_int; + +const Config = struct { + proxy_port: u16 = 28080, + turbodb_host: []const u8 = "127.0.0.1", + turbodb_host_owned: bool = false, + turbodb_port: u16 = 27017, + runtime: nano.server.Runtime = .auto, + worker_threads: usize = 0, + check_only: bool = false, + + fn deinit(self: *Config, allocator: std.mem.Allocator) void { + if (self.turbodb_host_owned) allocator.free(self.turbodb_host); + } +}; + +var g_config: *const Config = undefined; + +pub fn main(init: std.process.Init) !void { + const allocator = std.heap.smp_allocator; + var config = try configFromArgs(init.minimal.args, allocator); + defer config.deinit(allocator); + g_config = &config; + + var api = try nano.NanoAPI.init(allocator, .{ + .title = "TurboDB Agentic NanoAPI Proxy", + .docs_url = null, + .redoc_url = null, + .openapi_url = null, + }); + defer api.deinit(); + + try api.getStaticJson("/health", "{\"status\":\"ok\",\"gateway\":\"nanoapi-agent-proxy\"}", .{}); + try api.get("/turbodb/health", turboHealth, .{}); + try api.get("/turbodb/metrics", turboMetrics, .{}); + try api.post("/agent/event/{key}", writeEvent, .{}); + try api.post("/agent/tool/{key}", writeTool, .{}); + try api.get("/agent/event/{key}", readEvent, .{}); + try api.post("/agent/batch", batchEvents, .{}); + try api.get("/agent/context", contextScan, .{}); + + if (config.check_only) return; + + std.debug.print( + "nanoapi agent proxy listening on :{d}; turbodb={s}:{d}; runtime={t}; workers={d}\n", + .{ config.proxy_port, config.turbodb_host, config.turbodb_port, config.runtime, config.worker_threads }, + ); + try nano.server.serve(&api, allocator, .{ + .host = .{ 0, 0, 0, 0 }, + .port = config.proxy_port, + .runtime = config.runtime, + .worker_threads = config.worker_threads, + .read_buffer_size = 128 * 1024, + .write_buffer_size = 128 * 1024, + }); +} + +fn turboHealth(req: *nano.Request) anyerror!nano.Response { + return turboJson(req, "GET", "/health", ""); +} + +fn turboMetrics(req: *nano.Request) anyerror!nano.Response { + return turboJson(req, "GET", "/metrics", ""); +} + +fn writeEvent(req: *nano.Request) anyerror!nano.Response { + const key = req.pathParam("key") orelse return jsonError(req, 400, "missing key"); + const path = try std.fmt.allocPrint(req.allocator, "/db/agent_events/{s}", .{key}); + return turboJson(req, "PUT", path, req.body); +} + +fn writeTool(req: *nano.Request) anyerror!nano.Response { + const key = req.pathParam("key") orelse return jsonError(req, 400, "missing key"); + const path = try std.fmt.allocPrint(req.allocator, "/db/agent_tools/{s}", .{key}); + return turboJson(req, "PUT", path, req.body); +} + +fn readEvent(req: *nano.Request) anyerror!nano.Response { + const key = req.pathParam("key") orelse return jsonError(req, 400, "missing key"); + const path = try std.fmt.allocPrint(req.allocator, "/db/agent_events/{s}", .{key}); + return turboJson(req, "GET", path, ""); +} + +fn batchEvents(req: *nano.Request) anyerror!nano.Response { + return turboJson(req, "POST", "/db/agent_events/bulk", req.body); +} + +fn contextScan(req: *nano.Request) anyerror!nano.Response { + const limit_raw = req.queryParam("limit") orelse "20"; + const path = try std.fmt.allocPrint(req.allocator, "/db/agent_events?limit={s}", .{limit_raw}); + return turboJson(req, "GET", path, ""); +} + +fn turboJson(req: *nano.Request, method: []const u8, path: []const u8, body: []const u8) anyerror!nano.Response { + const bytes = callTurbo(req.allocator, method, path, body) catch |err| { + const msg = try std.fmt.allocPrint(req.allocator, "{{\"error\":\"turbodb proxy failed\",\"detail\":\"{t}\"}}", .{err}); + return nano.Response.fromOwnedBody(req.allocator, msg, .{ .status_code = 502, .media_type = "application/json" }); + }; + return nano.Response.fromOwnedBody(req.allocator, bytes, .{ .media_type = "application/json" }); +} + +fn jsonError(req: *nano.Request, code: u16, msg: []const u8) !nano.Response { + const bytes = try std.fmt.allocPrint(req.allocator, "{{\"error\":\"{s}\"}}", .{msg}); + return nano.Response.fromOwnedBody(req.allocator, bytes, .{ .status_code = code, .media_type = "application/json" }); +} + +fn callTurbo(allocator: std.mem.Allocator, method: []const u8, path: []const u8, body: []const u8) ![]u8 { + const cfg = g_config; + const fd = try connectTcp(cfg.turbodb_host, cfg.turbodb_port); + defer _ = close(fd); + + var req_bytes: std.ArrayList(u8) = .empty; + defer req_bytes.deinit(allocator); + try req_bytes.print( + allocator, + "{s} {s} HTTP/1.1\r\nHost: turbodb\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ method, path, body.len }, + ); + try req_bytes.appendSlice(allocator, body); + try sendAll(fd, req_bytes.items); + + var response: std.ArrayList(u8) = .empty; + defer response.deinit(allocator); + var scratch: [8192]u8 = undefined; + while (response.items.len < 1024 * 1024) { + const n = recvOnce(fd, &scratch) catch |err| switch (err) { + error.ConnectionClosed => break, + else => return err, + }; + if (n == 0) break; + try response.appendSlice(allocator, scratch[0..n]); + if (responseBodyComplete(response.items)) break; + } + + const body_bytes = responseBody(response.items) orelse return error.BadHttpResponse; + return try allocator.dupe(u8, body_bytes); +} + +fn responseBodyComplete(bytes: []const u8) bool { + const frame = inspectResponse(bytes) orelse return false; + return bytes.len >= frame.header_end + frame.content_length; +} + +fn responseBody(bytes: []const u8) ?[]const u8 { + const frame = inspectResponse(bytes) orelse return null; + if (bytes.len < frame.header_end + frame.content_length) return null; + return bytes[frame.header_end .. frame.header_end + frame.content_length]; +} + +const HttpFrame = struct { + header_end: usize, + content_length: usize, +}; + +fn inspectResponse(bytes: []const u8) ?HttpFrame { + const header_end = if (std.mem.indexOf(u8, bytes, "\r\n\r\n")) |p| p + 4 else return null; + return .{ .header_end = header_end, .content_length = extractContentLength(bytes[0..header_end]) }; +} + +fn extractContentLength(headers: []const u8) usize { + var lines = std.mem.splitSequence(u8, headers, "\r\n"); + while (lines.next()) |line_raw| { + const line = std.mem.trim(u8, line_raw, " \t\r\n"); + const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue; + const name = std.mem.trim(u8, line[0..colon], " \t"); + if (!std.ascii.eqlIgnoreCase(name, "content-length")) continue; + const value = std.mem.trim(u8, line[colon + 1 ..], " \t"); + return std.fmt.parseInt(usize, value, 10) catch 0; + } + return 0; +} + +fn connectTcp(host: []const u8, port: u16) !std.c.fd_t { + const fd = socket(std.c.AF.INET, std.c.SOCK.STREAM, 0); + switch (std.c.errno(fd)) { + .SUCCESS => {}, + else => |err| return errnoError(err), + } + errdefer _ = close(fd); + + var addr: std.c.sockaddr.in = .{ + .port = std.mem.nativeToBig(u16, port), + .addr = @bitCast(try parseIpv4(host)), + }; + const rc = std.c.connect(fd, @ptrCast(&addr), @sizeOf(std.c.sockaddr.in)); + switch (std.c.errno(rc)) { + .SUCCESS => {}, + else => |err| return errnoError(err), + } + return fd; +} + +fn parseIpv4(ip: []const u8) ![4]u8 { + var out: [4]u8 = undefined; + var it = std.mem.splitScalar(u8, ip, '.'); + var i: usize = 0; + while (it.next()) |part| { + if (i >= out.len or part.len == 0) return error.InvalidIpAddress; + out[i] = try std.fmt.parseInt(u8, part, 10); + i += 1; + } + if (i != out.len) return error.InvalidIpAddress; + return out; +} + +fn recvOnce(fd: std.c.fd_t, buf: []u8) !usize { + while (true) { + const n = std.c.recv(fd, buf.ptr, buf.len, 0); + switch (std.c.errno(n)) { + .SUCCESS => return @intCast(n), + .INTR => continue, + .AGAIN => continue, + .CONNRESET => return error.ConnectionClosed, + else => |err| return errnoError(err), + } + } +} + +fn sendAll(fd: std.c.fd_t, bytes: []const u8) !void { + var written: usize = 0; + while (written < bytes.len) { + const flags = if (@hasDecl(std.c.MSG, "NOSIGNAL")) std.c.MSG.NOSIGNAL else 0; + const n = std.c.send(fd, bytes[written..].ptr, bytes.len - written, flags); + switch (std.c.errno(n)) { + .SUCCESS => { + if (n == 0) return error.ConnectionClosed; + written += @intCast(n); + }, + .INTR => continue, + .AGAIN => continue, + else => |err| return errnoError(err), + } + } +} + +fn errnoError(err: std.c.E) error{ + BadHttpResponse, + ConnectionClosed, + ConnectFailed, + InvalidIpAddress, + Unexpected, +} { + switch (err) { + .CONNREFUSED, .HOSTUNREACH, .NETUNREACH, .TIMEDOUT => return error.ConnectFailed, + .CONNRESET, .PIPE, .NOTCONN => return error.ConnectionClosed, + else => return error.Unexpected, + } +} + +fn configFromArgs(args_state: std.process.Args, allocator: std.mem.Allocator) !Config { + var args = std.process.Args.Iterator.init(args_state); + defer args.deinit(); + _ = args.next(); + + var config: Config = .{}; + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "--check")) { + config.check_only = true; + } else if (std.mem.eql(u8, arg, "--port")) { + config.proxy_port = try std.fmt.parseInt(u16, args.next() orelse return error.InvalidArgument, 10); + } else if (std.mem.eql(u8, arg, "--turbodb-host")) { + config.turbodb_host = try allocator.dupe(u8, args.next() orelse return error.InvalidArgument); + config.turbodb_host_owned = true; + } else if (std.mem.eql(u8, arg, "--turbodb-port")) { + config.turbodb_port = try std.fmt.parseInt(u16, args.next() orelse return error.InvalidArgument, 10); + } else if (std.mem.eql(u8, arg, "--runtime")) { + config.runtime = try parseRuntime(args.next() orelse return error.InvalidArgument); + } else if (std.mem.eql(u8, arg, "--workers")) { + config.worker_threads = try std.fmt.parseInt(usize, args.next() orelse return error.InvalidArgument, 10); + } else { + return error.InvalidArgument; + } + } + return config; +} + +fn parseRuntime(raw: []const u8) !nano.server.Runtime { + if (std.mem.eql(u8, raw, "auto")) return .auto; + if (std.mem.eql(u8, raw, "io_uring")) return .io_uring; + if (std.mem.eql(u8, raw, "thread_per_connection")) return .thread_per_connection; + if (std.mem.eql(u8, raw, "event_loop")) return .event_loop; + return error.InvalidRuntime; +} + +test "parse response body" { + const resp = "HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\n{\"x\":1}"; + try std.testing.expectEqualStrings("{\"x\":1}", responseBody(resp).?); +} diff --git a/bench/run_apple_container_bench.py b/bench/run_apple_container_bench.py index 7f6e57e..c1cffaf 100644 --- a/bench/run_apple_container_bench.py +++ b/bench/run_apple_container_bench.py @@ -208,7 +208,7 @@ def start_turbodb(self) -> Service: name = self.name("turbodb") data_volume = self.volume("turbodb-data") delete_container(name) - run([ + cmd = [ "container", "run", "-d", "--name", name, "--network", self.network, @@ -219,7 +219,10 @@ def start_turbodb(self) -> Service: "--data", "/data", "--port", "27017", "--http", - ]) + ] + if self.args.turbodb_http_runtime: + cmd.extend(["--http-runtime", self.args.turbodb_http_runtime]) + run(cmd) self.containers.append(name) host = container_ip(name) wait_http(self.network, f"http://{host}:27017/health", timeout_s=60) @@ -397,6 +400,7 @@ def parse_args() -> argparse.Namespace: ap.add_argument("--turbodb-ffi-lib", default="/work/zig-out-ffi/lib/libturbodb.so") ap.add_argument("--turbodb-ffi-dir", default="/tmp/turbodb_ffi_shape_bench") ap.add_argument("--turbodb-bulk-mode", choices=("ndjson", "binary"), default="ndjson") + ap.add_argument("--turbodb-http-runtime", choices=("threaded", "nanoapi-raw"), default=None) ap.add_argument("--python-image", default="python:3.12-slim") ap.add_argument("--postgres-image", default="postgres:18") ap.add_argument("--mysql-image", default="mysql:8.4") diff --git a/bench/run_container_agentic_nanoapi_bench.py b/bench/run_container_agentic_nanoapi_bench.py new file mode 100644 index 0000000..134b0c3 --- /dev/null +++ b/bench/run_container_agentic_nanoapi_bench.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Run TurboDB behind a nanoapi proxy and pound only the proxy from a client container.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def shlex_join(argv: list[str]) -> str: + import shlex + + return " ".join(shlex.quote(part) for part in argv) + + +def run(argv: list[str], *, check: bool = True, capture: bool = False, timeout: int | None = None) -> subprocess.CompletedProcess[str]: + print(f"+ {shlex_join(argv)}", flush=True) + return subprocess.run( + argv, + cwd=ROOT, + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.STDOUT if capture else None, + check=check, + timeout=timeout, + ) + + +def container_exists(name: str) -> bool: + return run(["container", "inspect", name], check=False, capture=True).returncode == 0 + + +def delete_container(name: str) -> None: + if container_exists(name): + run(["container", "stop", name], check=False, capture=True) + run(["container", "rm", name], check=False, capture=True) + + +def network_exists(name: str) -> bool: + proc = run(["container", "network", "list"], check=False, capture=True) + return proc.returncode == 0 and proc.stdout is not None and any(line.split(maxsplit=1)[0] == name for line in proc.stdout.splitlines()[1:]) + + +def create_network(name: str) -> None: + if not network_exists(name): + run(["container", "network", "create", name]) + + +def delete_network(name: str) -> None: + if network_exists(name): + run(["container", "network", "delete", name], check=False, capture=True) + + +def volume_exists(name: str) -> bool: + proc = run(["container", "volume", "list"], check=False, capture=True) + return proc.returncode == 0 and proc.stdout is not None and any(line.split(maxsplit=1)[0] == name for line in proc.stdout.splitlines()[1:]) + + +def create_volume(name: str) -> None: + if not volume_exists(name): + run(["container", "volume", "create", name]) + + +def delete_volume(name: str) -> None: + if volume_exists(name): + run(["container", "volume", "delete", name], check=False, capture=True) + + +def inspect_json(name: str) -> object: + proc = run(["container", "inspect", name], capture=True) + assert proc.stdout is not None + return json.loads(proc.stdout) + + +def container_ip(name: str) -> str: + data = inspect_json(name) + candidates = data if isinstance(data, list) else [data] + for item in candidates: + if not isinstance(item, dict): + continue + for net in item.get("networks") or []: + address = net.get("ipv4Address") or net.get("address") + if address: + return str(address).split("/", 1)[0] + raise RuntimeError(f"could not find IPv4 address for {name}") + + +def wait_http(network: str, url: str, timeout_s: int = 45) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + proc = run( + ["container", "run", "--rm", "--network", network, "--entrypoint", "wget", "alpine:3.20", "-qO-", url], + check=False, + capture=True, + timeout=20, + ) + if proc.returncode == 0: + return + time.sleep(0.5) + raise RuntimeError(f"timed out waiting for {url}") + + +def logs_tail(name: str, lines: int = 160) -> str: + proc = run(["container", "logs", "-n", str(lines), name], check=False, capture=True) + return (proc.stdout or "").strip() + + +def parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser(description="Agentic nanoapi-over-TurboDB container stress harness") + ap.add_argument("--run-id", default=f"agentic-nanoapi-{int(time.time())}") + ap.add_argument("--network") + ap.add_argument("--output") + ap.add_argument("--target", default="aarch64-linux-musl") + ap.add_argument("--optimize", default="ReleaseFast") + ap.add_argument("--agents", type=int, default=32) + ap.add_argument("--rounds", type=int, default=20) + ap.add_argument("--events-per-round", type=int, default=3) + ap.add_argument("--context-limit", type=int, default=20) + ap.add_argument("--proxy-runtime", choices=("auto", "io_uring", "thread_per_connection"), default="io_uring") + ap.add_argument("--proxy-workers", type=int, default=1) + ap.add_argument("--turbodb-runtime", choices=("threaded", "nanoapi-raw"), default="threaded") + ap.add_argument("--python-image", default="python:3.12-slim") + ap.add_argument("--keep", action="store_true") + return ap.parse_args() + + +def main() -> int: + if not shutil.which("container"): + print("Apple container CLI is required", file=sys.stderr) + return 2 + + args = parse_args() + run_id = args.run_id + network = args.network or f"tdb-agentic-{run_id}" + output = Path(args.output or ROOT / "benchmark-results" / f"{run_id}.json").resolve() + output.parent.mkdir(parents=True, exist_ok=True) + + turbodb_name = f"tdb-{run_id}-db" + proxy_name = f"tdb-{run_id}-proxy" + client_name = f"tdb-{run_id}-client" + volume = f"tdb-{run_id}-data" + + try: + create_network(network) + create_volume(volume) + run(["zig", "build", f"-Dtarget={args.target}", f"-Doptimize={args.optimize}"]) + + delete_container(turbodb_name) + run([ + "container", "run", "-d", + "--name", turbodb_name, + "--network", network, + "--mount", f"type=bind,source={ROOT},target=/work,readonly", + "--volume", f"{volume}:/data", + "--entrypoint", "/work/zig-out/bin/turbodb", + "alpine:3.20", + "--data", "/data", + "--port", "27017", + "--http", + "--http-runtime", args.turbodb_runtime, + ]) + turbodb_host = container_ip(turbodb_name) + wait_http(network, f"http://{turbodb_host}:27017/health", timeout_s=60) + + delete_container(proxy_name) + run([ + "container", "run", "-d", + "--name", proxy_name, + "--network", network, + "--mount", f"type=bind,source={ROOT},target=/work,readonly", + "--entrypoint", "/work/zig-out/bin/nanoapi-agent-proxy", + "alpine:3.20", + "--port", "28080", + "--turbodb-host", turbodb_host, + "--turbodb-port", "27017", + "--runtime", args.proxy_runtime, + "--workers", str(args.proxy_workers), + ]) + proxy_host = container_ip(proxy_name) + wait_http(network, f"http://{proxy_host}:28080/health", timeout_s=60) + wait_http(network, f"http://{proxy_host}:28080/turbodb/health", timeout_s=60) + + delete_container(client_name) + proc = run([ + "container", "run", "--rm", + "--name", client_name, + "--network", network, + "--mount", f"type=bind,source={ROOT},target=/work", + "-w", "/work", + "--entrypoint", "python", + args.python_image, + "bench/agentic_nanoapi_client.py", + "--host", proxy_host, + "--port", "28080", + "--agents", str(args.agents), + "--rounds", str(args.rounds), + "--events-per-round", str(args.events_per_round), + "--context-limit", str(args.context_limit), + "--run-id", run_id, + "--output", f"/work/{output.relative_to(ROOT)}", + ], check=False) + + if proc.returncode != 0: + print("\nproxy logs:\n" + logs_tail(proxy_name), file=sys.stderr) + print("\nturbodb logs:\n" + logs_tail(turbodb_name), file=sys.stderr) + print(f"benchmark output: {output}") + return proc.returncode + finally: + if args.keep: + print(f"keeping network={network} db={turbodb_name} proxy={proxy_name} volume={volume}") + else: + delete_container(client_name) + delete_container(proxy_name) + delete_container(turbodb_name) + delete_network(network) + delete_volume(volume) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/wire2_perf_client.zig b/bench/wire2_perf_client.zig new file mode 100644 index 0000000..bb8ee8c --- /dev/null +++ b/bench/wire2_perf_client.zig @@ -0,0 +1,690 @@ +const std = @import("std"); +const compat = @import("compat"); + +extern "c" fn socket(domain: c_uint, sock_type: c_uint, protocol: c_uint) c_int; +extern "c" fn close(fd: std.c.fd_t) c_int; + +const OUTER_HDR: usize = 5; +const WIRE2_HDR: usize = 16; +const FRAME_HDR: usize = OUTER_HDR + WIRE2_HDR; +const MAX_SERVER_RESPONSE: usize = 4 * 1024 * 1024; +const MAX_WIRE2_FRAME: usize = 64 * 1024 * 1024 + OUTER_HDR; + +const OP_WIRE2: u8 = 0x20; +const WIRE2_VERSION: u8 = 2; +const WIRE2_OP_HELLO: u8 = 0x00; +const WIRE2_OP_GET: u8 = 0x02; +const WIRE2_OP_PUT: u8 = 0x03; +const WIRE2_OP_BATCH_GET: u8 = 0x06; +const WIRE2_OP_BULK_INSERT: u8 = 0x07; +const WIRE2_OP_AUTH: u8 = 0x0A; + +const STATUS_OK: u8 = 0x00; +const STATUS_NOT_FOUND: u8 = 0x01; + +const Config = struct { + host: []const u8 = "127.0.0.1", + host_owned: bool = false, + port: u16 = 27017, + collection: []const u8 = "wire2_perf", + collection_owned: bool = false, + rows: usize = 1_000_000, + batch_size: usize = 10_000, + get_rounds: usize = 100, + get_batch_size: usize = 0, + pipeline_window: usize = 4, + auth_key: []const u8 = "", + auth_key_owned: bool = false, + output: []const u8 = "", + output_owned: bool = false, + + fn deinit(self: *Config, allocator: std.mem.Allocator) void { + if (self.host_owned) allocator.free(self.host); + if (self.collection_owned) allocator.free(self.collection); + if (self.auth_key_owned) allocator.free(self.auth_key); + if (self.output_owned) allocator.free(self.output); + } +}; + +const HelloInfo = struct { + max_frame: u32 = 0, + max_response: u32 = 0, + features: u32 = 0, +}; + +const Wire2Response = struct { + request_id: u64, + op: u8, + status: u8, + body: []const u8, +}; + +const Wire2Stats = struct { + inserted: u64 = 0, + updated: u64 = 0, + deleted: u64 = 0, + missing: u64 = 0, + errors: u64 = 0, + bytes: u64 = 0, +}; + +const PhaseMetrics = struct { + operations: u64 = 0, + seconds: f64 = 0, + per_second: f64 = 0, + bytes: u64 = 0, + batches: u64 = 0, + inserted: u64 = 0, + found: u64 = 0, + missing: u64 = 0, + errors: u64 = 0, +}; + +const BenchResult = struct { + hello: HelloInfo, + probe_seconds: f64, + bulk: PhaseMetrics, + batch_get: PhaseMetrics, +}; + +pub fn main(init: std.process.Init) !void { + const allocator = std.heap.smp_allocator; + const args = try compat.argsAlloc(allocator, init); + defer compat.argsFree(allocator, args); + + var config = try parseArgs(args, allocator); + defer config.deinit(allocator); + validateConfig(config) catch |err| { + usage(); + return err; + }; + + const result = try runBenchmark(allocator, config); + try emitResult(config, result); +} + +fn parseArgs(args: [][:0]const u8, allocator: std.mem.Allocator) !Config { + var config: Config = .{}; + var i: usize = 1; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (std.mem.eql(u8, arg, "--host")) { + i += 1; + config.host = try dupArg(args, i, allocator); + config.host_owned = true; + } else if (std.mem.eql(u8, arg, "--port")) { + i += 1; + config.port = try parseArg(u16, args, i); + } else if (std.mem.eql(u8, arg, "--collection")) { + i += 1; + config.collection = try dupArg(args, i, allocator); + config.collection_owned = true; + } else if (std.mem.eql(u8, arg, "--rows")) { + i += 1; + config.rows = try parseArg(usize, args, i); + } else if (std.mem.eql(u8, arg, "--batch-size")) { + i += 1; + config.batch_size = try parseArg(usize, args, i); + } else if (std.mem.eql(u8, arg, "--get-rounds")) { + i += 1; + config.get_rounds = try parseArg(usize, args, i); + } else if (std.mem.eql(u8, arg, "--get-batch-size")) { + i += 1; + config.get_batch_size = try parseArg(usize, args, i); + } else if (std.mem.eql(u8, arg, "--pipeline-window")) { + i += 1; + config.pipeline_window = try parseArg(usize, args, i); + } else if (std.mem.eql(u8, arg, "--auth-key")) { + i += 1; + config.auth_key = try dupArg(args, i, allocator); + config.auth_key_owned = true; + } else if (std.mem.eql(u8, arg, "--output")) { + i += 1; + config.output = try dupArg(args, i, allocator); + config.output_owned = true; + } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { + usage(); + std.process.exit(0); + } else { + return error.InvalidArgument; + } + } + if (config.get_batch_size == 0) config.get_batch_size = config.batch_size; + return config; +} + +fn dupArg(args: [][:0]const u8, i: usize, allocator: std.mem.Allocator) ![]const u8 { + if (i >= args.len) return error.InvalidArgument; + return allocator.dupe(u8, args[i]); +} + +fn parseArg(comptime T: type, args: [][:0]const u8, i: usize) !T { + if (i >= args.len) return error.InvalidArgument; + return std.fmt.parseInt(T, args[i], 10); +} + +fn validateConfig(config: Config) !void { + if (config.host.len == 0 or config.collection.len == 0) return error.InvalidArgument; + if (config.rows == 0 or config.batch_size == 0 or config.get_rounds == 0 or config.get_batch_size == 0) return error.InvalidArgument; + if (config.pipeline_window == 0) return error.InvalidArgument; + if (config.batch_size > std.math.maxInt(u32) or config.get_batch_size > std.math.maxInt(u32)) return error.InvalidArgument; + if (config.collection.len > std.math.maxInt(u16) or config.auth_key.len > std.math.maxInt(u16)) return error.InvalidArgument; + if (config.get_batch_size > config.rows) return error.InvalidArgument; +} + +fn usage() void { + std.debug.print( + \\usage: wire2-perf-client [options] + \\ --host IP TurboDB wire host (default 127.0.0.1) + \\ --port PORT TurboDB wire port (default 27017) + \\ --collection NAME Collection to benchmark (default wire2_perf) + \\ --rows N Rows to bulk insert (default 1000000) + \\ --batch-size N Rows per bulk insert request (default 10000) + \\ --get-rounds N Batch-get repetitions (default 100) + \\ --get-batch-size N Keys per batch-get request (default batch-size) + \\ --pipeline-window N Batch-get requests in flight (default 4) + \\ --auth-key KEY Optional wire2 auth key + \\ --output PATH Optional JSON output path + \\ + , .{}); +} + +fn runBenchmark(allocator: std.mem.Allocator, config: Config) !BenchResult { + const request_capacity = try requiredRequestCapacity(config); + const request_buf = try allocator.alloc(u8, request_capacity); + defer allocator.free(request_buf); + const response_buf = try allocator.alloc(u8, MAX_SERVER_RESPONSE); + defer allocator.free(response_buf); + + const fd = try connectTcp(config.host, config.port); + defer _ = close(fd); + + var request_id: u64 = 1; + const hello = try doHello(fd, request_buf, response_buf, &request_id); + if (config.auth_key.len > 0) try doAuth(fd, request_buf, response_buf, &request_id, config.auth_key); + + const probe_start = compat.nanoTimestamp(); + try doProbe(fd, request_buf, response_buf, &request_id, config.collection); + const probe_seconds = nsToSeconds(compat.nanoTimestamp() - probe_start); + + const bulk = try doBulkInsert(fd, request_buf, response_buf, &request_id, config); + const batch_get = try doBatchGet(fd, request_buf, response_buf, &request_id, config); + + return .{ + .hello = hello, + .probe_seconds = probe_seconds, + .bulk = bulk, + .batch_get = batch_get, + }; +} + +fn requiredRequestCapacity(config: Config) !usize { + const row_width: usize = 6 + 32 + 96; + const key_width: usize = 2 + 32; + const bulk_body = 2 + config.collection.len + 4 + config.batch_size * row_width; + const get_body = 2 + config.collection.len + 4 + config.get_batch_size * key_width; + const need = FRAME_HDR + @max(bulk_body, get_body); + if (need > MAX_WIRE2_FRAME) return error.FrameTooLarge; + return @max(need, 64 * 1024); +} + +fn doHello(fd: std.c.fd_t, request_buf: []u8, response_buf: []u8, request_id: *u64) !HelloInfo { + const resp = try callWire2(fd, request_buf, response_buf, request_id, WIRE2_OP_HELLO, 0); + try expectStatus(resp, WIRE2_OP_HELLO, STATUS_OK); + if (resp.body.len < 12) return error.BadResponse; + return .{ + .max_frame = rdU32LE(resp.body[0..4]), + .max_response = rdU32LE(resp.body[4..8]), + .features = rdU32LE(resp.body[8..12]), + }; +} + +fn doAuth(fd: std.c.fd_t, request_buf: []u8, response_buf: []u8, request_id: *u64, auth_key: []const u8) !void { + if (auth_key.len > std.math.maxInt(u16)) return error.InvalidArgument; + const body = requestBody(request_buf); + if (body.len < 2 + auth_key.len) return error.FrameTooLarge; + wrU16LE(body[0..2], @intCast(auth_key.len)); + @memcpy(body[2..][0..auth_key.len], auth_key); + const resp = try callWire2(fd, request_buf, response_buf, request_id, WIRE2_OP_AUTH, 2 + auth_key.len); + try expectStatus(resp, WIRE2_OP_AUTH, STATUS_OK); +} + +fn doProbe(fd: std.c.fd_t, request_buf: []u8, response_buf: []u8, request_id: *u64, collection: []const u8) !void { + const key = "probe-native"; + const value = "{\"probe\":\"wire2-zig\"}"; + const body_len = encodeKeyValue(requestBody(request_buf), collection, key, value) catch return error.FrameTooLarge; + var resp = try callWire2(fd, request_buf, response_buf, request_id, WIRE2_OP_PUT, body_len); + try expectStatus(resp, WIRE2_OP_PUT, STATUS_OK); + + const get_len = encodeKey(requestBody(request_buf), collection, key) catch return error.FrameTooLarge; + resp = try callWire2(fd, request_buf, response_buf, request_id, WIRE2_OP_GET, get_len); + try expectStatus(resp, WIRE2_OP_GET, STATUS_OK); + if (resp.body.len < 13) return error.BadResponse; + const value_len: usize = rdU32LE(resp.body[9..13]); + if (resp.body.len != 13 + value_len) return error.BadResponse; + if (!std.mem.eql(u8, resp.body[13..], value)) return error.BadResponse; +} + +fn doBulkInsert(fd: std.c.fd_t, request_buf: []u8, response_buf: []u8, request_id: *u64, config: Config) !PhaseMetrics { + var metrics: PhaseMetrics = .{}; + const started = compat.nanoTimestamp(); + + var base: usize = 0; + while (base < config.rows) { + const count = @min(config.batch_size, config.rows - base); + const body_len = try encodeBulkInsertBody(requestBody(request_buf), config.collection, base, count); + const resp = try callWire2(fd, request_buf, response_buf, request_id, WIRE2_OP_BULK_INSERT, body_len); + try expectStatus(resp, WIRE2_OP_BULK_INSERT, STATUS_OK); + const stats = try parseStats(resp.body); + metrics.inserted += stats.inserted; + metrics.errors += stats.errors; + metrics.bytes += stats.bytes; + metrics.operations += count; + metrics.batches += 1; + if (stats.errors != 0) return error.ServerErrors; + base += count; + } + + metrics.seconds = nsToSeconds(compat.nanoTimestamp() - started); + metrics.per_second = rate(metrics.operations, metrics.seconds); + return metrics; +} + +fn doBatchGet(fd: std.c.fd_t, request_buf: []u8, response_buf: []u8, request_id: *u64, config: Config) !PhaseMetrics { + var metrics: PhaseMetrics = .{}; + const started = compat.nanoTimestamp(); + + const first_request_id = request_id.*; + var sent_rounds: usize = 0; + var received_rounds: usize = 0; + while (received_rounds < config.get_rounds) { + while (sent_rounds < config.get_rounds and sent_rounds - received_rounds < config.pipeline_window) : (sent_rounds += 1) { + const base = (sent_rounds * config.get_batch_size) % config.rows; + const body_len = try encodeBatchGetBody(requestBody(request_buf), config.collection, base, config.get_batch_size, config.rows); + const id = request_id.*; + request_id.* += 1; + try sendAll(fd, finishRequest(request_buf, id, WIRE2_OP_BATCH_GET, body_len)); + } + + const resp = try readWire2Response(fd, response_buf); + const expected_id = first_request_id + @as(u64, @intCast(received_rounds)); + if (resp.request_id != expected_id or resp.op != WIRE2_OP_BATCH_GET) return error.BadResponse; + try expectStatus(resp, WIRE2_OP_BATCH_GET, STATUS_OK); + const counts = try parseBatchGet(resp.body); + metrics.found += counts.found; + metrics.missing += counts.missing; + metrics.operations += config.get_batch_size; + metrics.batches += 1; + if (counts.missing != 0) return error.BadResponse; + received_rounds += 1; + } + + metrics.seconds = nsToSeconds(compat.nanoTimestamp() - started); + metrics.per_second = rate(metrics.operations, metrics.seconds); + return metrics; +} + +fn callWire2(fd: std.c.fd_t, request_buf: []u8, response_buf: []u8, request_id: *u64, op: u8, body_len: usize) !Wire2Response { + const id = request_id.*; + request_id.* += 1; + const frame = finishRequest(request_buf, id, op, body_len); + try sendAll(fd, frame); + const resp = try readWire2Response(fd, response_buf); + if (resp.request_id != id or resp.op != op) return error.BadResponse; + return resp; +} + +fn requestBody(request_buf: []u8) []u8 { + return request_buf[FRAME_HDR..]; +} + +fn finishRequest(request_buf: []u8, request_id: u64, op: u8, body_len: usize) []const u8 { + const total_len = FRAME_HDR + body_len; + wrU32BE(request_buf[0..4], @intCast(total_len)); + request_buf[4] = OP_WIRE2; + request_buf[5] = WIRE2_VERSION; + request_buf[6] = 0; + wrU64LE(request_buf[7..15], request_id); + request_buf[15] = op; + request_buf[16] = 0; + wrU32LE(request_buf[17..21], @intCast(body_len)); + return request_buf[0..total_len]; +} + +fn encodeBulkInsertBody(out: []u8, collection: []const u8, base: usize, count: usize) !usize { + var pos = try encodeCollectionHeader(out, collection, count); + for (0..count) |i| { + const n = base + i; + if (pos + 6 + 32 + 96 > out.len) return error.FrameTooLarge; + const key = try std.fmt.bufPrint(out[pos + 6 ..][0..32], "k-{d:0>10}", .{n}); + const value = try std.fmt.bufPrint(out[pos + 6 + key.len ..][0..96], "{{\"n\":{d},\"v\":\"wire2\"}}", .{n}); + wrU16LE(out[pos..][0..2], @intCast(key.len)); + wrU32LE(out[pos + 2 ..][0..4], @intCast(value.len)); + pos += 6 + key.len + value.len; + } + return pos; +} + +fn encodeBatchGetBody(out: []u8, collection: []const u8, base: usize, count: usize, rows: usize) !usize { + var pos = try encodeCollectionHeader(out, collection, count); + for (0..count) |i| { + const n = (base + i) % rows; + if (pos + 2 + 32 > out.len) return error.FrameTooLarge; + const key = try std.fmt.bufPrint(out[pos + 2 ..][0..32], "k-{d:0>10}", .{n}); + wrU16LE(out[pos..][0..2], @intCast(key.len)); + pos += 2 + key.len; + } + return pos; +} + +fn encodeCollectionHeader(out: []u8, collection: []const u8, count: usize) !usize { + if (collection.len > std.math.maxInt(u16) or count > std.math.maxInt(u32)) return error.InvalidArgument; + if (out.len < 2 + collection.len + 4) return error.FrameTooLarge; + wrU16LE(out[0..2], @intCast(collection.len)); + @memcpy(out[2..][0..collection.len], collection); + wrU32LE(out[2 + collection.len ..][0..4], @intCast(count)); + return 2 + collection.len + 4; +} + +fn encodeKey(out: []u8, collection: []const u8, key: []const u8) !usize { + if (collection.len > std.math.maxInt(u16) or key.len > std.math.maxInt(u16)) return error.InvalidArgument; + const need = 2 + collection.len + 2 + key.len; + if (out.len < need) return error.FrameTooLarge; + wrU16LE(out[0..2], @intCast(collection.len)); + @memcpy(out[2..][0..collection.len], collection); + const key_off = 2 + collection.len; + wrU16LE(out[key_off..][0..2], @intCast(key.len)); + @memcpy(out[key_off + 2 ..][0..key.len], key); + return need; +} + +fn encodeKeyValue(out: []u8, collection: []const u8, key: []const u8, value: []const u8) !usize { + if (collection.len > std.math.maxInt(u16) or key.len > std.math.maxInt(u16)) return error.InvalidArgument; + if (value.len > std.math.maxInt(u32)) return error.InvalidArgument; + const need = 2 + collection.len + 2 + key.len + 4 + value.len; + if (out.len < need) return error.FrameTooLarge; + wrU16LE(out[0..2], @intCast(collection.len)); + @memcpy(out[2..][0..collection.len], collection); + const key_off = 2 + collection.len; + wrU16LE(out[key_off..][0..2], @intCast(key.len)); + @memcpy(out[key_off + 2 ..][0..key.len], key); + const val_off = key_off + 2 + key.len; + wrU32LE(out[val_off..][0..4], @intCast(value.len)); + @memcpy(out[val_off + 4 ..][0..value.len], value); + return need; +} + +fn parseStats(body: []const u8) !Wire2Stats { + if (body.len != 28) return error.BadResponse; + return .{ + .inserted = rdU32LE(body[0..4]), + .updated = rdU32LE(body[4..8]), + .deleted = rdU32LE(body[8..12]), + .missing = rdU32LE(body[12..16]), + .errors = rdU32LE(body[16..20]), + .bytes = rdU64LE(body[20..28]), + }; +} + +fn parseBatchGet(body: []const u8) !struct { found: u64, missing: u64 } { + if (body.len < 4) return error.BadResponse; + const count = rdU32LE(body[0..4]); + var pos: usize = 4; + var found: u64 = 0; + var missing: u64 = 0; + for (0..count) |_| { + if (pos + 14 > body.len) return error.BadResponse; + const status = body[pos]; + const value_len: usize = rdU32LE(body[pos + 10 ..][0..4]); + pos += 14; + if (pos + value_len > body.len) return error.BadResponse; + pos += value_len; + if (status == STATUS_OK) { + found += 1; + } else if (status == STATUS_NOT_FOUND) { + missing += 1; + } else { + return error.BadResponse; + } + } + if (pos != body.len) return error.BadResponse; + return .{ .found = found, .missing = missing }; +} + +fn expectStatus(resp: Wire2Response, op: u8, status: u8) !void { + if (resp.op != op) return error.BadResponse; + if (resp.status != status) return error.BadStatus; +} + +fn readWire2Response(fd: std.c.fd_t, response_buf: []u8) !Wire2Response { + if (response_buf.len < FRAME_HDR) return error.BadResponse; + try recvExact(fd, response_buf[0..OUTER_HDR]); + const frame_len: usize = rdU32BE(response_buf[0..4]); + if (frame_len < FRAME_HDR or frame_len > response_buf.len) return error.BadResponse; + if (response_buf[4] != OP_WIRE2) return error.BadResponse; + try recvExact(fd, response_buf[OUTER_HDR..frame_len]); + if (response_buf[5] != WIRE2_VERSION) return error.BadResponse; + const body_len: usize = rdU32LE(response_buf[17..21]); + if (FRAME_HDR + body_len != frame_len) return error.BadResponse; + return .{ + .request_id = rdU64LE(response_buf[7..15]), + .op = response_buf[15], + .status = response_buf[16], + .body = response_buf[FRAME_HDR..frame_len], + }; +} + +fn connectTcp(host: []const u8, port: u16) !std.c.fd_t { + const fd = socket(std.c.AF.INET, std.c.SOCK.STREAM, 0); + switch (std.c.errno(fd)) { + .SUCCESS => {}, + else => |err| return errnoError(err), + } + errdefer _ = close(fd); + + var addr: std.c.sockaddr.in = .{ + .port = std.mem.nativeToBig(u16, port), + .addr = @bitCast(try parseIpv4(host)), + }; + const rc = std.c.connect(fd, @ptrCast(&addr), @sizeOf(std.c.sockaddr.in)); + switch (std.c.errno(rc)) { + .SUCCESS => {}, + else => |err| return errnoError(err), + } + return fd; +} + +fn parseIpv4(ip: []const u8) ![4]u8 { + var out: [4]u8 = undefined; + var it = std.mem.splitScalar(u8, ip, '.'); + var i: usize = 0; + while (it.next()) |part| { + if (i >= out.len or part.len == 0) return error.InvalidIpAddress; + out[i] = try std.fmt.parseInt(u8, part, 10); + i += 1; + } + if (i != out.len) return error.InvalidIpAddress; + return out; +} + +fn recvExact(fd: std.c.fd_t, buf: []u8) !void { + var got: usize = 0; + while (got < buf.len) { + const n = std.c.recv(fd, buf[got..].ptr, buf.len - got, 0); + switch (std.c.errno(n)) { + .SUCCESS => { + if (n == 0) return error.ConnectionClosed; + got += @intCast(n); + }, + .INTR => continue, + .AGAIN => continue, + .CONNRESET => return error.ConnectionClosed, + else => |err| return errnoError(err), + } + } +} + +fn sendAll(fd: std.c.fd_t, bytes: []const u8) !void { + var written: usize = 0; + while (written < bytes.len) { + const flags = if (@hasDecl(std.c.MSG, "NOSIGNAL")) std.c.MSG.NOSIGNAL else 0; + const n = std.c.send(fd, bytes[written..].ptr, bytes.len - written, flags); + switch (std.c.errno(n)) { + .SUCCESS => { + if (n == 0) return error.ConnectionClosed; + written += @intCast(n); + }, + .INTR => continue, + .AGAIN => continue, + else => |err| return errnoError(err), + } + } +} + +fn emitResult(config: Config, result: BenchResult) !void { + var buf: [8192]u8 = undefined; + const json = try std.fmt.bufPrint(&buf, + \\{{ + \\ "benchmark": "wire2_perf_client_zig", + \\ "timestamp_ms": {d}, + \\ "config": {{ + \\ "host": "{s}", + \\ "port": {d}, + \\ "collection": "{s}", + \\ "rows": {d}, + \\ "batch_size": {d}, + \\ "get_rounds": {d}, + \\ "get_batch_size": {d}, + \\ "pipeline_window": {d}, + \\ "auth": {s} + \\ }}, + \\ "hello": {{ + \\ "max_frame": {d}, + \\ "max_response": {d}, + \\ "features": {d} + \\ }}, + \\ "probe": {{ + \\ "seconds": {d:.9} + \\ }}, + \\ "bulk_insert": {{ + \\ "rows": {d}, + \\ "batches": {d}, + \\ "seconds": {d:.9}, + \\ "rows_per_second": {d:.3}, + \\ "inserted": {d}, + \\ "errors": {d}, + \\ "bytes": {d} + \\ }}, + \\ "batch_get": {{ + \\ "items": {d}, + \\ "batches": {d}, + \\ "seconds": {d:.9}, + \\ "items_per_second": {d:.3}, + \\ "found": {d}, + \\ "missing": {d} + \\ }} + \\}} + , .{ + compat.milliTimestamp(), + config.host, + config.port, + config.collection, + config.rows, + config.batch_size, + config.get_rounds, + config.get_batch_size, + config.pipeline_window, + if (config.auth_key.len > 0) "true" else "false", + result.hello.max_frame, + result.hello.max_response, + result.hello.features, + result.probe_seconds, + result.bulk.operations, + result.bulk.batches, + result.bulk.seconds, + result.bulk.per_second, + result.bulk.inserted, + result.bulk.errors, + result.bulk.bytes, + result.batch_get.operations, + result.batch_get.batches, + result.batch_get.seconds, + result.batch_get.per_second, + result.batch_get.found, + result.batch_get.missing, + }); + + std.debug.print("{s}\n", .{json}); + if (config.output.len > 0) { + const file = try compat.cwd().createFile(config.output, .{}); + defer file.close(); + try file.writeAll(json); + try file.writeAll("\n"); + } +} + +fn rate(ops: u64, seconds: f64) f64 { + if (seconds <= 0) return 0; + return @as(f64, @floatFromInt(ops)) / seconds; +} + +fn nsToSeconds(ns: i128) f64 { + return @as(f64, @floatFromInt(ns)) / 1_000_000_000.0; +} + +fn rdU32BE(b: []const u8) u32 { + return (@as(u32, b[0]) << 24) | (@as(u32, b[1]) << 16) | (@as(u32, b[2]) << 8) | b[3]; +} + +fn rdU16LE(b: []const u8) u16 { + return @as(u16, b[0]) | (@as(u16, b[1]) << 8); +} + +fn rdU32LE(b: []const u8) u32 { + return @as(u32, b[0]) | (@as(u32, b[1]) << 8) | (@as(u32, b[2]) << 16) | (@as(u32, b[3]) << 24); +} + +fn rdU64LE(b: []const u8) u64 { + return @as(u64, rdU32LE(b[0..4])) | (@as(u64, rdU32LE(b[4..8])) << 32); +} + +fn wrU32BE(w: []u8, v: u32) void { + w[0] = @intCast((v >> 24) & 0xff); + w[1] = @intCast((v >> 16) & 0xff); + w[2] = @intCast((v >> 8) & 0xff); + w[3] = @intCast(v & 0xff); +} + +fn wrU16LE(w: []u8, v: u16) void { + w[0] = @intCast(v & 0xff); + w[1] = @intCast((v >> 8) & 0xff); +} + +fn wrU32LE(w: []u8, v: u32) void { + w[0] = @intCast(v & 0xff); + w[1] = @intCast((v >> 8) & 0xff); + w[2] = @intCast((v >> 16) & 0xff); + w[3] = @intCast((v >> 24) & 0xff); +} + +fn wrU64LE(w: []u8, v: u64) void { + wrU32LE(w[0..4], @intCast(v & 0xffffffff)); + wrU32LE(w[4..8], @intCast((v >> 32) & 0xffffffff)); +} + +fn errnoError(err: std.c.E) error{ + BadResponse, + BadStatus, + ConnectionClosed, + ConnectFailed, + FrameTooLarge, + InvalidIpAddress, + ServerErrors, + Unexpected, +} { + switch (err) { + .CONNREFUSED, .HOSTUNREACH, .NETUNREACH, .TIMEDOUT => return error.ConnectFailed, + .CONNRESET, .PIPE, .NOTCONN => return error.ConnectionClosed, + else => return error.Unexpected, + } +} diff --git a/bench/wire2_smoke_client.py b/bench/wire2_smoke_client.py new file mode 100644 index 0000000..82b5e38 --- /dev/null +++ b/bench/wire2_smoke_client.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Smoke and throughput probe for TurboDB wire2.""" + +from __future__ import annotations + +import argparse +import json +import socket +import struct +import time +from dataclasses import dataclass +from pathlib import Path + + +OUTER_OP_WIRE2 = 0x20 +WIRE2_VERSION = 2 + +OP_HELLO = 0x00 +OP_PING = 0x01 +OP_GET = 0x02 +OP_PUT = 0x03 +OP_INSERT = 0x04 +OP_DELETE = 0x05 +OP_BATCH_GET = 0x06 +OP_BULK_INSERT = 0x07 +OP_BULK_UPSERT = 0x08 +OP_BULK_DELETE = 0x09 +OP_AUTH = 0x0A + +STATUS_OK = 0x00 +STATUS_NOT_FOUND = 0x01 + + +@dataclass +class Response: + request_id: int + op: int + status: int + body: bytes + + +class Wire2Client: + def __init__(self, host: str, port: int, timeout: float) -> None: + self.sock = socket.create_connection((host, port), timeout=timeout) + self.sock.settimeout(timeout) + self.request_id = 1 + + def close(self) -> None: + self.sock.close() + + def request(self, op: int, body: bytes = b"") -> Response: + rid = self.request_id + self.request_id += 1 + payload = struct.pack("IB", len(payload) + 5, OUTER_OP_WIRE2) + payload + self.sock.sendall(frame) + hdr = self._read_exact(5) + frame_len, outer_op = struct.unpack(">IB", hdr) + if outer_op != OUTER_OP_WIRE2 or frame_len < 21: + raise RuntimeError(f"bad outer response op={outer_op} len={frame_len}") + payload = self._read_exact(frame_len - 5) + version, _flags, resp_id, resp_op, status, body_len = struct.unpack(" bytes: + chunks: list[bytes] = [] + remaining = n + while remaining: + chunk = self.sock.recv(remaining) + if not chunk: + raise RuntimeError("connection closed") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def key_payload(collection: str, key: str) -> bytes: + col = collection.encode() + k = key.encode() + return struct.pack(" bytes: + col = collection.encode() + k = key.encode() + return struct.pack(" bytes: + col = collection.encode() + return struct.pack(" bytes: + k = key.encode() + return struct.pack(" bytes: + k = key.encode() + return struct.pack(" dict[str, int]: + if len(body) != 28: + raise RuntimeError(f"bad stats body length {len(body)}") + inserted, updated, deleted, missing, errors, byte_count = struct.unpack(" bytes: + if len(body) < 13: + raise RuntimeError("bad get body") + value_len = struct.unpack(" list[tuple[int, bytes]]: + if len(body) < 4: + raise RuntimeError("bad batch get body") + count = struct.unpack(" len(body): + raise RuntimeError("truncated batch item") + status = body[pos] + value_len = struct.unpack(" int: + ap = argparse.ArgumentParser(description="TurboDB wire2 smoke client") + ap.add_argument("--host", required=True) + ap.add_argument("--port", type=int, default=27017) + ap.add_argument("--collection", default="wire2_events") + ap.add_argument("--ops", type=int, default=1000) + ap.add_argument("--batch-size", type=int, default=100) + ap.add_argument("--get-rounds", type=int, default=1) + ap.add_argument("--output") + ap.add_argument("--timeout", type=float, default=10.0) + ap.add_argument("--auth-key") + args = ap.parse_args() + + client = Wire2Client(args.host, args.port, args.timeout) + try: + hello = client.request(OP_HELLO) + if hello.status != STATUS_OK: + raise RuntimeError(f"hello failed status={hello.status}") + max_frame, max_response, features = struct.unpack(" 0: + n = min(args.batch_size, remaining) + body = bytearray(collection_batch_prefix(args.collection, n)) + for _ in range(n): + value = json.dumps({"kind": "bulk", "n": key_index}, separators=(",", ":")).encode() + body += kv_record(f"k-{key_index:08d}", value) + key_index += 1 + start = time.perf_counter() + resp = client.request(OP_BULK_INSERT, bytes(body)) + bulk_seconds += time.perf_counter() - start + if resp.status != STATUS_OK: + raise RuntimeError(f"bulk insert failed status={resp.status}") + stats = decode_stats(resp.body) + if stats["errors"] != 0: + raise RuntimeError(f"bulk insert errors: {stats}") + inserted += stats["inserted"] + remaining -= n + + get_batch_size = min(args.batch_size, args.ops) + batch_seconds = 0.0 + batch_items = 0 + found = 0 + not_found = 0 + for round_idx in range(args.get_rounds): + body = bytearray(collection_batch_prefix(args.collection, get_batch_size + 1)) + offset = (round_idx * get_batch_size) % max(args.ops, 1) + for i in range(get_batch_size): + body += key_record(f"k-{(offset + i) % args.ops:08d}") + body += key_record(f"does-not-exist-{round_idx}") + start = time.perf_counter() + batch_resp = client.request(OP_BATCH_GET, bytes(body)) + batch_seconds += time.perf_counter() - start + if batch_resp.status != STATUS_OK: + raise RuntimeError(f"batch get failed status={batch_resp.status}") + items = decode_batch_get(batch_resp.body) + batch_items += len(items) + found += sum(1 for status, _ in items if status == STATUS_OK) + not_found += sum(1 for status, _ in items if status == STATUS_NOT_FOUND) + + summary = { + "status": "ok", + "config": { + "ops": args.ops, + "batch_size": args.batch_size, + "get_rounds": args.get_rounds, + "collection": args.collection, + "auth": bool(args.auth_key), + }, + "hello": { + "max_frame": max_frame, + "max_response": max_response, + "features": features, + }, + "single_put": decode_stats(put.body), + "bulk_insert": { + "inserted": inserted, + "seconds": bulk_seconds, + "ops_sec": inserted / bulk_seconds if bulk_seconds > 0 else None, + }, + "batch_get": { + "items": batch_items, + "found": found, + "not_found": not_found, + "seconds": batch_seconds, + "ops_sec": batch_items / batch_seconds if batch_seconds > 0 else None, + }, + } + if args.output: + out_path = Path(args.output) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 + finally: + client.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build.zig b/build.zig index 3494519..e65b40f 100644 --- a/build.zig +++ b/build.zig @@ -4,6 +4,12 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const nanoapi_dep = b.dependency("nanoapi", .{ + .target = target, + .optimize = optimize, + }); + const nanoapi_mod = nanoapi_dep.module("nanoapi"); + // ── Compat module (Zig 0.16 compatibility layer) ───────────────────────── const compat_mod = b.createModule(.{ .root_source_file = b.path("src/compat.zig"), @@ -42,7 +48,6 @@ pub fn build(b: *std.Build) void { }); wal_mod.addImport("compat", compat_mod); - // ── Helper: wire storage imports into a module ────────────────────────── const wireStorage = struct { fn f(mod: *std.Build.Module, mmap: *std.Build.Module, wal: *std.Build.Module, epoch: *std.Build.Module, seqlock: *std.Build.Module, compat: *std.Build.Module) void { @@ -62,6 +67,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); wireStorage(turbodb_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); + turbodb_mod.addImport("nanoapi", nanoapi_mod); const turbodb = b.addExecutable(.{ .name = "turbodb", @@ -195,7 +201,7 @@ pub fn build(b: *std.Build) void { const profile_mod = b.createModule(.{ .root_source_file = b.path("src/profile_index.zig"), .target = target, - .optimize = .ReleaseSafe, // ALWAYS safe — catches segfaults + .optimize = .ReleaseSafe, // ALWAYS safe — catches segfaults .link_libc = true, }); wireStorage(profile_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); @@ -232,6 +238,36 @@ pub fn build(b: *std.Build) void { const bench_step = b.step("bench", "Run native Zig benchmark"); bench_step.dependOn(&bench_run.step); + // ── NanoAPI agent proxy benchmark service ────────────────────────────── + const nano_proxy_mod = b.createModule(.{ + .root_source_file = b.path("bench/nanoapi_agent_proxy.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + nano_proxy_mod.addImport("nanoapi", nanoapi_mod); + + const nano_proxy_exe = b.addExecutable(.{ + .name = "nanoapi-agent-proxy", + .root_module = nano_proxy_mod, + }); + b.installArtifact(nano_proxy_exe); + + // ── Wire2 native performance client ──────────────────────────────────── + const wire2_perf_mod = b.createModule(.{ + .root_source_file = b.path("bench/wire2_perf_client.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + wire2_perf_mod.addImport("compat", compat_mod); + + const wire2_perf_exe = b.addExecutable(.{ + .name = "wire2-perf-client", + .root_module = wire2_perf_mod, + }); + b.installArtifact(wire2_perf_exe); + // ── WAL microbenchmark ────────────────────────────────────────────────── const walbench_mod = b.createModule(.{ .root_source_file = b.path("src/bench_wal.zig"), @@ -311,7 +347,6 @@ pub fn build(b: *std.Build) void { const calvin_test_step = b.step("test-calvin", "Run Calvin replication E2E test"); calvin_test_step.dependOn(&calvin_test_run.step); - // ── Test steps ────────────────────────────────────────────────────────── // Helper: add a test module with storage imports @@ -327,7 +362,7 @@ pub fn build(b: *std.Build) void { mod.addImport("wal", wl); mod.addImport("epoch", ep); mod.addImport("seqlock", sl); - mod.addImport("compat", cm); + mod.addImport("compat", cm); // Extract just the filename without path for the test name. const basename = std.fs.path.stem(src); return b2.addTest(.{ .name = basename, .root_module = mod }); @@ -395,6 +430,13 @@ pub fn build(b: *std.Build) void { const run_col_test = b.addRunArtifact(col_test); test_all_step.dependOn(&run_col_test.step); + // Wire protocol tests, including wire2 envelope and binary bulk routes. + const wire_test = addTestMod(b, "src/wire.zig", target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); + const run_wire_test = b.addRunArtifact(wire_test); + const wire_test_step = b.step("test-wire", "Run wire protocol tests"); + wire_test_step.dependOn(&run_wire_test.step); + test_all_step.dependOn(&run_wire_test.step); + // Parallel WAL test const pwal_test = addTestMod(b, "src/storage/parallel_wal.zig", target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const run_pwal_test = b.addRunArtifact(pwal_test); diff --git a/build.zig.zon b/build.zig.zon index f31a0a8..8f30a30 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,7 +3,11 @@ .version = "0.1.0", .fingerprint = 0x2d3f19d996a45887, .minimum_zig_version = "0.16.0", - .dependencies = .{}, + .dependencies = .{ + .nanoapi = .{ + .path = "../nanoapi", + }, + }, .paths = .{ "build.zig", "build.zig.zon", diff --git a/src/codeindex.zig b/src/codeindex.zig index bbd4e77..0f38f5d 100644 --- a/src/codeindex.zig +++ b/src/codeindex.zig @@ -138,6 +138,12 @@ pub const WordIndex = struct { } } + if (words_set.count() == 0) { + words_set.deinit(); + self.allocator.free(owned_path); + return; + } + try self.file_words.put(owned_path, words_set); } @@ -182,6 +188,24 @@ pub const WordIndex = struct { } }; +test "WordIndex skips empty per-file bookkeeping after hit cap" { + const alloc = std.testing.allocator; + var idx = WordIndex.init(alloc); + defer idx.deinit(); + + var saturated: std.ArrayList(WordHit) = .empty; + try saturated.ensureTotalCapacity(alloc, WordIndex.MAX_HITS_PER_WORD); + for (0..WordIndex.MAX_HITS_PER_WORD) |_| { + saturated.appendAssumeCapacity(.{ .path = "existing", .line_num = 1 }); + } + + const owned_word = try alloc.dupe(u8, "common"); + try idx.index.put(owned_word, saturated); + + try idx.indexFile("overflow", "common common common"); + try std.testing.expectEqual(@as(usize, 0), idx.file_words.count()); +} + // ── Trigram index ─────────────────────────────────────────── // Maps 3-byte sequences → set of file paths. // Enables fast substring search: extract trigrams from query, diff --git a/src/collection.zig b/src/collection.zig index 36ade87..955f692 100644 --- a/src/collection.zig +++ b/src/collection.zig @@ -28,6 +28,7 @@ pub const MAX_TENANT_ID_LEN: usize = 64; pub const MAX_COLLECTION_NAME_LEN: usize = 64; const DEFAULT_INDEX_QUEUE_MEMORY_LIMIT: usize = 64 * 1024 * 1024; const INDEX_QUEUE_MEMORY_FRACTION: usize = 16; +const DEFAULT_TEXT_INDEX_MIN_VALUE_BYTES: usize = 128; fn writeCommittedDocumentWal(wal_log: *WAL, op: wal_mod.OpCode, payload: []const u8) !void { const txn = wal_log.next_lsn.load(.monotonic); @@ -51,6 +52,10 @@ fn indexEntryBytes(key: []const u8, value: []const u8) usize { return std.math.add(usize, key.len, value.len) catch std.math.maxInt(usize); } +fn textIndexMinValueBytes() usize { + return compat.envUsize("TURBODB_TEXT_INDEX_MIN_VALUE_BYTES", DEFAULT_TEXT_INDEX_MIN_VALUE_BYTES); +} + // ─── IndexQueue ────────────────────────────────────────────────────────── // Lock-free MPSC queue for deferring trigram+word index builds to a background thread. // Insert path pushes owned (key, value) pairs; background thread pops and indexes. @@ -272,6 +277,7 @@ pub const Collection = struct { queue_toggle: std.atomic.Value(u32), indexing_count: std.atomic.Value(u32), index_queue_bytes: std.atomic.Value(usize), + text_index_min_value_bytes: usize, // Vector embedding column (optional) vectors: ?*vector.VectorColumn = null, @@ -330,6 +336,7 @@ pub const Collection = struct { col.queue_toggle = std.atomic.Value(u32).init(0); col.indexing_count = std.atomic.Value(u32).init(0); col.index_queue_bytes = std.atomic.Value(usize).init(0); + col.text_index_min_value_bytes = textIndexMinValueBytes(); col.vectors = null; col.vector_field_len = 0; col.vec_entries = .empty; @@ -415,9 +422,11 @@ pub const Collection = struct { return self.name_buf[0..self.name_len]; } - /// Return the number of live indexed documents (excludes deleted docs). - pub fn docCount(self: *const Collection) u64 { - return @intCast(self.tri.fileCount()); + /// Return the number of live key entries (excludes deleted docs). + pub fn docCount(self: *Collection) u64 { + self.meta_mu.lock(); + defer self.meta_mu.unlock(); + return @intCast(self.hash_idx.count()); } pub fn storageBytes(self: *const Collection) usize { @@ -893,7 +902,7 @@ pub const Collection = struct { const terms = terms_buf[0..term_count]; // For single terms or exact phrase, try trigram index on full query first. - if (term_count == 1) { + if (term_count == 1 and self.hasCompleteTextIndex()) { const cand_paths = self.tri.candidates(query, alloc) orelse { return self.bruteForceSearch(query, limit, alloc); }; @@ -922,6 +931,10 @@ pub const Collection = struct { if (t.len > terms[longest_idx].len) longest_idx = i; } + if (!self.hasCompleteTextIndex()) { + return self.multiTermBruteForce(terms, limit, alloc); + } + const cand_paths = self.tri.candidates(terms[longest_idx], alloc) orelse { return self.multiTermBruteForce(terms, limit, alloc); }; @@ -1140,6 +1153,17 @@ pub const Collection = struct { return @ptrCast(@alignCast(data[entry.page_off..].ptr)); } + fn shouldIndexText(self: *const Collection, value: []const u8) bool { + return value.len >= self.text_index_min_value_bytes; + } + + fn hasCompleteTextIndex(self: *Collection) bool { + self.meta_mu.lock(); + const live_docs = self.hash_idx.count(); + self.meta_mu.unlock(); + return live_docs > 0 and self.tri.fileCount() >= live_docs; + } + fn findOrAllocLeaf(self: *Collection, needed: usize) !u32 { if (self.append_leaf_hint != 0) { const ph = self.pf.pageHeader(self.append_leaf_hint); @@ -1191,7 +1215,7 @@ pub const Collection = struct { if (d.header.flags & DocHeader.DELETED == 0) { self.hash_idx.put(d.header.key_hash, entry) catch {}; self.key_doc_ids.put(d.header.key_hash, d.header.doc_id) catch {}; - if (d.value.len >= 3) { + if (self.shouldIndexText(d.value)) { self.tri.indexFile(d.key, d.value) catch {}; self.words.indexFile(d.key, d.value) catch {}; } @@ -1214,7 +1238,7 @@ pub const Collection = struct { } fn enqueueTextIndex(self: *Collection, key: []const u8, value: []const u8) void { - if (value.len < 3) return; + if (!self.shouldIndexText(value)) return; if (self.index_thread == null and self.index_thread2 == null) { self.tri.indexFile(key, value) catch {}; self.words.indexFile(key, value) catch {}; @@ -1261,7 +1285,7 @@ pub const Collection = struct { } fn enqueueStoredTextIndex(self: *Collection, entry: BTreeEntry, key: []const u8, value: []const u8) void { - if (value.len < 3) return; + if (!self.shouldIndexText(value)) return; if (self.index_thread == null and self.index_thread2 == null) { self.tri.indexFile(key, value) catch {}; self.words.indexFile(key, value) catch {}; @@ -1802,10 +1826,16 @@ fn indexWorkerQ(col: *Collection, queue: *IndexQueue) void { const entry = queue.pop() orelse break; if (entry.stored) { const doc = col.readEntry(entry.loc) orelse continue; - if (doc.value.len < 3) continue; + if (!col.shouldIndexText(doc.value)) continue; batch_keys[n] = doc.key; batch_vals[n] = doc.value; } else { + if (!col.shouldIndexText(entry.value)) { + col.releaseIndexQueueBytes(entry.queued_bytes); + col.alloc.free(entry.value); + col.alloc.free(entry.key); + continue; + } batch_keys[n] = entry.key; batch_vals[n] = entry.value; } @@ -1837,12 +1867,15 @@ fn indexWorkerQ(col: *Collection, queue: *IndexQueue) void { while (queue.pop()) |entry| { if (entry.stored) { if (col.readEntry(entry.loc)) |doc| { + if (!col.shouldIndexText(doc.value)) continue; col.tri.indexFile(doc.key, doc.value) catch {}; col.words.indexFile(doc.key, doc.value) catch {}; } } else { - col.tri.indexFile(entry.key, entry.value) catch {}; - col.words.indexFile(entry.key, entry.value) catch {}; + if (col.shouldIndexText(entry.value)) { + col.tri.indexFile(entry.key, entry.value) catch {}; + col.words.indexFile(entry.key, entry.value) catch {}; + } col.releaseIndexQueueBytes(entry.queued_bytes); col.alloc.free(entry.value); col.alloc.free(entry.key); @@ -2524,6 +2557,29 @@ test "collection scan limit zero returns no documents" { try std.testing.expectEqual(@as(usize, 0), result.docs.len); } +test "small structured docs skip automatic text index and search falls back" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_small_doc_text_index_skip"; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + + const col = try db.collection("events"); + _ = try col.insert("small", "{\"kind\":\"tiny\"}"); + _ = try col.insert("large", "{\"body\":\"needle abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789\"}"); + col.flushIndex(); + + try std.testing.expectEqual(@as(u32, 1), col.tri.fileCount()); + + const result = try col.searchText("tiny", 10, alloc); + defer result.deinit(); + try std.testing.expectEqual(@as(usize, 1), result.docs.len); + try std.testing.expectEqualStrings("small", result.docs[0].key); +} + test "tenant quotas apply per tenant" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_tenant_quotas"; diff --git a/src/main.zig b/src/main.zig index dec99ea..289a670 100644 --- a/src/main.zig +++ b/src/main.zig @@ -18,6 +18,7 @@ pub fn main(init: std.process.Init) !void { var port: u16 = 27017; var use_wire: bool = true; // wire protocol by default var use_http: bool = false; + var http_runtime: server.HttpRuntime = .threaded; var unix_path: ?[]const u8 = null; var auth_key: ?[]const u8 = null; @@ -31,9 +32,11 @@ pub fn main(init: std.process.Init) !void { var i: usize = 1; while (i < args.len) : (i += 1) { if (std.mem.eql(u8, args[i], "--data") and i + 1 < args.len) { - i += 1; data_dir = args[i]; + i += 1; + data_dir = args[i]; } else if (std.mem.eql(u8, args[i], "--port") and i + 1 < args.len) { - i += 1; port = try std.fmt.parseInt(u16, args[i], 10); + i += 1; + port = try std.fmt.parseInt(u16, args[i], 10); } else if (std.mem.eql(u8, args[i], "--wire")) { use_wire = true; use_http = false; @@ -43,6 +46,18 @@ pub fn main(init: std.process.Init) !void { } else if (std.mem.eql(u8, args[i], "--both")) { use_wire = true; use_http = true; + } else if (std.mem.eql(u8, args[i], "--http-runtime") and i + 1 < args.len) { + i += 1; + if (std.mem.eql(u8, args[i], "threaded")) { + http_runtime = .threaded; + } else if (std.mem.eql(u8, args[i], "nanoapi-raw")) { + http_runtime = .nanoapi_raw; + } else { + std.log.err("unknown HTTP runtime: {s}", .{args[i]}); + return error.InvalidArgument; + } + } else if (std.mem.eql(u8, args[i], "--http-nanoapi-raw")) { + http_runtime = .nanoapi_raw; } else if (std.mem.eql(u8, args[i], "--unix") and i + 1 < args.len) { i += 1; unix_path = args[i]; @@ -71,6 +86,8 @@ pub fn main(init: std.process.Init) !void { \\ --wire binary wire protocol (default) \\ --http HTTP REST API \\ --both run wire + HTTP (wire on port, HTTP on port+1) + \\ --http-runtime HTTP runtime: threaded, nanoapi-raw + \\ --http-nanoapi-raw shortcut for --http-runtime nanoapi-raw \\ --unix also listen on a Unix domain socket \\ --auth-key require this API key for all requests \\ @@ -164,13 +181,13 @@ pub fn main(init: std.process.Init) !void { if (use_http and use_wire) { // Run HTTP in a background thread, wire in foreground - const http_thread = try std.Thread.spawn(.{}, server.Server.run, .{&http_srv}); + const http_thread = try std.Thread.spawn(.{}, server.Server.runWithRuntime, .{ &http_srv, http_runtime }); _ = http_thread; try wire_srv.run(); } else if (use_wire) { try wire_srv.run(); } else { - try http_srv.run(); + try http_srv.runWithRuntime(http_runtime); } std.log.info("TurboDB stopped.", .{}); } diff --git a/src/server.zig b/src/server.zig index b267c9d..bb1f6d9 100644 --- a/src/server.zig +++ b/src/server.zig @@ -18,7 +18,9 @@ /// GET /metrics server metrics /// GET /context/:col smart context discovery (q, limit query params) const std = @import("std"); +const builtin = @import("builtin"); const compat = @import("compat"); +const nanoapi = @import("nanoapi"); const activity = @import("activity.zig"); const auth = @import("auth.zig"); const collection = @import("collection.zig"); @@ -26,9 +28,15 @@ const doc_mod = @import("doc.zig"); const page_mod = @import("page.zig"); const Database = collection.Database; +pub const HttpRuntime = enum { + threaded, + nanoapi_raw, +}; + const MAX_REQ = 65536; // 64 KiB (initial read) const MAX_RESP = 131072; // 128 KiB const MAX_BODY = 65536; // 64 KiB +const MAX_WRITE_BATCH = 65536; // 64 KiB coalesced keep-alive responses const MAX_BULK = 64 * 1024 * 1024; // 64 MiB for large bulk inserts const BULK_INSERT_CHUNK_ROWS: usize = 16384; const BULK_INSERT_CHUNK_BYTES: usize = 16 * 1024 * 1024; @@ -44,6 +52,7 @@ const ConnBufs = struct { req: [MAX_REQ]u8, resp: [MAX_RESP]u8, body: [MAX_BODY]u8, + write: [MAX_WRITE_BATCH]u8, }; const CONN_THREAD_STACK_SIZE = 1024 * 1024; @@ -270,6 +279,37 @@ pub const Server = struct { } } + pub fn runWithRuntime(self: *Server, runtime: HttpRuntime) !void { + switch (runtime) { + .threaded => return self.run(), + .nanoapi_raw => return self.runNanoapiRaw(), + } + } + + pub fn runNanoapiRaw(self: *Server) !void { + var ctx = RawDispatchContext{ .srv = self }; + const worker_threads = compat.envUsize("TURBODB_HTTP_WORKERS", 0); + const io_entries = @as(u16, @intCast(@min(compat.envUsize("TURBODB_HTTP_IO_URING_ENTRIES", 1024), std.math.maxInt(u16)))); + var raw_srv = nanoapi.raw.Server.init(&ctx, rawDispatch, self.alloc, .{ + .host = .{ 0, 0, 0, 0 }, + .port = self.port, + .backlog = 2048, + .read_buffer_size = MAX_REQ, + .response_buffer_size = MAX_RESP, + .write_buffer_size = MAX_WRITE_BATCH, + .max_request_size = MAX_BULK + MAX_REQ, + .runtime = if (builtin.os.tag == .linux) .io_uring else .thread_per_connection, + .worker_threads = worker_threads, + .io_uring_entries = io_entries, + .max_connections = MAX_CONNECTIONS, + .running = &self.running, + }); + defer raw_srv.deinit(); + + std.log.info("TurboDB HTTP nanoapi.raw runtime listening on :{d}", .{self.port}); + try raw_srv.listenAndServe(); + } + pub fn runUnix(self: *Server, path: []const u8) !void { // Remove any existing socket file // Remove existing socket @@ -356,6 +396,11 @@ pub const Server = struct { }; threadlocal var tl_bulk_reservation: ?Server.BulkMemoryReservation = null; +threadlocal var tl_raw_dispatch_bufs: ?*ConnBufs = null; + +const RawDispatchContext = struct { + srv: *Server, +}; const ParsedRequestTarget = struct { method: []const u8, @@ -377,6 +422,65 @@ const BulkPreflight = union(enum) { }, }; +const RequestInput = struct { + buf: []u8, + head: usize = 0, + len: usize = 0, + + fn bytes(self: *const RequestInput) []const u8 { + return self.buf[self.head .. self.head + self.len]; + } + + fn readAppend(self: *RequestInput, stream: compat.net.Stream) !bool { + if (self.head + self.len == self.buf.len) { + if (self.head == 0) return error.RequestTooLarge; + if (self.len > 0) { + std.mem.copyForwards(u8, self.buf[0..self.len], self.buf[self.head .. self.head + self.len]); + } + self.head = 0; + } + + const tail_start = self.head + self.len; + const n = try stream.read(self.buf[tail_start..]); + if (n == 0) return false; + self.len += n; + return true; + } + + fn consume(self: *RequestInput, amount: usize) void { + if (amount >= self.len) { + self.head = 0; + self.len = 0; + return; + } + self.head += amount; + self.len -= amount; + } +}; + +const HttpFrame = struct { + header_end: usize, + total_len: usize, + content_length: usize, +}; + +fn inspectHttpFrame(raw: []const u8) !HttpFrame { + const header_end = if (std.mem.indexOf(u8, raw, "\r\n\r\n")) |p| + p + 4 + else if (std.mem.indexOf(u8, raw, "\n\n")) |p| + p + 2 + else + return error.IncompleteRequestHead; + + const content_length = extractContentLength(raw[0..header_end]); + const total_len = std.math.add(usize, header_end, content_length) catch return error.RequestTooLarge; + return .{ + .header_end = header_end, + .total_len = total_len, + .content_length = content_length, + }; +} + fn bulkGlobalMemoryLimit() usize { const explicit = compat.envUsize("TURBODB_BULK_MEMORY_LIMIT_BYTES", 0); if (explicit > 0) return explicit; @@ -495,6 +599,63 @@ fn handleConnWrapped(srv: *Server, conn: compat.net.Server.Connection) void { handleConn(srv, conn); } +fn getRawDispatchBufs() ?*ConnBufs { + if (tl_raw_dispatch_bufs) |bufs| return bufs; + const bufs = std.heap.page_allocator.create(ConnBufs) catch return null; + tl_raw_dispatch_bufs = bufs; + return bufs; +} + +fn rawDispatch(ctx_opaque: *anyopaque, raw: []const u8, response: []u8) usize { + const ctx: *RawDispatchContext = @ptrCast(@alignCast(ctx_opaque)); + const srv = ctx.srv; + const bufs = getRawDispatchBufs() orelse + return rawResponseError(response, 500, "Internal Server Error", "request buffers unavailable"); + + tl_bufs = bufs; + defer tl_bufs = null; + defer releaseThreadBulkReservation(); + + const frame = inspectHttpFrame(raw) catch + return rawResponseError(response, 400, "Bad Request", "bad request"); + const header_bytes = raw[0..frame.header_end]; + + if (headerContains(header_bytes, "upgrade", "websocket")) { + return rawResponseError(response, 426, "Upgrade Required", "websocket requires threaded runtime"); + } + + switch (preflightBulkAdmission(srv, header_bytes, frame.content_length)) { + .not_bulk => {}, + .reserved => |reservation| tl_bulk_reservation = reservation, + .reject => |rejection| { + const resp_len = err(rejection.code, rejection.message); + return copyRawResponse(response, bufs.resp[0..resp_len]); + }, + } + + _ = srv.req_count.fetchAdd(1, .monotonic); + const resp_len = dispatch(srv, raw, std.heap.page_allocator); + return copyRawResponse(response, bufs.resp[0..resp_len]); +} + +fn copyRawResponse(dst: []u8, src: []const u8) usize { + if (src.len > dst.len) { + return rawResponseError(dst, 500, "Internal Server Error", "response too large"); + } + @memcpy(dst[0..src.len], src); + return src.len; +} + +fn rawResponseError(out: []u8, code: u16, status: []const u8, msg: []const u8) usize { + const body_len = msg.len + "{\"error\":\"\"}".len; + const bytes = std.fmt.bufPrint( + out, + "HTTP/1.1 {d} {s}\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n{{\"error\":\"{s}\"}}", + .{ code, status, body_len, msg }, + ) catch return 0; + return bytes.len; +} + fn handleConn(srv: *Server, conn: compat.net.Server.Connection) void { defer conn.stream.close(); @@ -505,76 +666,132 @@ fn handleConn(srv: *Server, conn: compat.net.Server.Connection) void { tl_bufs = bufs; defer tl_bufs = null; - while (true) { - var n = conn.stream.read(&bufs.req) catch return; - if (n == 0) return; - _ = srv.req_count.fetchAdd(1, .monotonic); - - const initial = bufs.req[0..n]; + var input = RequestInput{ .buf = bufs.req[0..] }; + var write_len: usize = 0; - // WebSocket upgrade: switch to persistent framed mode. - if (headerContains(initial, "upgrade", "websocket")) { - handleWebSocket(srv, conn, initial) catch {}; - return; // WS handler owns the connection until close + while (true) { + if (input.len == 0) { + flushResponseBatch(conn, bufs, &write_len) catch return; + if ((input.readAppend(conn.stream) catch return) == false) return; } - // Read the full body based on Content-Length. The initial read may only - // contain part of a large body. - const content_length = extractContentLength(initial); - if (content_length > 0) { - const header_end = if (std.mem.indexOf(u8, initial, "\r\n\r\n")) |p| p + 4 else if (std.mem.indexOf(u8, initial, "\n\n")) |p| p + 2 else n; - const total_size = header_end + content_length; - if (content_length > MAX_BULK) { + while (input.len > 0) { + const current = input.bytes(); + const frame = inspectHttpFrame(current) catch |e| switch (e) { + error.IncompleteRequestHead => { + flushResponseBatch(conn, bufs, &write_len) catch return; + if ((input.readAppend(conn.stream) catch return) == false) return; + continue; + }, + error.RequestTooLarge => { + flushResponseBatch(conn, bufs, &write_len) catch return; + const resp_len = err(413, "request too large"); + conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + return; + }, + }; + + if (frame.content_length > MAX_BULK) { + flushResponseBatch(conn, bufs, &write_len) catch return; const resp_len = err(413, "request too large"); conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; return; } - switch (preflightBulkAdmission(srv, initial, content_length)) { - .not_bulk => {}, - .reserved => |reservation| tl_bulk_reservation = reservation, - .reject => |rejection| { - const resp_len = err(rejection.code, rejection.message); - conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; - return; - }, + const header_bytes = current[0..frame.header_end]; + // WebSocket upgrade: switch to persistent framed mode. + if (headerContains(header_bytes, "upgrade", "websocket")) { + flushResponseBatch(conn, bufs, &write_len) catch return; + handleWebSocket(srv, conn, current[0..@min(current.len, frame.total_len)]) catch {}; + return; // WS handler owns the connection until close } - if (total_size > bufs.req.len) { - const big_buf = std.heap.page_allocator.alloc(u8, total_size) catch { + if (frame.total_len > input.buf.len) { + const local_preflight = preflightBulkAdmission(srv, header_bytes, frame.content_length); + switch (local_preflight) { + .not_bulk => {}, + .reserved => |reservation| tl_bulk_reservation = reservation, + .reject => |rejection| { + flushResponseBatch(conn, bufs, &write_len) catch return; + const resp_len = err(rejection.code, rejection.message); + conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + return; + }, + } + + var n = current.len; + const big_buf = std.heap.page_allocator.alloc(u8, frame.total_len) catch { releaseThreadBulkReservation(); + flushResponseBatch(conn, bufs, &write_len) catch return; const resp_len = err(500, "request allocation failed"); conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; return; }; defer std.heap.page_allocator.free(big_buf); - @memcpy(big_buf[0..n], initial); - // Read remaining bytes - while (n < total_size) { - const r = conn.stream.read(big_buf[n..total_size]) catch break; - if (r == 0) break; + @memcpy(big_buf[0..n], current); + while (n < frame.total_len) { + const r = conn.stream.read(big_buf[n..frame.total_len]) catch { + releaseThreadBulkReservation(); + return; + }; + if (r == 0) { + releaseThreadBulkReservation(); + return; + } n += r; } - const resp_len = dispatch(srv, big_buf[0..n], std.heap.page_allocator); + _ = srv.req_count.fetchAdd(1, .monotonic); + const resp_len = dispatch(srv, big_buf[0..frame.total_len], std.heap.page_allocator); releaseThreadBulkReservation(); - conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + queueResponse(conn, bufs, &write_len, bufs.resp[0..resp_len]) catch return; + input.consume(input.len); continue; - } else { - while (n < total_size) { - const r = conn.stream.read(bufs.req[n..total_size]) catch break; - if (r == 0) break; - n += r; - } - const resp_len = dispatch(srv, bufs.req[0..n], std.heap.page_allocator); - releaseThreadBulkReservation(); - conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + } + + if (current.len < frame.total_len) { + flushResponseBatch(conn, bufs, &write_len) catch return; + if ((input.readAppend(conn.stream) catch return) == false) return; continue; } + + const raw = current[0..frame.total_len]; + switch (preflightBulkAdmission(srv, header_bytes, frame.content_length)) { + .not_bulk => {}, + .reserved => |reservation| tl_bulk_reservation = reservation, + .reject => |rejection| { + flushResponseBatch(conn, bufs, &write_len) catch return; + const resp_len = err(rejection.code, rejection.message); + conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + return; + }, + } + + _ = srv.req_count.fetchAdd(1, .monotonic); + const resp_len = dispatch(srv, raw, std.heap.page_allocator); + releaseThreadBulkReservation(); + queueResponse(conn, bufs, &write_len, bufs.resp[0..resp_len]) catch return; + input.consume(frame.total_len); } + } +} - const resp_len = dispatch(srv, initial, std.heap.page_allocator); - conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; +fn flushResponseBatch(conn: compat.net.Server.Connection, bufs: *ConnBufs, write_len: *usize) !void { + if (write_len.* == 0) return; + const bytes = bufs.write[0..write_len.*]; + write_len.* = 0; + try conn.stream.writeAll(bytes); +} + +fn queueResponse(conn: compat.net.Server.Connection, bufs: *ConnBufs, write_len: *usize, response: []const u8) !void { + if (response.len > bufs.write.len) { + try flushResponseBatch(conn, bufs, write_len); + return conn.stream.writeAll(response); + } + if (write_len.* + response.len > bufs.write.len) { + try flushResponseBatch(conn, bufs, write_len); } + @memcpy(bufs.write[write_len.*..][0..response.len], response); + write_len.* += response.len; } fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { // Parse request line. @@ -2580,6 +2797,21 @@ test "respond writes bodies larger than format scratch buffer" { try std.testing.expect(std.mem.endsWith(u8, bufs.resp[0..n], body[0..])); } +test "HTTP frame parser preserves pipelined request boundary" { + const raw = + "POST /db/users HTTP/1.1\r\nContent-Length: 3\r\n\r\nabc" ++ + "GET /health HTTP/1.1\r\n\r\n"; + const frame = try inspectHttpFrame(raw); + try std.testing.expectEqual(@as(usize, "POST /db/users HTTP/1.1\r\nContent-Length: 3\r\n\r\nabc".len), frame.total_len); + try std.testing.expectEqual(@as(usize, 3), frame.content_length); + + var backing: [128]u8 = undefined; + @memcpy(backing[0..raw.len], raw); + var input = RequestInput{ .buf = backing[0..], .len = raw.len }; + input.consume(frame.total_len); + try std.testing.expectEqualStrings("GET /health HTTP/1.1\r\n\r\n", input.bytes()); +} + test "read-only auth cannot write basic db methods" { var ctx = auth.AuthContext{ .perm = .read_only, diff --git a/src/storage/wal.zig b/src/storage/wal.zig index e7e2254..cefbe75 100644 --- a/src/storage/wal.zig +++ b/src/storage/wal.zig @@ -112,12 +112,15 @@ pub const WAL = struct { file: compat.File, uring: ?compat.LinuxUring, write_buf: std.ArrayList(u8), // pending (not yet flushed) + spare_buf: std.ArrayList(u8), // reusable buffer from the previous flush next_lsn: std.atomic.Value(u64), checkpoint_lsn: u64, + buffered_lsn: u64, append_offset: u64, uring_flushes: usize, sync_flushes: usize, uring_min_write_and_sync: usize, + max_write_buf: usize, allocator: std.mem.Allocator, // Group commit state — guarded by mu @@ -132,7 +135,8 @@ pub const WAL = struct { flush_running: std.atomic.Value(bool), /// Backpressure: block writers if pending buffer exceeds this. - const MAX_WRITE_BUF: usize = 8 * 1024 * 1024; // 8 MiB + const DEFAULT_MAX_WRITE_BUF: usize = 32 * 1024 * 1024; // 32 MiB + const DEFAULT_INITIAL_WRITE_BUF: usize = 1024 * 1024; // 1 MiB const DEFAULT_URING_MIN_WRITE_AND_SYNC: usize = 32 * 1024; // ── Lifecycle ───────────────────────────────────────────────────────────── @@ -144,16 +148,19 @@ pub const WAL = struct { break :blk compat.File{ .handle = fd }; }; const end = std.c.lseek(f.handle, 0, std.c.SEEK.END); - const wal = WAL{ + var wal = WAL{ .file = f, .uring = compat.initLinuxUring(8), .write_buf = .empty, + .spare_buf = .empty, .next_lsn = std.atomic.Value(u64).init(1), .checkpoint_lsn = 0, + .buffered_lsn = 0, .append_offset = if (end >= 0) @intCast(end) else 0, .uring_flushes = 0, .sync_flushes = 0, .uring_min_write_and_sync = compat.envUsize("TURBODB_IO_URING_MIN_BYTES", DEFAULT_URING_MIN_WRITE_AND_SYNC), + .max_write_buf = compat.envUsize("TURBODB_WAL_MAX_BUFFER_BYTES", DEFAULT_MAX_WRITE_BUF), .allocator = allocator, .mu = .{}, .cond = .{}, @@ -163,6 +170,13 @@ pub const WAL = struct { .flush_thread = null, .flush_running = std.atomic.Value(bool).init(false), }; + const initial_write_buf = compat.envUsize("TURBODB_WAL_INITIAL_BUFFER_BYTES", DEFAULT_INITIAL_WRITE_BUF); + if (initial_write_buf > 0) { + wal.write_buf.ensureTotalCapacity(allocator, initial_write_buf) catch |e| { + wal.close(); + return e; + }; + } return wal; } @@ -198,15 +212,14 @@ pub const WAL = struct { return; } self.flushing = true; - var to_write = self.write_buf; - const target = self.next_lsn.load(.monotonic) -| 1; - self.write_buf = .empty; + const target = self.buffered_lsn; + var to_write = self.takeWriteBufferForFlush(); self.mu.unlock(); const io_err = self.writeAndSync(to_write.items); - to_write.deinit(self.allocator); self.mu.lock(); + self.recycleFlushedBuffer(&to_write); if (io_err) |e| { self.last_flush_error = e; } else { @@ -240,6 +253,7 @@ pub const WAL = struct { // Flush any remaining entries self.flushPending(); self.write_buf.deinit(self.allocator); + self.spare_buf.deinit(self.allocator); if (self.uring) |*ring| { compat.deinitLinuxUring(ring); self.uring = null; @@ -251,7 +265,7 @@ pub const WAL = struct { /// Encode an entry into the shared write buffer and return its LSN. /// Thread-safe. Does NOT guarantee durability — call commit(txn_id, db_tag) for that. - /// Blocks if write buffer exceeds MAX_WRITE_BUF to apply backpressure. + /// Blocks if the write buffer exceeds the configured backpressure limit. pub fn write( self: *WAL, txn_id: u64, @@ -262,21 +276,8 @@ pub const WAL = struct { ) !u64 { if (payload.len > MAX_ENTRY_PAYLOAD) return error.WALPayloadTooLarge; - const lsn = self.next_lsn.fetchAdd(1, .monotonic); const pad = paddingTo8(HEADER_SIZE + payload.len); - - var hdr = EntryHeader{ - .lsn = lsn, - .length = @intCast(payload.len), - .crc32 = 0, - .op_code = @intFromEnum(op), - .db_tag = db_tag, - .flags = flags, - .txn_id = txn_id, - .reserved = 0, - }; - const hdr_bytes = std.mem.asBytes(&hdr); - hdr.crc32 = entryChecksum(hdr_bytes, payload); + const entry_size = HEADER_SIZE + payload.len + pad; self.mu.lock(); if (self.last_flush_error) |e| { @@ -285,7 +286,7 @@ pub const WAL = struct { } // Backpressure: wait until flusher drains the buffer below threshold. // Use condition variable instead of yield-loop so the flusher can wake us. - while (self.write_buf.items.len >= MAX_WRITE_BUF) { + while (self.write_buf.items.len >= self.maxWriteBuf()) { if (self.last_flush_error) |e| { self.mu.unlock(); return e; @@ -293,10 +294,28 @@ pub const WAL = struct { // Release lock and wait for flusher to signal. self.cond.wait(&self.mu); } + self.write_buf.ensureUnusedCapacity(self.allocator, entry_size) catch |e| { + self.mu.unlock(); + return e; + }; + const lsn = self.next_lsn.fetchAdd(1, .monotonic); + var hdr = EntryHeader{ + .lsn = lsn, + .length = @intCast(payload.len), + .crc32 = 0, + .op_code = @intFromEnum(op), + .db_tag = db_tag, + .flags = flags, + .txn_id = txn_id, + .reserved = 0, + }; + const hdr_bytes = std.mem.asBytes(&hdr); + hdr.crc32 = entryChecksum(hdr_bytes, payload); defer self.mu.unlock(); - try self.write_buf.appendSlice(self.allocator, std.mem.asBytes(&hdr)); - try self.write_buf.appendSlice(self.allocator, payload); - if (pad > 0) try self.write_buf.appendNTimes(self.allocator, 0, pad); + self.write_buf.appendSliceAssumeCapacity(std.mem.asBytes(&hdr)); + self.write_buf.appendSliceAssumeCapacity(payload); + if (pad > 0) self.write_buf.appendNTimesAssumeCapacity(0, pad); + self.buffered_lsn = lsn; return lsn; } @@ -336,7 +355,7 @@ pub const WAL = struct { self.mu.unlock(); return e; } - while (self.write_buf.items.len >= MAX_WRITE_BUF) { + while (self.write_buf.items.len >= self.maxWriteBuf()) { if (self.last_flush_error) |e| { self.mu.unlock(); return e; @@ -368,6 +387,7 @@ pub const WAL = struct { self.write_buf.appendSliceAssumeCapacity(payload); if (pad > 0) self.write_buf.appendNTimesAssumeCapacity(0, pad); } + self.buffered_lsn = start_lsn + @as(u64, @intCast(payloads.len - 1)); self.mu.unlock(); return start_lsn + @as(u64, @intCast(payloads.len - 1)); @@ -393,15 +413,14 @@ pub const WAL = struct { } self.flushing = true; - var to_write: std.ArrayList(u8) = self.write_buf; - const flushed_lsn = self.next_lsn.load(.monotonic) - 1; - self.write_buf = .empty; + const flushed_lsn = self.buffered_lsn; + var to_write = self.takeWriteBufferForFlush(); self.mu.unlock(); const io_err = self.writeAndSync(to_write.items); - to_write.deinit(self.allocator); self.mu.lock(); + self.recycleFlushedBuffer(&to_write); if (io_err) |e| { self.last_flush_error = e; } else { @@ -452,17 +471,16 @@ pub const WAL = struct { // We are the flusher self.flushing = true; // Snapshot the buffer and target LSN under the lock - var to_write: std.ArrayList(u8) = self.write_buf; - const target = self.next_lsn.load(.monotonic) - 1; - self.write_buf = .empty; + const target = self.buffered_lsn; + var to_write = self.takeWriteBufferForFlush(); self.mu.unlock(); // ── I/O outside lock ────────────────────────────────────────────── const io_err = self.writeAndSync(to_write.items); - to_write.deinit(self.allocator); // ───────────────────────────────────────────────────────────────── self.mu.lock(); + self.recycleFlushedBuffer(&to_write); if (io_err) |e| { self.last_flush_error = e; } else { @@ -497,13 +515,12 @@ pub const WAL = struct { return e; } self.flushing = true; - var to_write = self.write_buf; - self.write_buf = .empty; + var to_write = self.takeWriteBufferForFlush(); self.mu.unlock(); const io_err = self.writeAndSync(to_write.items); - to_write.deinit(self.allocator); if (io_err) |e| { self.mu.lock(); + self.recycleFlushedBuffer(&to_write); self.last_flush_error = e; self.flushing = false; self.cond.broadcast(); @@ -517,6 +534,7 @@ pub const WAL = struct { self.append_offset = 0; self.mu.lock(); + self.recycleFlushedBuffer(&to_write); self.synced_lsn = lsn; self.last_flush_error = null; self.flushing = false; @@ -584,6 +602,31 @@ pub const WAL = struct { return (8 - (n % 8)) % 8; } + fn maxWriteBuf(self: *const WAL) usize { + return @max(self.max_write_buf, HEADER_SIZE + 1); + } + + fn takeWriteBufferForFlush(self: *WAL) std.ArrayList(u8) { + const to_write = self.write_buf; + self.write_buf = self.spare_buf; + self.spare_buf = .empty; + self.write_buf.clearRetainingCapacity(); + self.cond.broadcast(); + return to_write; + } + + fn recycleFlushedBuffer(self: *WAL, flushed: *std.ArrayList(u8)) void { + flushed.clearRetainingCapacity(); + if (self.write_buf.items.len == 0) { + self.write_buf.deinit(self.allocator); + self.write_buf = flushed.*; + } else { + self.spare_buf.deinit(self.allocator); + self.spare_buf = flushed.*; + } + flushed.* = .empty; + } + fn writeAndSync(self: *WAL, bytes: []const u8) ?anyerror { if (bytes.len >= self.uring_min_write_and_sync) { if (self.uring) |*ring| { @@ -788,6 +831,26 @@ test "writeCommitted buffers committed entry without forcing fsync" { } } +test "flushPending only marks buffered LSNs durable" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "buffered-target.wal"); + defer allocator.free(path); + + var wal = try WAL.open(path, allocator); + const lsn = try wal.writeCommitted(41, .doc_insert, DB_TAG_DOC, "inline"); + + _ = wal.next_lsn.fetchAdd(5, .monotonic); + wal.flushPending(); + + try std.testing.expectEqual(lsn, wal.synced_lsn); + try std.testing.expectEqual(@as(usize, 0), wal.write_buf.items.len); + + wal.close(); +} + test "writeCommittedBatch plus flushUpTo durably replays all entries" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); diff --git a/src/wire.zig b/src/wire.zig index a6b433e..04550df 100644 --- a/src/wire.zig +++ b/src/wire.zig @@ -8,9 +8,15 @@ /// /// Batch v1 request payload: [count:u16 LE] repeated [op:u8][payload_len:u32 LE][payload]. /// Batch v1 response payload: [status:u8][count:u16 LE] repeated [op:u8][status:u8][payload_len:u32 LE][payload]. +/// +/// Wire v2 rides inside the same outer frame with op=0x20 so v1 clients can +/// keep using the same listener. V2 payload: +/// request: [ver:u8=2][flags:u8][request_id:u64 LE][op:u8][reserved:u8][body_len:u32 LE][body] +/// response: [ver:u8=2][flags:u8][request_id:u64 LE][op:u8][status:u8][body_len:u32 LE][body] const std = @import("std"); const compat = @import("compat"); const activity = @import("activity.zig"); +const auth = @import("auth.zig"); const collection_mod = @import("collection.zig"); const Database = collection_mod.Database; const Collection = collection_mod.Collection; @@ -22,19 +28,43 @@ const OP_DELETE: u8 = 0x04; const OP_SCAN: u8 = 0x05; const OP_PING: u8 = 0x06; const OP_BATCH: u8 = 0x07; +const OP_WIRE2: u8 = 0x20; + +pub const WIRE2_VERSION: u8 = 2; +pub const WIRE2_OP_HELLO: u8 = 0x00; +pub const WIRE2_OP_PING: u8 = 0x01; +pub const WIRE2_OP_GET: u8 = 0x02; +pub const WIRE2_OP_PUT: u8 = 0x03; +pub const WIRE2_OP_INSERT: u8 = 0x04; +pub const WIRE2_OP_DELETE: u8 = 0x05; +pub const WIRE2_OP_BATCH_GET: u8 = 0x06; +pub const WIRE2_OP_BULK_INSERT: u8 = 0x07; +pub const WIRE2_OP_BULK_UPSERT: u8 = 0x08; +pub const WIRE2_OP_BULK_DELETE: u8 = 0x09; +pub const WIRE2_OP_AUTH: u8 = 0x0A; const STATUS_OK: u8 = 0x00; const STATUS_NOT_FOUND: u8 = 0x01; const STATUS_ERROR: u8 = 0x02; +const STATUS_UNAUTHORIZED: u8 = 0x03; +const STATUS_FORBIDDEN: u8 = 0x04; +const STATUS_TOO_LARGE: u8 = 0x05; const HDR: usize = 5; const RD_BUF: usize = 65536; -const WR_BUF: usize = 131072; -const MAX_FRAME: usize = RD_BUF; +const WR_BUF: usize = 4 * 1024 * 1024; +const MAX_FRAME: usize = 64 * 1024 * 1024 + HDR; const BATCH_REQ_HDR: usize = 2; const BATCH_ITEM_HDR: usize = 5; const BATCH_RESP_HDR: usize = HDR + 1 + 2; const BATCH_ITEM_RESP_HDR: usize = 1 + 1 + 4; +const WIRE2_HDR: usize = 16; +const WIRE2_FEATURE_AUTH: u32 = 1 << 0; +const WIRE2_FEATURE_PIPELINING: u32 = 1 << 1; +const WIRE2_FEATURE_BATCH_GET: u32 = 1 << 2; +const WIRE2_FEATURE_BULK_WRITE: u32 = 1 << 3; +const WIRE2_BULK_INSERT_CHUNK_ROWS: usize = 16384; +const WIRE2_BULK_INSERT_CHUNK_BYTES: usize = 16 * 1024 * 1024; const CONN_THREAD_STACK_SIZE = 1024 * 1024; pub const WireServer = struct { @@ -101,10 +131,16 @@ pub const WireServer = struct { const Bufs = struct { rd: [RD_BUF]u8, wr: [WR_BUF]u8 }; +const WireConnState = struct { + authenticated: bool = false, + auth_ctx: ?auth.AuthContext = null, +}; + fn handleConn(srv: *WireServer, conn: compat.net.Server.Connection) void { defer conn.stream.close(); const bufs = std.heap.page_allocator.create(Bufs) catch return; defer std.heap.page_allocator.destroy(bufs); + var state = WireConnState{}; // Pre-resolve the most common collection for the fast path var cached_col: ?*collection_mod.Collection = null; @@ -114,7 +150,6 @@ fn handleConn(srv: *WireServer, conn: compat.net.Server.Connection) void { var rp: usize = 0; while (true) { - if (rp >= RD_BUF) rp = 0; const n = conn.stream.read(bufs.rd[rp..]) catch return; if (n == 0) return; rp += n; @@ -123,24 +158,31 @@ fn handleConn(srv: *WireServer, conn: compat.net.Server.Connection) void { while (consumed + HDR <= rp) { const flen = rdU32BE(bufs.rd[consumed..]); if (flen < HDR or flen > MAX_FRAME) return; - if (consumed + flen > rp) break; - const op = bufs.rd[consumed + 4]; - const payload = bufs.rd[consumed + HDR .. consumed + flen]; - // ── FAST PATH: inline GET with zero-copy ── - if (op == OP_GET) { - srv.activity.recordQuery(); - const wn = fastGet(srv, payload, &bufs.wr, &cached_col, &cached_col_name, &cached_col_len); - conn.stream.writeAll(bufs.wr[0..wn]) catch return; - } else { - // Invalidate collection cache on writes - if (op == OP_INSERT or op == OP_UPDATE or op == OP_DELETE or op == OP_BATCH) { - cached_col = null; + if (flen > RD_BUF) { + if (consumed != 0) break; + const big = std.heap.page_allocator.alloc(u8, flen) catch return; + defer std.heap.page_allocator.free(big); + + @memcpy(big[0..rp], bufs.rd[0..rp]); + var have = rp; + while (have < flen) { + const got = conn.stream.read(big[have..flen]) catch return; + if (got == 0) return; + have += got; } - srv.activity.recordQuery(); - const wn = dispatch(srv, op, payload, &bufs.wr); + + const wn = processFrame(srv, &state, big[0..flen], &bufs.wr, &cached_col, &cached_col_name, &cached_col_len); conn.stream.writeAll(bufs.wr[0..wn]) catch return; + rp = 0; + consumed = 0; + break; } + + if (consumed + flen > rp) break; + const frame = bufs.rd[consumed .. consumed + flen]; + const wn = processFrame(srv, &state, frame, &bufs.wr, &cached_col, &cached_col_name, &cached_col_len); + conn.stream.writeAll(bufs.wr[0..wn]) catch return; consumed += flen; } if (consumed > 0) { @@ -150,6 +192,37 @@ fn handleConn(srv: *WireServer, conn: compat.net.Server.Connection) void { } } +fn processFrame( + srv: *WireServer, + state: *WireConnState, + frame: []const u8, + w: *[WR_BUF]u8, + cached_col: *?*collection_mod.Collection, + cached_name: *[128]u8, + cached_len: *usize, +) usize { + const op = frame[4]; + const payload = frame[HDR..]; + + if (op == OP_WIRE2) { + srv.activity.recordQuery(); + return dispatchWire2(srv, state, payload, w); + } + + // ── FAST PATH: inline GET with zero-copy ── + if (op == OP_GET) { + srv.activity.recordQuery(); + return fastGet(srv, payload, w, cached_col, cached_name, cached_len); + } + + // Invalidate collection cache on writes. + if (op == OP_INSERT or op == OP_UPDATE or op == OP_DELETE or op == OP_BATCH) { + cached_col.* = null; + } + srv.activity.recordQuery(); + return dispatch(srv, op, payload, w); +} + /// Inlined GET: collection lookup is cached, response writes mmap'd bytes directly. fn fastGet( srv: *WireServer, @@ -418,6 +491,403 @@ fn doPing(w: *[WR_BUF]u8) usize { return HDR + 1; } +// ── Wire v2 ──────────────────────────────────────────────────────────────── + +const Wire2Request = struct { + flags: u8, + request_id: u64, + op: u8, + body: []const u8, +}; + +const Wire2Result = struct { + status: u8, + payload_len: usize = 0, +}; + +const Wire2CollectionBody = struct { + col: []const u8, + count: u32, + pos: usize, +}; + +const Wire2Stats = struct { + inserted: u32 = 0, + updated: u32 = 0, + deleted: u32 = 0, + missing: u32 = 0, + errors: u32 = 0, + bytes: u64 = 0, +}; + +fn dispatchWire2(srv: *WireServer, state: *WireConnState, p: []const u8, w: *[WR_BUF]u8) usize { + const req = parseWire2Request(p) orelse { + const fallback = Wire2Request{ .flags = 0, .request_id = 0, .op = 0, .body = &.{} }; + return writeWire2Response(w, fallback, STATUS_ERROR, 0); + }; + + if (req.op == WIRE2_OP_HELLO) return doWire2Hello(req, w); + if (req.op == WIRE2_OP_AUTH) return doWire2Auth(srv, state, req, w); + + if (srv.db.auth.isEnabled() and !state.authenticated) { + return writeWire2Response(w, req, STATUS_UNAUTHORIZED, 0); + } + + const out = wire2BodyOut(w); + const result = switch (req.op) { + WIRE2_OP_PING => Wire2Result{ .status = STATUS_OK }, + WIRE2_OP_GET => doWire2Get(srv, state, req.body, out), + WIRE2_OP_PUT => doWire2Put(srv, state, req.body, out), + WIRE2_OP_INSERT => doWire2Insert(srv, state, req.body, out), + WIRE2_OP_DELETE => doWire2Delete(srv, state, req.body), + WIRE2_OP_BATCH_GET => doWire2BatchGet(srv, state, req.body, out), + WIRE2_OP_BULK_INSERT => doWire2BulkInsert(srv, state, req.body, out), + WIRE2_OP_BULK_UPSERT => doWire2BulkUpsert(srv, state, req.body, out), + WIRE2_OP_BULK_DELETE => doWire2BulkDelete(srv, state, req.body, out), + else => Wire2Result{ .status = STATUS_ERROR }, + }; + return writeWire2Response(w, req, result.status, result.payload_len); +} + +fn parseWire2Request(p: []const u8) ?Wire2Request { + if (p.len < WIRE2_HDR) return null; + if (p[0] != WIRE2_VERSION) return null; + const body_len: usize = rdU32LE(p[12..16]); + if (WIRE2_HDR + body_len != p.len) return null; + return .{ + .flags = p[1], + .request_id = rdU64LE(p[2..10]), + .op = p[10], + .body = p[WIRE2_HDR..], + }; +} + +fn wire2BodyOut(w: *[WR_BUF]u8) []u8 { + return w[HDR + WIRE2_HDR ..]; +} + +fn writeWire2Response(w: *[WR_BUF]u8, req: Wire2Request, status: u8, payload_len: usize) usize { + if (payload_len > WR_BUF - HDR - WIRE2_HDR) { + return writeWire2Response(w, req, STATUS_TOO_LARGE, 0); + } + const total_len = HDR + WIRE2_HDR + payload_len; + wrU32BE(w, @intCast(total_len)); + w[4] = OP_WIRE2; + w[5] = WIRE2_VERSION; + w[6] = req.flags; + wrU64LE(w[7..15], req.request_id); + w[15] = req.op; + w[16] = status; + wrU32LE(w[17..21], @intCast(payload_len)); + return total_len; +} + +fn doWire2Hello(req: Wire2Request, w: *[WR_BUF]u8) usize { + const out = wire2BodyOut(w); + wrU32LE(out[0..4], @intCast(MAX_FRAME)); + wrU32LE(out[4..8], @intCast(WR_BUF - HDR - WIRE2_HDR)); + wrU32LE(out[8..12], WIRE2_FEATURE_AUTH | WIRE2_FEATURE_PIPELINING | WIRE2_FEATURE_BATCH_GET | WIRE2_FEATURE_BULK_WRITE); + return writeWire2Response(w, req, STATUS_OK, 12); +} + +fn doWire2Auth(srv: *WireServer, state: *WireConnState, req: Wire2Request, w: *[WR_BUF]u8) usize { + if (req.body.len < 2) return writeWire2Response(w, req, STATUS_ERROR, 0); + const key_len: usize = rdU16LE(req.body[0..2]); + if (2 + key_len != req.body.len) return writeWire2Response(w, req, STATUS_ERROR, 0); + const key = req.body[2..][0..key_len]; + + const ctx = srv.db.auth.resolve(key) orelse return writeWire2Response(w, req, STATUS_UNAUTHORIZED, 0); + state.authenticated = true; + state.auth_ctx = ctx; + + const out = wire2BodyOut(w); + const tenant = ctx.tenant(); + if (3 + tenant.len > out.len) return writeWire2Response(w, req, STATUS_TOO_LARGE, 0); + out[0] = @intFromEnum(ctx.perm); + wrU16LE(out[1..3], @intCast(tenant.len)); + if (tenant.len > 0) @memcpy(out[3..][0..tenant.len], tenant); + return writeWire2Response(w, req, STATUS_OK, 3 + tenant.len); +} + +fn doWire2Get(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + if (!parseKeyExact(body)) return .{ .status = STATUS_ERROR }; + const a = parseKey(body).?; + const col = wire2Collection(srv, state, a.col) catch return .{ .status = STATUS_ERROR }; + const d = col.get(a.key) orelse return .{ .status = STATUS_NOT_FOUND }; + const payload_len = 8 + 1 + 4 + d.value.len; + if (payload_len > out.len) return .{ .status = STATUS_TOO_LARGE }; + wrU64LE(out[0..8], d.header.doc_id); + out[8] = d.header.version; + wrU32LE(out[9..13], @intCast(d.value.len)); + if (d.value.len > 0) @memcpy(out[13..][0..d.value.len], d.value); + return .{ .status = STATUS_OK, .payload_len = payload_len }; +} + +fn doWire2Insert(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + if (!wire2CanWrite(state)) return .{ .status = STATUS_FORBIDDEN }; + if (!parseKVExact(body)) return .{ .status = STATUS_ERROR }; + if (out.len < 8) return .{ .status = STATUS_TOO_LARGE }; + const a = parseKV(body).?; + const ref = wire2ResolveCollectionRef(state, a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR }; + srv.db.ensureTenantStorageAvailable(ref.tenant_id, a.val.len) catch return .{ .status = STATUS_ERROR }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR }; + const doc_id = col.insert(a.key, a.val) catch return .{ .status = STATUS_ERROR }; + wrU64LE(out[0..8], doc_id); + return .{ .status = STATUS_OK, .payload_len = 8 }; +} + +fn doWire2Put(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + if (!wire2CanWrite(state)) return .{ .status = STATUS_FORBIDDEN }; + if (!parseKVExact(body)) return .{ .status = STATUS_ERROR }; + const a = parseKV(body).?; + const ref = wire2ResolveCollectionRef(state, a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR }; + srv.db.ensureTenantStorageAvailable(ref.tenant_id, a.val.len) catch return .{ .status = STATUS_ERROR }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR }; + + var stats = Wire2Stats{ .bytes = a.key.len + a.val.len }; + const updated = col.update(a.key, a.val) catch return .{ .status = STATUS_ERROR }; + if (updated) { + stats.updated = 1; + } else { + _ = col.insert(a.key, a.val) catch return .{ .status = STATUS_ERROR }; + stats.inserted = 1; + } + return writeWire2Stats(out, stats); +} + +fn doWire2Delete(srv: *WireServer, state: *const WireConnState, body: []const u8) Wire2Result { + if (!wire2CanWrite(state)) return .{ .status = STATUS_FORBIDDEN }; + if (!parseKeyExact(body)) return .{ .status = STATUS_ERROR }; + const a = parseKey(body).?; + const ref = wire2ResolveCollectionRef(state, a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR }; + const deleted = col.delete(a.key) catch return .{ .status = STATUS_ERROR }; + return .{ .status = if (deleted) STATUS_OK else STATUS_NOT_FOUND }; +} + +fn doWire2BatchGet(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + const parsed = parseWire2CollectionBody(body) orelse return .{ .status = STATUS_ERROR }; + const col = wire2Collection(srv, state, parsed.col) catch return .{ .status = STATUS_ERROR }; + + if (out.len < 4) return .{ .status = STATUS_TOO_LARGE }; + wrU32LE(out[0..4], parsed.count); + var out_pos: usize = 4; + var pos = parsed.pos; + for (0..parsed.count) |_| { + const key = nextWire2Key(body, &pos) orelse return .{ .status = STATUS_ERROR }; + const base_len: usize = 1 + 8 + 1 + 4; + if (out_pos + base_len > out.len) return .{ .status = STATUS_TOO_LARGE }; + + if (col.get(key)) |d| { + const item_len = base_len + d.value.len; + if (out_pos + item_len > out.len) return .{ .status = STATUS_TOO_LARGE }; + out[out_pos] = STATUS_OK; + wrU64LE(out[out_pos + 1 ..][0..8], d.header.doc_id); + out[out_pos + 9] = d.header.version; + wrU32LE(out[out_pos + 10 ..][0..4], @intCast(d.value.len)); + if (d.value.len > 0) @memcpy(out[out_pos + base_len ..][0..d.value.len], d.value); + out_pos += item_len; + } else { + out[out_pos] = STATUS_NOT_FOUND; + wrU64LE(out[out_pos + 1 ..][0..8], 0); + out[out_pos + 9] = 0; + wrU32LE(out[out_pos + 10 ..][0..4], 0); + out_pos += base_len; + } + } + if (pos != body.len) return .{ .status = STATUS_ERROR }; + return .{ .status = STATUS_OK, .payload_len = out_pos }; +} + +fn doWire2BulkInsert(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + if (!wire2CanWrite(state)) return .{ .status = STATUS_FORBIDDEN }; + const parsed = parseWire2CollectionBody(body) orelse return .{ .status = STATUS_ERROR }; + const ref = wire2ResolveCollectionRef(state, parsed.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR }; + srv.db.ensureTenantStorageAvailable(ref.tenant_id, body.len) catch return .{ .status = STATUS_ERROR }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR }; + + var rows: std.ArrayList(Collection.BulkInsertRow) = .empty; + defer rows.deinit(srv.db.alloc); + rows.ensureTotalCapacity(srv.db.alloc, @min(WIRE2_BULK_INSERT_CHUNK_ROWS, @max(@as(usize, 1), body.len / 80))) catch + return .{ .status = STATUS_TOO_LARGE }; + + var stats = Wire2Stats{}; + var chunk_bytes: usize = 0; + var pos = parsed.pos; + var malformed = false; + const Flush = struct { + fn run(col_arg: *Collection, rows_arg: *std.ArrayList(Collection.BulkInsertRow), stats_arg: *Wire2Stats, chunk_bytes_arg: *usize) !void { + if (rows_arg.items.len == 0) return; + const bulk = try col_arg.insertBulk(rows_arg.items); + stats_arg.inserted += bulk.inserted; + stats_arg.errors += bulk.errors; + stats_arg.bytes += bulk.bytes; + rows_arg.clearRetainingCapacity(); + chunk_bytes_arg.* = 0; + } + }; + + for (0..parsed.count) |_| { + const rec = nextWire2KeyValue(body, &pos) orelse { + stats.errors += 1; + malformed = true; + break; + }; + const row_bytes = rec.key.len + rec.val.len + 128; + if (rows.items.len > 0 and chunk_bytes + row_bytes > WIRE2_BULK_INSERT_CHUNK_BYTES) { + Flush.run(col, &rows, &stats, &chunk_bytes) catch return .{ .status = STATUS_ERROR }; + } + rows.append(srv.db.alloc, .{ .key = rec.key, .value = rec.val, .line_len = 6 + rec.key.len + rec.val.len }) catch + return .{ .status = STATUS_TOO_LARGE }; + chunk_bytes += row_bytes; + if (rows.items.len >= WIRE2_BULK_INSERT_CHUNK_ROWS) { + Flush.run(col, &rows, &stats, &chunk_bytes) catch return .{ .status = STATUS_ERROR }; + } + } + if (!malformed and pos != body.len) stats.errors += 1; + Flush.run(col, &rows, &stats, &chunk_bytes) catch return .{ .status = STATUS_ERROR }; + return writeWire2Stats(out, stats); +} + +fn doWire2BulkUpsert(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + if (!wire2CanWrite(state)) return .{ .status = STATUS_FORBIDDEN }; + const parsed = parseWire2CollectionBody(body) orelse return .{ .status = STATUS_ERROR }; + const ref = wire2ResolveCollectionRef(state, parsed.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR }; + srv.db.ensureTenantStorageAvailable(ref.tenant_id, body.len) catch return .{ .status = STATUS_ERROR }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR }; + + var stats = Wire2Stats{}; + var pos = parsed.pos; + var malformed = false; + for (0..parsed.count) |_| { + const rec = nextWire2KeyValue(body, &pos) orelse { + stats.errors += 1; + malformed = true; + break; + }; + const updated = col.update(rec.key, rec.val) catch { + stats.errors += 1; + continue; + }; + if (updated) { + stats.updated += 1; + } else { + _ = col.insert(rec.key, rec.val) catch { + stats.errors += 1; + continue; + }; + stats.inserted += 1; + } + stats.bytes += rec.key.len + rec.val.len; + } + if (!malformed and pos != body.len) stats.errors += 1; + return writeWire2Stats(out, stats); +} + +fn doWire2BulkDelete(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + if (!wire2CanWrite(state)) return .{ .status = STATUS_FORBIDDEN }; + const parsed = parseWire2CollectionBody(body) orelse return .{ .status = STATUS_ERROR }; + const ref = wire2ResolveCollectionRef(state, parsed.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR }; + + var stats = Wire2Stats{}; + var pos = parsed.pos; + var malformed = false; + for (0..parsed.count) |_| { + const key = nextWire2Key(body, &pos) orelse { + stats.errors += 1; + malformed = true; + break; + }; + const deleted = col.delete(key) catch { + stats.errors += 1; + continue; + }; + if (deleted) stats.deleted += 1 else stats.missing += 1; + stats.bytes += key.len; + } + if (!malformed and pos != body.len) stats.errors += 1; + return writeWire2Stats(out, stats); +} + +fn writeWire2Stats(out: []u8, stats: Wire2Stats) Wire2Result { + const payload_len: usize = 28; + if (out.len < payload_len) return .{ .status = STATUS_TOO_LARGE }; + wrU32LE(out[0..4], stats.inserted); + wrU32LE(out[4..8], stats.updated); + wrU32LE(out[8..12], stats.deleted); + wrU32LE(out[12..16], stats.missing); + wrU32LE(out[16..20], stats.errors); + wrU64LE(out[20..28], stats.bytes); + return .{ .status = STATUS_OK, .payload_len = payload_len }; +} + +fn parseWire2CollectionBody(body: []const u8) ?Wire2CollectionBody { + if (body.len < 6) return null; + const col_len: usize = rdU16LE(body[0..2]); + if (col_len == 0 or 2 + col_len + 4 > body.len) return null; + const count = rdU32LE(body[2 + col_len ..][0..4]); + return .{ + .col = body[2..][0..col_len], + .count = count, + .pos = 2 + col_len + 4, + }; +} + +fn nextWire2Key(body: []const u8, pos: *usize) ?[]const u8 { + if (pos.* + 2 > body.len) return null; + const key_len: usize = rdU16LE(body[pos.*..][0..2]); + pos.* += 2; + if (key_len == 0 or pos.* + key_len > body.len) return null; + const key = body[pos.*..][0..key_len]; + pos.* += key_len; + return key; +} + +fn nextWire2KeyValue(body: []const u8, pos: *usize) ?struct { key: []const u8, val: []const u8 } { + if (pos.* + 6 > body.len) return null; + const key_len: usize = rdU16LE(body[pos.*..][0..2]); + const val_len: usize = rdU32LE(body[pos.* + 2 ..][0..4]); + pos.* += 6; + if (key_len == 0 or pos.* + key_len > body.len) return null; + const key = body[pos.*..][0..key_len]; + pos.* += key_len; + if (pos.* + val_len > body.len) return null; + const val = body[pos.*..][0..val_len]; + pos.* += val_len; + return .{ .key = key, .val = val }; +} + +fn wire2Collection(srv: *WireServer, state: *const WireConnState, full_name: []const u8) !*Collection { + const ref = wire2ResolveCollectionRef(state, full_name); + try srv.db.recordTenantOperation(ref.tenant_id); + return srv.db.collectionForTenant(ref.tenant_id, ref.collection_name); +} + +fn wire2ResolveCollectionRef(state: *const WireConnState, full_name: []const u8) collection_mod.TenantCollectionRef { + if (state.auth_ctx) |ctx| { + if (ctx.perm != .admin) { + const tenant_id = ctx.tenant(); + return .{ + .tenant_id = if (tenant_id.len > 0) tenant_id else collection_mod.DEFAULT_TENANT, + .collection_name = full_name, + }; + } + } + return resolveCollectionRef(full_name); +} + +fn wire2CanWrite(state: *const WireConnState) bool { + const ctx = state.auth_ctx orelse return true; + return ctx.perm != .read_only; +} + // ── Helpers ───────────────────────────────────────────────────────────────── fn errResp(w: *[WR_BUF]u8, op: u8, status: u8) usize { @@ -485,6 +955,16 @@ fn rdU16LE(b: []const u8) u16 { fn rdU32LE(b: []const u8) u32 { return @as(u32, b[0]) | (@as(u32, b[1]) << 8) | (@as(u32, b[2]) << 16) | (@as(u32, b[3]) << 24); } +fn rdU64LE(b: []const u8) u64 { + return @as(u64, b[0]) | + (@as(u64, b[1]) << 8) | + (@as(u64, b[2]) << 16) | + (@as(u64, b[3]) << 24) | + (@as(u64, b[4]) << 32) | + (@as(u64, b[5]) << 40) | + (@as(u64, b[6]) << 48) | + (@as(u64, b[7]) << 56); +} fn wrU32BE(w: *[WR_BUF]u8, v: u32) void { w[0] = @intCast(v >> 24); w[1] = @intCast((v >> 16) & 0xFF); @@ -554,3 +1034,161 @@ test "wire batch envelope rejects unsupported and trailing payloads" { @memcpy(bad_item[BATCH_REQ_HDR + BATCH_ITEM_HDR ..], &get_payload_with_trailing); try std.testing.expect(validateBatchEnvelope(&bad_item) == null); } + +test "wire2 envelope parses request id and rejects mismatched body length" { + var payload: [WIRE2_HDR + 3]u8 = undefined; + payload[0] = WIRE2_VERSION; + payload[1] = 0; + wrU64LE(payload[2..10], 0xAABBCCDD); + payload[10] = WIRE2_OP_PING; + payload[11] = 0; + wrU32LE(payload[12..16], 3); + @memcpy(payload[16..19], "abc"); + + const req = parseWire2Request(&payload).?; + try std.testing.expectEqual(@as(u64, 0xAABBCCDD), req.request_id); + try std.testing.expectEqual(WIRE2_OP_PING, req.op); + try std.testing.expectEqualStrings("abc", req.body); + + wrU32LE(payload[12..16], 2); + try std.testing.expect(parseWire2Request(&payload) == null); +} + +test "wire2 auth gates writes for read-only keys" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_wire2_auth_test"; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + _ = db.auth.addKeyForTenant("reader-key", "reader", "tenant-a", .read_only); + + var srv = WireServer.init(db, 27017); + var state = WireConnState{}; + const resp = try std.heap.page_allocator.create([WR_BUF]u8); + defer std.heap.page_allocator.destroy(resp); + + var body: [128]u8 = undefined; + var body_len: usize = 0; + wrU16LE(body[0..2], 5); + @memcpy(body[2..7], "users"); + wrU16LE(body[7..9], 2); + @memcpy(body[9..11], "u1"); + body_len = 11; + + var req_buf: [256]u8 = undefined; + const get_req = makeWire2TestRequest(&req_buf, 1, WIRE2_OP_GET, body[0..body_len]); + _ = dispatchWire2(&srv, &state, get_req, resp); + try std.testing.expectEqual(STATUS_UNAUTHORIZED, resp[16]); + + wrU16LE(body[0..2], 10); + @memcpy(body[2..12], "reader-key"); + const auth_req = makeWire2TestRequest(&req_buf, 2, WIRE2_OP_AUTH, body[0..12]); + _ = dispatchWire2(&srv, &state, auth_req, resp); + try std.testing.expectEqual(STATUS_OK, resp[16]); + try std.testing.expect(state.authenticated); + + body_len = encodeWire2TestKeyValue(&body, "users", "u1", "{\"x\":1}"); + const put_req = makeWire2TestRequest(&req_buf, 3, WIRE2_OP_PUT, body[0..body_len]); + _ = dispatchWire2(&srv, &state, put_req, resp); + try std.testing.expectEqual(STATUS_FORBIDDEN, resp[16]); +} + +test "wire2 bulk insert and batch get use binary bodies" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_wire2_bulk_test"; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + + var srv = WireServer.init(db, 27017); + var state = WireConnState{}; + const resp = try std.heap.page_allocator.create([WR_BUF]u8); + defer std.heap.page_allocator.destroy(resp); + + var body: [512]u8 = undefined; + var pos: usize = 0; + pos += encodeWire2TestCollectionBody(body[pos..], "events", 2); + pos += encodeWire2TestKeyValueRecord(body[pos..], "a", "{\"n\":1}"); + pos += encodeWire2TestKeyValueRecord(body[pos..], "b", "{\"n\":2}"); + + var req_buf: [768]u8 = undefined; + const bulk_req = makeWire2TestRequest(&req_buf, 10, WIRE2_OP_BULK_INSERT, body[0..pos]); + _ = dispatchWire2(&srv, &state, bulk_req, resp); + try std.testing.expectEqual(STATUS_OK, resp[16]); + const bulk_body = wire2TestResponseBody(resp); + try std.testing.expectEqual(@as(u32, 2), rdU32LE(bulk_body[0..4])); // inserted + try std.testing.expectEqual(@as(u32, 0), rdU32LE(bulk_body[16..20])); // errors + + pos = 0; + pos += encodeWire2TestCollectionBody(body[pos..], "events", 3); + pos += encodeWire2TestKeyRecord(body[pos..], "a"); + pos += encodeWire2TestKeyRecord(body[pos..], "b"); + pos += encodeWire2TestKeyRecord(body[pos..], "missing"); + const get_req = makeWire2TestRequest(&req_buf, 11, WIRE2_OP_BATCH_GET, body[0..pos]); + _ = dispatchWire2(&srv, &state, get_req, resp); + try std.testing.expectEqual(STATUS_OK, resp[16]); + + const get_body = wire2TestResponseBody(resp); + try std.testing.expectEqual(@as(u32, 3), rdU32LE(get_body[0..4])); + var rp: usize = 4; + try std.testing.expectEqual(STATUS_OK, get_body[rp]); + const first_len = rdU32LE(get_body[rp + 10 ..][0..4]); + rp += 14 + first_len; + try std.testing.expectEqual(STATUS_OK, get_body[rp]); + const second_len = rdU32LE(get_body[rp + 10 ..][0..4]); + rp += 14 + second_len; + try std.testing.expectEqual(STATUS_NOT_FOUND, get_body[rp]); +} + +fn makeWire2TestRequest(buf: []u8, request_id: u64, op: u8, body: []const u8) []const u8 { + buf[0] = WIRE2_VERSION; + buf[1] = 0; + wrU64LE(buf[2..10], request_id); + buf[10] = op; + buf[11] = 0; + wrU32LE(buf[12..16], @intCast(body.len)); + @memcpy(buf[16..][0..body.len], body); + return buf[0 .. WIRE2_HDR + body.len]; +} + +fn wire2TestResponseBody(resp: *[WR_BUF]u8) []const u8 { + const body_len: usize = rdU32LE(resp[17..21]); + return resp[21 .. 21 + body_len]; +} + +fn encodeWire2TestCollectionBody(out: []u8, col: []const u8, count: u32) usize { + wrU16LE(out[0..2], @intCast(col.len)); + @memcpy(out[2..][0..col.len], col); + wrU32LE(out[2 + col.len ..][0..4], count); + return 2 + col.len + 4; +} + +fn encodeWire2TestKeyRecord(out: []u8, key: []const u8) usize { + wrU16LE(out[0..2], @intCast(key.len)); + @memcpy(out[2..][0..key.len], key); + return 2 + key.len; +} + +fn encodeWire2TestKeyValueRecord(out: []u8, key: []const u8, value: []const u8) usize { + wrU16LE(out[0..2], @intCast(key.len)); + wrU32LE(out[2..6], @intCast(value.len)); + @memcpy(out[6..][0..key.len], key); + @memcpy(out[6 + key.len ..][0..value.len], value); + return 6 + key.len + value.len; +} + +fn encodeWire2TestKeyValue(out: []u8, col: []const u8, key: []const u8, value: []const u8) usize { + wrU16LE(out[0..2], @intCast(col.len)); + @memcpy(out[2..][0..col.len], col); + const key_off = 2 + col.len; + wrU16LE(out[key_off..][0..2], @intCast(key.len)); + @memcpy(out[key_off + 2 ..][0..key.len], key); + const val_off = key_off + 2 + key.len; + wrU32LE(out[val_off..][0..4], @intCast(value.len)); + @memcpy(out[val_off + 4 ..][0..value.len], value); + return val_off + 4 + value.len; +} From 636994d30a8cc40470d8420f41be7c02aa700f7c Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Fri, 1 May 2026 01:56:29 +0800 Subject: [PATCH 26/27] speed up wire batch insert path --- src/collection.zig | 2 + src/wire.zig | 126 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/collection.zig b/src/collection.zig index 955f692..6174f13 100644 --- a/src/collection.zig +++ b/src/collection.zig @@ -227,6 +227,7 @@ pub const Collection = struct { inserted: u32 = 0, errors: u32 = 0, bytes: u64 = 0, + first_doc_id: u64 = 0, }; const BulkPreparedDoc = struct { @@ -568,6 +569,7 @@ pub const Collection = struct { try encoded.ensureTotalCapacity(self.alloc, estimated_bytes); const first_doc_id = self.next_doc_id.fetchAdd(@intCast(valid_rows), .monotonic); + result.first_doc_id = first_doc_id; var doc_id_offset: u64 = 0; for (rows) |row| { const total_size = bulkEncodedLen(row) orelse continue; diff --git a/src/wire.zig b/src/wire.zig index 04550df..fd9f66f 100644 --- a/src/wire.zig +++ b/src/wire.zig @@ -72,9 +72,16 @@ pub const WireServer = struct { port: u16, running: std.atomic.Value(bool), activity: activity.ActivityTracker, + op_mu: compat.RwLock, pub fn init(db: *Database, port: u16) WireServer { - return .{ .db = db, .port = port, .running = std.atomic.Value(bool).init(false), .activity = activity.ActivityTracker.init() }; + return .{ + .db = db, + .port = port, + .running = std.atomic.Value(bool).init(false), + .activity = activity.ActivityTracker.init(), + .op_mu = .{}, + }; } pub fn run(self: *WireServer) !void { @@ -212,6 +219,8 @@ fn processFrame( // ── FAST PATH: inline GET with zero-copy ── if (op == OP_GET) { srv.activity.recordQuery(); + srv.op_mu.lockShared(); + defer srv.op_mu.unlockShared(); return fastGet(srv, payload, w, cached_col, cached_name, cached_len); } @@ -220,9 +229,26 @@ fn processFrame( cached_col.* = null; } srv.activity.recordQuery(); + const lock_mutation = wireOpMutates(op); + if (lock_mutation) { + srv.op_mu.lock(); + } else if (wireOpReadsStorage(op)) { + srv.op_mu.lockShared(); + } + defer { + if (lock_mutation) srv.op_mu.unlock() else if (wireOpReadsStorage(op)) srv.op_mu.unlockShared(); + } return dispatch(srv, op, payload, w); } +fn wireOpMutates(op: u8) bool { + return op == OP_INSERT or op == OP_UPDATE or op == OP_DELETE or op == OP_BATCH; +} + +fn wireOpReadsStorage(op: u8) bool { + return op == OP_SCAN; +} + /// Inlined GET: collection lookup is cached, response writes mmap'd bytes directly. fn fastGet( srv: *WireServer, @@ -377,6 +403,8 @@ const BatchEnvelope = struct { count: u16 }; fn doBatch(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { const env = validateBatchEnvelope(p) orelse return errResp(w, OP_BATCH, STATUS_ERROR); + if (doInsertOnlyBatch(srv, p, env, w)) |written| return written; + var req_pos: usize = BATCH_REQ_HDR; var resp_pos: usize = BATCH_RESP_HDR; w[4] = OP_BATCH; @@ -408,6 +436,76 @@ fn doBatch(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { return resp_pos; } +fn doInsertOnlyBatch(srv: *WireServer, p: []const u8, env: BatchEnvelope, w: *[WR_BUF]u8) ?usize { + if (env.count == 0) return null; + + var rows: std.ArrayList(Collection.BulkInsertRow) = .empty; + defer rows.deinit(srv.db.alloc); + rows.ensureTotalCapacity(srv.db.alloc, env.count) catch return null; + + var req_pos: usize = BATCH_REQ_HDR; + var batch_col: []const u8 = ""; + var total_value_bytes: usize = 0; + for (0..env.count) |idx| { + const op = p[req_pos]; + if (op != OP_INSERT) return null; + const payload_len: usize = rdU32LE(p[req_pos + 1 ..][0..4]); + const payload = p[req_pos + BATCH_ITEM_HDR ..][0..payload_len]; + const kv = parseKV(payload) orelse return null; + + if (idx == 0) { + batch_col = kv.col; + } else if (!std.mem.eql(u8, batch_col, kv.col)) { + return null; + } + + if (kv.key.len == 0 or kv.key.len > 1024 or kv.val.len > 64 * 1024 * 1024) return null; + total_value_bytes = std.math.add(usize, total_value_bytes, kv.val.len) catch return null; + rows.appendAssumeCapacity(.{ + .key = kv.key, + .value = kv.val, + .line_len = kv.key.len + kv.val.len, + }); + req_pos += BATCH_ITEM_HDR + payload_len; + } + + const ref = resolveCollectionRef(batch_col); + srv.db.recordTenantOperation(ref.tenant_id) catch return allBatchItemsError(w, env.count); + srv.db.ensureTenantStorageAvailable(ref.tenant_id, total_value_bytes) catch return allBatchItemsError(w, env.count); + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return allBatchItemsError(w, env.count); + const result = col.insertBulk(rows.items) catch return allBatchItemsError(w, env.count); + if (result.errors != 0 or result.inserted != env.count) return allBatchItemsError(w, env.count); + + w[4] = OP_BATCH; + w[5] = STATUS_OK; + wrU16LE(w[6..8], env.count); + var resp_pos: usize = BATCH_RESP_HDR; + for (0..env.count) |i| { + w[resp_pos] = OP_INSERT; + w[resp_pos + 1] = STATUS_OK; + wrU32LE(w[resp_pos + 2 ..][0..4], 8); + wrU64LE(w[resp_pos + BATCH_ITEM_RESP_HDR ..][0..8], result.first_doc_id + @as(u64, @intCast(i))); + resp_pos += BATCH_ITEM_RESP_HDR + 8; + } + wrU32BE(w, @intCast(resp_pos)); + return resp_pos; +} + +fn allBatchItemsError(w: *[WR_BUF]u8, count: u16) usize { + w[4] = OP_BATCH; + w[5] = STATUS_OK; + wrU16LE(w[6..8], count); + var resp_pos: usize = BATCH_RESP_HDR; + for (0..count) |_| { + w[resp_pos] = OP_INSERT; + w[resp_pos + 1] = STATUS_ERROR; + wrU32LE(w[resp_pos + 2 ..][0..4], 0); + resp_pos += BATCH_ITEM_RESP_HDR; + } + wrU32BE(w, @intCast(resp_pos)); + return resp_pos; +} + fn execBatchItem(srv: *WireServer, op: u8, p: []const u8, out: []u8) ItemResult { return switch (op) { OP_INSERT => execInsertPayload(srv, p, out), @@ -534,6 +632,15 @@ fn dispatchWire2(srv: *WireServer, state: *WireConnState, p: []const u8, w: *[WR } const out = wire2BodyOut(w); + const lock_mutation = wire2OpMutates(req.op); + if (lock_mutation) { + srv.op_mu.lock(); + } else if (wire2OpReadsStorage(req.op)) { + srv.op_mu.lockShared(); + } + defer { + if (lock_mutation) srv.op_mu.unlock() else if (wire2OpReadsStorage(req.op)) srv.op_mu.unlockShared(); + } const result = switch (req.op) { WIRE2_OP_PING => Wire2Result{ .status = STATUS_OK }, WIRE2_OP_GET => doWire2Get(srv, state, req.body, out), @@ -549,6 +656,23 @@ fn dispatchWire2(srv: *WireServer, state: *WireConnState, p: []const u8, w: *[WR return writeWire2Response(w, req, result.status, result.payload_len); } +fn wire2OpMutates(op: u8) bool { + return switch (op) { + WIRE2_OP_PUT, + WIRE2_OP_INSERT, + WIRE2_OP_DELETE, + WIRE2_OP_BULK_INSERT, + WIRE2_OP_BULK_UPSERT, + WIRE2_OP_BULK_DELETE, + => true, + else => false, + }; +} + +fn wire2OpReadsStorage(op: u8) bool { + return op == WIRE2_OP_GET or op == WIRE2_OP_BATCH_GET; +} + fn parseWire2Request(p: []const u8) ?Wire2Request { if (p.len < WIRE2_HDR) return null; if (p[0] != WIRE2_VERSION) return null; From a0bc4bf27aae8888a284a518a08bd5d5ab2651c3 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Fri, 1 May 2026 09:51:33 +0800 Subject: [PATCH 27/27] speed up bulk btree updates --- src/btree.zig | 64 ++++++++++++++++++++++++++++++++-------------- src/collection.zig | 16 ++++++++---- 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/src/btree.zig b/src/btree.zig index 137dff9..1581045 100644 --- a/src/btree.zig +++ b/src/btree.zig @@ -20,12 +20,14 @@ const PAGE_USABLE = page_mod.PAGE_USABLE; pub const BTreeEntry = extern struct { key_hash: u64 align(8), // 0 - doc_id: u64, // 8 - page_no: u32, // 16 - page_off: u16, // 20 + doc_id: u64, // 8 + page_no: u32, // 16 + page_off: u16, // 20 // total: 24 (extern struct padded to align(8)) pub const size = @sizeOf(BTreeEntry); - comptime { std.debug.assert(size == 24); } + comptime { + std.debug.assert(size == 24); + } }; const MAX_ENTRIES_PER_LEAF: usize = (PAGE_USABLE - 2) / BTreeEntry.size; @@ -37,7 +39,7 @@ const MIN_KEYS: usize = MAX_KEYS_PER_INTERNAL / 2; pub const BTree = struct { pf: *PageFile, - root: u32, // root page number (0 = not yet created) + root: u32, // root page number (0 = not yet created) mu: compat.RwLock, pub fn init(pf: *PageFile, root_page: u32) BTree { @@ -73,6 +75,19 @@ pub const BTree = struct { self.mu.lock(); defer self.mu.unlock(); + try self.insertLocked(entry); + } + + pub fn insertMany(self: *BTree, entries: []const BTreeEntry) !void { + if (entries.len == 0) return; + + self.mu.lock(); + defer self.mu.unlock(); + + for (entries) |entry| try self.insertLocked(entry); + } + + fn insertLocked(self: *BTree, entry: BTreeEntry) !void { if (self.root == 0) { // Create first root leaf (btree_leaf, NOT document leaf). self.root = try self.pf.allocPage(.btree_leaf); @@ -115,8 +130,7 @@ pub const BTree = struct { var hi: usize = n; while (lo < hi) { const mid = lo + (hi - lo) / 2; - if (entries[mid].key_hash < entry.key_hash) lo = mid + 1 - else hi = mid; + if (entries[mid].key_hash < entry.key_hash) lo = mid + 1 else hi = mid; } if (lo < n and entries[lo].key_hash == entry.key_hash) { // Overwrite. @@ -222,8 +236,7 @@ fn searchLeaf(data: []const u8, key_hash: u64) ?BTreeEntry { var hi: usize = n; while (lo < hi) { const mid = lo + (hi - lo) / 2; - if (entries[mid].key_hash < key_hash) lo = mid + 1 - else hi = mid; + if (entries[mid].key_hash < key_hash) lo = mid + 1 else hi = mid; } if (lo < n and entries[lo].key_hash == key_hash) return entries[lo]; return null; @@ -236,8 +249,7 @@ fn leafDelete(data: []u8, key_hash: u64) bool { var hi: usize = n; while (lo < hi) { const mid = lo + (hi - lo) / 2; - if (entries[mid].key_hash < key_hash) lo = mid + 1 - else hi = mid; + if (entries[mid].key_hash < key_hash) lo = mid + 1 else hi = mid; } if (lo >= n or entries[lo].key_hash != key_hash) return false; var i = lo; @@ -260,8 +272,12 @@ fn internalSetKeyCount(data: []u8, n: usize) void { } /// Offset of child[i] within data (after the 2-byte count + 2-byte pad). -fn childOff(i: usize) usize { return 4 + i * 12; } -fn keyOff(i: usize) usize { return 4 + i * 12 + 4; } +fn childOff(i: usize) usize { + return 4 + i * 12; +} +fn keyOff(i: usize) usize { + return 4 + i * 12 + 4; +} fn internalGetChild(data: []const u8, i: usize) u32 { const off = childOff(i); @@ -350,8 +366,12 @@ fn internalSplit( tc += 1; inserted = true; } - if (i < n) { tmp_keys[tk] = internalGetKey(ldata, i); tk += 1; } - tmp_children[tc] = internalGetChild(ldata, i); tc += 1; + if (i < n) { + tmp_keys[tk] = internalGetKey(ldata, i); + tk += 1; + } + tmp_children[tc] = internalGetChild(ldata, i); + tc += 1; } if (!inserted) { tmp_keys[tk] = median; @@ -366,13 +386,19 @@ fn internalSplit( // Left: keys 0..mid-1, children 0..mid internalSetKeyCount(ldata, mid); - for (0..mid) |j| { internalSetKey(ldata, j, tmp_keys[j]); internalSetChild(ldata, j, tmp_children[j]); } + for (0..mid) |j| { + internalSetKey(ldata, j, tmp_keys[j]); + internalSetChild(ldata, j, tmp_children[j]); + } internalSetChild(ldata, mid, tmp_children[mid]); // Right: keys mid+1..total_keys-1, children mid+1..total_keys const right_n = total_keys - mid - 1; internalSetKeyCount(rdata, right_n); - for (0..right_n) |j| { internalSetKey(rdata, j, tmp_keys[mid + 1 + j]); internalSetChild(rdata, j, tmp_children[mid + 1 + j]); } + for (0..right_n) |j| { + internalSetKey(rdata, j, tmp_keys[mid + 1 + j]); + internalSetChild(rdata, j, tmp_children[mid + 1 + j]); + } internalSetChild(rdata, right_n, tmp_children[total_keys]); return split_median; @@ -386,7 +412,7 @@ test "btree leaf insert/search" { leafSetCount(&data, 0); const e1 = BTreeEntry{ .key_hash = 100, .doc_id = 1, .page_no = 0, .page_off = 0 }; - const e2 = BTreeEntry{ .key_hash = 50, .doc_id = 2, .page_no = 0, .page_off = 0 }; + const e2 = BTreeEntry{ .key_hash = 50, .doc_id = 2, .page_no = 0, .page_off = 0 }; const e3 = BTreeEntry{ .key_hash = 200, .doc_id = 3, .page_no = 0, .page_off = 0 }; // Insert e1 @@ -407,7 +433,7 @@ test "btree leaf insert/search" { leafSetCount(&data, 3); try std.testing.expect(searchLeaf(&data, 100) != null); - try std.testing.expect(searchLeaf(&data, 50) != null); + try std.testing.expect(searchLeaf(&data, 50) != null); try std.testing.expect(searchLeaf(&data, 200) != null); try std.testing.expect(searchLeaf(&data, 999) == null); try std.testing.expectEqual(@as(u64, 1), searchLeaf(&data, 100).?.doc_id); diff --git a/src/collection.zig b/src/collection.zig index 6174f13..100d996 100644 --- a/src/collection.zig +++ b/src/collection.zig @@ -548,6 +548,8 @@ pub const Collection = struct { defer encoded.deinit(self.alloc); var prepared: std.ArrayList(BulkPreparedDoc) = .empty; defer prepared.deinit(self.alloc); + var btree_entries: std.ArrayList(BTreeEntry) = .empty; + defer btree_entries.deinit(self.alloc); var payloads: std.ArrayList(wal_mod.BatchPayload) = .empty; defer payloads.deinit(self.alloc); @@ -565,6 +567,7 @@ pub const Collection = struct { if (valid_rows == 0) return result; try prepared.ensureTotalCapacity(self.alloc, valid_rows); + try btree_entries.ensureTotalCapacity(self.alloc, valid_rows); try payloads.ensureTotalCapacity(self.alloc, valid_rows); try encoded.ensureTotalCapacity(self.alloc, estimated_bytes); @@ -599,7 +602,6 @@ pub const Collection = struct { const last_lsn = try self.wal_log.writeCommittedBatch(.doc_insert, wal_mod.DB_TAG_DOC, payloads.items); try self.wal_log.flushUpTo(last_lsn); - const first_epoch = self.epochs.advanceMany(prepared.items.len); self.meta_mu.lock(); { defer self.meta_mu.unlock(); @@ -608,7 +610,7 @@ pub const Collection = struct { try self.key_epochs.ensureUnusedCapacity(@intCast(prepared.items.len)); try self.versions.ensureUnusedCapacity(self.alloc, prepared.items.len); - for (prepared.items, 0..) |*item, i| { + for (prepared.items) |*item| { const enc = encoded.items[item.enc_off .. item.enc_off + item.enc_len]; const pno = try self.findOrAllocLeaf(enc.len); const page_off = self.pf.leafAppend(pno, enc) orelse return error.PageFull; @@ -618,13 +620,17 @@ pub const Collection = struct { .page_no = pno, .page_off = page_off, }; - try self.idx.insert(entry); - self.hash_idx.putAssumeCapacity(item.key_hash, entry); + btree_entries.appendAssumeCapacity(entry); item.page_no = pno; item.page_off = page_off; + } + try self.idx.insertMany(btree_entries.items); + const first_epoch = self.epochs.advanceMany(prepared.items.len); + for (prepared.items, btree_entries.items, 0..) |*item, entry, i| { + self.hash_idx.putAssumeCapacity(item.key_hash, entry); const epoch = first_epoch + @as(u64, @intCast(i)); - self.versions.appendFreshVersionAssumeCapacity(item.doc_id, pno, page_off, epoch); + self.versions.appendFreshVersionAssumeCapacity(item.doc_id, entry.page_no, entry.page_off, epoch); self.key_doc_ids.putAssumeCapacity(item.key_hash, item.doc_id); self.key_epochs.putAssumeCapacity(item.key_hash, item.doc_id);