Skip to content
zit Artifact [6c3e0ba2cb]
Login

Artifact [6c3e0ba2cb]

Artifact 6c3e0ba2cb9538f7cdc9ccf69796179ec5ef9cc61fba4aa4555c834ba955e1b4:


//! Serve-readiness probe (`zig build bench -- serve [--artifacts N]`).
//!
//! A run-on-demand milestone probe that pins the load-bearing bring-up order of
//! `zit serve` fixed under ticket #13: the listener is opened BEFORE the
//! O(store) prime (`cmd_sync.runServe` — `serve_mod.listen` first, then the
//! config read, then `ctx.baseSnapshot` primes the whole log, then
//! `serve_mod.serve`). A client that connects during the prime queues in the
//! accept backlog rather than being refused, so serve readiness begins at the
//! bind, not after the scan.
//!
//! That ordering CANNOT be pinned by a unit test: reverting it (priming before
//! the bind) leaves the whole suite green — the regression only manifests when
//! prime latency exceeds a client's readiness window, a wall-clock property no
//! in-process assertion sees. So this lives in the compiled-but-manual bench
//! tier (like `zig build smoke`), and defends the reorder empirically:
//!
//!   1. Synthesize a deterministic on-disk `.zit` with `--artifacts` frames,
//!      sized so priming it is a real O(store) cost (>> the connect window).
//!   2. Measure the prime IN-PROCESS over the same store — exactly what the
//!      server pays at `baseSnapshot`: `openReadOnly` + a full content read of
//!      every frame (the `readServedStore`/`loadContentStore` path). This is a
//!      lower bound on the server's own prime (it omits the MemStore inserts),
//!      which is the safe direction for proving the window is real.
//!   3. Spawn the REAL `zit serve --port <ephemeral>` against that store and
//!      measure time-to-first-successful-TCP-connect from the spawn instant, by
//!      retry-connecting in a tight loop (the `waitForListen` shape, but timed).
//!   4. Assert BOTH `prime > prime_floor` (the window genuinely existed — a
//!      trivial prime would make the ratio prove nothing) AND `connect/prime <
//!      connect_fraction` (readiness is early RELATIVE to the prime). The ratio,
//!      not an absolute ms bound, is the discriminator: cold child-spawn latency
//!      (fork+exec+runtime init+discoverRoot) alone can exceed any small
//!      absolute threshold yet is still a tiny fraction of an O(store) prime, and
//!      has nothing to do with the ordering. Fail (exit non-zero) if either
//!      breaches; the reverted prime-before-listen order opens the listener only
//!      AFTER the whole prime, so connect ≈ spawn+prime and the ratio jumps to
//!      ≈1.0, breaching the fraction by a wide margin.
//!
//! Determinism: corpus bytes derive solely from a fixed seed and the frame
//! index (same generator as logstore_benchmark). The only ambient effects are
//! the monotonic clock (`Io.Clock.awake`) and the temp store/child process,
//! all reached through the injected `std.Io`. ReleaseFast (the bench module is
//! always optimized), matching the optimization the spawned server runs under.

const std = @import("std");
const Io = std.Io;
const net = std.Io.net;
const build_options = @import("build_options");
const storage = @import("zit").storage;
const logstore = @import("zit").logstore;

/// Closed harness error set (never `anyerror`). Store faults fold in from the
/// LogStore; `BadArgs` is a rejected flag; `Spawn`/`ServerNeverBound` are the
/// child-process seam; `ProbeTooSlow`/`PrimeTooFast` are the two probe verdicts
/// (connect over threshold, prime under floor). `RunError` adds the writer's
/// own error set for reporting.
pub const Error = error{
    OutOfMemory,
    Storage,
    Corrupt,
    BadArgs,
    Spawn,
    ServerNeverBound,
    ProbeTooSlow,
    PrimeTooFast,
};
pub const RunError = Error || Io.Writer.Error;

const ns_per_ms: u64 = 1_000_000;

/// Default frame count: chosen so the prime is comfortably a few hundred ms —
/// well above `prime_floor` — on a modern machine, giving the reorder a real
/// window to defend. Overridable with `--artifacts`.
const default_artifacts: u64 = 60_000;
const min_artifacts: u64 = 1;
/// TigerStyle upper bound (matches sync_benchmark's cap); an overflow is BadArgs.
const max_artifacts: u64 = 1_000_000;

/// Per-frame blob width. Fixed (not a flag — the probe measures readiness, not
/// throughput): 4 KiB × 60k ≈ 240 MB of frames, enough scan + content work to
/// keep the prime firmly above the floor. The 8-byte index prefix in `fillBlob`
/// guarantees distinct content, hence distinct hashes.
const blob_len: usize = 4096;
const hash_len: usize = 32;
const seed: u64 = 42;
/// fsync cadence while building — bounds the number of syncs (60 for the
/// default) without leaving the durable tail torn for the read-back.
const commit_every: u64 = 1000;

/// The reorder guard, RELATIVE to prime: a ready server's connect time must be
/// a small FRACTION of its prime. Under the fixed bind-before-prime order the
/// listener is up before any O(store) work, so connect is only child-spawn
/// latency — a few percent of the prime. Under the reverted prime-before-bind
/// order the listener opens only AFTER the whole prime, so connect ≈ spawn+prime
/// and the ratio is ≈1.0. 0.5 passes the fixed order with an order of magnitude
/// of margin (observed ratios ~0.01–0.1) and fails the revert decisively. An
/// absolute ms bound is NOT usable here: cold fork+exec+runtime-init alone can
/// exceed a few hundred ms while remaining a tiny fraction of the prime.
const connect_fraction: f64 = 0.5;
/// The floor the measured prime must clear for the probe to be non-vacuous: it
/// proves the accept-backlog window the ratio relies on was real and large. A
/// trivial prime would make the ratio meaningless (any spawn latency would
/// dominate a near-zero denominator), so a prime under this floor is reported as
/// an inconclusive run, not a pass.
const prime_floor_ns: u64 = 250 * ns_per_ms;

/// Dispatched from logstore_benchmark's `run` when argv[1] == "serve". `args`
/// is the whole process argv; serve's flags begin at index 2.
pub fn run(gpa: std.mem.Allocator, io: Io, args: []const []const u8, out: *Io.Writer) RunError!void {
    const artifacts = try parseArgs(args);

    var repo = try TempRepo.create(io);
    defer repo.cleanup(io);
    try buildStore(gpa, io, repo.zitPath(), artifacts);

    // Prime first (child not yet spawned): under the FIXED order the child's own
    // prime happens after it accepts connections, so this in-process pre-read
    // never affects the measured connect time. It captures the O(store) window.
    const prime_ns = try measurePrime(gpa, io, repo.zitPath());
    // Then the real server: time-to-first-connect from the spawn instant.
    const connect_ns = try measureConnect(gpa, io, repo.dirPath());

    // Emit and flush BEFORE the verdict returns: `main` exits without flushing
    // `out` on an error, so the three numbers (and the ratio) must already be on
    // the wire.
    const ratio = ratioOf(connect_ns, prime_ns);
    try out.print(
        "# serve readiness probe (#13): listener opens BEFORE the O(store) prime\n" ++
            "serve artifacts={d} blob={d}: connect={d:.2}ms prime={d:.1}ms ratio={d:.3}" ++
            " (non-vacuity: prime>{d}ms, reorder guard: connect/prime<{d:.2})\n",
        .{
            artifacts,          blob_len,
            nsToMs(connect_ns), nsToMs(prime_ns),
            ratio,              prime_floor_ns / ns_per_ms,
            connect_fraction,
        },
    );
    try out.flush();

    // Non-vacuity first: a prime under the floor means the store was too small to
    // prove anything, so the ratio carries no signal — report that.
    if (prime_ns <= prime_floor_ns) return error.PrimeTooFast;
    // The reorder guard, RELATIVE to prime: under the fixed order connect is a
    // small fraction of prime (listener up early, clients queue in the backlog);
    // under the reverted order the listener opens only after the whole prime, so
    // connect ≈ spawn+prime and the ratio reaches ≈1.0. The fraction — not an
    // absolute ms bound — is the true discriminator (cold child-spawn latency
    // alone would false-positive an absolute threshold).
    if (ratio >= connect_fraction) return error.ProbeTooSlow;

    try out.writeAll("serve: PASS (listener ready well within the prime window)\n");
    try out.flush();
}

fn parseArgs(args: []const []const u8) Error!u64 {
    std.debug.assert(args.len >= 2);
    var artifacts: u64 = default_artifacts;
    var i: usize = 2;
    while (i < args.len) : (i += 1) {
        if (std.mem.eql(u8, args[i], "--artifacts")) {
            i += 1;
            if (i >= args.len) return error.BadArgs; // flag with no value
            artifacts = std.fmt.parseInt(u64, args[i], 0) catch return error.BadArgs;
        } else return error.BadArgs;
    }
    if (artifacts < min_artifacts or artifacts > max_artifacts) return error.BadArgs;
    return artifacts;
}

// ---------------------------------------------------------------------------
// Deterministic corpus (pure — no clock, no ambient entropy). Same generator
// as logstore_benchmark: bytes derive from (seed, index) alone.
// ---------------------------------------------------------------------------

fn fillBlob(index: u64, buf: []u8) void {
    std.debug.assert(buf.len >= 8);
    var mix = std.hash.Wyhash.init(seed);
    mix.update(std.mem.asBytes(&index));
    var prng = std.Random.DefaultPrng.init(mix.final());
    prng.random().bytes(buf);
    std.mem.writeInt(u64, buf[0..8], index, .little);
}

fn hashBlob(buf: []const u8) [hash_len]u8 {
    var h: [hash_len]u8 = undefined;
    std.crypto.hash.sha2.Sha256.hash(buf, &h, .{});
    return h;
}

// ---------------------------------------------------------------------------
// Temp repository: a throwaway `/tmp/zit-serve-bench-<nonce>/` directory holding
// one `.zit`, so a spawned `zit serve` with that cwd discovers it. Named from
// the monotonic clock so concurrent runs never collide; removed on cleanup.
// ---------------------------------------------------------------------------

const TempRepo = struct {
    dir_buf: [64]u8,
    dir_len: usize,
    zit_buf: [72]u8,
    zit_len: usize,
    chk_buf: [80]u8,
    chk_len: usize,

    fn create(io: Io) Error!TempRepo {
        var self: TempRepo = undefined;
        const nonce = std.Io.Clock.now(.awake, io).nanoseconds;
        const d = std.fmt.bufPrint(&self.dir_buf, "/tmp/zit-serve-bench-{d}", .{nonce}) catch return error.BadArgs;
        self.dir_len = d.len;
        std.Io.Dir.createDirAbsolute(io, self.dirPath(), .default_dir) catch return error.Storage;
        const z = std.fmt.bufPrint(&self.zit_buf, "{s}/.zit", .{self.dirPath()}) catch return error.BadArgs;
        self.zit_len = z.len;
        // The checkout marker `.zit-checkout` — `cli.discoverRoot` requires BOTH
        // it and `.zit` present before it will call a directory a repository
        // root. `serve` never READS it (it opens only `.zit`), so an empty marker
        // is enough to make the served store discoverable.
        const c = std.fmt.bufPrint(&self.chk_buf, "{s}/.zit-checkout", .{self.dirPath()}) catch return error.BadArgs;
        self.chk_len = c.len;
        var chk = std.Io.Dir.createFileAbsolute(io, self.checkoutPath(), .{}) catch return error.Storage;
        chk.close(io);
        return self;
    }

    fn dirPath(self: *const TempRepo) []const u8 {
        return self.dir_buf[0..self.dir_len];
    }

    fn zitPath(self: *const TempRepo) []const u8 {
        return self.zit_buf[0..self.zit_len];
    }

    fn checkoutPath(self: *const TempRepo) []const u8 {
        return self.chk_buf[0..self.chk_len];
    }

    fn cleanup(self: *TempRepo, io: Io) void {
        std.Io.Dir.deleteFileAbsolute(io, self.zitPath()) catch {};
        std.Io.Dir.deleteFileAbsolute(io, self.checkoutPath()) catch {};
        std.Io.Dir.deleteDirAbsolute(io, self.dirPath()) catch {};
    }
};

/// Write `artifacts` distinct raw frames into a fresh `.zit` and fsync. Reuses
/// the production LogStore append path (`put` hardcodes `.raw`), so the store is
/// byte-for-byte what `zit serve`'s read-only scanner opens. `hash_kind` 0 on a
/// fresh file is a don't-care (no config frame is written; `serve` falls back to
/// its placeholder project code — see `runServe`).
fn buildStore(gpa: std.mem.Allocator, io: Io, zit_path: []const u8, artifacts: u64) Error!void {
    var file = std.Io.Dir.createFileAbsolute(io, zit_path, .{ .read = true }) catch return error.Storage;
    defer file.close(io);
    var fs = storage.FileStorage.init(io, file, 0);
    var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch |e| return mapOpen(e);
    defer log.deinit(gpa);

    var blob: [blob_len]u8 = undefined;
    const n: usize = @intCast(artifacts);
    for (0..n) |i| {
        fillBlob(@intCast(i), &blob);
        const h = hashBlob(&blob);
        _ = log.put(&h, &blob) catch |e| return mapPut(e);
        if ((i + 1) % commit_every == 0) log.commit() catch return error.Storage;
    }
    log.commit() catch return error.Storage;
}

/// The server's startup prime, measured in-process over the same store: the
/// exact O(store) work `runServe` does at `ctx.baseSnapshot` — `openReadOnly`
/// (header validate + full-frame scan + index rebuild) then a `content()` read
/// of every frame (`readServedStore`/`loadContentStore`). It omits the MemStore
/// inserts, so it is a LOWER bound on the server's real prime — the safe
/// direction for proving the readiness window is genuinely large.
fn measurePrime(gpa: std.mem.Allocator, io: Io, zit_path: []const u8) Error!u64 {
    var file = std.Io.Dir.openFileAbsolute(io, zit_path, .{ .mode = .read_only }) catch return error.Storage;
    defer file.close(io);
    const st = file.stat(io) catch return error.Storage;

    const t0 = std.Io.Clock.now(.awake, io).nanoseconds;
    var fs = storage.FileStorage.init(io, file, st.size);
    var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| return mapOpen(e);
    defer log.deinit(gpa);
    const rids = log.ridsInPersistedOrder(gpa) catch return error.OutOfMemory;
    defer gpa.free(rids);
    for (rids) |rid| {
        const maybe = log.content(gpa, rid) catch |e| return mapContent(e);
        if (maybe) |bytes| gpa.free(bytes);
    }
    const t1 = std.Io.Clock.now(.awake, io).nanoseconds;
    return diffNs(t0, t1);
}

/// Spawn the real `zit serve --port <ephemeral>` against the store at `dir_path`
/// and return the elapsed nanoseconds from the spawn instant to the first
/// successful loopback TCP connect. A successful connect completes on the listen
/// backlog — before `accept`, and (with the fixed order) before the prime — so
/// this is exactly the serve-readiness latency #13 pins.
fn measureConnect(gpa: std.mem.Allocator, io: Io, dir_path: []const u8) Error!u64 {
    const port = freshLoopbackPort(io) catch return error.ServerNeverBound;
    const addr = net.IpAddress.parseIp4("127.0.0.1", port) catch return error.ServerNeverBound;

    // Parent-side prep is NOT part of serve readiness — build argv/env, then
    // start the clock immediately before the OS spawn.
    var env = std.process.Environ.Map.init(gpa);
    defer env.deinit();
    env.put("TZ", "UTC") catch return error.Spawn;
    env.put("HOME", "/zit-serve-bench-isolated-home-does-not-exist") catch return error.Spawn;
    env.put("PATH", "/usr/bin:/bin") catch return error.Spawn;

    const cwd_abs = std.process.currentPathAlloc(io, gpa) catch return error.Spawn;
    defer gpa.free(cwd_abs);
    const abs_exe = std.fs.path.resolve(gpa, &.{ cwd_abs, build_options.zit_exe }) catch return error.OutOfMemory;
    defer gpa.free(abs_exe);
    const port_str = std.fmt.allocPrint(gpa, "{d}", .{port}) catch return error.OutOfMemory;
    defer gpa.free(port_str);

    const t0 = std.Io.Clock.now(.awake, io).nanoseconds;
    var child = std.process.spawn(io, .{
        .argv = &.{ abs_exe, "serve", "--port", port_str },
        .cwd = .{ .path = dir_path },
        .environ_map = &env,
        .stdout = .ignore,
        .stderr = .ignore,
    }) catch return error.Spawn;
    defer child.kill(io);

    // Retry-connect until the child binds. A short sleep between attempts YIELDS
    // the core: a tight busy-loop pins one CPU at 100% and starves the very child
    // we are timing, inflating the measured connect time by seconds under load.
    // The 0.5 ms poll keeps resolution two orders of magnitude finer than the
    // 150 ms threshold while letting the child make progress. The deadline is the
    // runaway backstop if the server never binds (never hit by a live serve).
    const poll_ns: u64 = 500_000; // 0.5 ms
    const deadline_ns: i96 = 30_000_000_000; // 30 s
    while (true) {
        if (addr.connect(io, .{ .mode = .stream })) |stream| {
            const t1 = std.Io.Clock.now(.awake, io).nanoseconds;
            stream.close(io);
            return diffNs(t0, t1);
        } else |_| {}
        if (std.Io.Clock.now(.awake, io).nanoseconds - t0 > deadline_ns) return error.ServerNeverBound;
        io.sleep(.{ .nanoseconds = poll_ns }, .awake) catch {};
    }
}

/// Reserve a kernel-assigned ephemeral loopback port, then release it for the
/// child to bind. The close→child-bind race is unavoidable (a child server
/// cannot inherit the listener) and harmless: the retry-connect loop tolerates
/// the brief pre-bind window. Same pattern as smoke/e2e's `freshLoopbackPort`.
fn freshLoopbackPort(io: Io) !u16 {
    const addr = try net.IpAddress.parseIp4("127.0.0.1", 0);
    var server = try addr.listen(io, .{});
    const port = server.socket.address.getPort();
    server.deinit(io);
    if (port == 0) return error.NoLoopbackPort;
    return port;
}

// ---------------------------------------------------------------------------
// Timing + error mapping
// ---------------------------------------------------------------------------

/// Monotonic (`.awake`) so a negative delta is impossible; clamp to 0 anyway
/// rather than assert, so a clock quirk degrades a number, never crashes a probe.
fn diffNs(t0: i96, t1: i96) u64 {
    const d = t1 - t0;
    return if (d < 0) 0 else @intCast(d);
}

fn nsToMs(ns: u64) f64 {
    return @as(f64, @floatFromInt(ns)) / 1_000_000.0;
}

/// connect/prime as a fraction. A zero prime (guarded separately by the floor)
/// yields +inf rather than a NaN/crash, so the ratio still prints and the floor
/// check fires the inconclusive verdict.
fn ratioOf(connect_ns: u64, prime_ns: u64) f64 {
    if (prime_ns == 0) return std.math.inf(f64);
    return @as(f64, @floatFromInt(connect_ns)) / @as(f64, @floatFromInt(prime_ns));
}

fn mapOpen(e: logstore.OpenError) Error {
    return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.Storage => error.Storage,
        error.Corrupt => error.Corrupt,
    };
}

fn mapContent(e: logstore.ContentError) Error {
    return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.Storage => error.Storage,
        error.Corrupt => error.Corrupt,
    };
}

fn mapPut(e: logstore.PutError) Error {
    return switch (e) {
        error.OutOfMemory => error.OutOfMemory,
        error.Storage => error.Storage,
    };
}

// ---------------------------------------------------------------------------
// Tests (Debug, wired into `zig build test` via logstore_benchmark's test
// block): the corpus generator is pure and deterministic, and the two verdict
// thresholds must stay ordered so the probe's PASS window is real. No timing
// loop, no child process, and no filesystem touch run under `test`.
// ---------------------------------------------------------------------------

test "serve bench corpus: one index reproduces identical blobs; distinct indices give distinct hashes" {
    var a: [blob_len]u8 = undefined;
    var b: [blob_len]u8 = undefined;

    fillBlob(7, &a);
    fillBlob(7, &b);
    try std.testing.expectEqualSlices(u8, &a, &b);

    fillBlob(8, &b);
    try std.testing.expect(!std.mem.eql(u8, &a, &b));
    try std.testing.expect(!std.mem.eql(u8, &hashBlob(&a), &hashBlob(&b)));
}

test "serve bench guard: the connect/prime ratio passes the fixed order and fails the revert" {
    // The fraction must leave real room on both sides — not a coincidental bound.
    try std.testing.expect(connect_fraction > 0.0 and connect_fraction < 1.0);

    // Fixed bind-before-prime order: connect is only child-spawn latency, a small
    // fraction of an O(store) prime — comfortably under the fraction even when the
    // spawn is unusually slow (576 ms spawn against a 6.2 s prime -> ~0.093).
    const fixed_ratio = ratioOf(576 * ns_per_ms, 6225 * ns_per_ms);
    try std.testing.expect(fixed_ratio < connect_fraction);

    // Reverted prime-before-bind order: the listener opens only AFTER the whole
    // prime, so connect ≈ spawn + prime and the ratio exceeds 1.0 — well past the
    // fraction, so the guard fires.
    const reverted_ratio = ratioOf((6225 + 60) * ns_per_ms, 6225 * ns_per_ms);
    try std.testing.expect(reverted_ratio >= connect_fraction);
    try std.testing.expect(reverted_ratio > 1.0);
}