search for standard sites pub-search.waow.tech
search zig blog atproto
0

Configure Feed

Select the types of activity you want to include in your feed.

pub-search / backend / src / builder.zig
12 kB 279 lines
1//! Builder mode: offline snapshot build + verified R2 publish. 2//! 3//! Invariant #1 of docs/scaling-plan.md: background data movement never 4//! touches the serving box. This runs as a separate process (BUILDER_MODE=1 5//! on an ephemeral machine or a cron), builds a fresh replica from turso into 6//! a scratch path, refuses to publish unless sentinels pass, and uploads 7//! artifact + manifest to R2 — latest pointer last, so a reader can never see 8//! a pointer to an incomplete artifact (typeahead's channel discipline). 9//! 10//! Channels: staging by default; prod requires BUILDER_ALLOW_PROD=1 so a 11//! scratch run can never overwrite the production pointer. 12//! 13//! Env: TURSO_URL, TURSO_TOKEN, INDEX_R2_{ENDPOINT,BUCKET,ACCESS_KEY_ID, 14//! SECRET_ACCESS_KEY}; optional BUILD_DIR (default /tmp/leaflet-build), 15//! BUILDER_CHANNEL (staging|prod), BUILDER_VERSION (git sha), SKIP_UPLOAD=1 16//! (build + verify only). 17 18const std = @import("std"); 19const Io = std.Io; 20const Allocator = std.mem.Allocator; 21const logfire = @import("logfire"); 22const zqlite = @import("zqlite"); 23const db = @import("db.zig"); 24const LocalDb = @import("db/LocalDb.zig"); 25const sync = @import("db/sync.zig"); 26const r2 = @import("r2.zig"); 27 28pub const MANIFEST_VERSION = 1; 29 30const Channel = enum { staging, prod }; 31const DEFAULT_TIMEOUT_SECS: u64 = 45 * 60; 32 33fn getenv(name: [*:0]const u8) ?[]const u8 { 34 return if (std.c.getenv(name)) |v| std.mem.span(v) else null; 35} 36 37fn parseTimeoutSeconds(raw: ?[]const u8) u64 { 38 const value = std.fmt.parseInt(u64, raw orelse return DEFAULT_TIMEOUT_SECS, 10) catch 39 return DEFAULT_TIMEOUT_SECS; 40 return if (value == 0) DEFAULT_TIMEOUT_SECS else value; 41} 42 43fn timeoutWatchdog(io: Io, timeout_s: u64) void { 44 io.sleep(Io.Duration.fromSeconds(@intCast(timeout_s)), .awake) catch return; 45 logfire.err("builder: exceeded {d}s wall-clock timeout; terminating", .{timeout_s}); 46 std.process.exit(1); 47} 48 49pub fn run(allocator: Allocator, io: Io) !void { 50 const started_s: i64 = @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_s)); 51 const timeout_s = parseTimeoutSeconds(getenv("BUILDER_TIMEOUT_SECS")); 52 const watchdog = try std.Thread.spawn(.{}, timeoutWatchdog, .{ io, timeout_s }); 53 watchdog.detach(); 54 logfire.info("builder: watchdog armed ({d}s)", .{timeout_s}); 55 56 const channel: Channel = blk: { 57 const raw = getenv("BUILDER_CHANNEL") orelse "staging"; 58 if (std.mem.eql(u8, raw, "prod")) { 59 if (getenv("BUILDER_ALLOW_PROD") == null) { 60 logfire.err("builder: BUILDER_CHANNEL=prod requires BUILDER_ALLOW_PROD=1", .{}); 61 return error.ProdNotArmed; 62 } 63 break :blk .prod; 64 } 65 break :blk .staging; 66 }; 67 68 const build_dir = getenv("BUILD_DIR") orelse "/tmp/leaflet-build"; 69 _ = std.c.mkdir(@ptrCast((std.fmt.allocPrintSentinel(allocator, "{s}", .{build_dir}, 0) catch return error.OutOfMemory).ptr), 0o755); 70 71 var id_buf: [48]u8 = undefined; 72 const nanos: u64 = @intCast(@mod(Io.Timestamp.now(io, .real).nanoseconds, 65536)); 73 const build_id = try std.fmt.bufPrint(&id_buf, "b{d}-{x:0>4}", .{ started_s, nanos }); 74 75 const db_path = try std.fmt.allocPrint(allocator, "{s}/replica.db", .{build_dir}); 76 const manifest_path = try std.fmt.allocPrint(allocator, "{s}/manifest.json", .{build_dir}); 77 78 // fresh scratch file every run — stale partial builds must not leak in 79 for ([_][]const u8{ "", "-wal", "-shm" }) |suffix| { 80 const path = try std.fmt.allocPrintSentinel(allocator, "{s}{s}", .{ db_path, suffix }, 0); 81 _ = std.c.unlink(path.ptr); 82 } 83 84 logfire.info("builder: starting build {s} (channel={s}) -> {s}", .{ build_id, @tagName(channel), db_path }); 85 86 try db.initTurso(io); 87 const turso = db.getClient() orelse return error.TursoNotInitialized; 88 89 // watermark BEFORE paging: docs indexed mid-build land in the next 90 // snapshot; the overlay/promote side uses this as its freshness cutoff 91 const watermark = blk: { 92 var result = try turso.query("SELECT COALESCE(MAX(indexed_at), '') FROM documents", &.{}); 93 defer result.deinit(); 94 if (result.first()) |row| { 95 break :blk try allocator.dupe(u8, row.text(0)); 96 } 97 break :blk try allocator.dupe(u8, ""); 98 }; 99 100 var local = LocalDb.init(allocator, io); 101 try local.openAt(db_path); 102 103 const counts = try sync.buildSnapshot(turso, &local, watermark); 104 logfire.info("builder: built {d} docs, {d} pubs, {d} tags, {d} popular, {d} recommends, {d} subscriptions", .{ 105 counts.documents, counts.publications, counts.tags, counts.popular, counts.recommends, counts.subscriptions, 106 }); 107 108 // ------------------------------------------------------------------ 109 // verification gates: refuse to publish a snapshot that fails any 110 // ------------------------------------------------------------------ 111 const conn = local.getConn() orelse return error.LocalNotOpen; 112 113 // sync_meta: a server adopting this snapshot (or offline catchup) resumes 114 // incrementally from the build start, not from epoch 115 { 116 var ts_buf: [20]u8 = undefined; 117 const ts_str = std.fmt.bufPrint(&ts_buf, "{d}", .{started_s}) catch "0"; 118 try conn.exec("INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('last_sync', ?)", .{ts_str}); 119 } 120 121 // gate 1: row count vs turso, both sides pinned to the watermark + the 122 // same policy filters. The set is immutable below the watermark, so any 123 // mismatch beyond deletes-during-build is a real bug; keep a small 124 // tolerance for those deletes only. 125 { 126 logfire.info("builder: phase=verify_remote", .{}); 127 var result = try turso.query(sync.BUILD_DOC_COUNT_SQL, &.{watermark}); 128 defer result.deinit(); 129 const expected: usize = if (result.first()) |row| @intCast(row.int(0)) else 0; 130 const tolerance = @max(expected / 1000, 10); // 0.1%, floor 10 — deletes mid-build only 131 const diff = if (expected > counts.documents) expected - counts.documents else counts.documents - expected; 132 if (expected == 0 or diff > tolerance) { 133 logfire.err("builder: GATE FAIL doc count: built {d}, turso expects {d} (±{d})", .{ counts.documents, expected, tolerance }); 134 return error.DocCountGate; 135 } 136 } 137 138 // gate 2: FTS answers a query 139 { 140 const row = try conn.row("SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH 'the'", .{}); 141 if (row) |r| { 142 defer r.deinit(); 143 if (r.int(0) == 0) { 144 logfire.err("builder: GATE FAIL fts sentinel returned 0 rows", .{}); 145 return error.FtsSentinelGate; 146 } 147 } else return error.FtsSentinelGate; 148 } 149 150 // leaving WAL mode requires being the ONLY connection — close LocalDb's 151 // write conn + read pool first, then compact on a fresh solo connection 152 local.deinit(); 153 154 { 155 logfire.info("builder: phase=compact", .{}); 156 const path_z = try std.fmt.allocPrintSentinel(allocator, "{s}", .{db_path}, 0); 157 const solo = try zqlite.open(path_z.ptr, zqlite.OpenFlags.ReadWrite); 158 defer solo.close(); 159 160 // compact to a single file: checkpoint WAL, drop to DELETE journal, 161 // VACUUM, then back to WAL (serving re-asserts WAL at open anyway) 162 try solo.exec("PRAGMA wal_checkpoint(TRUNCATE)", .{}); 163 try solo.exec("PRAGMA journal_mode=DELETE", .{}); 164 try solo.exec("VACUUM", .{}); 165 try solo.exec("PRAGMA journal_mode=WAL", .{}); 166 try solo.exec("PRAGMA wal_checkpoint(TRUNCATE)", .{}); 167 168 // gate 3: page-structure integrity on the final file 169 const row = try solo.row("PRAGMA quick_check", .{}); 170 if (row) |r| { 171 defer r.deinit(); 172 if (!std.mem.eql(u8, r.text(0), "ok")) { 173 logfire.err("builder: GATE FAIL quick_check: {s}", .{r.text(0)}); 174 return error.QuickCheckGate; 175 } 176 } else return error.QuickCheckGate; 177 } 178 179 // hash + size of the exact bytes that will be served 180 var sha_hex: [64]u8 = undefined; 181 const byte_size = try sha256File(allocator, io, db_path, &sha_hex); 182 logfire.info("builder: replica.db {d} bytes sha256={s}", .{ byte_size, sha_hex }); 183 184 const prefix = switch (channel) { 185 .prod => "", 186 .staging => "staging/", 187 }; 188 const snapshot_key = try std.fmt.allocPrint(allocator, "{s}builds/{s}/replica.db", .{ prefix, build_id }); 189 const manifest_key = try std.fmt.allocPrint(allocator, "{s}builds/{s}/manifest.json", .{ prefix, build_id }); 190 const latest_key: []const u8 = switch (channel) { 191 .prod => "latest.json", 192 .staging => "latest.staging.json", 193 }; 194 195 const manifest = try std.fmt.allocPrint(allocator, 196 \\{{ 197 \\ "manifest_version": {d}, 198 \\ "schema_version": {d}, 199 \\ "build_id": "{s}", 200 \\ "channel": "{s}", 201 \\ "snapshot_key": "{s}", 202 \\ "byte_size": {d}, 203 \\ "sha256": "{s}", 204 \\ "source_watermark": "{s}", 205 \\ "doc_count": {d}, 206 \\ "pub_count": {d}, 207 \\ "tag_count": {d}, 208 \\ "rec_count": {d}, 209 \\ "created_at": {d}, 210 \\ "builder_version": "{s}" 211 \\}} 212 \\ 213 , .{ 214 MANIFEST_VERSION, LocalDb.SCHEMA_VERSION, build_id, @tagName(channel), 215 snapshot_key, byte_size, &sha_hex, watermark, 216 counts.documents, counts.publications, counts.tags, counts.recommends, 217 started_s, getenv("BUILDER_VERSION") orelse "dev", 218 }); 219 220 try writeFile(io, manifest_path, manifest); 221 222 if (getenv("SKIP_UPLOAD") != null) { 223 logfire.info("builder: SKIP_UPLOAD set — artifacts left at {s} ({s})", .{ build_dir, build_id }); 224 std.debug.print("builder: done (no upload). replica={s} manifest={s}\n", .{ db_path, manifest_path }); 225 return; 226 } 227 228 const cfg = try r2.configure(allocator, io); 229 logfire.info("builder: phase=upload", .{}); 230 try r2.upload(allocator, io, cfg, db_path, snapshot_key); 231 try r2.upload(allocator, io, cfg, manifest_path, manifest_key); 232 // latest pointer LAST — readers can never see a pointer to a partial build 233 try r2.upload(allocator, io, cfg, manifest_path, latest_key); 234 235 const took_s: i64 = @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_s) - started_s); 236 logfire.info("builder: published {s} to {s} channel ({d} docs, {d}s)", .{ build_id, @tagName(channel), counts.documents, took_s }); 237} 238 239test "builder timeout parser uses a safe nonzero default" { 240 try std.testing.expectEqual(DEFAULT_TIMEOUT_SECS, parseTimeoutSeconds(null)); 241 try std.testing.expectEqual(DEFAULT_TIMEOUT_SECS, parseTimeoutSeconds("")); 242 try std.testing.expectEqual(DEFAULT_TIMEOUT_SECS, parseTimeoutSeconds("nope")); 243 try std.testing.expectEqual(DEFAULT_TIMEOUT_SECS, parseTimeoutSeconds("0")); 244 try std.testing.expectEqual(@as(u64, 90), parseTimeoutSeconds("90")); 245} 246 247fn writeFile(io: Io, path: []const u8, content: []const u8) !void { 248 const file = try Io.Dir.createFileAbsolute(io, path, .{ .truncate = true }); 249 defer file.close(io); 250 var wbuf: [4096]u8 = undefined; 251 var fw = Io.File.Writer.init(file, io, &wbuf); 252 try fw.interface.writeAll(content); 253 try fw.interface.flush(); 254} 255 256/// Streaming sha256 of a file; returns byte count, writes lowercase hex into out. 257fn sha256File(allocator: Allocator, io: Io, path: []const u8, out_hex: *[64]u8) !u64 { 258 _ = allocator; 259 const file = try Io.Dir.openFileAbsolute(io, path, .{}); 260 defer file.close(io); 261 262 var hasher = std.crypto.hash.sha2.Sha256.init(.{}); 263 var total: u64 = 0; 264 var buf: [256 * 1024]u8 = undefined; 265 while (true) { 266 const n = file.readStreaming(io, &.{&buf}) catch |err| switch (err) { 267 error.EndOfStream => break, 268 else => return err, 269 }; 270 if (n == 0) break; 271 hasher.update(buf[0..n]); 272 total += n; 273 } 274 var digest: [32]u8 = undefined; 275 hasher.final(&digest); 276 const hex = std.fmt.bytesToHex(digest, .lower); 277 @memcpy(out_hex, &hex); 278 return total; 279}