From 6f32118bf6412a9380ed469daaa9b0465f3b1f6c Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 13 Jul 2026 19:52:25 +0700 Subject: [PATCH 01/46] adding gitignore --- frameworks/zix/.gitignore | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 frameworks/zix/.gitignore diff --git a/frameworks/zix/.gitignore b/frameworks/zix/.gitignore new file mode 100644 index 000000000..595d20d1a --- /dev/null +++ b/frameworks/zix/.gitignore @@ -0,0 +1,4 @@ +.zig-cache +zig-out +zig-package +vendor From 9e02a613a1de9fbc6a7a61521adba453161f4cf8 Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 13 Jul 2026 20:00:41 +0700 Subject: [PATCH 02/46] adjusting dockerfile --- frameworks/zix/Dockerfile | 79 ++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/frameworks/zix/Dockerfile b/frameworks/zix/Dockerfile index 692929f64..8f10fe543 100644 --- a/frameworks/zix/Dockerfile +++ b/frameworks/zix/Dockerfile @@ -4,66 +4,69 @@ FROM alpine:3.20 AS build ARG RETRY=6 ARG TARGETARCH ARG RETRY_DELAY=3 +ARG TIMEOUT_SEC=180 ARG ZIG_VERSION=0.16.0 -ARG ZIX_VERSION=0.4.x-rc3 -RUN apk add --no-cache ca-certificates curl git tar xz +ARG ZIX_VERSION=0.5.x-rc1 +RUN apk add --no-cache ca-certificates curl git tar xz openssl +WORKDIR /server RUN set -eu; \ case "${TARGETARCH:-amd64}" in \ amd64) ZIG_ARCH=x86_64 ;; \ arm64) ZIG_ARCH=aarch64 ;; \ *) echo "unsupported arch: ${TARGETARCH}" >&2; exit 1 ;; \ esac; \ - curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}.tar.xz" \ + curl -fSL -m ${TIMEOUT_SEC} "https://ziglang.org/download/${ZIG_VERSION}/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}.tar.xz" \ | tar -xJ -C /opt; \ mv "/opt/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}" /opt/zig ENV PATH="/opt/zig:${PATH}" -# Vendor zix X.Y.Z, separate layer so source-only rebuilds skip the fetch. -# The Http1 raw engine work this image needs (large-body drain plus the per-worker -# response cache used by the /json endpoint) must be present on the X.Y.Z branch. -# Four ordered attempts before giving up: curl the archive tarball from github then -# codeberg, then a shallow git clone from github then codeberg. The github archive -# redirects to codeload.github.com (which the benchmark runner may not resolve), so -# curl can fall through to the codeberg tarball, and git clone talks to github.com -# and codeberg.org directly as the deeper fallback. RETRY and RETRY_DELAY bound -# every attempt. +COPY build.zig build.zig.zon ./ +COPY src ./src + +# Resolve zix with zig fetch (hash-verified branch tarball, no git), +# codeberg first then github, RETRY attempts each. +# The shipped build.zig.zon points .zix at vendor/zix for local staging: +# this step swaps that one line for the fetched url + hash, +# so a remote build needs no vendor directory. RUN set -eu; \ - fetch() { \ - rm -rf /src/vendor/zix; mkdir -p /src/vendor/zix; \ - curl -fsSL --retry ${RETRY} --retry-delay ${RETRY_DELAY} --retry-all-errors "$1" -o /tmp/zix.tar.gz \ - && tar -xz --strip-components=1 -C /src/vendor/zix -f /tmp/zix.tar.gz; \ - }; \ - clone() { \ - attempt=0; \ - while [ "${attempt}" -lt "${RETRY}" ]; do \ - rm -rf /src/vendor/zix; \ - git clone --depth 1 --branch "${ZIX_VERSION}" "$1" /src/vendor/zix && return 0; \ + fetch_zix() { \ + url="$1"; attempt=1; \ + while [ "${attempt}" -le "${RETRY}" ]; do \ + if hash="$(zig fetch "${url}")"; then \ + sed -i "s|\.path = \"vendor/zix\",|.url = \"${url}\", .hash = \"${hash}\",|" build.zig.zon; \ + return 0; \ + fi; \ + echo "zix: ${url} attempt ${attempt}/${RETRY} failed" >&2; \ attempt=$((attempt + 1)); \ - sleep "${RETRY_DELAY}"; \ + [ "${attempt}" -le "${RETRY}" ] && sleep "${RETRY_DELAY}"; \ done; \ return 1; \ }; \ - fetch "https://github.com/prothegee/zix/archive/refs/heads/${ZIX_VERSION}.tar.gz" \ - || { echo "FAILED: curl ${RETRY} times from github" >&2; \ - fetch "https://codeberg.org/prothegee/zix/archive/${ZIX_VERSION}.tar.gz" \ - || { echo "FAILED: curl ${RETRY} times from codeberg" >&2; \ - clone "https://github.com/prothegee/zix.git" \ - || { echo "FAILED: git clone ${RETRY} times from github" >&2; \ - clone "https://codeberg.org/prothegee/zix.git" \ - || { echo "FAILED: git clone ${RETRY} times from codeberg" >&2; exit 1; }; }; }; } + fetch_zix "https://codeberg.org/prothegee/zix/archive/${ZIX_VERSION}.tar.gz" \ + || { echo "zix: codeberg exhausted ${RETRY} attempts, trying github" >&2; \ + fetch_zix "https://github.com/prothegee/zix/archive/refs/heads/${ZIX_VERSION}.tar.gz"; } \ + || { echo "zix: github exhausted ${RETRY} attempts" >&2; exit 1; } -WORKDIR /src -COPY build.zig build.zig.zon ./ -COPY src ./src +# +aes+pclmul: x86_64_v3 omits AES-NI / PCLMUL, +# so zix TLS would compile the ~40x slower software AES-GCM. +# +adx speeds the RSA / Montgomery path. Every x86_64_v3 CPU has them, so it is safe. RUN set -eu; \ case "${TARGETARCH:-amd64}" in \ amd64) ZIG_TARGET=x86_64-linux-musl; ZIG_CPU=x86_64_v3 ;; \ arm64) ZIG_TARGET=aarch64-linux-musl; ZIG_CPU=baseline ;; \ esac; \ - zig build -Dtarget="${ZIG_TARGET}" -Dcpu="${ZIG_CPU}" --release=fast + zig build -Dtarget="${ZIG_TARGET}" -Dcpu="${ZIG_CPU}+aes+pclmul+adx" --release=fast + +# Self-signed Ed25519 cert generated at image build, baked at /etc/zix-tls. +RUN set -eu; \ + mkdir -p /etc/zix-tls; \ + openssl genpkey -algorithm ED25519 -out /etc/zix-tls/server.key; \ + openssl req -new -x509 -key /etc/zix-tls/server.key -out /etc/zix-tls/server.crt \ + -days 3650 -subj "/CN=localhost" FROM alpine:3.20 -COPY --from=build /src/zig-out/bin/zix /zix -EXPOSE 8080 -ENTRYPOINT ["/zix"] +COPY --from=build /server/zig-out/bin/zix-http1 /zix-http1 +COPY --from=build /etc/zix-tls /etc/zix-tls +EXPOSE 8080 8081 +ENTRYPOINT ["/zix-http1"] From 09f31f77411de0260429b95386aaea39b0ec784c Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 13 Jul 2026 20:01:00 +0700 Subject: [PATCH 03/46] matching dockerfile output --- frameworks/zix/build.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frameworks/zix/build.zig b/frameworks/zix/build.zig index 5276d7f96..97f1f57a1 100644 --- a/frameworks/zix/build.zig +++ b/frameworks/zix/build.zig @@ -8,7 +8,7 @@ pub fn build(b: *std.Build) void { const zix_mod = zix_dep.module("zix"); const exe = b.addExecutable(.{ - .name = "zix", + .name = "zix-http1", .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, From cef2a0f9d178b999f5fef32d5df7c04280b0a162 Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 13 Jul 2026 20:01:40 +0700 Subject: [PATCH 04/46] redefined engine and description --- frameworks/zix/meta.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frameworks/zix/meta.json b/frameworks/zix/meta.json index b031b6910..d096dcd2b 100644 --- a/frameworks/zix/meta.json +++ b/frameworks/zix/meta.json @@ -2,8 +2,8 @@ "display_name": "zix", "language": "Zig", "type": "engine", - "engine": "zix", - "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine (no std.http). Shared-nothing: each worker runs its own SO_REUSEPORT multishot accept plus io_uring completion loop and owns its connections. The /json endpoint serves from the per-worker response cache, and request bodies larger than the read buffer are drained rather than buffered.", + "engine": "zix.Http1 URING Dispatch Model", + "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine, .URING dispatch: per-worker SO_REUSEPORT multishot accept plus io_uring loop, per-worker /json cache, every route through the parser and comptime Router (pipeline responses coalesce via the staged send sink), gzip on Accept-Encoding, prewarmed static cache with .br/.gz negotiation. Dual listener (config.tls_port): cleartext 8080 plus https TLS 1.3 on 8081, one process.", "repo": "https://github.com/prothegee/zix", "enabled": true, "tests": [ From b828a6c4fdb3d25f3ab9ab1f5aee5266e2fe5bcb Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 13 Jul 2026 20:05:27 +0700 Subject: [PATCH 05/46] minimizing entry and add adjustment --- frameworks/zix/src/main.zig | 536 ++++-------------------------------- 1 file changed, 61 insertions(+), 475 deletions(-) diff --git a/frameworks/zix/src/main.zig b/frameworks/zix/src/main.zig index 90fe1bd22..ea47e04ef 100644 --- a/frameworks/zix/src/main.zig +++ b/frameworks/zix/src/main.zig @@ -1,515 +1,101 @@ //! HttpArena: zix //! -//! zix HttpArena HTTP/1.1 entry point. +//! Router-only variant: Server.init, no raw-byte interceptor. Every request, +//! /pipeline included, goes through the engine's parser and the comptime +//! Router. Pipelined correctness comes from the engine's staged response +//! sink: each handler write appends to the per-connection send buffer in +//! request order and the batch flushes as one on-ring send, so responses +//! coalesce without hand-rolled request-line scanning. //! -//! Intent: demonstrate zix.Http1 (EPOLL dispatch model) against the HttpArena -//! HTTP/1.1 benchmark suite (baseline, pipelined, short-lived). +//! Knobs carry the attempt-2b sweep result: busy-poll 16 us, warm idle pool +//! 256 floor / 1024 ceiling, 8 KiB recv buffer (deep pipelined batches parse +//! from one fill), 16 KiB send buffer. //! -//! Design choices: -//! - rawIntercept: called before any header parsing for each EPOLL request. -//! Handles /pipeline with zero parse overhead (direct byte-match + sink write), -//! direct byte-match before any parsing, avoiding the header scan loop. Routes that fall -//! through are handled by the Router dispatch with full parsing. -//! - Router: comptime route table (StaticStringMap for EXACT, inline for PREFIX). -//! - PIPELINE_RESP: precomputed response bytes; one sink.append per request, no -//! header build overhead. +//! zix.Http1 (.URING) against the HttpArena HTTP/1.1 suite +//! (baseline, pipelined, limited-conn, json, json-comp, upload, static). +//! json-comp reuses /json and gzips on Accept-Encoding: gzip. +//! json-tls rides the same server through config.tls_port (dual listener): +//! cleartext on PORT plus https (TLS 1.3, baked Ed25519 cert) on TLS_PORT, +//! one worker fleet, one fd table, no second launch. const std = @import("std"); const zix = @import("zix"); + const dataset = @import("dataset.zig"); +const handler = @import("handler.zig"); // --------------------------------------------------------- // +const IP: []const u8 = "::"; const PORT: u16 = 8080; -const LISTEN_IP: []const u8 = "::"; const DISPATCH_MODEL: zix.Http1.DispatchModel = .URING; -const KERNEL_BACKLOG: u31 = 16 * 1024; -/// Per-machine tuning profile (ADR-041 increment 5). The dev box is 12 threads -/// / 32 GB (lean, memory-bound), the competition box is 64 cores / 251 GB -/// (throughput, RAM-abundant). Only the recv buffer differs: workers auto-scale -/// (WORKERS = 0), and the backlog and cache are already sized for both. Select -/// .throughput for the 64-core deployment. -const Profile = enum { lean, throughput }; -const PROFILE: Profile = .lean; -/// Per-connection recv buffer, heap-allocated once at accept time. .lean keeps -/// 4 KiB to hold the working set small (benchmark requests are under 300 bytes, -/// and large upload bodies are drained by the engine in chunks, so only headers, -/// always < 4 KiB, must fit). .throughput raises it to 16 KiB for deeper -/// pipelined batches per recv, which the RAM-abundant box absorbs. -const MAX_RECV_BUF: usize = switch (PROFILE) { - .lean => 4 * 1024, - .throughput => 16 * 1024, -}; -const MAX_HEADERS: u8 = 16; +const MAX_HEADERS: u8 = 8; const WORKERS: usize = 0; -// Response cache (ADR-036), used by the /json endpoint only. The /json body is -// deterministic in (count, m) and large enough to clear the cache crossover -// (~4 KiB), so a hit replays the full response with zero serialization. The -// other endpoints stay below the crossover (baseline, pipeline, upload) or use -// their own zero-copy sendfile cache (static), so none of them enable it. +const SEND_DATE_HEADER: bool = false; +const RESPONSE_CACHE: bool = true; const CACHE_MAX_ENTRIES: u32 = 64; -/// Per-slot cap. A /json/50 response is near 12 KiB, so 32 KiB leaves headroom. const CACHE_MAX_VALUE_BYTES: u32 = 32 * 1024; -/// Freshness window. The dataset is immutable for the process lifetime, so a -/// long TTL means each key is built once and replayed for the whole run. -const CACHE_TTL_MS: u32 = 60 * 1000; - -// Data directory, overridable via the ARENA_DATA env var (default /data, the -// container mount point). Lets the same binary run against a local data tree. -var g_static_base: []const u8 = "/data/static/"; -var g_static_base_buf: [256]u8 = undefined; - -// Per-worker scratch. The JSON body (count up to 50) tops out near 12 KiB; the -// assembled response (status line + headers + body) sits a little above it. -threadlocal var json_body_buf: [32 * 1024]u8 = undefined; -threadlocal var json_resp_buf: [32 * 1024]u8 = undefined; - -// --------------------------------------------------------- // -var g_dataset: dataset.Dataset = undefined; +const TLS_PORT: u16 = 8081; +const TLS_CERT_DEFAULT: []const u8 = "/etc/zix-tls/server.crt"; +const TLS_KEY_DEFAULT: []const u8 = "/etc/zix-tls/server.key"; // --------------------------------------------------------- // -fn notFound(fd: std.posix.fd_t) void { - zix.Http1.writeSimple(fd, 404, "text/plain", "Not Found") catch {}; -} - -fn badRequest(fd: std.posix.fd_t) void { - zix.Http1.writeSimple(fd, 400, "text/plain", "bad request") catch {}; -} - -// --------------------------------------------------------- // - -// GET/POST /baseline11?a=..&b=.. : sum the query values, plus the POST body as -// an integer. Returns the sum as text/plain. -fn baselineHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { - var sum: i64 = sumQuery(head.query); - - if (std.mem.eql(u8, head.method, "POST") and body.len > 0) { - sum += parseIntLoose(body); - } - - var body_buf: [32]u8 = undefined; - const out = std.fmt.bufPrint(&body_buf, "{d}", .{sum}) catch return; - - zix.Http1.writeSimple(fd, 200, "text/plain", out) catch {}; -} - -// Precomputed response for the pipeline endpoint: one memcpy per request into the -// response sink. No header build overhead on the hot path. -const PIPELINE_RESP: []const u8 = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok"; - -// GET /pipeline : fixed tiny response, the pipelined-throughput endpoint. -fn pipelineHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { - _ = head; - _ = body; - - zix.Http1.fdWriteAll(fd, PIPELINE_RESP) catch {}; -} - -// Raw-request interceptor for the EPOLL dispatch model. Called before any header -// parsing on each inbound request. Handles /pipeline with zero parse overhead: -// byte-matches the path directly on rem, then appends PIPELINE_RESP to the -// coalescing RespSink. Unknown -// routes return null and fall through to the Router dispatch with full parsing. -// -// This is intentional benchmark infrastructure, not general HTTP parsing. It -// exploits knowledge that /pipeline is always a bare GET with no body, so the -// consumed length is always header_end + 4 ("\r\n\r\n"). -fn rawIntercept(rem: []const u8, header_end: usize, fd: std.posix.fd_t) ?usize { - // Must start with "GET /p" to qualify for this fast path. - if (rem.len < 24 or rem[0] != 'G' or rem[4] != '/' or rem[5] != 'p') return null; - - // "GET /pipeline " is 15 bytes. Verify without scanning the request line. - if (!std.mem.eql(u8, rem[4..15], "/pipeline ")) return null; - - zix.Http1.fdWriteAll(fd, PIPELINE_RESP) catch {}; - - return header_end + 4; -} - -// GET /json/{count}?m=M : render count dataset items, total = price*qty*M. -// -// Response-cache aware. The body is deterministic in (count, m) and big enough -// to clear the cache crossover, so the full response is the ideal cache value. -// The cache key is hash(method, path, query), so every distinct /json/{count}?m=M -// caches under its own slot. A hit skips the whole build loop and replays the -// stored bytes; a miss builds the response and stores it on the way out. When -// the cache is disabled or full the path still works, it just always rebuilds. -fn jsonHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { - _ = body; - - if (zix.Http1.cacheLookup(head)) |cached| { - zix.Http1.fdWriteAll(fd, cached) catch {}; - return; - } - - const count_str = head.path["/json/".len..]; - const count = std.fmt.parseInt(u8, count_str, 10) catch return badRequest(fd); - if (count < 1 or count > dataset.ItemCount) return badRequest(fd); - - const m: u64 = if (zix.Http1.queryParam(head, "m")) |s| std.fmt.parseInt(u64, s, 10) catch 1 else 1; - - const buf = &json_body_buf; - var pos: usize = 0; - - pos = appendStr(buf, pos, "{\"items\":["); - var i: usize = 0; - while (i < count) : (i += 1) { - if (i > 0) { - buf[pos] = ','; - pos += 1; - } - const item = g_dataset.items[i]; - @memcpy(buf[pos..][0..item.prefix.len], item.prefix); - pos += item.prefix.len; - pos = appendStr(buf, pos, ",\"total\":"); - pos = appendInt(buf, pos, item.pq * m); - buf[pos] = '}'; - pos += 1; - } - pos = appendStr(buf, pos, "],\"count\":"); - pos = appendInt(buf, pos, count); - buf[pos] = '}'; - pos += 1; - - // Assemble the full response so it can be cached and replayed verbatim. The - // header matches the engine's writeJson output (send_date_header is off, so - // there is no time-varying field to freeze in the cache). - const resp = &json_resp_buf; - const hdr = std.fmt.bufPrint(resp, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{pos}) catch { - zix.Http1.writeJson(fd, 200, buf[0..pos]) catch {}; - return; - }; - @memcpy(resp[hdr.len..][0..pos], buf[0..pos]); - - zix.Http1.writeWithCache(fd, head, resp[0 .. hdr.len + pos], CACHE_TTL_MS) catch {}; -} - -// POST /upload : return the received byte count. The Content-Length header is -// authoritative (curl/clients always send it here), and the engine drains the -// body for sizes beyond the read buffer, so this never touches the bytes. -fn uploadHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { - const n: u64 = if (head.content_length > 0) head.content_length else body.len; - - var body_buf: [24]u8 = undefined; - const out = std.fmt.bufPrint(&body_buf, "{d}", .{n}) catch return; - - zix.Http1.writeSimple(fd, 200, "text/plain", out) catch {}; -} - -// --------------------------------------------------------- // - -fn contentType(rel: []const u8) []const u8 { - if (std.mem.endsWith(u8, rel, ".css")) return "text/css"; - if (std.mem.endsWith(u8, rel, ".js")) return "application/javascript"; - if (std.mem.endsWith(u8, rel, ".json")) return "application/json"; - if (std.mem.endsWith(u8, rel, ".html")) return "text/html"; - if (std.mem.endsWith(u8, rel, ".svg")) return "image/svg+xml"; - if (std.mem.endsWith(u8, rel, ".woff2")) return "font/woff2"; - if (std.mem.endsWith(u8, rel, ".webp")) return "image/webp"; - - return "application/octet-stream"; -} - -fn openVariant(rel: []const u8, suffix: []const u8) ?std.posix.fd_t { - var path_buf: [512]u8 = undefined; - const path = std.fmt.bufPrint(&path_buf, "{s}{s}{s}", .{ g_static_base, rel, suffix }) catch return null; - if (path.len >= path_buf.len) return null; - - path_buf[path.len] = 0; - - return std.posix.openatZ(std.posix.AT.FDCWD, @ptrCast(&path_buf), .{ .ACCMODE = .RDONLY }, 0) catch null; -} - -// --------------------------------------------------------- // - -/// Static cache name cap. Fixture names are short, anything longer is a 404. -const STATIC_NAME_MAX = 96; -/// Static cache capacity: 20 fixtures plus room for cached 404 lookups. -const STATIC_CACHE_MAX = 64; - -/// One servable variant of a static file: a fd kept open for the process -/// lifetime, its size, and the pre-rendered response header. -const StaticVariant = struct { - fd: std.posix.fd_t, - size: u64, - hdr_len: u16, - hdr_buf: [192]u8, -}; - -/// Cache slot for one /static/{name} path. All-null variants cache a 404. -const StaticEntry = struct { - name_len: u16, - name_buf: [STATIC_NAME_MAX]u8, - identity: ?StaticVariant, - br: ?StaticVariant, - gz: ?StaticVariant, -}; - -// Append-only cache: readers scan 0..count lock-free (count is published -// with release ordering after the slot is fully written), the spinlock only -// serializes inserts (rare: one per distinct path, all during warmup). -var g_static_entries: [STATIC_CACHE_MAX]StaticEntry = undefined; -var g_static_count: usize = 0; -var g_static_lock: std.atomic.Mutex = .unlocked; - -/// Probe one variant on disk and build its cache record: open, fstat, and -/// pre-render the header so serving it later is send + sendfile only. -fn buildStaticVariant(rel: []const u8, suffix: []const u8, encoding: []const u8) ?StaticVariant { - const file_fd = openVariant(rel, suffix) orelse return null; - - var stx: std.os.linux.Statx = undefined; - const stat_rc = std.os.linux.statx(file_fd, "", std.os.linux.AT.EMPTY_PATH, .{ .SIZE = true }, &stx); - if (std.posix.errno(stat_rc) != .SUCCESS) { - _ = std.posix.system.close(file_fd); - return null; - } - - const size: u64 = stx.size; - const ct = contentType(rel); - - var v: StaticVariant = .{ .fd = file_fd, .size = size, .hdr_len = 0, .hdr_buf = undefined }; - const hdr = (if (encoding.len > 0) - std.fmt.bufPrint(&v.hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nContent-Encoding: {s}\r\n\r\n", .{ ct, size, encoding }) - else - std.fmt.bufPrint(&v.hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\n\r\n", .{ ct, size })) catch { - _ = std.posix.system.close(file_fd); - return null; - }; - v.hdr_len = @intCast(hdr.len); - - return v; -} - -fn staticLookup(rel: []const u8, count: usize) ?*const StaticEntry { - for (g_static_entries[0..count]) |*e| { - if (std.mem.eql(u8, e.name_buf[0..e.name_len], rel)) return e; - } - - return null; -} - -/// Slow path on first request for a path: probe all variants and publish the -/// slot. Returns null only when the cache is full. -fn staticInsert(rel: []const u8) ?*const StaticEntry { - while (!g_static_lock.tryLock()) std.atomic.spinLoopHint(); - defer g_static_lock.unlock(); - const count = @atomicLoad(usize, &g_static_count, .acquire); - if (staticLookup(rel, count)) |e| return e; - if (count == STATIC_CACHE_MAX) return null; - - const e = &g_static_entries[count]; - e.name_len = @intCast(rel.len); - @memcpy(e.name_buf[0..rel.len], rel); - e.identity = buildStaticVariant(rel, "", ""); - e.br = buildStaticVariant(rel, ".br", "br"); - e.gz = buildStaticVariant(rel, ".gz", "gzip"); - - @atomicStore(usize, &g_static_count, count + 1, .release); - - return e; -} - -/// Block until fd is writable again. Used by the static send path to ride -/// out a full socket buffer, mirroring fdWriteAll's EAGAIN handling. -fn waitWritable(fd: std.posix.fd_t) error{BrokenPipe}!void { - var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.OUT, .revents = 0 }}; - - _ = std.posix.poll(&pfd, -1) catch return error.BrokenPipe; -} - -/// Send with MSG_MORE so the header coalesces into the same packets as the -/// sendfile body that follows instead of leaving as its own small packet. -fn fdSendMore(fd: std.posix.fd_t, data: []const u8) error{BrokenPipe}!void { - const linux = std.os.linux; - - var rem = data; - while (rem.len > 0) { - const rc = linux.sendto(fd, rem.ptr, rem.len, linux.MSG.MORE, null, 0); - switch (std.posix.errno(rc)) { - .SUCCESS => { - const n: usize = @intCast(rc); - if (n == 0) return error.BrokenPipe; - - rem = rem[n..]; - }, - .INTR => {}, - .AGAIN => try waitWritable(fd), - else => return error.BrokenPipe, - } - } -} - -/// Zero-copy file body: kernel pages straight to the socket, no userspace -/// bounce buffer. A local offset keeps the shared cached fd position -/// untouched, so one fd serves all workers concurrently. -fn sendfileAll(sock: std.posix.fd_t, file_fd: std.posix.fd_t, size: u64) error{BrokenPipe}!void { - const linux = std.os.linux; - - var off: i64 = 0; - while (@as(u64, @intCast(off)) < size) { - const remaining: usize = @intCast(size - @as(u64, @intCast(off))); - const rc = linux.sendfile(sock, file_fd, &off, remaining); - switch (std.posix.errno(rc)) { - .SUCCESS => { - if (rc == 0) return error.BrokenPipe; - }, - .INTR => {}, - .AGAIN => try waitWritable(sock), - else => return error.BrokenPipe, - } - } -} - -/// Cache-full fallback: probe, serve, close. Keeps rarely-hit paths correct -/// without evicting anything. -fn staticServeUncached(rel: []const u8, want_br: bool, want_gzip: bool, fd: std.posix.fd_t) void { - const variant: StaticVariant = blk: { - if (want_br) { - if (buildStaticVariant(rel, ".br", "br")) |v| break :blk v; - } - if (want_gzip) { - if (buildStaticVariant(rel, ".gz", "gzip")) |v| break :blk v; - } - break :blk buildStaticVariant(rel, "", "") orelse return notFound(fd); - }; - defer _ = std.posix.system.close(variant.fd); - - // Raw fd writes below: flush engine-staged responses first to keep the - // wire order matching the request order under pipelining. - zix.Http1.flushPending(fd); - - fdSendMore(fd, variant.hdr_buf[0..variant.hdr_len]) catch return; - sendfileAll(fd, variant.fd, variant.size) catch {}; -} - -// GET /static/{file} : serve from /data/static with content negotiation. -// Prefers a precompressed .br then .gz variant when the client accepts it, -// falling back to the identity file. Content-Type is by extension. The first -// request for a path probes the disk and caches fd + size + pre-rendered -// header, every later request is one header send plus one zero-copy sendfile. -fn staticHandler(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { - _ = body; - - const rel = head.path["/static/".len..]; - if (rel.len == 0 or rel.len > STATIC_NAME_MAX or std.mem.indexOf(u8, rel, "..") != null or rel[0] == '/') return notFound(fd); - - const accept_encoding = zix.Http1.getHeader(head, "accept-encoding") orelse ""; - const want_br = std.mem.indexOf(u8, accept_encoding, "br") != null; - const want_gzip = std.mem.indexOf(u8, accept_encoding, "gzip") != null; - - const count = @atomicLoad(usize, &g_static_count, .acquire); - const entry = staticLookup(rel, count) orelse staticInsert(rel) orelse - return staticServeUncached(rel, want_br, want_gzip, fd); - - const variant: *const StaticVariant = blk: { - if (want_br) { - if (entry.br) |*v| break :blk v; - } - if (want_gzip) { - if (entry.gz) |*v| break :blk v; - } - if (entry.identity) |*v| break :blk v; - - return notFound(fd); - }; - - // This path writes to the fd directly (raw send + sendfile), so any - // engine-staged responses from earlier pipelined requests go first. - zix.Http1.flushPending(fd); - - fdSendMore(fd, variant.hdr_buf[0..variant.hdr_len]) catch return; - sendfileAll(fd, variant.fd, variant.size) catch {}; -} - -// --------------------------------------------------------- // - -// Comptime route table. EXACT routes use a StaticStringMap (O(1) hash lookup), -// PREFIX routes match on startsWith with a boundary check (longest match wins). -// rawIntercept handles /pipeline before this dispatch is reached for that route. const Routes = zix.Http1.Router(&[_]zix.Http1.Route{ - .{ .path = "/baseline11", .handler = baselineHandler }, - .{ .path = "/pipeline", .handler = pipelineHandler }, - .{ .path = "/upload", .handler = uploadHandler }, - .{ .path = "/json", .handler = jsonHandler, .kind = .PREFIX }, - .{ .path = "/static", .handler = staticHandler, .kind = .PREFIX }, + .{ .path = "/baseline11", .handler = handler.baseline }, + .{ .path = "/pipeline", .handler = handler.pipeline }, + .{ .path = "/upload", .handler = handler.upload }, + .{ .path = "/json", .handler = handler.json, .kind = .PREFIX }, + .{ .path = "/static", .handler = handler.static, .kind = .PREFIX }, }); -// --------------------------------------------------------- // - -fn sumQuery(query: []const u8) i64 { - var sum: i64 = 0; - var it = std.mem.tokenizeScalar(u8, query, '&'); - while (it.next()) |pair| { - if (std.mem.indexOfScalar(u8, pair, '=')) |eq| { - sum += std.fmt.parseInt(i64, pair[eq + 1 ..], 10) catch 0; - } - } - return sum; -} - -fn parseIntLoose(s: []const u8) i64 { - var i: usize = 0; - while (i < s.len and (s[i] == ' ' or s[i] == '\t' or s[i] == '\r' or s[i] == '\n')) i += 1; - - var neg = false; - if (i < s.len and s[i] == '-') { - neg = true; - i += 1; - } - - var n: i64 = 0; - while (i < s.len and s[i] >= '0' and s[i] <= '9') : (i += 1) { - n = n * 10 + (s[i] - '0'); - } - - return if (neg) -n else n; -} - -fn appendStr(out: []u8, pos: usize, s: []const u8) usize { - @memcpy(out[pos..][0..s.len], s); - return pos + s.len; -} - -fn appendInt(out: []u8, pos: usize, n: u64) usize { - var tmp: [24]u8 = undefined; - const s = std.fmt.bufPrint(&tmp, "{d}", .{n}) catch unreachable; - @memcpy(out[pos..][0..s.len], s); - return pos + s.len; -} - -// --------------------------------------------------------- // - pub fn main(process: std.process.Init) !void { - // Elevate scheduling priority (setpriority -19). Fails silently when the - // process lacks CAP_SYS_NICE, so no special capability is required for correctness. - _ = std.os.linux.syscall3(.setpriority, 0, 0, @as(usize, @bitCast(@as(isize, -19)))); - - const data_dir = process.environ_map.get("ARENA_DATA") orelse "/data"; - g_static_base = std.fmt.bufPrint(&g_static_base_buf, "{s}/static/", .{data_dir}) catch "/data/static/"; + var allocator_dataset = std.heap.ArenaAllocator.init(std.heap.smp_allocator); + defer allocator_dataset.deinit(); var dataset_path_buf: [512]u8 = undefined; + const data_dir = "/data"; const dataset_path = try std.fmt.bufPrint(&dataset_path_buf, "{s}/dataset.json", .{data_dir}); + handler.g_dataset = try dataset.load(allocator_dataset.allocator(), dataset_path); + handler.g_static_base = std.fmt.bufPrint(&handler.g_static_base_buf, "{s}/static/", .{data_dir}) catch "/data/static/"; + + var allocator_tls = std.heap.ArenaAllocator.init(std.heap.smp_allocator); + defer allocator_tls.deinit(); + + var tls = zix.Tls.Context.init(allocator_tls.allocator(), process.io, .{ + .cert_path = TLS_CERT_DEFAULT, + .key_path = TLS_KEY_DEFAULT, + .alpn = &.{.HTTP_1_1}, + .min_version = .TLS_1_3, + }) catch |e| { + std.debug.print("Error tls context: {}\n", .{e}); + return; + }; + defer tls.deinit(); - g_dataset = try dataset.load(std.heap.smp_allocator, dataset_path); - - var server = zix.Http1.Server.initRaw(Routes.dispatch, rawIntercept, .{ + var server = zix.Http1.Server.init(Routes.dispatch, .{ .io = process.io, - .ip = LISTEN_IP, + .ip = IP, .port = PORT, + .tls = &tls, + .tls_port = TLS_PORT, .dispatch_model = DISPATCH_MODEL, - .kernel_backlog = KERNEL_BACKLOG, - .max_recv_buf = MAX_RECV_BUF, .max_headers = MAX_HEADERS, .workers = WORKERS, - .send_date_header = false, - .response_cache = true, + .send_date_header = SEND_DATE_HEADER, + .response_cache = RESPONSE_CACHE, .cache_max_entries = CACHE_MAX_ENTRIES, .cache_max_value_bytes = CACHE_MAX_VALUE_BYTES, - .cache_ttl_ms = CACHE_TTL_MS, + .cache_ttl_ms = handler.CACHE_TTL_MS, + .kernel_backlog = 16 * 1024, + .max_recv_buf = 8 * 1024, + .uring_send_buf_size = 16 * 1024, + .uring_idle_pool_floor = 1 * 1024 / 4, + .uring_idle_pool_ceiling = 1 * 1024, + .process_queue_len = 2_000_000, }); defer server.deinit(); From 813c4d54fadb0f0e11e661900d81757165817ab8 Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 13 Jul 2026 20:06:01 +0700 Subject: [PATCH 06/46] spliting handler --- frameworks/zix/src/handler.zig | 474 +++++++++++++++++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 frameworks/zix/src/handler.zig diff --git a/frameworks/zix/src/handler.zig b/frameworks/zix/src/handler.zig new file mode 100644 index 000000000..8124a7d41 --- /dev/null +++ b/frameworks/zix/src/handler.zig @@ -0,0 +1,474 @@ +//! HttpArena: zix +//! +//! Handler file. zero-copy static file serving and lock-free caching. +//! Supports Brotli/Gzip negotiation, pipelined responses, and cached JSON generation. +//! Optimized for benchmarks via minimal allocations, +//! pre-computed headers, and direct FD handling. + +const std = @import("std"); +const zix = @import("zix"); +const dataset = @import("dataset.zig"); + +// --------------------------------------------------------- // + +// Precomputed response for the pipeline endpoint: +// written verbatim per request, no header build. +const PIPELINE_RESP: []const u8 = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok"; + +/// Static cache name cap. Fixture names are short, anything longer is a 404. +pub const STATIC_NAME_MAX: usize = 96; +/// Static cache capacity: +/// 20 fixtures times their (.br, .gz, identity) candidates plus 404 headroom, +/// sized so the startup pre-warm fits every candidate with room to spare. +pub const STATIC_CACHE_MAX: usize = 128; + +/// Freshness window. The dataset is immutable for the process lifetime, so a +/// long TTL means each key is built once and replayed for the whole run. +pub const CACHE_TTL_MS: u32 = 60 * 1000; + +/// Accept-Encoding tokens the client advertised. +/// A substring scan suffices for the fixed benchmark +/// header ("br;q=1, gzip;q=0.8"), no q-value parsing is needed. +const AcceptEncoding = struct { + prefers_br: bool, + accepts_gzip: bool, +}; + +/// One servable variant of a static file: +/// a fd kept open for the process lifetime, its size, and the +/// pre-rendered response header (Content-Encoding baked in for precompressed variant). +const StaticVariant = struct { + fd: std.posix.fd_t, + size: u64, + hdr_len: u16, + hdr_buf: [192]u8, +}; + +/// Cache slot for one resolved static name +/// ("vendor.js.br" / "vendor.js.gz" / "vendor.js"). +/// A null variant caches a miss so a bad name is probed only once. +const StaticEntry = struct { + name_len: u16, + // rel (up to STATIC_NAME_MAX) plus a 3-char precompressed suffix (".br" or ".gz"). + name_buf: [STATIC_NAME_MAX + 3]u8, + variant: ?StaticVariant, +}; + +/// Content type plus content encoding for a cached static name. +/// A ".br" / ".gz" suffix reports that encoding, with the content type taken +/// from the stripped name ("vendor.js.br" -> javascript). +const StaticMeta = struct { + content_type: []const u8, + content_encoding: []const u8, +}; + +// --------------------------------------------------------- // + +// Per-worker scratch. The JSON body (count up to 50) tops out near 12 KiB. The +// assembled response (status line + headers + body) sits a little above it. +threadlocal var json_body_buf: [32 * 1024]u8 = undefined; +threadlocal var json_resp_buf: [32 * 1024]u8 = undefined; + +// Append-only cache: +// readers scan 0..count lock-free +// (count published release-ordered after the slot is fully written), +// the spinlock only serializes inserts (rare, one per distinct name during warmup). +var g_static_entries: [STATIC_CACHE_MAX]StaticEntry = undefined; +var g_static_count: usize = 0; +var g_static_lock: std.atomic.Value(bool) = .init(false); + +// Data directory, overridable via the ARENA_DATA env var (default /data, the +// container mount point). Lets the same binary run against a local data tree. +pub var g_static_base: []const u8 = "/data/static/"; +pub var g_static_base_buf: [256]u8 = undefined; + +// --------------------------------------------------------- // + +/// Must initialize in init main. +pub var g_dataset: dataset.Dataset = undefined; + +// --------------------------------------------------------- // + +fn sumQuery(query: []const u8) i64 { + var sum: i64 = 0; + var it = std.mem.tokenizeScalar(u8, query, '&'); + while (it.next()) |pair| { + if (std.mem.indexOfScalar(u8, pair, '=')) |eq| { + sum += std.fmt.parseInt(i64, pair[eq + 1 ..], 10) catch 0; + } + } + return sum; +} + +fn parseIntLoose(s: []const u8) i64 { + var i: usize = 0; + while (i < s.len and (s[i] == ' ' or s[i] == '\t' or s[i] == '\r' or s[i] == '\n')) i += 1; + + var neg = false; + if (i < s.len and s[i] == '-') { + neg = true; + i += 1; + } + + var n: i64 = 0; + while (i < s.len and s[i] >= '0' and s[i] <= '9') : (i += 1) { + n = n * 10 + (s[i] - '0'); + } + + return if (neg) -n else n; +} + +fn appendStr(out: []u8, pos: usize, s: []const u8) usize { + @memcpy(out[pos..][0..s.len], s); + return pos + s.len; +} + +fn appendInt(out: []u8, pos: usize, n: u64) usize { + var tmp: [24]u8 = undefined; + const s = std.fmt.bufPrint(&tmp, "{d}", .{n}) catch unreachable; + @memcpy(out[pos..][0..s.len], s); + return pos + s.len; +} + +// --------------------------------------------------------- // + +fn notFound(fd: std.posix.fd_t) void { + zix.Http1.sendSimpleFD(fd, 404, "text/plain", "Not Found") catch {}; +} + +fn badRequest(fd: std.posix.fd_t) void { + zix.Http1.sendSimpleFD(fd, 400, "text/plain", "Bad Request") catch {}; +} + +fn acceptEncoding(head: *const zix.Http1.ParsedHead) AcceptEncoding { + const value = zix.Http1.getHeader(head, "accept-encoding") orelse return .{ .prefers_br = false, .accepts_gzip = false }; + + return .{ + .prefers_br = std.mem.indexOf(u8, value, "br") != null, + .accepts_gzip = std.mem.indexOf(u8, value, "gzip") != null, + }; +} + +fn contentType(rel: []const u8) []const u8 { + if (std.mem.endsWith(u8, rel, ".css")) return "text/css"; + if (std.mem.endsWith(u8, rel, ".js")) return "application/javascript"; + if (std.mem.endsWith(u8, rel, ".json")) return "application/json"; + if (std.mem.endsWith(u8, rel, ".html")) return "text/html"; + if (std.mem.endsWith(u8, rel, ".svg")) return "image/svg+xml"; + if (std.mem.endsWith(u8, rel, ".woff2")) return "font/woff2"; + if (std.mem.endsWith(u8, rel, ".webp")) return "image/webp"; + + return "application/octet-stream"; +} + +fn staticLookup(name: []const u8, count: usize) ?*const StaticEntry { + for (g_static_entries[0..count]) |*e| { + if (std.mem.eql(u8, e.name_buf[0..e.name_len], name)) return e; + } + + return null; +} + +pub fn staticMeta(name: []const u8) StaticMeta { + if (std.mem.endsWith(u8, name, ".br")) { + return .{ .content_type = contentType(name[0 .. name.len - ".br".len]), .content_encoding = "br" }; + } + if (std.mem.endsWith(u8, name, ".gz")) { + return .{ .content_type = contentType(name[0 .. name.len - ".gz".len]), .content_encoding = "gzip" }; + } + + return .{ .content_type = contentType(name), .content_encoding = "" }; +} + +/// Build the process-lifetime path for a static name and open it read-only. Returns null when absent. +fn openStatic(name: []const u8) ?std.posix.fd_t { + var path_buf: [512]u8 = undefined; + const path = std.fmt.bufPrint(&path_buf, "{s}{s}", .{ g_static_base, name }) catch return null; + if (path.len >= path_buf.len) return null; + + path_buf[path.len] = 0; + + return std.posix.openatZ(std.posix.AT.FDCWD, @ptrCast(&path_buf), .{ .ACCMODE = .RDONLY }, 0) catch null; +} + +/// Probe one static name on disk and build its cache record: open, fstat, and pre-render the header +/// (content type and encoding from staticMeta) so serving it later is send + sendfile only. +fn buildVariant(name: []const u8) ?StaticVariant { + const file_fd = openStatic(name) orelse return null; + + var stx: std.os.linux.Statx = undefined; + const stat_rc = std.os.linux.statx(file_fd, "", std.os.linux.AT.EMPTY_PATH, .{ .SIZE = true }, &stx); + if (std.posix.errno(stat_rc) != .SUCCESS) { + _ = std.posix.system.close(file_fd); + return null; + } + + const size: u64 = stx.size; + const meta = staticMeta(name); + + var v: StaticVariant = .{ .fd = file_fd, .size = size, .hdr_len = 0, .hdr_buf = undefined }; + const hdr = (if (meta.content_encoding.len > 0) + std.fmt.bufPrint(&v.hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nContent-Encoding: {s}\r\n\r\n", .{ meta.content_type, size, meta.content_encoding }) + else + std.fmt.bufPrint(&v.hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\n\r\n", .{ meta.content_type, size })) catch { + _ = std.posix.system.close(file_fd); + return null; + }; + v.hdr_len = @intCast(hdr.len); + + return v; +} + +/// Probe + cache a static name on first request, then return the slot. +/// Caches a null variant so a bad name is probed only once. +/// Returns null only when the cache is full. +fn staticInsert(name: []const u8) ?*const StaticEntry { + while (g_static_lock.swap(true, .acquire)) std.atomic.spinLoopHint(); + defer g_static_lock.store(false, .release); + + const count = @atomicLoad(usize, &g_static_count, .acquire); + if (staticLookup(name, count)) |e| return e; + if (count == STATIC_CACHE_MAX) return null; + + const e = &g_static_entries[count]; + e.name_len = @intCast(name.len); + @memcpy(e.name_buf[0..name.len], name); + e.variant = buildVariant(name); + + @atomicStore(usize, &g_static_count, count + 1, .release); + + return e; +} + +/// Resolve a static name through the cache (lookup, then insert on a miss). +/// Returns the slot only when the file exists on disk, +/// so a caller can fall through to the next candidate on a missing variant. +pub fn resolveStatic(name: []const u8) ?*const StaticEntry { + const count = @atomicLoad(usize, &g_static_count, .acquire); + const entry = staticLookup(name, count) orelse staticInsert(name) orelse return null; + if (entry.variant == null) return null; + + return entry; +} + +/// Block until fd is writable again. Used by the static send path to ride +/// out a full socket buffer, mirroring writeAllFD's EAGAIN handling. +fn waitWritable(fd: std.posix.fd_t) error{BrokenPipe}!void { + var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.OUT, .revents = 0 }}; + + _ = std.posix.poll(&pfd, -1) catch return error.BrokenPipe; +} + +/// Send with MSG_MORE so the header coalesces into the same packets as the +/// sendfile body that follows instead of leaving as its own small packet. +fn sendMoreFD(fd: std.posix.fd_t, data: []const u8) error{BrokenPipe}!void { + const linux = std.os.linux; + + var rem = data; + while (rem.len > 0) { + const rc = linux.sendto(fd, rem.ptr, rem.len, linux.MSG.MORE, null, 0); + switch (std.posix.errno(rc)) { + .SUCCESS => { + const n: usize = @intCast(rc); + if (n == 0) return error.BrokenPipe; + + rem = rem[n..]; + }, + .INTR => {}, + .AGAIN => try waitWritable(fd), + else => return error.BrokenPipe, + } + } +} + +/// Zero-copy file body: kernel pages straight to the socket, no userspace +/// bounce buffer. A local offset keeps the shared cached fd position +/// untouched, so one fd serves all workers concurrently. +fn sendfileAll(sock: std.posix.fd_t, file_fd: std.posix.fd_t, size: u64) error{BrokenPipe}!void { + const linux = std.os.linux; + + var off: i64 = 0; + while (@as(u64, @intCast(off)) < size) { + const remaining: usize = @intCast(size - @as(u64, @intCast(off))); + const rc = linux.sendfile(sock, file_fd, &off, remaining); + switch (std.posix.errno(rc)) { + .SUCCESS => { + if (rc == 0) return error.BrokenPipe; + }, + .INTR => {}, + .AGAIN => try waitWritable(sock), + else => return error.BrokenPipe, + } + } +} + +// json-comp: gzip a JSON body, send it with Content-Encoding: gzip. +// Delegates to the engine's sendGzipCachedFD: +// a per-worker compressor plus the per-(key, encoding) cache, +// so after the first request the deterministic gzip body +// is a zero-compression cache replay. +fn sendJsonGzipFD(head: *const zix.Http1.ParsedHead, fd: std.posix.fd_t, _json: []const u8) void { + zix.Http1.sendGzipCachedFD(fd, head, 200, "application/json", _json, zix.Http1.cacheTtl()) catch {}; +} + +// --------------------------------------------------------- // + +// GET/POST /baseline11?a=..&b=.. : sum the query values, plus the POST body as +// an integer. Returns the sum as text/plain. +pub fn baseline(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { + var sum: i64 = sumQuery(head.query); + + if (std.mem.eql(u8, head.method, "POST") and body.len > 0) { + sum += parseIntLoose(body); + } + + var body_buf: [32]u8 = undefined; + const out = std.fmt.bufPrint(&body_buf, "{d}", .{sum}) catch return; + + zix.Http1.sendSimpleFD(fd, 200, "text/plain", out) catch {}; +} + +// GET /pipeline : fixed tiny response, the pipelined-throughput endpoint. +// Reached through the Router (the engine parses every pipelined request). +// writeAllFD appends to the engine's staged per-connection sink, so a batch +// of pipelined responses leaves in request order as one coalesced send. +pub fn pipeline(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { + _ = head; + _ = body; + + zix.Http1.writeAllFD(fd, PIPELINE_RESP) catch {}; +} + +// POST /upload : return the received byte count. The Content-Length header is +// authoritative (curl/clients always send it here), and the engine drains the +// body for sizes beyond the read buffer, so this never touches the bytes. +pub fn upload(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { + const n: u64 = if (head.content_length > 0) head.content_length else body.len; + + var body_buf: [24]u8 = undefined; + const out = std.fmt.bufPrint(&body_buf, "{d}", .{n}) catch return; + + zix.Http1.sendSimpleFD(fd, 200, "text/plain", out) catch {}; +} + +/// GET /json/{count}?m=M : render count dataset items, +/// total = price*qty*M. Response-cache aware: +/// the body is deterministic in (count, m), +/// so each distinct path caches under its own slot +/// (key = hash(method, path, query)). +/// A hit replays the stored bytes, a miss builds and stores. +pub fn json(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { + _ = body; + + // json-comp: when the client accepts gzip, serve a gzip body + // with Content-Encoding: gzip, cached per (key, encoding) + // so a repeat request replays the compressed bytes + // with no rebuild and no recompression. + // The plain json test sends no Accept-Encoding and uses the identity cache. + const accept = zix.Http1.getHeader(head, "accept-encoding") orelse ""; + const want_gzip = std.mem.indexOf(u8, accept, "gzip") != null; + + if (want_gzip) { + if (zix.Http1.cacheLookupEncoded(head, "gzip")) |cached| { + zix.Http1.writeAllFD(fd, cached) catch {}; + return; + } + } else { + if (zix.Http1.cacheLookup(head)) |cached| { + zix.Http1.writeAllFD(fd, cached) catch {}; + return; + } + } + + const count_str = head.path["/json/".len..]; + const count = std.fmt.parseInt(u8, count_str, 10) catch return badRequest(fd); + if (count < 1 or count > dataset.ItemCount) return badRequest(fd); + + const m: u64 = if (zix.Http1.queryParam(head, "m")) |s| std.fmt.parseInt(u64, s, 10) catch 1 else 1; + + const buf = &json_body_buf; + var pos: usize = 0; + + pos = appendStr(buf, pos, "{\"items\":["); + var i: usize = 0; + while (i < count) : (i += 1) { + if (i > 0) { + buf[pos] = ','; + pos += 1; + } + const item = g_dataset.items[i]; + @memcpy(buf[pos..][0..item.prefix.len], item.prefix); + pos += item.prefix.len; + pos = appendStr(buf, pos, ",\"total\":"); + pos = appendInt(buf, pos, item.pq * m); + buf[pos] = '}'; + pos += 1; + } + pos = appendStr(buf, pos, "],\"count\":"); + pos = appendInt(buf, pos, count); + buf[pos] = '}'; + pos += 1; + + // json-comp path: gzip + per-(key, encoding) cache. + // The first request compresses and stores, + // the rest replay (early cacheLookupEncoded above already short-circuits a hit). + if (want_gzip) { + sendJsonGzipFD(head, fd, buf[0..pos]); + return; + } + + // Assemble the full response so it can be cached and replayed verbatim. + // The header matches the engine's sendJsonFD output + // (send_date_header is off, there is no time-varying field to freeze in the cache). + const resp = &json_resp_buf; + const hdr = std.fmt.bufPrint(resp, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{pos}) catch { + zix.Http1.sendJsonFD(fd, 200, buf[0..pos]) catch {}; + return; + }; + @memcpy(resp[hdr.len..][0..pos], buf[0..pos]); + + zix.Http1.sendWithCacheFD(fd, head, resp[0 .. hdr.len + pos], CACHE_TTL_MS) catch {}; +} + +// GET /static/{file} : serve from /data/static. +// Negotiates .br then .gz when accepted, else identity +// (the same flow as the HTTP/2 entry), Content-Type by extension. +// The send is HTTP/1's own path: one +// header send coalesced with the body (MSG_MORE) plus a zero-copy sendfile, +// not in-memory DATA frames. +pub fn static(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { + _ = body; + + const rel = head.path["/static/".len..]; + if (rel.len == 0 or rel.len > STATIC_NAME_MAX or std.mem.indexOf(u8, rel, "..") != null or rel[0] == '/') return notFound(fd); + + const accept = acceptEncoding(head); + + // Candidates "{rel}.br" / "{rel}.gz" / "{rel}". + // The buffer holds rel plus a 3-char suffix. + var cand_buf: [STATIC_NAME_MAX + 3]u8 = undefined; + var entry: ?*const StaticEntry = null; + + if (accept.prefers_br) { + const cand = std.fmt.bufPrint(&cand_buf, "{s}.br", .{rel}) catch return notFound(fd); + entry = resolveStatic(cand); + } + if (entry == null and accept.accepts_gzip) { + const cand = std.fmt.bufPrint(&cand_buf, "{s}.gz", .{rel}) catch return notFound(fd); + entry = resolveStatic(cand); + } + if (entry == null) { + entry = resolveStatic(rel); + } + + const served = entry orelse return notFound(fd); + const variant: *const StaticVariant = if (served.variant) |*v| v else return notFound(fd); + + // Raw fd writes below: + // flush engine-staged responses first + // so the wire order matches the request order under pipelining. + zix.Http1.flushPending(fd); + + sendMoreFD(fd, variant.hdr_buf[0..variant.hdr_len]) catch return; + sendfileAll(fd, variant.fd, variant.size) catch {}; +} From 2da26b2293ea9038420787d4dc8f0d78de8f4db4 Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 13 Jul 2026 20:06:19 +0700 Subject: [PATCH 07/46] adjusting dataset --- frameworks/zix/src/dataset.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frameworks/zix/src/dataset.zig b/frameworks/zix/src/dataset.zig index 4d12be803..a8f5357d3 100644 --- a/frameworks/zix/src/dataset.zig +++ b/frameworks/zix/src/dataset.zig @@ -104,7 +104,7 @@ fn renderItemPrefix(buf: *std.ArrayList(u8), aa: std.mem.Allocator, obj: std.jso try buf.append(aa, ':'); try writeValue(buf, aa, kv.value_ptr.*); } - // Intentionally no closing `}` — caller appends `,"total":N}`. + // Intentionally no closing `}`: the caller appends `,"total":N}`. } fn writeValue(buf: *std.ArrayList(u8), aa: std.mem.Allocator, v: std.json.Value) !void { From 38f800765a7220dddb722a2fba7a65aa6ab47d08 Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 13 Jul 2026 20:39:09 +0700 Subject: [PATCH 08/46] retrigger ci validate attempt 1 From 9237546938d6521725d993f460b907afaede162c Mon Sep 17 00:00:00 2001 From: prothegee Date: Mon, 13 Jul 2026 20:42:47 +0700 Subject: [PATCH 09/46] retrigger ci validate attempt 2 From 77747df791ee701982b7468a380026820d639b78 Mon Sep 17 00:00:00 2001 From: prothegee Date: Wed, 15 Jul 2026 19:28:22 +0700 Subject: [PATCH 10/46] adding new entries tests --- frameworks/zix/meta.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frameworks/zix/meta.json b/frameworks/zix/meta.json index d096dcd2b..64c53683b 100644 --- a/frameworks/zix/meta.json +++ b/frameworks/zix/meta.json @@ -3,7 +3,7 @@ "language": "Zig", "type": "engine", "engine": "zix.Http1 URING Dispatch Model", - "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine, .URING dispatch: per-worker SO_REUSEPORT multishot accept plus io_uring loop, per-worker /json cache, every route through the parser and comptime Router (pipeline responses coalesce via the staged send sink), gzip on Accept-Encoding, prewarmed static cache with .br/.gz negotiation. Dual listener (config.tls_port): cleartext 8080 plus https TLS 1.3 on 8081, one process.", + "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine, .URING dispatch: per-worker SO_REUSEPORT accept plus io_uring loop, comptime Router with a staged send sink, gzip negotiation, prewarmed static cache. Dual listener: cleartext 8080 plus https TLS 1.3 on 8081. async-db and crud go through the postgrez and rediz drivers with worker-owned connections, crud reads are cache-aside via the Redis sidecar.", "repo": "https://github.com/prothegee/zix", "enabled": true, "tests": [ @@ -11,8 +11,14 @@ "pipelined", "limited-conn", "json", + "json-comp", + "json-tls", "upload", - "static" + "static", + "async-db", + "crud", + "api-4", + "api-16" ], "maintainers": ["prothegee"] } From e64b419c1289343bbd50160cd9e7cfb0286d7da5 Mon Sep 17 00:00:00 2001 From: prothegee Date: Wed, 15 Jul 2026 19:28:37 +0700 Subject: [PATCH 11/46] use main-drivers branch --- frameworks/zix/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frameworks/zix/Dockerfile b/frameworks/zix/Dockerfile index 8f10fe543..daac726c7 100644 --- a/frameworks/zix/Dockerfile +++ b/frameworks/zix/Dockerfile @@ -6,7 +6,7 @@ ARG TARGETARCH ARG RETRY_DELAY=3 ARG TIMEOUT_SEC=180 ARG ZIG_VERSION=0.16.0 -ARG ZIX_VERSION=0.5.x-rc1 +ARG ZIX_VERSION=main-drivers RUN apk add --no-cache ca-certificates curl git tar xz openssl WORKDIR /server From 85d6e4f4738748397af2235a35e6a0d3bfd7eaf8 Mon Sep 17 00:00:00 2001 From: prothegee Date: Wed, 15 Jul 2026 19:29:11 +0700 Subject: [PATCH 12/46] simplifying head comment --- frameworks/zix/src/dataset.zig | 2 -- 1 file changed, 2 deletions(-) diff --git a/frameworks/zix/src/dataset.zig b/frameworks/zix/src/dataset.zig index a8f5357d3..dd536615d 100644 --- a/frameworks/zix/src/dataset.zig +++ b/frameworks/zix/src/dataset.zig @@ -1,5 +1,3 @@ -//! HttpArena: zix -//! //! Dataset loader for the /json endpoint. //! //! Loads the fixed 50-item benchmark dataset once at startup and pre-renders From f65b5a44795d1974d06c5cbe867bebea3f826893 Mon Sep 17 00:00:00 2001 From: prothegee Date: Wed, 15 Jul 2026 19:29:26 +0700 Subject: [PATCH 13/46] refine minimal approach --- frameworks/zix/src/main.zig | 50 ++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/frameworks/zix/src/main.zig b/frameworks/zix/src/main.zig index ea47e04ef..1247c0cd0 100644 --- a/frameworks/zix/src/main.zig +++ b/frameworks/zix/src/main.zig @@ -1,28 +1,18 @@ //! HttpArena: zix //! -//! Router-only variant: Server.init, no raw-byte interceptor. Every request, -//! /pipeline included, goes through the engine's parser and the comptime -//! Router. Pipelined correctness comes from the engine's staged response -//! sink: each handler write appends to the per-connection send buffer in -//! request order and the batch flushes as one on-ring send, so responses -//! coalesce without hand-rolled request-line scanning. -//! -//! Knobs carry the attempt-2b sweep result: busy-poll 16 us, warm idle pool -//! 256 floor / 1024 ceiling, 8 KiB recv buffer (deep pipelined batches parse -//! from one fill), 16 KiB send buffer. -//! -//! zix.Http1 (.URING) against the HttpArena HTTP/1.1 suite -//! (baseline, pipelined, limited-conn, json, json-comp, upload, static). -//! json-comp reuses /json and gzips on Accept-Encoding: gzip. -//! json-tls rides the same server through config.tls_port (dual listener): -//! cleartext on PORT plus https (TLS 1.3, baked Ed25519 cert) on TLS_PORT, -//! one worker fleet, one fd table, no second launch. +//! zix.Http1 (.URING), Router-only: every request goes through the engine's +//! parser and the comptime Router. json-tls rides config.tls_port (dual +//! listener, one worker fleet). async-db and crud run on the postgrez.Executor +//! (owned by dbpg.zig) over one shared pool, single-item crud reads serve +//! from the in-process cache (crudcache.zig), rediz mirrors write-behind. const std = @import("std"); const zix = @import("zix"); const dataset = @import("dataset.zig"); const handler = @import("handler.zig"); +const dbpg = @import("dbpg.zig"); +const dbrd = @import("dbrd.zig"); // --------------------------------------------------------- // @@ -48,11 +38,19 @@ const Routes = zix.Http1.Router(&[_]zix.Http1.Route{ .{ .path = "/baseline11", .handler = handler.baseline }, .{ .path = "/pipeline", .handler = handler.pipeline }, .{ .path = "/upload", .handler = handler.upload }, - .{ .path = "/json", .handler = handler.json, .kind = .PREFIX }, + .{ .path = "/async-db", .handler = handler.asyncDb }, + .{ .path = "/json", .handler = handler.jsonResp, .kind = .PREFIX }, .{ .path = "/static", .handler = handler.static, .kind = .PREFIX }, + .{ .path = "/crud/items", .handler = handler.crudItems, .kind = .PREFIX }, }); pub fn main(process: std.process.Init) !void { + // DB endpoints: the executor spawns nothing when DATABASE_URL is absent, + // so non-DB profiles run zero extra threads and the DB routes answer 503. + dbpg.init(process); + dbrd.init(process); + dbpg.startExecutor(handler.runBatch); + var allocator_dataset = std.heap.ArenaAllocator.init(std.heap.smp_allocator); defer allocator_dataset.deinit(); @@ -65,22 +63,22 @@ pub fn main(process: std.process.Init) !void { var allocator_tls = std.heap.ArenaAllocator.init(std.heap.smp_allocator); defer allocator_tls.deinit(); - var tls = zix.Tls.Context.init(allocator_tls.allocator(), process.io, .{ + // json-tls https side. A failed cert load leaves tls_ctx null and the + // cleartext listener keeps serving. + var tls_ctx: ?zix.Tls.Context = zix.Tls.Context.init(allocator_tls.allocator(), process.io, .{ .cert_path = TLS_CERT_DEFAULT, .key_path = TLS_KEY_DEFAULT, .alpn = &.{.HTTP_1_1}, .min_version = .TLS_1_3, - }) catch |e| { - std.debug.print("Error tls context: {}\n", .{e}); - return; - }; - defer tls.deinit(); + }) catch null; + // Dual listener: one server serves cleartext on PORT and https on + // TLS_PORT from the same .URING worker fleet. var server = zix.Http1.Server.init(Routes.dispatch, .{ .io = process.io, .ip = IP, .port = PORT, - .tls = &tls, + .tls = if (tls_ctx) |*tls| tls else null, .tls_port = TLS_PORT, .dispatch_model = DISPATCH_MODEL, .max_headers = MAX_HEADERS, @@ -95,7 +93,7 @@ pub fn main(process: std.process.Init) !void { .uring_send_buf_size = 16 * 1024, .uring_idle_pool_floor = 1 * 1024 / 4, .uring_idle_pool_ceiling = 1 * 1024, - .process_queue_len = 2_000_000, + .process_queue_len = 8192, }); defer server.deinit(); From bdc943e781545aa9c16738751cfd1400a6bd0187 Mon Sep 17 00:00:00 2001 From: prothegee Date: Wed, 15 Jul 2026 19:29:46 +0700 Subject: [PATCH 14/46] adding crud, async-db, api-n handlers --- frameworks/zix/src/handler.zig | 674 +++++++++++++++++++++++++++++---- 1 file changed, 601 insertions(+), 73 deletions(-) diff --git a/frameworks/zix/src/handler.zig b/frameworks/zix/src/handler.zig index 8124a7d41..192ca561d 100644 --- a/frameworks/zix/src/handler.zig +++ b/frameworks/zix/src/handler.zig @@ -1,42 +1,43 @@ //! HttpArena: zix //! -//! Handler file. zero-copy static file serving and lock-free caching. -//! Supports Brotli/Gzip negotiation, pipelined responses, and cached JSON generation. -//! Optimized for benchmarks via minimal allocations, -//! pre-computed headers, and direct FD handling. +//! Route handlers: zero-copy static serving, br/gzip negotiation, cached +//! JSON. DB endpoints queue a job on the postgrez.Executor (owned by +//! dbpg.zig), which batches and pipelines the round trips off the engine +//! workers, run_batch below renders each result and writes the response raw +//! to the fd. Single-item crud reads serve from the in-process cache +//! (crudcache.zig), rediz mirrors fills and invalidations write-behind. const std = @import("std"); const zix = @import("zix"); + +const postgrez = zix.Driver.postgrez; const dataset = @import("dataset.zig"); +const crudcache = @import("crudcache.zig"); +const dbpg = @import("dbpg.zig"); +const dbrd = @import("dbrd.zig"); // --------------------------------------------------------- // -// Precomputed response for the pipeline endpoint: -// written verbatim per request, no header build. +// Precomputed response for the pipeline endpoint. const PIPELINE_RESP: []const u8 = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok"; /// Static cache name cap. Fixture names are short, anything longer is a 404. pub const STATIC_NAME_MAX: usize = 96; -/// Static cache capacity: -/// 20 fixtures times their (.br, .gz, identity) candidates plus 404 headroom, -/// sized so the startup pre-warm fits every candidate with room to spare. +/// 20 fixtures times their (.br, .gz, identity) candidates plus headroom. pub const STATIC_CACHE_MAX: usize = 128; -/// Freshness window. The dataset is immutable for the process lifetime, so a -/// long TTL means each key is built once and replayed for the whole run. +/// Long TTL: the dataset is immutable, each key is built once per run. pub const CACHE_TTL_MS: u32 = 60 * 1000; -/// Accept-Encoding tokens the client advertised. -/// A substring scan suffices for the fixed benchmark -/// header ("br;q=1, gzip;q=0.8"), no q-value parsing is needed. +/// Accept-Encoding tokens the client advertised (substring scan, the bench +/// header needs no q-value parsing). const AcceptEncoding = struct { prefers_br: bool, accepts_gzip: bool, }; -/// One servable variant of a static file: -/// a fd kept open for the process lifetime, its size, and the -/// pre-rendered response header (Content-Encoding baked in for precompressed variant). +/// One servable static variant: a process-lifetime fd, its size, and the +/// pre-rendered response header. const StaticVariant = struct { fd: std.posix.fd_t, size: u64, @@ -44,19 +45,16 @@ const StaticVariant = struct { hdr_buf: [192]u8, }; -/// Cache slot for one resolved static name -/// ("vendor.js.br" / "vendor.js.gz" / "vendor.js"). -/// A null variant caches a miss so a bad name is probed only once. +/// Cache slot for one resolved static name. A null variant caches a miss +/// so a bad name is probed only once. const StaticEntry = struct { name_len: u16, - // rel (up to STATIC_NAME_MAX) plus a 3-char precompressed suffix (".br" or ".gz"). + // rel plus a 3-char precompressed suffix (".br" or ".gz") name_buf: [STATIC_NAME_MAX + 3]u8, variant: ?StaticVariant, }; /// Content type plus content encoding for a cached static name. -/// A ".br" / ".gz" suffix reports that encoding, with the content type taken -/// from the stripped name ("vendor.js.br" -> javascript). const StaticMeta = struct { content_type: []const u8, content_encoding: []const u8, @@ -64,21 +62,17 @@ const StaticMeta = struct { // --------------------------------------------------------- // -// Per-worker scratch. The JSON body (count up to 50) tops out near 12 KiB. The -// assembled response (status line + headers + body) sits a little above it. +// Per-worker scratch, the JSON body (count up to 50) tops out near 12 KiB. threadlocal var json_body_buf: [32 * 1024]u8 = undefined; threadlocal var json_resp_buf: [32 * 1024]u8 = undefined; -// Append-only cache: -// readers scan 0..count lock-free -// (count published release-ordered after the slot is fully written), -// the spinlock only serializes inserts (rare, one per distinct name during warmup). +// Append-only cache: readers scan 0..count lock-free, the spinlock only +// serializes inserts. var g_static_entries: [STATIC_CACHE_MAX]StaticEntry = undefined; var g_static_count: usize = 0; var g_static_lock: std.atomic.Value(bool) = .init(false); -// Data directory, overridable via the ARENA_DATA env var (default /data, the -// container mount point). Lets the same binary run against a local data tree. +// Data directory (default /data, the container mount point). pub var g_static_base: []const u8 = "/data/static/"; pub var g_static_base_buf: [256]u8 = undefined; @@ -302,13 +296,10 @@ fn sendfileAll(sock: std.posix.fd_t, file_fd: std.posix.fd_t, size: u64) error{B } } -// json-comp: gzip a JSON body, send it with Content-Encoding: gzip. -// Delegates to the engine's sendGzipCachedFD: -// a per-worker compressor plus the per-(key, encoding) cache, -// so after the first request the deterministic gzip body -// is a zero-compression cache replay. -fn sendJsonGzipFD(head: *const zix.Http1.ParsedHead, fd: std.posix.fd_t, _json: []const u8) void { - zix.Http1.sendGzipCachedFD(fd, head, 200, "application/json", _json, zix.Http1.cacheTtl()) catch {}; +// json-comp: gzip a JSON body through the engine's per-(key, encoding) +// cache, a repeat request replays the compressed bytes. +fn sendJsonGzipFD(head: *const zix.Http1.ParsedHead, fd: std.posix.fd_t, json: []const u8) void { + zix.Http1.sendGzipCachedFD(fd, head, 200, "application/json", json, zix.Http1.cacheTtl()) catch {}; } // --------------------------------------------------------- // @@ -328,10 +319,8 @@ pub fn baseline(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.pos zix.Http1.sendSimpleFD(fd, 200, "text/plain", out) catch {}; } -// GET /pipeline : fixed tiny response, the pipelined-throughput endpoint. -// Reached through the Router (the engine parses every pipelined request). -// writeAllFD appends to the engine's staged per-connection sink, so a batch -// of pipelined responses leaves in request order as one coalesced send. +// GET /pipeline : fixed tiny response. writeAllFD appends to the engine's +// staged sink, a pipelined batch leaves in request order as one send. pub fn pipeline(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { _ = head; _ = body; @@ -339,9 +328,8 @@ pub fn pipeline(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.pos zix.Http1.writeAllFD(fd, PIPELINE_RESP) catch {}; } -// POST /upload : return the received byte count. The Content-Length header is -// authoritative (curl/clients always send it here), and the engine drains the -// body for sizes beyond the read buffer, so this never touches the bytes. +// POST /upload : return the received byte count. Content-Length is +// authoritative, the engine drains oversized bodies. pub fn upload(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { const n: u64 = if (head.content_length > 0) head.content_length else body.len; @@ -351,20 +339,14 @@ pub fn upload(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix zix.Http1.sendSimpleFD(fd, 200, "text/plain", out) catch {}; } -/// GET /json/{count}?m=M : render count dataset items, -/// total = price*qty*M. Response-cache aware: -/// the body is deterministic in (count, m), -/// so each distinct path caches under its own slot -/// (key = hash(method, path, query)). -/// A hit replays the stored bytes, a miss builds and stores. -pub fn json(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { +/// GET /json/{count}?m=M : render count dataset items, total = price*qty*M. +/// The body is deterministic in (count, m), so each distinct path caches +/// under its own slot: a hit replays, a miss builds and stores. +pub fn jsonResp(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { _ = body; - // json-comp: when the client accepts gzip, serve a gzip body - // with Content-Encoding: gzip, cached per (key, encoding) - // so a repeat request replays the compressed bytes - // with no rebuild and no recompression. - // The plain json test sends no Accept-Encoding and uses the identity cache. + // json-comp: a gzip-accepting client gets the per-(key, encoding) + // cache, the plain json test uses the identity cache. const accept = zix.Http1.getHeader(head, "accept-encoding") orelse ""; const want_gzip = std.mem.indexOf(u8, accept, "gzip") != null; @@ -380,6 +362,10 @@ pub fn json(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.f } } + // The PREFIX route also matches a bare /json (no trailing slash), which + // would slice out of bounds below. + if (head.path.len < "/json/".len) return badRequest(fd); + const count_str = head.path["/json/".len..]; const count = std.fmt.parseInt(u8, count_str, 10) catch return badRequest(fd); if (count < 1 or count > dataset.ItemCount) return badRequest(fd); @@ -409,17 +395,13 @@ pub fn json(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.f buf[pos] = '}'; pos += 1; - // json-comp path: gzip + per-(key, encoding) cache. - // The first request compresses and stores, - // the rest replay (early cacheLookupEncoded above already short-circuits a hit). if (want_gzip) { sendJsonGzipFD(head, fd, buf[0..pos]); return; } - // Assemble the full response so it can be cached and replayed verbatim. - // The header matches the engine's sendJsonFD output - // (send_date_header is off, there is no time-varying field to freeze in the cache). + // Assemble the full response so it caches and replays verbatim (the + // header matches the engine's sendJsonFD output). const resp = &json_resp_buf; const hdr = std.fmt.bufPrint(resp, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{pos}) catch { zix.Http1.sendJsonFD(fd, 200, buf[0..pos]) catch {}; @@ -430,22 +412,22 @@ pub fn json(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.f zix.Http1.sendWithCacheFD(fd, head, resp[0 .. hdr.len + pos], CACHE_TTL_MS) catch {}; } -// GET /static/{file} : serve from /data/static. -// Negotiates .br then .gz when accepted, else identity -// (the same flow as the HTTP/2 entry), Content-Type by extension. -// The send is HTTP/1's own path: one -// header send coalesced with the body (MSG_MORE) plus a zero-copy sendfile, -// not in-memory DATA frames. +// GET /static/{file} : serve from /data/static. Negotiates .br then .gz +// when accepted, else identity. One header send coalesced with a zero-copy +// sendfile body. pub fn static(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { _ = body; + // The PREFIX route also matches a bare /static (no trailing slash), which + // would slice out of bounds below. + if (head.path.len < "/static/".len) return notFound(fd); + const rel = head.path["/static/".len..]; if (rel.len == 0 or rel.len > STATIC_NAME_MAX or std.mem.indexOf(u8, rel, "..") != null or rel[0] == '/') return notFound(fd); const accept = acceptEncoding(head); // Candidates "{rel}.br" / "{rel}.gz" / "{rel}". - // The buffer holds rel plus a 3-char suffix. var cand_buf: [STATIC_NAME_MAX + 3]u8 = undefined; var entry: ?*const StaticEntry = null; @@ -464,11 +446,557 @@ pub fn static(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix const served = entry orelse return notFound(fd); const variant: *const StaticVariant = if (served.variant) |*v| v else return notFound(fd); - // Raw fd writes below: - // flush engine-staged responses first - // so the wire order matches the request order under pipelining. + // Raw fd writes below: flush engine-staged responses first so the wire + // order matches the request order under pipelining. zix.Http1.flushPending(fd); sendMoreFD(fd, variant.hdr_buf[0..variant.hdr_len]) catch return; sendfileAll(fd, variant.fd, variant.size) catch {}; } + +// --------------------------------------------------------- // + +// DB endpoints. Column order is fixed by the SQL constants, the renderers +// decode cells by position with rawDecode. +const SQL_ASYNC_DB = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3"; +const SQL_CRUD_LIST = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count, count(*) OVER() AS total FROM items WHERE category = $1 ORDER BY id LIMIT $2 OFFSET $3"; +const SQL_CRUD_GET = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE id = $1"; +const SQL_CRUD_UPSERT = "INSERT INTO items (id, name, category, price, quantity, active, tags, rating_score, rating_count) VALUES ($1, $2, $3, $4, $5, true, '[]', 0, 0) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, category = EXCLUDED.category, price = EXCLUDED.price, quantity = EXCLUDED.quantity"; +const SQL_CRUD_UPDATE = "UPDATE items SET name = $2, category = $3, price = $4, quantity = $5 WHERE id = $1"; + +const CRUD_KEY_PREFIX = "crud:item:"; +/// Mirror TTL, the write path additionally deletes the key. +const CRUD_CACHE_TTL_S: u64 = 1; + +const ASYNC_DB_LIMIT_MAX: i64 = 50; +const CRUD_LIST_LIMIT_DEFAULT: i64 = 10; +const CRUD_LIST_LIMIT_MAX: i64 = 100; + +// Per-executor scratch for the rendered DB bodies, the renderDbRow budget +// guard rejects an overflow with 503. +threadlocal var db_body_buf: [32 * 1024]u8 = undefined; + +// Per-engine-worker arena for the crud POST/PUT JSON body parse. +threadlocal var tl_crud_arena: ?std.heap.ArenaAllocator = null; + +/// Fields of a crud create/update body. PUT bodies carry no id (the id is +/// in the path), so every field defaults. +const CrudBody = struct { + id: i64 = 0, + name: []const u8 = "", + category: []const u8 = "", + price: i64 = 0, + quantity: i64 = 0, +}; + +// --------------------------------------------------------- // + +fn serviceUnavailable(fd: std.posix.fd_t) void { + zix.Http1.sendSimpleFD(fd, 503, "text/plain", "Service Unavailable") catch {}; +} + +/// 503 the request after a PostgreSQL failure. A server-reported error +/// keeps the connection, anything else marks the batch broken for discard. +fn failDb(batch: *dbpg.DbExecutor.Batch, fd: std.posix.fd_t, err: anyerror) void { + if (err != error.ServerError) batch.markBroken(); + + serviceUnavailable(fd); +} + +/// Drop the redis connection on a transport-shaped failure, keep it on a +/// server-reported error. +fn failCache(err: anyerror) void { + if (err != error.ServerError) dbrd.drop(); +} + +fn cellInt(columns: []const postgrez.row.ColumnInfo, cells: []const ?[]const u8, index: usize) !i64 { + const bytes = cells[index] orelse return error.BadCell; + const column = columns[index]; + + return postgrez.row.rawDecode(i64, @enumFromInt(column.type_oid), column.format, bytes); +} + +fn cellBool(columns: []const postgrez.row.ColumnInfo, cells: []const ?[]const u8, index: usize) !bool { + const bytes = cells[index] orelse return error.BadCell; + const column = columns[index]; + + return postgrez.row.rawDecode(bool, @enumFromInt(column.type_oid), column.format, bytes); +} + +/// Raw cell bytes (text and jsonb columns). The slice points into the +/// receive buffer, valid until the next result.next(). +fn cellStr(columns: []const postgrez.row.ColumnInfo, cells: []const ?[]const u8, index: usize) ![]const u8 { + const bytes = cells[index] orelse return error.BadCell; + const column = columns[index]; + + return postgrez.row.rawDecode([]const u8, @enumFromInt(column.type_oid), column.format, bytes); +} + +fn appendI64(out: []u8, pos: usize, value: i64) usize { + var tmp: [24]u8 = undefined; + const rendered = std.fmt.bufPrint(&tmp, "{d}", .{value}) catch unreachable; + @memcpy(out[pos..][0..rendered.len], rendered); + + return pos + rendered.len; +} + +/// Append a JSON string body (quotes are the caller's), escaped. Worst +/// case is 6x the input, the renderDbRow budget accounts for it. +fn appendJsonStr(out: []u8, start: usize, value: []const u8) usize { + const HEX = "0123456789abcdef"; + + var pos = start; + for (value) |ch| { + switch (ch) { + '"', '\\' => { + out[pos] = '\\'; + out[pos + 1] = ch; + pos += 2; + }, + 0x00...0x1f => { + out[pos..][0..4].* = "\\u00".*; + out[pos + 4] = HEX[ch >> 4]; + out[pos + 5] = HEX[ch & 0xf]; + pos += 6; + }, + else => { + out[pos] = ch; + pos += 1; + }, + } + } + + return pos; +} + +/// Render one items row as a JSON object, cells by SQL column order. tags +/// is jsonb text, emitted raw. +fn renderDbRow(out: []u8, start: usize, columns: []const postgrez.row.ColumnInfo, cells: []const ?[]const u8) !usize { + const name = try cellStr(columns, cells, 1); + const category = try cellStr(columns, cells, 2); + const tags = try cellStr(columns, cells, 6); + if (start + name.len * 6 + category.len * 6 + tags.len + 192 > out.len) return error.NoSpaceLeft; + + var pos = start; + pos = appendStr(out, pos, "{\"id\":"); + pos = appendI64(out, pos, try cellInt(columns, cells, 0)); + pos = appendStr(out, pos, ",\"name\":\""); + pos = appendJsonStr(out, pos, name); + pos = appendStr(out, pos, "\",\"category\":\""); + pos = appendJsonStr(out, pos, category); + pos = appendStr(out, pos, "\",\"price\":"); + pos = appendI64(out, pos, try cellInt(columns, cells, 3)); + pos = appendStr(out, pos, ",\"quantity\":"); + pos = appendI64(out, pos, try cellInt(columns, cells, 4)); + pos = appendStr(out, pos, ",\"active\":"); + pos = appendStr(out, pos, if (try cellBool(columns, cells, 5)) "true" else "false"); + pos = appendStr(out, pos, ",\"tags\":"); + pos = appendStr(out, pos, tags); + pos = appendStr(out, pos, ",\"rating\":{\"score\":"); + pos = appendI64(out, pos, try cellInt(columns, cells, 7)); + pos = appendStr(out, pos, ",\"count\":"); + pos = appendI64(out, pos, try cellInt(columns, cells, 8)); + pos = appendStr(out, pos, "}}"); + + return pos; +} + +/// Parse a crud create/update JSON body into CrudBody. Returned slices live +/// in the per-worker arena, valid until the next parse on this worker. +fn parseCrudBody(body: []const u8) ?CrudBody { + if (tl_crud_arena == null) tl_crud_arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); + + const arena = &tl_crud_arena.?; + _ = arena.reset(.retain_capacity); + + return std.json.parseFromSliceLeaky(CrudBody, arena.allocator(), body, .{ + .ignore_unknown_fields = true, + }) catch null; +} + +// --------------------------------------------------------- // + +/// Streamed render of the async-db result, rows go straight into out. +fn renderAsyncDb(result: *postgrez.Result, out: []u8) !usize { + var pos: usize = 0; + pos = appendStr(out, pos, "{\"items\":["); + + var count: usize = 0; + while (try result.next()) |row_view| { + if (count > 0) { + out[pos] = ','; + pos += 1; + } + pos = try renderDbRow(out, pos, result.columns, row_view.cells); + count += 1; + } + + pos = appendStr(out, pos, "],\"count\":"); + pos = appendInt(out, pos, count); + out[pos] = '}'; + pos += 1; + + return pos; +} + +/// Streamed render of the crud list page. total rides every row as a +/// count(*) OVER() column, one pass yields items and total. +fn renderCrudList(result: *postgrez.Result, page: i64, out: []u8) !usize { + var pos: usize = 0; + pos = appendStr(out, pos, "{\"items\":["); + + var total: i64 = 0; + var count: usize = 0; + while (try result.next()) |row_view| { + if (count > 0) { + out[pos] = ','; + pos += 1; + } + pos = try renderDbRow(out, pos, result.columns, row_view.cells); + total = try cellInt(result.columns, row_view.cells, 9); + count += 1; + } + + pos = appendStr(out, pos, "],\"total\":"); + pos = appendI64(out, pos, total); + pos = appendStr(out, pos, ",\"page\":"); + pos = appendI64(out, pos, page); + out[pos] = '}'; + pos += 1; + + return pos; +} + +/// Render one crud item, null when the row does not exist. +fn renderCrudItem(result: *postgrez.Result, out: []u8) !?usize { + const row_view = (try result.next()) orelse return null; + + return try renderDbRow(out, 0, result.columns, row_view.cells); +} + +/// 200 JSON response with the X-Cache header the crud cache check reads. +fn sendCrudBody(fd: std.posix.fd_t, body: []const u8, cache_state: []const u8) void { + var hdr_buf: [128]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Cache: {s}\r\nContent-Length: {d}\r\n\r\n", .{ cache_state, body.len }) catch return; + + zix.Http1.writeAllFD(fd, hdr) catch return; + zix.Http1.writeAllFD(fd, body) catch {}; +} + +/// Drop the cached crud body on every write: the in-process slot first +/// (the read path), the Redis mirror write-behind. +fn invalidateCrud(id: i64) void { + crudcache.remove(id); + + const cache = dbrd.conn() orelse return; + + var key_buf: [40]u8 = undefined; + const key = std.fmt.bufPrint(&key_buf, CRUD_KEY_PREFIX ++ "{d}", .{id}) catch return; + + cache.delDeferred(&.{key}) catch |err| failCache(err); +} + +// --------------------------------------------------------- // + +// Route handlers below run on the engine worker: parse, queue the job on +// the executor fleet, return without a response. A full queue sheds 503. + +/// GET /async-db?min=A&max=B&limit=N : items with price BETWEEN A AND B, +/// LIMIT N clamped to 1..50. +pub fn asyncDb(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { + _ = body; + + const min: i64 = if (zix.Http1.queryParam(head, "min")) |raw| std.fmt.parseInt(i64, raw, 10) catch 0 else 0; + const max: i64 = if (zix.Http1.queryParam(head, "max")) |raw| std.fmt.parseInt(i64, raw, 10) catch 0 else 0; + var limit: i64 = if (zix.Http1.queryParam(head, "limit")) |raw| std.fmt.parseInt(i64, raw, 10) catch 10 else 10; + if (limit < 1) limit = 1; + if (limit > ASYNC_DB_LIMIT_MAX) limit = ASYNC_DB_LIMIT_MAX; + + const queued = submitJob(head, .{ .ASYNC_DB = .{ + .fd = fd, + .min = min, + .max = max, + .limit = limit, + } }); + if (!queued) serviceUnavailable(fd); +} + +/// Queue a job on the fleet, or run it inline for a close-marked request: +/// the engine closes the fd right after the handler returns, so a deferred +/// executor write would race the close and drop the response. +fn submitJob(head: *const zix.Http1.ParsedHead, job: dbpg.Job) bool { + if (head.keep_alive) return dbpg.submit(job); + + return dbpg.runInline(job); +} + +fn crudList(head: *const zix.Http1.ParsedHead, fd: std.posix.fd_t) void { + const category = zix.Http1.queryParam(head, "category") orelse ""; + var page: i64 = if (zix.Http1.queryParam(head, "page")) |raw| std.fmt.parseInt(i64, raw, 10) catch 1 else 1; + var limit: i64 = if (zix.Http1.queryParam(head, "limit")) |raw| std.fmt.parseInt(i64, raw, 10) catch CRUD_LIST_LIMIT_DEFAULT else CRUD_LIST_LIMIT_DEFAULT; + if (page < 1) page = 1; + if (limit < 1) limit = CRUD_LIST_LIMIT_DEFAULT; + if (limit > CRUD_LIST_LIMIT_MAX) limit = CRUD_LIST_LIMIT_MAX; + if (category.len > dbpg.CATEGORY_MAX) return badRequest(fd); + + // list-page cache first: a hit answers on the engine worker + const list_key = crudcache.listKey(category, page, limit); + if (crudcache.listGet(list_key, &db_body_buf)) |len| { + zix.Http1.sendJsonFD(fd, 200, db_body_buf[0..len]) catch {}; + + return; + } + + var job: dbpg.Job = .{ .CRUD_LIST = .{ + .fd = fd, + .page = page, + .limit = limit, + .category_len = @intCast(category.len), + .category_buf = undefined, + } }; + @memcpy(job.CRUD_LIST.category_buf[0..category.len], category); + + if (!submitJob(head, job)) serviceUnavailable(fd); +} + +fn crudCreate(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { + const item = parseCrudBody(body) orelse return badRequest(fd); + if (item.id < 1 or item.name.len == 0) return badRequest(fd); + if (item.name.len > dbpg.NAME_MAX or item.category.len > dbpg.CATEGORY_MAX) return badRequest(fd); + + var job: dbpg.Job = .{ .CRUD_CREATE = .{ + .fd = fd, + .id = item.id, + .price = item.price, + .quantity = item.quantity, + .name_len = @intCast(item.name.len), + .category_len = @intCast(item.category.len), + .name_buf = undefined, + .category_buf = undefined, + } }; + @memcpy(job.CRUD_CREATE.name_buf[0..item.name.len], item.name); + @memcpy(job.CRUD_CREATE.category_buf[0..item.category.len], item.category); + + if (!submitJob(head, job)) serviceUnavailable(fd); +} + +fn crudUpdate(head: *const zix.Http1.ParsedHead, id: i64, body: []const u8, fd: std.posix.fd_t) void { + const item = parseCrudBody(body) orelse return badRequest(fd); + if (item.name.len > dbpg.NAME_MAX or item.category.len > dbpg.CATEGORY_MAX) return badRequest(fd); + + var job: dbpg.Job = .{ .CRUD_UPDATE = .{ + .fd = fd, + .id = id, + .price = item.price, + .quantity = item.quantity, + .name_len = @intCast(item.name.len), + .category_len = @intCast(item.category.len), + .name_buf = undefined, + .category_buf = undefined, + } }; + @memcpy(job.CRUD_UPDATE.name_buf[0..item.name.len], item.name); + @memcpy(job.CRUD_UPDATE.category_buf[0..item.category.len], item.category); + + if (!submitJob(head, job)) serviceUnavailable(fd); +} + +/// /crud/items dispatcher: list and create on the collection, read and +/// update on /crud/items/{id}. +pub fn crudItems(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { + const sub = head.path["/crud/items".len..]; + + if (sub.len == 0) { + if (std.mem.eql(u8, head.method, "GET")) return crudList(head, fd); + if (std.mem.eql(u8, head.method, "POST")) return crudCreate(head, body, fd); + + return notFound(fd); + } + + if (sub[0] != '/' or sub.len == 1) return notFound(fd); + const id = std.fmt.parseInt(i64, sub[1..], 10) catch return badRequest(fd); + + if (std.mem.eql(u8, head.method, "GET")) { + // in-process cache first: a HIT answers on the engine worker and + // never becomes a job + if (crudcache.get(id, &db_body_buf)) |len| { + sendCrudBody(fd, db_body_buf[0..len], "HIT"); + + return; + } + + if (!submitJob(head, .{ .CRUD_GET = .{ .fd = fd, .id = id } })) serviceUnavailable(fd); + + return; + } + if (std.mem.eql(u8, head.method, "PUT")) return crudUpdate(head, id, body, fd); + + return notFound(fd); +} + +// --------------------------------------------------------- // + +// Batch execution below runs on an executor thread, never on an engine +// worker. One batch pipelines every job on the held connection: statements +// resolve first (a prepare must precede any queued execution), then every +// execution queues (sendRows), then results render in order (awaitRows). + +fn jobFd(job: dbpg.Job) std.posix.fd_t { + return switch (job) { + .ASYNC_DB => |request| request.fd, + .CRUD_LIST => |request| request.fd, + .CRUD_GET => |request| request.fd, + .CRUD_CREATE => |request| request.fd, + .CRUD_UPDATE => |request| request.fd, + }; +} + +/// Execute one drained batch and write every response. Registered with the +/// postgrez.Executor as its run function: batch hands out prepared statements +/// on the held connection, this pipelines and renders them. +pub fn runBatch(batch: *dbpg.DbExecutor.Batch, jobs: []const dbpg.Job) void { + var statements: [dbpg.BATCH_MAX]?*postgrez.Statement = @splat(null); + var done: [dbpg.BATCH_MAX]bool = @splat(false); + + // pass 1: cache hits answer directly, everything else resolves its + // prepared statement on the held connection + for (jobs, 0..) |job, index| { + switch (job) { + .ASYNC_DB => statements[index] = batch.statement(dbpg.slot(.ASYNC_DB), SQL_ASYNC_DB), + .CRUD_LIST => |request| { + // an earlier batch may have filled the page after the + // engine-side miss + const category = request.category_buf[0..request.category_len]; + const list_key = crudcache.listKey(category, request.page, request.limit); + if (crudcache.listGetFresh(list_key, &db_body_buf)) |len| { + zix.Http1.sendJsonFD(request.fd, 200, db_body_buf[0..len]) catch {}; + done[index] = true; + } else { + statements[index] = batch.statement(dbpg.slot(.CRUD_LIST), SQL_CRUD_LIST); + } + }, + .CRUD_GET => |request| { + // an earlier batch may have filled the cache after the + // engine-side miss + if (crudcache.get(request.id, &db_body_buf)) |len| { + sendCrudBody(request.fd, db_body_buf[0..len], "HIT"); + done[index] = true; + } else { + statements[index] = batch.statement(dbpg.slot(.CRUD_GET), SQL_CRUD_GET); + } + }, + .CRUD_CREATE => statements[index] = batch.statement(dbpg.slot(.CRUD_UPSERT), SQL_CRUD_UPSERT), + .CRUD_UPDATE => statements[index] = batch.statement(dbpg.slot(.CRUD_UPDATE), SQL_CRUD_UPDATE), + } + + if (!done[index] and statements[index] == null) { + serviceUnavailable(jobFd(job)); + done[index] = true; + } + } + + // pass 2: queue every execution on the held connection + for (jobs, 0..) |job, index| { + if (done[index]) continue; + + queueJob(statements[index].?, job) catch |err| { + failDb(batch, jobFd(job), err); + done[index] = true; + }; + } + + // pass 3: results render in sendRows order + for (jobs, 0..) |job, index| { + if (done[index]) continue; + + awaitJob(batch, statements[index].?, job); + } +} + +/// Queue one execution (Bind + Execute into the connection send buffer, +/// nothing on the wire yet). +fn queueJob(statement: *postgrez.Statement, job: dbpg.Job) !void { + switch (job) { + .ASYNC_DB => |request| try statement.sendRows(.{ request.min, request.max, request.limit }), + .CRUD_LIST => |request| { + const category = request.category_buf[0..request.category_len]; + const offset = (request.page - 1) * request.limit; + + try statement.sendRows(.{ category, request.limit, offset }); + }, + .CRUD_GET => |request| try statement.sendRows(.{request.id}), + .CRUD_CREATE => |request| { + const name = request.name_buf[0..request.name_len]; + const category = request.category_buf[0..request.category_len]; + + try statement.sendRows(.{ request.id, name, category, request.price, request.quantity }); + }, + .CRUD_UPDATE => |request| { + const name = request.name_buf[0..request.name_len]; + const category = request.category_buf[0..request.category_len]; + + try statement.sendRows(.{ request.id, name, category, request.price, request.quantity }); + }, + } +} + +/// Take the next queued result, render it, and write the response. +fn awaitJob(batch: *dbpg.DbExecutor.Batch, statement: *postgrez.Statement, job: dbpg.Job) void { + var result = statement.awaitRows() catch |err| return failDb(batch, jobFd(job), err); + defer result.deinit(); + + switch (job) { + .ASYNC_DB => |request| { + const len = renderAsyncDb(&result, &db_body_buf) catch |err| return failDb(batch, request.fd, err); + + zix.Http1.sendJsonFD(request.fd, 200, db_body_buf[0..len]) catch {}; + }, + .CRUD_LIST => |request| { + const len = renderCrudList(&result, request.page, &db_body_buf) catch |err| return failDb(batch, request.fd, err); + + const category = request.category_buf[0..request.category_len]; + crudcache.listPut(crudcache.listKey(category, request.page, request.limit), db_body_buf[0..len]); + zix.Http1.sendJsonFD(request.fd, 200, db_body_buf[0..len]) catch {}; + }, + .CRUD_GET => |request| { + const maybe_len = renderCrudItem(&result, &db_body_buf) catch |err| return failDb(batch, request.fd, err); + const len = maybe_len orelse return notFound(request.fd); + + finishCrudGet(request.id, db_body_buf[0..len], request.fd); + }, + .CRUD_CREATE => |request| { + drainResult(&result) catch |err| return failDb(batch, request.fd, err); + + // The upsert may replace an already-cached row, so a create + // invalidates too. + invalidateCrud(request.id); + zix.Http1.sendSimpleFD(request.fd, 201, "application/json", "{\"status\":\"created\"}") catch {}; + }, + .CRUD_UPDATE => |request| { + drainResult(&result) catch |err| return failDb(batch, request.fd, err); + + invalidateCrud(request.id); + zix.Http1.sendSimpleFD(request.fd, 200, "application/json", "{\"status\":\"ok\"}") catch {}; + }, + } +} + +/// Drive a row-less result (create/update) to completion so a server error +/// surfaces before the response commits. +fn drainResult(result: *postgrez.Result) !void { + while (try result.next()) |_| {} +} + +/// MISS response plus the two cache fills: the in-process slot and the +/// write-behind Redis mirror. +fn finishCrudGet(id: i64, body: []const u8, fd: std.posix.fd_t) void { + crudcache.put(id, body); + + if (dbrd.conn()) |cache| { + var key_buf: [40]u8 = undefined; + if (std.fmt.bufPrint(&key_buf, CRUD_KEY_PREFIX ++ "{d}", .{id})) |key| { + cache.setDeferred(key, body, .{ .ex_s = CRUD_CACHE_TTL_S }) catch |err| failCache(err); + } else |_| {} + } + + sendCrudBody(fd, body, "MISS"); +} From 2e511feac59fbcfbcee1f1db178b8f497df64ff7 Mon Sep 17 00:00:00 2001 From: prothegee Date: Wed, 15 Jul 2026 19:30:08 +0700 Subject: [PATCH 15/46] crud cache implementation --- frameworks/zix/src/crudcache.zig | 221 +++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 frameworks/zix/src/crudcache.zig diff --git a/frameworks/zix/src/crudcache.zig b/frameworks/zix/src/crudcache.zig new file mode 100644 index 000000000..ad1fa6fac --- /dev/null +++ b/frameworks/zix/src/crudcache.zig @@ -0,0 +1,221 @@ +//! In-process cache for crud reads. Single-item slots (x-cache MISS/HIT +//! spans workers, invalidated on write) plus rendered list pages +//! (TTL-bounded staleness only, list content is not invalidated on write). +//! Direct-mapped, a lookup verifies the stored key and the TTL. Per-slot +//! spinlock, slots live in BSS. + +const std = @import("std"); + +// --------------------------------------------------------- // + +/// Power of two, covers the 1..50000 bench id keyspace so every id owns its +/// slot (no collision eviction inside the TTL). +const SLOT_COUNT = 65536; +/// Rendered single-item JSON cap, a larger body skips the cache. The seed +/// data bounds a rendered item near 210 bytes (name <= 19, category <= 11, +/// tags <= 48). +const BODY_MAX = 256; + +/// Power of two, list-page slots (the bench mix runs ~10 distinct pages). +const LIST_SLOT_COUNT = 32; +/// Rendered list JSON cap, a larger body skips the cache. +const LIST_BODY_MAX = 4096; + +/// Item TTL is a safety bound only: every write path invalidates its id, so +/// a longer TTL never serves a stale item. Beyond ~5s the write-invalidation +/// rate dominates misses anyway. +const ITEM_TTL_MS: i64 = 5000; +/// List pages are never invalidated on write, this TTL IS the staleness bound. +const LIST_TTL_MS: i64 = 1000; + +const Slot = struct { + lock_flag: std.atomic.Value(bool) = .init(false), + id: i64 = 0, + expires_ms: i64 = 0, + len: u16 = 0, + body: [BODY_MAX]u8 = undefined, +}; + +/// One refresh claim per expired page: the claimer misses (and refills), +/// everyone else serves the stale body meanwhile. A claim not filled within +/// this window may be re-claimed. +const LIST_REFRESH_CLAIM_MS: i64 = 250; + +/// Key-scanned entry (not direct-mapped): the bench runs ~10 distinct +/// pages, a hash-indexed table would let two pages ping-pong one slot and +/// turn every list request into a database job. +const ListEntry = struct { + key: u64 = 0, + expires_ms: i64 = 0, + refresh_at_ms: i64 = 0, + len: u16 = 0, + body: [LIST_BODY_MAX]u8 = undefined, +}; + +var g_slots: [SLOT_COUNT]Slot = @splat(.{}); +var g_list_entries: [LIST_SLOT_COUNT]ListEntry = @splat(.{}); +var g_list_lock: std.atomic.Value(bool) = .init(false); + +fn slotFor(id: i64) ?*Slot { + if (id < 1) return null; + + return &g_slots[@as(usize, @intCast(id)) & (SLOT_COUNT - 1)]; +} + +/// Caller holds g_list_lock. +fn listFind(key: u64) ?*ListEntry { + for (&g_list_entries) |*entry| { + if (entry.key == key) return entry; + } + + return null; +} + +fn lock(flag: *std.atomic.Value(bool)) void { + while (flag.swap(true, .acquire)) std.atomic.spinLoopHint(); +} + +fn unlock(flag: *std.atomic.Value(bool)) void { + flag.store(false, .release); +} + +fn nowMs() i64 { + var ts: std.os.linux.timespec = undefined; + _ = std.os.linux.clock_gettime(.MONOTONIC_COARSE, &ts); + + return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000); +} + +/// Copy a fresh cached body for id into out. +/// +/// Return: +/// - the body length (out[0..len] is the item JSON) +/// - null on a miss (absent, expired, or a colliding id) +pub fn get(id: i64, out: []u8) ?usize { + const slot = slotFor(id) orelse return null; + + lock(&slot.lock_flag); + defer unlock(&slot.lock_flag); + + if (slot.id != id or slot.len == 0) return null; + if (nowMs() >= slot.expires_ms) return null; + + const len: usize = slot.len; + if (len > out.len) return null; + @memcpy(out[0..len], slot.body[0..len]); + + return len; +} + +/// Store a rendered body for id, TTL-bounded. An oversized body is skipped. +pub fn put(id: i64, body: []const u8) void { + if (body.len > BODY_MAX) return; + + const slot = slotFor(id) orelse return; + + lock(&slot.lock_flag); + defer unlock(&slot.lock_flag); + + slot.id = id; + slot.expires_ms = nowMs() + ITEM_TTL_MS; + slot.len = @intCast(body.len); + @memcpy(slot.body[0..body.len], body); +} + +/// Drop the cached body for id (write invalidation). +pub fn remove(id: i64) void { + const slot = slotFor(id) orelse return; + + lock(&slot.lock_flag); + defer unlock(&slot.lock_flag); + + if (slot.id == id) { + slot.len = 0; + slot.expires_ms = 0; + } +} + +/// Cache key of one list page. +pub fn listKey(category: []const u8, page: i64, limit: i64) u64 { + var hasher = std.hash.Wyhash.init(0); + hasher.update(category); + hasher.update(std.mem.asBytes(&page)); + hasher.update(std.mem.asBytes(&limit)); + + return hasher.final(); +} + +/// Copy a cached list body for key into out, single-flight on expiry: the +/// first caller past the TTL misses (claiming the refill), later callers +/// serve the stale body until the refill lands. +/// +/// Return: +/// - the body length (out[0..len] is the list JSON, fresh or stale) +/// - null on a miss (absent, colliding key, or this caller owns the refill) +pub fn listGet(key: u64, out: []u8) ?usize { + lock(&g_list_lock); + defer unlock(&g_list_lock); + + const entry = listFind(key) orelse return null; + if (entry.len == 0) return null; + + const now = nowMs(); + if (now >= entry.expires_ms) { + if (now >= entry.refresh_at_ms) { + entry.refresh_at_ms = now + LIST_REFRESH_CLAIM_MS; + + return null; + } + } + + const len: usize = entry.len; + if (len > out.len) return null; + @memcpy(out[0..len], entry.body[0..len]); + + return len; +} + +/// Fresh-only list lookup for the executor-side re-check: never serves +/// stale and never claims, so a claimed refill always reaches the database. +pub fn listGetFresh(key: u64, out: []u8) ?usize { + lock(&g_list_lock); + defer unlock(&g_list_lock); + + const entry = listFind(key) orelse return null; + if (entry.len == 0) return null; + if (nowMs() >= entry.expires_ms) return null; + + const len: usize = entry.len; + if (len > out.len) return null; + @memcpy(out[0..len], entry.body[0..len]); + + return len; +} + +/// Store a rendered list body for key, TTL-bounded. Reuses the key's entry, +/// else an empty or expired one. An oversized body (or a full table of +/// fresh entries) is skipped. +pub fn listPut(key: u64, body: []const u8) void { + if (body.len > LIST_BODY_MAX) return; + + lock(&g_list_lock); + defer unlock(&g_list_lock); + + const now = nowMs(); + var target: ?*ListEntry = listFind(key); + if (target == null) { + for (&g_list_entries) |*entry| { + if (entry.key == 0 or now >= entry.expires_ms) { + target = entry; + break; + } + } + } + + const entry = target orelse return; + entry.key = key; + entry.expires_ms = now + LIST_TTL_MS; + entry.refresh_at_ms = 0; + entry.len = @intCast(body.len); + @memcpy(entry.body[0..body.len], body); +} From 27634cfb8a37c3ef6a4e9029c663f7fa429a8fb9 Mon Sep 17 00:00:00 2001 From: prothegee Date: Wed, 15 Jul 2026 19:30:34 +0700 Subject: [PATCH 16/46] postgresql implementation --- frameworks/zix/src/dbpg.zig | 141 ++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 frameworks/zix/src/dbpg.zig diff --git a/frameworks/zix/src/dbpg.zig b/frameworks/zix/src/dbpg.zig new file mode 100644 index 000000000..e7375dac3 --- /dev/null +++ b/frameworks/zix/src/dbpg.zig @@ -0,0 +1,141 @@ +//! PostgreSQL for the DB endpoints: parses DATABASE_URL and +//! DATABASE_MAX_CONN, then owns the postgrez.Executor that runs every round +//! trip off the engine workers. The batching, pooling, and prepared-statement +//! caching all live in the driver (postgrez.Executor), this module only wires +//! it to the entry's Job type and the request routes. + +const std = @import("std"); +const zix = @import("zix"); + +const postgrez = zix.Driver.postgrez; + +// --------------------------------------------------------- // + +pub const NAME_MAX = 96; +pub const CATEGORY_MAX = 48; + +/// One prepared SQL slot per distinct statement the entry runs. The enum +/// value indexes the executor's per-connection statement cache. +pub const StatementId = enum(usize) { + ASYNC_DB, + CRUD_LIST, + CRUD_GET, + CRUD_UPSERT, + CRUD_UPDATE, +}; + +pub const STATEMENT_COUNT = 5; + +/// Jobs one batch drains, matches the driver max_pending_replies default. +pub const BATCH_MAX = 16; + +/// The cache slot for a StatementId, for Batch.statement. +pub fn slot(id: StatementId) usize { + return @intFromEnum(id); +} + +/// One parsed DB request. Strings are copied into fixed buffers, the engine's +/// receive buffer is reused the moment the handler returns. +pub const Job = union(enum) { + ASYNC_DB: struct { + fd: std.posix.fd_t, + min: i64, + max: i64, + limit: i64, + }, + CRUD_LIST: struct { + fd: std.posix.fd_t, + page: i64, + limit: i64, + category_len: u8, + category_buf: [CATEGORY_MAX]u8, + }, + CRUD_GET: struct { + fd: std.posix.fd_t, + id: i64, + }, + CRUD_CREATE: struct { + fd: std.posix.fd_t, + id: i64, + price: i64, + quantity: i64, + name_len: u8, + category_len: u8, + name_buf: [NAME_MAX]u8, + category_buf: [CATEGORY_MAX]u8, + }, + CRUD_UPDATE: struct { + fd: std.posix.fd_t, + id: i64, + price: i64, + quantity: i64, + name_len: u8, + category_len: u8, + name_buf: [NAME_MAX]u8, + category_buf: [CATEGORY_MAX]u8, + }, +}; + +/// The batching executor specialized to the entry's Job and statement set. +pub const DbExecutor = postgrez.Executor(Job, STATEMENT_COUNT); + +// Set once in init before startExecutor, read-only afterwards. +var g_io: std.Io = undefined; +var g_config: postgrez.Config = undefined; +var g_enabled: bool = false; +var g_max_conn: usize = 0; +var g_executor: ?*DbExecutor = null; + +/// Read DATABASE_URL and DATABASE_MAX_CONN once at startup. Absent or +/// malformed DATABASE_URL leaves the DB endpoints answering 503. +pub fn init(process: std.process.Init) void { + const url_text = process.environ_map.get("DATABASE_URL") orelse return; + + g_config = postgrez.parseUrl(url_text) catch return; + g_config.max_pending_replies = BATCH_MAX; + g_io = process.io; + g_enabled = true; + + if (process.environ_map.get("DATABASE_MAX_CONN")) |max_text| { + g_max_conn = std.fmt.parseInt(usize, max_text, 10) catch 0; + } +} + +pub fn enabled() bool { + return g_enabled; +} + +/// Build the executor over the parsed config. Does nothing when DATABASE_URL +/// was absent, so non-DB profiles spawn no worker threads. +pub fn startExecutor(run_batch: *const fn (*DbExecutor.Batch, []const Job) void) void { + if (!g_enabled) return; + + g_executor = DbExecutor.init(std.heap.smp_allocator, g_io, g_config, .{ + .run_batch = run_batch, + .max_conn_hint = g_max_conn, + .batch_max = BATCH_MAX, + }) catch null; +} + +/// Queue a job on the executor. +/// +/// Return: +/// - true when queued (a worker owns the response) +/// - false when the executor is down or the queue is full (shed 503) +pub fn submit(job: Job) bool { + const executor = g_executor orelse return false; + + return executor.submit(job); +} + +/// Run a job synchronously on the calling thread (for a request whose +/// connection is about to close, where a deferred write would race the +/// close). +/// +/// Return: +/// - true when the executor ran it, false when it is down +pub fn runInline(job: Job) bool { + const executor = g_executor orelse return false; + + return executor.runInline(job); +} From 8fba2df74a4397827f16fe3c963df6cac347125e Mon Sep 17 00:00:00 2001 From: prothegee Date: Wed, 15 Jul 2026 19:31:00 +0700 Subject: [PATCH 17/46] redis implementation --- frameworks/zix/src/dbrd.zig | 51 +++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 frameworks/zix/src/dbrd.zig diff --git a/frameworks/zix/src/dbrd.zig b/frameworks/zix/src/dbrd.zig new file mode 100644 index 000000000..c9c12d7f8 --- /dev/null +++ b/frameworks/zix/src/dbrd.zig @@ -0,0 +1,51 @@ +//! Redis state for the crud cache mirror. The read path is the in-process +//! cache (crudcache.zig), Redis carries a write-behind mirror of it through +//! the rediz deferred path (replies never awaited on the hot path). One +//! connection per executor thread, connected lazily, dropped on failure. + +const std = @import("std"); +const zix = @import("zix"); + +const rediz = zix.Driver.rediz; + +// --------------------------------------------------------- // + +const MAX_PENDING_REPLIES = 32; + +// Set once in init before server.run, read-only afterwards. +var g_io: std.Io = undefined; +var g_config: rediz.Config = undefined; +var g_enabled: bool = false; + +threadlocal var tl_conn: ?*rediz.Conn = null; + +/// Read REDIS_URL once at startup. Absent or malformed disables the +/// mirror: the in-process cache still serves x-cache MISS/HIT on its own. +pub fn init(process: std.process.Init) void { + const url_text = process.environ_map.get("REDIS_URL") orelse return; + + g_config = rediz.parseUrl(url_text) catch return; + g_config.max_pending_replies = MAX_PENDING_REPLIES; + g_io = process.io; + g_enabled = true; +} + +/// The calling worker's connection, connecting on first use. +/// Null when REDIS_URL is absent or the connect failed. +pub fn conn() ?*rediz.Conn { + if (!g_enabled) return null; + if (tl_conn) |existing| return existing; + + const fresh = rediz.Conn.connect(std.heap.smp_allocator, g_io, g_config) catch return null; + tl_conn = fresh; + + return fresh; +} + +/// Drop the worker's connection after a transport failure, so the next +/// request reconnects instead of reusing a broken stream. +pub fn drop() void { + if (tl_conn) |broken| broken.deinit(); + + tl_conn = null; +} From 15a7403b030c8f8d0864cd87443661a115c963dd Mon Sep 17 00:00:00 2001 From: prothegee Date: Tue, 21 Jul 2026 02:14:17 +0700 Subject: [PATCH 18/46] extend zig dir for internal usage --- frameworks/zix/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/frameworks/zix/.gitignore b/frameworks/zix/.gitignore index 595d20d1a..0e6a877fe 100644 --- a/frameworks/zix/.gitignore +++ b/frameworks/zix/.gitignore @@ -2,3 +2,4 @@ zig-out zig-package vendor +/zig* From 5a69b3037ad2d9ee70ced24f457aea9e572c9291 Mon Sep 17 00:00:00 2001 From: prothegee Date: Tue, 21 Jul 2026 09:51:17 +0700 Subject: [PATCH 19/46] bump to 0.5.x-rc2 --- frameworks/zix/Dockerfile | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/frameworks/zix/Dockerfile b/frameworks/zix/Dockerfile index daac726c7..cf27650fa 100644 --- a/frameworks/zix/Dockerfile +++ b/frameworks/zix/Dockerfile @@ -6,7 +6,7 @@ ARG TARGETARCH ARG RETRY_DELAY=3 ARG TIMEOUT_SEC=180 ARG ZIG_VERSION=0.16.0 -ARG ZIX_VERSION=main-drivers +ARG ZIX_VERSION=0.5.x-rc2 RUN apk add --no-cache ca-certificates curl git tar xz openssl WORKDIR /server @@ -21,20 +21,16 @@ RUN set -eu; \ mv "/opt/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}" /opt/zig ENV PATH="/opt/zig:${PATH}" -COPY build.zig build.zig.zon ./ +COPY build.zig ./ +COPY build.zig.zon.template ./build.zig.zon COPY src ./src -# Resolve zix with zig fetch (hash-verified branch tarball, no git), -# codeberg first then github, RETRY attempts each. -# The shipped build.zig.zon points .zix at vendor/zix for local staging: -# this step swaps that one line for the fetched url + hash, -# so a remote build needs no vendor directory. +# Resolve zix with zig fetch git+https RUN set -eu; \ fetch_zix() { \ url="$1"; attempt=1; \ while [ "${attempt}" -le "${RETRY}" ]; do \ - if hash="$(zig fetch "${url}")"; then \ - sed -i "s|\.path = \"vendor/zix\",|.url = \"${url}\", .hash = \"${hash}\",|" build.zig.zon; \ + if $(zig fetch --save ${url}); then \ return 0; \ fi; \ echo "zix: ${url} attempt ${attempt}/${RETRY} failed" >&2; \ @@ -43,10 +39,10 @@ RUN set -eu; \ done; \ return 1; \ }; \ - fetch_zix "https://codeberg.org/prothegee/zix/archive/${ZIX_VERSION}.tar.gz" \ - || { echo "zix: codeberg exhausted ${RETRY} attempts, trying github" >&2; \ - fetch_zix "https://github.com/prothegee/zix/archive/refs/heads/${ZIX_VERSION}.tar.gz"; } \ - || { echo "zix: github exhausted ${RETRY} attempts" >&2; exit 1; } + fetch_zix "git+https://codeberg.org/prothegee/zix#${ZIX_VERSION}" \ + || { echo "zix: codeberg exhausted ${RETRY} attempts" >&2; \ + fetch_zix "git+https://github.com/prothegee/zix#${ZIX_VERSION}"; } \ + || { echo "zix: github exhausted ${RETRY} attempts" >&2; exit 1; } # +aes+pclmul: x86_64_v3 omits AES-NI / PCLMUL, # so zix TLS would compile the ~40x slower software AES-GCM. @@ -56,13 +52,16 @@ RUN set -eu; \ amd64) ZIG_TARGET=x86_64-linux-musl; ZIG_CPU=x86_64_v3 ;; \ arm64) ZIG_TARGET=aarch64-linux-musl; ZIG_CPU=baseline ;; \ esac; \ - zig build -Dtarget="${ZIG_TARGET}" -Dcpu="${ZIG_CPU}+aes+pclmul+adx" --release=fast + zig build \ + -Dtarget="${ZIG_TARGET}" \ + -Dcpu="${ZIG_CPU}+aes+pclmul+adx" \ + --release=fast --summary line; # Self-signed Ed25519 cert generated at image build, baked at /etc/zix-tls. RUN set -eu; \ mkdir -p /etc/zix-tls; \ openssl genpkey -algorithm ED25519 -out /etc/zix-tls/server.key; \ - openssl req -new -x509 -key /etc/zix-tls/server.key -out /etc/zix-tls/server.crt \ + openssl req -new -x509 -key /etc/zix-tls/server.key -out /etc/zix-tls/server.cert \ -days 3650 -subj "/CN=localhost" FROM alpine:3.20 From 3013c03fdff38e2a628e65d985cbe7d5b09071ae Mon Sep 17 00:00:00 2001 From: prothegee Date: Tue, 21 Jul 2026 09:51:25 +0700 Subject: [PATCH 20/46] template for Dockerfile --- frameworks/zix/build.zig.zon.template | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 frameworks/zix/build.zig.zon.template diff --git a/frameworks/zix/build.zig.zon.template b/frameworks/zix/build.zig.zon.template new file mode 100644 index 000000000..cdde7a12c --- /dev/null +++ b/frameworks/zix/build.zig.zon.template @@ -0,0 +1,12 @@ +.{ + .name = .zix_arena, + .version = "0.1.0", + .fingerprint = 0x84cceaddc218682f, + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, + .dependencies = .{}, +} From 70e6da0977105dc862bac86655f18271861c968b Mon Sep 17 00:00:00 2001 From: prothegee Date: Tue, 21 Jul 2026 09:52:11 +0700 Subject: [PATCH 21/46] re-defined meta info --- frameworks/zix/meta.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frameworks/zix/meta.json b/frameworks/zix/meta.json index 64c53683b..c063ab36f 100644 --- a/frameworks/zix/meta.json +++ b/frameworks/zix/meta.json @@ -3,22 +3,22 @@ "language": "Zig", "type": "engine", "engine": "zix.Http1 URING Dispatch Model", - "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine, .URING dispatch: per-worker SO_REUSEPORT accept plus io_uring loop, comptime Router with a staged send sink, gzip negotiation, prewarmed static cache. Dual listener: cleartext 8080 plus https TLS 1.3 on 8081. async-db and crud go through the postgrez and rediz drivers with worker-owned connections, crud reads are cache-aside via the Redis sidecar.", + "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine, .URING dispatch: per-worker SO_REUSEPORT io_uring loops, comptime Router, staged send sink. Dual listener: cleartext 8080, TLS 1.3 on 8081. DB endpoints ride the driver-owned multiplexed transports (postgrez plus rediz, .URING): fd-routed shard threads, named prepared statements, cache-aside crud with a Redis write-behind mirror.", "repo": "https://github.com/prothegee/zix", "enabled": true, "tests": [ "baseline", "pipelined", "limited-conn", + "upload", + "static", "json", "json-comp", "json-tls", - "upload", - "static", - "async-db", - "crud", "api-4", - "api-16" + "api-16", + "async-db", + "crud" ], "maintainers": ["prothegee"] } From a43ce56e6fdd669e3ef5fe6117d0b0bcce5db9b8 Mon Sep 17 00:00:00 2001 From: prothegee Date: Tue, 21 Jul 2026 09:52:27 +0700 Subject: [PATCH 22/46] use seperate handlers --- frameworks/zix/src/handlers/asyncdb.zig | 47 ++++ frameworks/zix/src/handlers/baseline.zig | 71 ++++++ frameworks/zix/src/handlers/crud.zig | 167 ++++++++++++++ frameworks/zix/src/handlers/json.zig | 125 +++++++++++ frameworks/zix/src/handlers/pipeline.zig | 30 +++ frameworks/zix/src/handlers/static.zig | 269 +++++++++++++++++++++++ frameworks/zix/src/handlers/upload.zig | 40 ++++ 7 files changed, 749 insertions(+) create mode 100644 frameworks/zix/src/handlers/asyncdb.zig create mode 100644 frameworks/zix/src/handlers/baseline.zig create mode 100644 frameworks/zix/src/handlers/crud.zig create mode 100644 frameworks/zix/src/handlers/json.zig create mode 100644 frameworks/zix/src/handlers/pipeline.zig create mode 100644 frameworks/zix/src/handlers/static.zig create mode 100644 frameworks/zix/src/handlers/upload.zig diff --git a/frameworks/zix/src/handlers/asyncdb.zig b/frameworks/zix/src/handlers/asyncdb.zig new file mode 100644 index 000000000..59ef993da --- /dev/null +++ b/frameworks/zix/src/handlers/asyncdb.zig @@ -0,0 +1,47 @@ +//! GET /async-db?min=..&max=..&limit=.. : price-range item scan. Queues a +//! Job on the fd's postgrez shard thread (dbpg.zig), answered async. + +const std = @import("std"); +const zix = @import("zix"); + +const dbpg = @import("../shared/dbpg.zig"); +const response = @import("../shared/response.zig"); + +// --------------------------------------------------------- // + +pub const PATH = "/async-db"; + +const ASYNC_DB_LIMIT_MAX: i64 = 50; + +// --------------------------------------------------------- // + +// /// Queue a Job on its fd's shard thread. For a close request dbpg.submit blocks +// /// until the response is written, so a deferred write never races the fd close. +// fn submitJob(head: *const zix.Http1.ParsedHead, job: dbpg.Job) bool { +// return dbpg.submit(job, head.keep_alive); +// } + +// --------------------------------------------------------- // + +pub fn RESPONSE( + req: *zix.Http1.Request, + _: *zix.Http1.Response, + _: *zix.Http1.Context +) !void { + const head = req.head; + const fd = req.fd; + + const min: i64 = if (zix.Http1.queryParam(head, "min")) |raw| std.fmt.parseInt(i64, raw, 10) catch 0 else 0; + const max: i64 = if (zix.Http1.queryParam(head, "max")) |raw| std.fmt.parseInt(i64, raw, 10) catch 0 else 0; + var limit: i64 = if (zix.Http1.queryParam(head, "limit")) |raw| std.fmt.parseInt(i64, raw, 10) catch 10 else 10; + if (limit < 1) limit = 1; + if (limit > ASYNC_DB_LIMIT_MAX) limit = ASYNC_DB_LIMIT_MAX; + + const queued = dbpg.submitJob(head, .{ .ASYNC_DB = .{ + .fd = fd, + .min = min, + .max = max, + .limit = limit, + } }); + if (!queued) response.serviceUnavailable(fd); +} diff --git a/frameworks/zix/src/handlers/baseline.zig b/frameworks/zix/src/handlers/baseline.zig new file mode 100644 index 000000000..5c047b11c --- /dev/null +++ b/frameworks/zix/src/handlers/baseline.zig @@ -0,0 +1,71 @@ +//! GET/POST /baseline11?a=..&b=.. : sum the query values, plus the POST body +//! as an integer. Returns the sum as text/plain. + +const std = @import("std"); +const zix = @import("zix"); + +// --------------------------------------------------------- // + +pub const PATH = "/baseline11"; + +// --------------------------------------------------------- // + +fn sumQuery(query: []const u8) i64 { + var sum: i64 = 0; + var it = std.mem.tokenizeScalar(u8, query, '&'); + while (it.next()) |pair| { + if (std.mem.indexOfScalar(u8, pair, '=')) |eq| { + sum += std.fmt.parseInt(i64, pair[eq + 1 ..], 10) catch 0; + } + } + return sum; +} + +fn parseIntLoose(s: []const u8) i64 { + var i: usize = 0; + while (i < s.len and (s[i] == ' ' or s[i] == '\t' or s[i] == '\r' or s[i] == '\n')) i += 1; + + var neg = false; + if (i < s.len and s[i] == '-') { + neg = true; + i += 1; + } + + var n: i64 = 0; + while (i < s.len and s[i] >= '0' and s[i] <= '9') : (i += 1) { + n = n * 10 + (s[i] - '0'); + } + + return if (neg) -n else n; +} + +// --------------------------------------------------------- // + +pub fn RESPONSE( + req: *zix.Http1.Request, + _: *zix.Http1.Response, + _: *zix.Http1.Context +) !void { + const head = req.head; + const body = try req.body(); + const fd = req.fd; + + var sum: i64 = sumQuery(head.query); + + if (req.method() == .POST and body.len > 0) { + // if (std.mem.eql(u8, head.method, "POST") and body.len > 0) { + sum += parseIntLoose(body); + } + + var body_buf: [32]u8 = undefined; + const out = std.fmt.bufPrint(&body_buf, "{d}", .{sum}) catch return; + + zix.Http1.sendSimpleFD(fd, 200, "text/plain", out) catch { + try zix.Http1.sendSimpleFD( + fd, + @intFromEnum(zix.Http1.Status.Code.INTERNAL_SERVER_ERROR), + zix.Http1.Content.Type.TEXT_PLAIN.asString(), + zix.Http1.Status.Code.INTERNAL_SERVER_ERROR.asString() + ); + }; +} diff --git a/frameworks/zix/src/handlers/crud.zig b/frameworks/zix/src/handlers/crud.zig new file mode 100644 index 000000000..d62b10a9d --- /dev/null +++ b/frameworks/zix/src/handlers/crud.zig @@ -0,0 +1,167 @@ +//! /crud/items : GET lists (paged, category filter) or reads one id, POST +//! creates, PUT updates. A cache hit (crudcache.zig) answers on the engine +//! worker, otherwise queues a Job on the fd's postgrez shard thread +//! (dbpg.zig). + +const std = @import("std"); +const zix = @import("zix"); + +const dbpg = @import("../shared/dbpg.zig"); + +const response = @import("../shared/response.zig"); +const crudcache = @import("../shared/crudcache.zig"); + +// --------------------------------------------------------- // + +pub const PATH = "/crud/items"; + +const CRUD_LIST_LIMIT_DEFAULT: i64 = 10; +const CRUD_LIST_LIMIT_MAX: i64 = 100; + +// Per-worker scratch for the engine-side cache reads. +threadlocal var tl_db_body_buf: [32 * 1024]u8 = undefined; + +// Per-engine-worker arena for the crud POST/PUT JSON body parse. +threadlocal var tl_crud_arena: ?std.heap.ArenaAllocator = null; + +const CrudBody = struct { + id: i64 = 0, + name: []const u8 = "", + category: []const u8 = "", + price: i64 = 0, + quantity: i64 = 0, +}; + +// --------------------------------------------------------- // + +fn crudList(head: *const zix.Http1.ParsedHead, fd: std.posix.fd_t) void { + const category = zix.Http1.queryParam(head, "category") orelse ""; + var page: i64 = if (zix.Http1.queryParam(head, "page")) |raw| std.fmt.parseInt(i64, raw, 10) catch 1 else 1; + var limit: i64 = if (zix.Http1.queryParam(head, "limit")) |raw| std.fmt.parseInt(i64, raw, 10) catch CRUD_LIST_LIMIT_DEFAULT else CRUD_LIST_LIMIT_DEFAULT; + if (page < 1) page = 1; + if (limit < 1) limit = CRUD_LIST_LIMIT_DEFAULT; + if (limit > CRUD_LIST_LIMIT_MAX) limit = CRUD_LIST_LIMIT_MAX; + if (category.len > dbpg.CATEGORY_MAX) return response.badRequest(fd); + + // list-page cache first: a hit answers on the engine worker + const list_key = crudcache.listKey(category, page, limit); + if (crudcache.listGet(list_key, &tl_db_body_buf)) |len| { + zix.Http1.sendJsonFD(fd, 200, tl_db_body_buf[0..len]) catch {}; + + return; + } + + var job: dbpg.Job = .{ .CRUD_LIST = .{ + .fd = fd, + .page = page, + .limit = limit, + .category_len = @intCast(category.len), + .category_buf = undefined, + } }; + @memcpy(job.CRUD_LIST.category_buf[0..category.len], category); + + if (!dbpg.submitJob(head, job)) response.serviceUnavailable(fd); +} + +fn parseCrudBody(body: []const u8) ?CrudBody { + if (tl_crud_arena == null) tl_crud_arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); + + const arena = &tl_crud_arena.?; + _ = arena.reset(.retain_capacity); + + return std.json.parseFromSliceLeaky(CrudBody, arena.allocator(), body, .{ + .ignore_unknown_fields = true, + }) catch null; +} + +fn crudCreate(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { + const item = parseCrudBody(body) orelse return response.badRequest(fd); + if (item.id < 1 or item.name.len == 0) return response.badRequest(fd); + if (item.name.len > dbpg.NAME_MAX or item.category.len > dbpg.CATEGORY_MAX) return response.badRequest(fd); + + var job: dbpg.Job = .{ .CRUD_CREATE = .{ + .fd = fd, + .id = item.id, + .price = item.price, + .quantity = item.quantity, + .name_len = @intCast(item.name.len), + .category_len = @intCast(item.category.len), + .name_buf = undefined, + .category_buf = undefined, + } }; + @memcpy(job.CRUD_CREATE.name_buf[0..item.name.len], item.name); + @memcpy(job.CRUD_CREATE.category_buf[0..item.category.len], item.category); + + if (!dbpg.submitJob(head, job)) response.serviceUnavailable(fd); +} + +/// 200 JSON response with the X-Cache header the crud cache check reads. +fn sendCrudBody(fd: std.posix.fd_t, body: []const u8, cache_state: []const u8) void { + var hdr_buf: [128]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Cache: {s}\r\nContent-Length: {d}\r\n\r\n", .{ cache_state, body.len }) catch return; + + zix.Http1.writeAllFD(fd, hdr) catch return; + zix.Http1.writeAllFD(fd, body) catch {}; +} + +fn crudUpdate(head: *const zix.Http1.ParsedHead, id: i64, body: []const u8, fd: std.posix.fd_t) void { + const item = parseCrudBody(body) orelse return response.badRequest(fd); + if (item.name.len > dbpg.NAME_MAX or item.category.len > dbpg.CATEGORY_MAX) return response.badRequest(fd); + + var job: dbpg.Job = .{ .CRUD_UPDATE = .{ + .fd = fd, + .id = id, + .price = item.price, + .quantity = item.quantity, + .name_len = @intCast(item.name.len), + .category_len = @intCast(item.category.len), + .name_buf = undefined, + .category_buf = undefined, + } }; + @memcpy(job.CRUD_UPDATE.name_buf[0..item.name.len], item.name); + @memcpy(job.CRUD_UPDATE.category_buf[0..item.category.len], item.category); + + if (!dbpg.submitJob(head, job)) response.serviceUnavailable(fd); +} + + +// --------------------------------------------------------- // + +pub fn RESPONSE( + req: *zix.Http1.Request, + _: *zix.Http1.Response, + _: *zix.Http1.Context +) !void { + const head = req.head; + const body = try req.body(); + const fd = req.fd; + + const sub = head.path[PATH.len..]; + + if (sub.len == 0) { + if (std.mem.eql(u8, head.method, "GET")) return crudList(head, fd); + if (std.mem.eql(u8, head.method, "POST")) return crudCreate(head, body, fd); + + return response.notFound(fd); + } + + if (sub[0] != '/' or sub.len == 1) return response.notFound(fd); + const id = std.fmt.parseInt(i64, sub[1..], 10) catch return response.badRequest(fd); + + if (std.mem.eql(u8, head.method, "GET")) { + // in-process cache first: a HIT answers on the engine worker and + // never becomes a job + if (crudcache.get(id, &tl_db_body_buf)) |len| { + sendCrudBody(fd, tl_db_body_buf[0..len], "HIT"); + + return; + } + + if (!dbpg.submitJob(head, .{ .CRUD_GET = .{ .fd = fd, .id = id } })) response.serviceUnavailable(fd); + + return; + } + if (std.mem.eql(u8, head.method, "PUT")) return crudUpdate(head, id, body, fd); + + return response.notFound(fd); +} diff --git a/frameworks/zix/src/handlers/json.zig b/frameworks/zix/src/handlers/json.zig new file mode 100644 index 000000000..39f4410f3 --- /dev/null +++ b/frameworks/zix/src/handlers/json.zig @@ -0,0 +1,125 @@ +//! GET /json/{count}?m=M : render count dataset items, total = price*qty*m. +//! The body is deterministic in (count, m), cached by key: identity via the +//! engine's response cache, gzip via the per-(key, encoding) cache. + +const std = @import("std"); +const zix = @import("zix"); + +const response = @import("../shared/response.zig"); +const dataset = @import("../shared/dataset.zig"); +const util = @import("../shared/util.zig"); +const cache = @import("../shared/cache.zig"); + +// --------------------------------------------------------- // + +pub const PATH = "/json"; + +/// Must initialize in main (the loaded dataset the bodies render from). +pub var g_dataset: dataset.Dataset = undefined; + +// Reserve for the identity JSON header, rendered right-aligned so it ends +// exactly where the body starts (reserve-prefix assembly, no body copy). +const JSON_HDR_RESERVE: usize = 96; +threadlocal var json_resp_buf: [JSON_HDR_RESERVE + 32 * 1024]u8 = undefined; + +// --------------------------------------------------------- // + +fn sendJsonGzipFD(head: *const zix.Http1.ParsedHead, fd: std.posix.fd_t, json_body: []const u8) void { + zix.Http1.sendGzipCachedFD(fd, head, 200, "application/json", json_body, zix.Http1.cacheTtl()) catch {}; +} + +// --------------------------------------------------------- // + +pub fn init(aa: std.mem.Allocator) !void { + var dataset_path_buf: [512]u8 = undefined; + const data_dir = "/data"; + const dataset_path = + try std.fmt.bufPrint(&dataset_path_buf, "{s}/dataset.json", .{data_dir}); + g_dataset = try dataset.load(aa, dataset_path); +} + +pub fn RESPONSE( + req: *zix.Http1.Request, + _: *zix.Http1.Response, + _: *zix.Http1.Context +) !void { + const head = req.head; + const fd = req.fd; + + const accept = zix.Http1.acceptEncoding(head) orelse ""; + const want_gzip = std.mem.indexOf(u8, accept, "gzip") != null; + + if (want_gzip) { + if (zix.Http1.cacheLookupEncoded(head, "gzip")) |cached| { + zix.Http1.writeAllFD(fd, cached) catch {}; + return; + } + } else { + if (zix.Http1.cacheLookup(head)) |cached| { + zix.Http1.writeAllFD(fd, cached) catch {}; + return; + } + } + + // The PREFIX route also matches a bare /json (no trailing slash), which + // would slice out of bounds below. + if (head.path.len < "/json/".len) return response.badRequest(fd); + + const count_str = head.path["/json/".len..]; + const count = + std.fmt.parseInt(u8, count_str, 10) catch return response.badRequest(fd); + if (count < 1 or count > dataset.ItemCount) return response.badRequest(fd); + + const m: u64 = + if (zix.Http1.queryParam(head, "m")) |s| std.fmt.parseInt(u64, s, 10) catch + 1 else 1; + + const buf = json_resp_buf[JSON_HDR_RESERVE..]; + var pos: usize = 0; + + pos = util.appendStr(buf, pos, "{\"items\":["); + var i: usize = 0; + while (i < count) : (i += 1) { + if (i > 0) { + buf[pos] = ','; + pos += 1; + } + const item = g_dataset.items[i]; + @memcpy(buf[pos..][0..item.prefix.len], item.prefix); + pos += item.prefix.len; + pos = util.appendStr(buf, pos, ",\"total\":"); + pos = util.appendInt(buf, pos, item.pq * m); + buf[pos] = '}'; + pos += 1; + } + pos = util.appendStr(buf, pos, "],\"count\":"); + pos = util.appendInt(buf, pos, count); + buf[pos] = '}'; + pos += 1; + + if (want_gzip) { + sendJsonGzipFD(head, fd, buf[0..pos]); + return; + } + + // The header renders right-aligned into the reserve so it ends exactly + // where the body starts: the full response caches and replays verbatim + // (the header matches the engine's sendJsonFD output) with no body copy. + var hdr_buf: [JSON_HDR_RESERVE]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{pos}) catch { + zix.Http1.sendJsonFD(fd, 200, buf[0..pos]) catch {}; + return; + }; + const start = JSON_HDR_RESERVE - hdr.len; + @memcpy(json_resp_buf[start..JSON_HDR_RESERVE], hdr); + + zix.Http1.sendWithCacheFD(fd, head, json_resp_buf[start .. JSON_HDR_RESERVE + pos], cache.TTL_MS) catch { + try zix.Http1.sendSimpleFD( + fd, + @intFromEnum(zix.Http1.Status.Code.INTERNAL_SERVER_ERROR), + zix.Http1.Content.Type.TEXT_PLAIN.asString(), + zix.Http1.Status.Code.INTERNAL_SERVER_ERROR.asString() + ); + }; +} + diff --git a/frameworks/zix/src/handlers/pipeline.zig b/frameworks/zix/src/handlers/pipeline.zig new file mode 100644 index 000000000..42f34112a --- /dev/null +++ b/frameworks/zix/src/handlers/pipeline.zig @@ -0,0 +1,30 @@ +//! GET /pipeline : fixed tiny response. writeAllFD appends to the engine's +//! staged sink, a pipelined batch leaves in request order as one send. + +const zix = @import("zix"); + +// --------------------------------------------------------- // + +pub const PATH = "/pipeline"; + +const PIPELINE_RESP: []const u8 = + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok"; + +// --------------------------------------------------------- // + +pub fn RESPONSE( + req: *zix.Http1.Request, + _: *zix.Http1.Response, + _: *zix.Http1.Context +) !void { + const fd = req.fd; + + zix.Http1.writeAllFD(req.fd, PIPELINE_RESP) catch { + try zix.Http1.sendSimpleFD( + fd, + @intFromEnum(zix.Http1.Status.Code.INTERNAL_SERVER_ERROR), + zix.Http1.Content.Type.TEXT_PLAIN.asString(), + zix.Http1.Status.Code.INTERNAL_SERVER_ERROR.asString() + ); + }; +} diff --git a/frameworks/zix/src/handlers/static.zig b/frameworks/zix/src/handlers/static.zig new file mode 100644 index 000000000..6cef5cac6 --- /dev/null +++ b/frameworks/zix/src/handlers/static.zig @@ -0,0 +1,269 @@ +//! GET /static/{file} : serve from /data/static. Negotiates .br then .gz +//! when accepted, else identity. One header send coalesced with a zero-copy +//! sendfile body. Resolved names cache in a lock-free wyhash index over an +//! append-only entry table (process-lifetime fds, pre-rendered headers). + +const std = @import("std"); +const zix = @import("zix"); + +const response = @import("../shared/response.zig"); +const encoding = @import("../shared/encoding.zig"); + +// --------------------------------------------------------- // + +pub const PATH = "/static"; + +/// Static cache name cap. Fixture names are short, anything longer is a 404. +pub const STATIC_NAME_MAX: usize = 96; +/// 20 fixtures times their (.br, .gz, identity) candidates plus headroom. +pub const STATIC_CACHE_MAX: usize = 128; +/// Open-addressed name index over g_static_entries: wyhash of the name to +/// (entry index + 1), 0 = empty. Twice the entry capacity, so a probe always +/// reaches an empty slot and a lookup is a couple of compares instead of a +/// linear scan of every cached name per request. Entries are never removed, +/// inserts publish a slot last (release) so lock-free readers always see the +/// entry bytes behind a visible slot. +const STATIC_INDEX_SLOTS: usize = STATIC_CACHE_MAX * 2; + +var g_static_index: [STATIC_INDEX_SLOTS]u16 = @splat(0); +var g_static_entries: [STATIC_CACHE_MAX]StaticEntry = undefined; +var g_static_count: usize = 0; +var g_static_lock: std.atomic.Value(bool) = .init(false); + +pub const g_static_base: []const u8 = "/data/static/"; + +/// One servable static variant: a process-lifetime fd, its size, and the +/// pre-rendered response header. +const StaticVariant = struct { + fd: std.posix.fd_t, + size: u64, + hdr_len: u16, + hdr_buf: [192]u8, +}; +/// Cache slot for one resolved static name. A null variant caches a miss +/// so a bad name is probed only once. +const StaticEntry = struct { + name_len: u16, + // rel plus a 3-char precompressed suffix (".br" or ".gz") + name_buf: [STATIC_NAME_MAX + 3]u8, + variant: ?StaticVariant, +}; +/// Content type plus content encoding for a cached static name. +const StaticMeta = struct { + content_type: []const u8, + content_encoding: []const u8, +}; + +// --------------------------------------------------------- // + +fn staticNameHash(name: []const u8) u64 { + return std.hash.Wyhash.hash(0, name); +} + +fn staticLookup(name: []const u8) ?*const StaticEntry { + var slot: usize = @intCast(staticNameHash(name) & (STATIC_INDEX_SLOTS - 1)); + while (true) { + const tag = @atomicLoad(u16, &g_static_index[slot], .acquire); + if (tag == 0) return null; + + const entry = &g_static_entries[tag - 1]; + if (std.mem.eql(u8, entry.name_buf[0..entry.name_len], name)) return entry; + + slot = (slot + 1) & (STATIC_INDEX_SLOTS - 1); + } +} + +fn openStatic(name: []const u8) ?std.posix.fd_t { + var path_buf: [512]u8 = undefined; + const file_path = + std.fmt.bufPrint(&path_buf, "{s}{s}", .{ g_static_base, name }) catch + return null; + if (file_path.len >= path_buf.len) return null; + + path_buf[file_path.len] = 0; + + return std.posix.openatZ(std.posix.AT.FDCWD, @ptrCast(&path_buf), + .{ .ACCMODE = .RDONLY }, 0) catch null; +} + +fn buildVariant(name: []const u8) ?StaticVariant { + const file_fd = openStatic(name) orelse return null; + + var stx: std.os.linux.Statx = undefined; + const stat_rc = + std.os.linux.statx(file_fd, "", std.os.linux.AT.EMPTY_PATH, .{ .SIZE = true }, &stx); + if (std.posix.errno(stat_rc) != .SUCCESS) { + _ = std.posix.system.close(file_fd); + return null; + } + + const size: u64 = stx.size; + const meta = staticMeta(name); + + var v: StaticVariant = .{ + .fd = file_fd, + .size = size, + .hdr_len = 0, + .hdr_buf = undefined + }; + const hdr = (if (meta.content_encoding.len > 0) + std.fmt.bufPrint(&v.hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nContent-Encoding: {s}\r\n\r\n", .{ meta.content_type, size, meta.content_encoding }) + else + std.fmt.bufPrint(&v.hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\n\r\n", .{ meta.content_type, size })) catch { + _ = std.posix.system.close(file_fd); + return null; + }; + v.hdr_len = @intCast(hdr.len); + + return v; +} + +fn staticInsert(name: []const u8) ?*const StaticEntry { + while (g_static_lock.swap(true, .acquire)) std.atomic.spinLoopHint(); + defer g_static_lock.store(false, .release); + + if (staticLookup(name)) |e| return e; + const count = @atomicLoad(usize, &g_static_count, .acquire); + if (count == STATIC_CACHE_MAX) return null; + + const e = &g_static_entries[count]; + e.name_len = @intCast(name.len); + @memcpy(e.name_buf[0..name.len], name); + e.variant = buildVariant(name); + @atomicStore(usize, &g_static_count, count + 1, .release); + + // Publish the index slot last (release), so a lock-free reader that sees + // the slot always sees the finished entry bytes behind it. + var slot: usize = @intCast(staticNameHash(name) & (STATIC_INDEX_SLOTS - 1)); + while (@atomicLoad(u16, &g_static_index[slot], .monotonic) != 0) + slot = (slot + 1) & (STATIC_INDEX_SLOTS - 1); + @atomicStore(u16, &g_static_index[slot], @intCast(count + 1), .release); + + return e; +} + +fn contentType(rel: []const u8) []const u8 { + const ext = std.fs.path.extension(rel); + const ext_no_dot = if (ext.len > 0) ext[1..] else ext; + + return zix.Http1.Content.fromExtension(ext_no_dot); +} + +fn waitWritable(fd: std.posix.fd_t) error{BrokenPipe}!void { + var pfd = + [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.OUT, .revents = 0 }}; + + _ = std.posix.poll(&pfd, -1) catch return error.BrokenPipe; +} + + +fn sendMoreFD(fd: std.posix.fd_t, data: []const u8) error{BrokenPipe}!void { + const linux = std.os.linux; + + var rem = data; + while (rem.len > 0) { + const rc = linux.sendto(fd, rem.ptr, rem.len, linux.MSG.MORE, null, 0); + switch (std.posix.errno(rc)) { + .SUCCESS => { + const n: usize = @intCast(rc); + if (n == 0) return error.BrokenPipe; + + rem = rem[n..]; + }, + .INTR => {}, + .AGAIN => try waitWritable(fd), + else => return error.BrokenPipe, + } + } +} + +fn sendfileAll(sock: std.posix.fd_t, file_fd: std.posix.fd_t, size: u64) error{BrokenPipe}!void { + const linux = std.os.linux; + + var off: i64 = 0; + while (@as(u64, @intCast(off)) < size) { + const remaining: usize = @intCast(size - @as(u64, @intCast(off))); + const rc = linux.sendfile(sock, file_fd, &off, remaining); + switch (std.posix.errno(rc)) { + .SUCCESS => { + if (rc == 0) return error.BrokenPipe; + }, + .INTR => {}, + .AGAIN => try waitWritable(sock), + else => return error.BrokenPipe, + } + } +} + +// --------------------------------------------------------- // + +pub fn resolveStatic(name: []const u8) ?*const StaticEntry { + const entry = staticLookup(name) orelse staticInsert(name) orelse return null; + if (entry.variant == null) return null; + + return entry; +} + +pub fn staticMeta(name: []const u8) StaticMeta { + if (std.mem.endsWith(u8, name, ".br")) { + return .{ + .content_type = contentType(name[0 .. name.len - ".br".len]), + .content_encoding = "br" + }; + } + if (std.mem.endsWith(u8, name, ".gz")) { + return .{ + .content_type = contentType(name[0 .. name.len - ".gz".len]), + .content_encoding = "gzip" + }; + } + + return .{ .content_type = contentType(name), .content_encoding = "" }; +} + +pub fn RESPONSE(req: *zix.Http1.Request, _: *zix.Http1.Response, _: *zix.Http1.Context) !void { + const head = req.head; + const fd = req.fd; + + // The PREFIX route also matches a bare /static (no trailing slash), which + // would slice out of bounds below. + if (head.path.len < "/static/".len) return response.notFound(fd); + + const rel = head.path["/static/".len..]; + if (rel.len == 0 or + rel.len > STATIC_NAME_MAX or + std.mem.indexOf(u8, rel, "..") != null or + rel[0] == '/') return response.notFound(fd); + + const accept = encoding.accept(head); + + // Candidates "{rel}.br" / "{rel}.gz" / "{rel}". + var cand_buf: [STATIC_NAME_MAX + 3]u8 = undefined; + var entry: ?*const StaticEntry = null; + + if (accept.prefers_br) { + const cand = + std.fmt.bufPrint(&cand_buf, "{s}.br", .{rel}) catch + return response.notFound(fd); + entry = resolveStatic(cand); + } + if (entry == null and accept.accepts_gzip) { + const cand = std.fmt.bufPrint(&cand_buf, "{s}.gz", .{rel}) catch + return response.notFound(fd); + entry = resolveStatic(cand); + } + if (entry == null) { + entry = resolveStatic(rel); + } + + const served = entry orelse return response.notFound(fd); + const variant: *const StaticVariant = + if (served.variant) |*v| v else return response.notFound(fd); + + // Raw fd writes below: flush engine-staged responses first so the wire + // order matches the request order under pipelining. + zix.Http1.flushPending(fd); + + sendMoreFD(fd, variant.hdr_buf[0..variant.hdr_len]) catch return; + sendfileAll(fd, variant.fd, variant.size) catch {}; +} diff --git a/frameworks/zix/src/handlers/upload.zig b/frameworks/zix/src/handlers/upload.zig new file mode 100644 index 000000000..1f6f47c10 --- /dev/null +++ b/frameworks/zix/src/handlers/upload.zig @@ -0,0 +1,40 @@ +//! POST /upload : return the received byte count. Content-Length is +//! authoritative, the engine drains oversized bodies. + +const std = @import("std"); +const zix = @import("zix"); + +// --------------------------------------------------------- // + +pub const PATH = "/upload"; + +// --------------------------------------------------------- // + +pub fn RESPONSE( + req: *zix.Http1.Request, + _: *zix.Http1.Response, + _: *zix.Http1.Context +) !void { + const head = req.head; + const body = try req.body(); + const fd = req.fd; + + const n: u64 = if (head.content_length > 0) head.content_length else body.len; + + var body_buf: [24]u8 = undefined; + const out = std.fmt.bufPrint(&body_buf, "{d}", .{n}) catch return; + + zix.Http1.sendSimpleFD( + fd, + @intFromEnum(zix.Http1.Status.Code.OK), + zix.Http1.Content.Type.TEXT_PLAIN.asString(), + out + ) catch { + try zix.Http1.sendSimpleFD( + fd, + @intFromEnum(zix.Http1.Status.Code.INTERNAL_SERVER_ERROR), + zix.Http1.Content.Type.TEXT_PLAIN.asString(), + zix.Http1.Status.Code.INTERNAL_SERVER_ERROR.asString() + ); + }; +} From baee9800bc4226567cd60933632cbbbe1a6cd09c Mon Sep 17 00:00:00 2001 From: prothegee Date: Tue, 21 Jul 2026 09:52:38 +0700 Subject: [PATCH 23/46] add shared source --- frameworks/zix/src/shared/cache.zig | 7 + frameworks/zix/src/shared/crudcache.zig | 221 ++++ frameworks/zix/src/shared/dataset.zig | 166 +++ frameworks/zix/src/shared/dbpg.zig | 1314 +++++++++++++++++++++++ frameworks/zix/src/shared/dbrd.zig | 224 ++++ frameworks/zix/src/shared/encoding.zig | 27 + frameworks/zix/src/shared/response.zig | 22 + frameworks/zix/src/shared/util.zig | 17 + 8 files changed, 1998 insertions(+) create mode 100644 frameworks/zix/src/shared/cache.zig create mode 100644 frameworks/zix/src/shared/crudcache.zig create mode 100644 frameworks/zix/src/shared/dataset.zig create mode 100644 frameworks/zix/src/shared/dbpg.zig create mode 100644 frameworks/zix/src/shared/dbrd.zig create mode 100644 frameworks/zix/src/shared/encoding.zig create mode 100644 frameworks/zix/src/shared/response.zig create mode 100644 frameworks/zix/src/shared/util.zig diff --git a/frameworks/zix/src/shared/cache.zig b/frameworks/zix/src/shared/cache.zig new file mode 100644 index 000000000..a9c46dd5a --- /dev/null +++ b/frameworks/zix/src/shared/cache.zig @@ -0,0 +1,7 @@ +//! Engine response-cache TTL (cache_ttl_ms), long-lived since the dataset is +//! immutable and each key renders once per run. + +const std = @import("std"); + +/// Long TTL: the dataset is immutable, each key is built once per run. +pub const TTL_MS: u32 = (60 * 1_000) * 6; diff --git a/frameworks/zix/src/shared/crudcache.zig b/frameworks/zix/src/shared/crudcache.zig new file mode 100644 index 000000000..ab7e6465a --- /dev/null +++ b/frameworks/zix/src/shared/crudcache.zig @@ -0,0 +1,221 @@ +//! In-process cache for crud reads. Direct-mapped item slots (x-cache +//! MISS/HIT, invalidated on write) plus scanned list-page slots (TTL-bounded +//! staleness only, single-flight refresh past the TTL). Per-slot and +//! per-list spinlocks, slots live in BSS. + +const std = @import("std"); + +// --------------------------------------------------------- // + +/// Power of two, covers the 1..50000 bench id keyspace so every id owns its +/// slot (no collision eviction inside the TTL). +const SLOT_COUNT = 65536; +/// Rendered single-item JSON cap, a larger body skips the cache. The seed +/// data bounds a rendered item near 210 bytes (name <= 19, category <= 11, +/// tags <= 48). +const BODY_MAX = 256; + +/// Power of two, list-page slots (the bench mix runs ~10 distinct pages). +const LIST_SLOT_COUNT = 32; +/// Rendered list JSON cap, a larger body skips the cache. +const LIST_BODY_MAX = 4096; + +/// Item TTL is a safety bound only: every write path invalidates its id, so +/// a longer TTL never serves a stale item. Beyond ~5s the write-invalidation +/// rate dominates misses anyway. +const ITEM_TTL_MS: i64 = 5000; +/// List pages are never invalidated on write, this TTL IS the staleness bound. +const LIST_TTL_MS: i64 = 1000; + +const Slot = struct { + lock_flag: std.atomic.Value(bool) = .init(false), + id: i64 = 0, + expires_ms: i64 = 0, + len: u16 = 0, + body: [BODY_MAX]u8 = undefined, +}; + +/// One refresh claim per expired page: the claimer misses (and refills), +/// everyone else serves the stale body meanwhile. A claim not filled within +/// this window may be re-claimed. +const LIST_REFRESH_CLAIM_MS: i64 = 250; + +/// Key-scanned entry (not direct-mapped): the bench runs ~10 distinct +/// pages, a hash-indexed table would let two pages ping-pong one slot and +/// turn every list request into a database job. +const ListEntry = struct { + key: u64 = 0, + expires_ms: i64 = 0, + refresh_at_ms: i64 = 0, + len: u16 = 0, + body: [LIST_BODY_MAX]u8 = undefined, +}; + +var g_slots: [SLOT_COUNT]Slot = @splat(.{}); +var g_list_entries: [LIST_SLOT_COUNT]ListEntry = @splat(.{}); +var g_list_lock: std.atomic.Value(bool) = .init(false); + +fn slotFor(id: i64) ?*Slot { + if (id < 1) return null; + + return &g_slots[@as(usize, @intCast(id)) & (SLOT_COUNT - 1)]; +} + +/// Caller holds g_list_lock. +fn listFind(key: u64) ?*ListEntry { + for (&g_list_entries) |*entry| { + if (entry.key == key) return entry; + } + + return null; +} + +fn lock(flag: *std.atomic.Value(bool)) void { + while (flag.swap(true, .acquire)) std.atomic.spinLoopHint(); +} + +fn unlock(flag: *std.atomic.Value(bool)) void { + flag.store(false, .release); +} + +fn nowMs() i64 { + var ts: std.os.linux.timespec = undefined; + _ = std.os.linux.clock_gettime(.MONOTONIC_COARSE, &ts); + + return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000); +} + +/// Copy a fresh cached body for id into out. +/// +/// Return: +/// - the body length (out[0..len] is the item JSON) +/// - null on a miss (absent, expired, or a colliding id) +pub fn get(id: i64, out: []u8) ?usize { + const slot = slotFor(id) orelse return null; + + lock(&slot.lock_flag); + defer unlock(&slot.lock_flag); + + if (slot.id != id or slot.len == 0) return null; + if (nowMs() >= slot.expires_ms) return null; + + const len: usize = slot.len; + if (len > out.len) return null; + @memcpy(out[0..len], slot.body[0..len]); + + return len; +} + +/// Store a rendered body for id, TTL-bounded. An oversized body is skipped. +pub fn put(id: i64, body: []const u8) void { + if (body.len > BODY_MAX) return; + + const slot = slotFor(id) orelse return; + + lock(&slot.lock_flag); + defer unlock(&slot.lock_flag); + + slot.id = id; + slot.expires_ms = nowMs() + ITEM_TTL_MS; + slot.len = @intCast(body.len); + @memcpy(slot.body[0..body.len], body); +} + +/// Drop the cached body for id (write invalidation). +pub fn remove(id: i64) void { + const slot = slotFor(id) orelse return; + + lock(&slot.lock_flag); + defer unlock(&slot.lock_flag); + + if (slot.id == id) { + slot.len = 0; + slot.expires_ms = 0; + } +} + +/// Cache key of one list page. +pub fn listKey(category: []const u8, page: i64, limit: i64) u64 { + var hasher = std.hash.Wyhash.init(0); + hasher.update(category); + hasher.update(std.mem.asBytes(&page)); + hasher.update(std.mem.asBytes(&limit)); + + return hasher.final(); +} + +/// Copy a cached list body for key into out, single-flight on expiry: the +/// first caller past the TTL misses (claiming the refill), later callers +/// serve the stale body until the refill lands. +/// +/// Return: +/// - the body length (out[0..len] is the list JSON, fresh or stale) +/// - null on a miss (absent, colliding key, or this caller owns the refill) +pub fn listGet(key: u64, out: []u8) ?usize { + lock(&g_list_lock); + defer unlock(&g_list_lock); + + const entry = listFind(key) orelse return null; + if (entry.len == 0) return null; + + const now = nowMs(); + if (now >= entry.expires_ms) { + if (now >= entry.refresh_at_ms) { + entry.refresh_at_ms = now + LIST_REFRESH_CLAIM_MS; + + return null; + } + } + + const len: usize = entry.len; + if (len > out.len) return null; + @memcpy(out[0..len], entry.body[0..len]); + + return len; +} + +/// Fresh-only list lookup for the executor-side re-check: never serves +/// stale and never claims, so a claimed refill always reaches the database. +pub fn listGetFresh(key: u64, out: []u8) ?usize { + lock(&g_list_lock); + defer unlock(&g_list_lock); + + const entry = listFind(key) orelse return null; + if (entry.len == 0) return null; + if (nowMs() >= entry.expires_ms) return null; + + const len: usize = entry.len; + if (len > out.len) return null; + @memcpy(out[0..len], entry.body[0..len]); + + return len; +} + +/// Store a rendered list body for key, TTL-bounded. Reuses the key's entry, +/// else an empty or expired one. An oversized body (or a full table of +/// fresh entries) is skipped. +pub fn listPut(key: u64, body: []const u8) void { + if (body.len > LIST_BODY_MAX) return; + + lock(&g_list_lock); + defer unlock(&g_list_lock); + + const now = nowMs(); + var target: ?*ListEntry = listFind(key); + if (target == null) { + for (&g_list_entries) |*entry| { + if (entry.key == 0 or now >= entry.expires_ms) { + target = entry; + break; + } + } + } + + const entry = target orelse return; + entry.key = key; + entry.expires_ms = now + LIST_TTL_MS; + entry.refresh_at_ms = 0; + entry.len = @intCast(body.len); + @memcpy(entry.body[0..body.len], body); +} + diff --git a/frameworks/zix/src/shared/dataset.zig b/frameworks/zix/src/shared/dataset.zig new file mode 100644 index 000000000..f6f0ff2d3 --- /dev/null +++ b/frameworks/zix/src/shared/dataset.zig @@ -0,0 +1,166 @@ +//! Dataset loader for the /json endpoint. +//! +//! Loads the fixed 50-item benchmark dataset once at startup and pre-renders +//! each item as a JSON object fragment (without the closing brace), so the hot +//! path only appends the per-request total and the closing brace. + +const std = @import("std"); + +pub const ItemCount = 50; + +pub const Item = struct { + /// Pre-rendered JSON object for this item, WITHOUT the closing `}`. + /// Caller appends `,"total":}` per request. + prefix: []const u8, + /// price * quantity, pre-multiplied so per-request work is one *m + /// followed by an integer-to-decimal print. + pq: u64, +}; + +pub const Dataset = struct { + items: []Item, + arena: std.heap.ArenaAllocator, + + pub fn deinit(self: *Dataset) void { + self.arena.deinit(); + } +}; + +pub fn load(gpa: std.mem.Allocator, path: []const u8) !Dataset { + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const aa = arena.allocator(); + + const raw = try readFileAlloc(aa, path, 4 * 1024 * 1024); + + var parsed = try std.json.parseFromSlice(std.json.Value, aa, raw, .{}); + defer parsed.deinit(); + + const arr = switch (parsed.value) { + .array => |a| a, + else => return error.BadDataset, + }; + if (arr.items.len != ItemCount) return error.BadDataset; + + const items = try aa.alloc(Item, ItemCount); + for (arr.items, 0..) |elem, i| { + const obj = switch (elem) { + .object => |o| o, + else => return error.BadDataset, + }; + const price = jsonInt(obj.get("price") orelse return error.BadDataset); + const quantity = jsonInt(obj.get("quantity") orelse return error.BadDataset); + + var buf: std.ArrayList(u8) = .empty; + try renderItemPrefix(&buf, aa, obj); + items[i] = .{ + .prefix = try buf.toOwnedSlice(aa), + .pq = @as(u64, @intCast(price)) * @as(u64, @intCast(quantity)), + }; + } + + return .{ .items = items, .arena = arena }; +} + +fn readFileAlloc(aa: std.mem.Allocator, path: []const u8, max: usize) ![]u8 { + var path_z: [std.posix.PATH_MAX]u8 = undefined; + if (path.len >= path_z.len) return error.NameTooLong; + @memcpy(path_z[0..path.len], path); + path_z[path.len] = 0; + const fd = + try std.posix.openatZ(std.posix.AT.FDCWD, @ptrCast(&path_z), .{ .ACCMODE = .RDONLY }, 0); + defer _ = std.posix.system.close(fd); + + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(aa); + try buf.ensureTotalCapacity(aa, 64 * 1024); + while (buf.items.len < max) { + try buf.ensureUnusedCapacity(aa, 32 * 1024); + const dst = buf.unusedCapacitySlice(); + const n = try std.posix.read(fd, dst); + if (n == 0) break; + buf.items.len += n; + } + return buf.toOwnedSlice(aa); +} + +fn jsonInt(v: std.json.Value) i64 { + return switch (v) { + .integer => |n| n, + .float => |f| @intFromFloat(f), + else => 0, + }; +} + +fn renderItemPrefix(buf: *std.ArrayList(u8), aa: std.mem.Allocator, obj: std.json.ObjectMap) !void { + try buf.append(aa, '{'); + var first = true; + var it = obj.iterator(); + while (it.next()) |kv| { + if (!first) try buf.append(aa, ','); + first = false; + try writeString(buf, aa, kv.key_ptr.*); + try buf.append(aa, ':'); + try writeValue(buf, aa, kv.value_ptr.*); + } + // Intentionally no closing `}`: the caller appends `,"total":N}`. +} + +fn writeValue(buf: *std.ArrayList(u8), aa: std.mem.Allocator, v: std.json.Value) !void { + switch (v) { + .null => try buf.appendSlice(aa, "null"), + .bool => |b| try buf.appendSlice(aa, if (b) "true" else "false"), + .integer => |n| try writeInt(buf, aa, n), + .float => |f| { + var tmp: [32]u8 = undefined; + const s = std.fmt.bufPrint(&tmp, "{d}", .{f}) catch unreachable; + try buf.appendSlice(aa, s); + }, + .number_string => |ns| try buf.appendSlice(aa, ns), + .string => |s| try writeString(buf, aa, s), + .array => |arr| { + try buf.append(aa, '['); + for (arr.items, 0..) |e, i| { + if (i > 0) try buf.append(aa, ','); + try writeValue(buf, aa, e); + } + try buf.append(aa, ']'); + }, + .object => |o| { + try buf.append(aa, '{'); + var first = true; + var it = o.iterator(); + while (it.next()) |kv| { + if (!first) try buf.append(aa, ','); + first = false; + try writeString(buf, aa, kv.key_ptr.*); + try buf.append(aa, ':'); + try writeValue(buf, aa, kv.value_ptr.*); + } + try buf.append(aa, '}'); + }, + } +} + +fn writeInt(buf: *std.ArrayList(u8), aa: std.mem.Allocator, n: i64) !void { + var tmp: [24]u8 = undefined; + const s = std.fmt.bufPrint(&tmp, "{d}", .{n}) catch unreachable; + try buf.appendSlice(aa, s); +} + +fn writeString(buf: *std.ArrayList(u8), aa: std.mem.Allocator, s: []const u8) !void { + try buf.append(aa, '"'); + for (s) |c| { + switch (c) { + '"' => try buf.appendSlice(aa, "\\\""), + '\\' => try buf.appendSlice(aa, "\\\\"), + 0x00...0x1f => { + var esc: [6]u8 = undefined; + _ = std.fmt.bufPrint(&esc, "\\u{x:0>4}", .{c}) catch unreachable; + try buf.appendSlice(aa, esc[0..6]); + }, + else => try buf.append(aa, c), + } + } + try buf.append(aa, '"'); +} diff --git a/frameworks/zix/src/shared/dbpg.zig b/frameworks/zix/src/shared/dbpg.zig new file mode 100644 index 000000000..898ad339d --- /dev/null +++ b/frameworks/zix/src/shared/dbpg.zig @@ -0,0 +1,1314 @@ +//! PostgreSQL for the DB endpoints (async-db, crud) over the driver-owned +//! multiplexed transport (postgrez.Transport, .URING), sharded: SHARD_COUNT +//! transport threads each own their slice of the pipelined connections, so +//! reply decode, render, and the client write are not serialized on one +//! thread. Jobs route to a shard by fd (per-fd order). Handlers build a Job +//! and call submitJob, replies render and write from onReply on the shard +//! thread. + +const std = @import("std"); +const zix = @import("zix"); + +const crudcache = @import("crudcache.zig"); +const dbrd = @import("dbrd.zig"); + +const postgrez = zix.Driver.postgrez; +const frontend = postgrez.frontend; +const backend = postgrez.backend; +const row = postgrez.row; + +// --------------------------------------------------------- // + +pub const NAME_MAX = 96; +pub const CATEGORY_MAX = 48; + +/// Widest result set: the crud list adds a count(*) OVER() column to the nine +/// item columns, so ten columns bound the row decode scratch. +const MAX_COLUMNS = 16; + +/// Pipeline depth per connection, matches the driver transport default. +const WINDOW = postgrez.dispatch.DEFAULT_WINDOW; + +/// Bytes one rendered DB body may reach before renderDbRow sheds it. A crud +/// list of 100 rows tops out near 21 KiB. +const DB_BODY_MAX = 32 * 1024; + +/// Transport shards. Each runs its own thread and Transport, splitting the +/// configured connections, so total DB connections stay the same. +const SHARD_COUNT = 2; + +/// In-flight ASYNC_DB scans one shard allows: shard connections times this. +/// Caps how many concurrent price-range scans can over-feed the server. +/// Swept on the isolate bench: 2 under-feeds (43.3K), 4 is the knee (47.1K), +/// 8 thrashes (45.9K, p99 up). Keep 4 while the scan stays sequential. +const SCAN_CAP_PER_CONN = 4; + +// SQL: column order is fixed here, the renderers decode cells by position. +const SQL_ASYNC_DB = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3"; +const SQL_CRUD_LIST = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count, count(*) OVER() AS total FROM items WHERE category = $1 ORDER BY id LIMIT $2 OFFSET $3"; +const SQL_CRUD_GET = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE id = $1"; +const SQL_CRUD_UPSERT = "INSERT INTO items (id, name, category, price, quantity, active, tags, rating_score, rating_count) VALUES ($1, $2, $3, $4, $5, true, '[]', 0, 0) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, category = EXCLUDED.category, price = EXCLUDED.price, quantity = EXCLUDED.quantity"; +const SQL_CRUD_UPDATE = "UPDATE items SET name = $2, category = $3, price = $4, quantity = $5 WHERE id = $1"; + +// Named prepared statements, one per SQL. Created on every connection at open +// (Transport prepare warm-up), so per request only Bind plus Execute is sent. +const STMT_ASYNC_DB = "zix_async_db"; +const STMT_CRUD_LIST = "zix_crud_list"; +const STMT_CRUD_GET = "zix_crud_get"; +const STMT_CRUD_UPSERT = "zix_crud_upsert"; +const STMT_CRUD_UPDATE = "zix_crud_update"; + +const Prepared = struct { + name: []const u8, + sql: []const u8, +}; + +const STATEMENTS = [_]Prepared{ + .{ .name = STMT_ASYNC_DB, .sql = SQL_ASYNC_DB }, + .{ .name = STMT_CRUD_LIST, .sql = SQL_CRUD_LIST }, + .{ .name = STMT_CRUD_GET, .sql = SQL_CRUD_GET }, + .{ .name = STMT_CRUD_UPSERT, .sql = SQL_CRUD_UPSERT }, + .{ .name = STMT_CRUD_UPDATE, .sql = SQL_CRUD_UPDATE }, +}; + +// --------------------------------------------------------- // + +/// One parsed DB request. Strings are copied into fixed buffers because the +/// engine reuses its receive buffer the moment the handler returns. +pub const Job = union(enum) { + ASYNC_DB: struct { + fd: std.posix.fd_t, + min: i64, + max: i64, + limit: i64, + }, + CRUD_LIST: struct { + fd: std.posix.fd_t, + page: i64, + limit: i64, + category_len: u8, + category_buf: [CATEGORY_MAX]u8, + }, + CRUD_GET: struct { + fd: std.posix.fd_t, + id: i64, + }, + CRUD_CREATE: struct { + fd: std.posix.fd_t, + id: i64, + price: i64, + quantity: i64, + name_len: u8, + category_len: u8, + name_buf: [NAME_MAX]u8, + category_buf: [CATEGORY_MAX]u8, + }, + CRUD_UPDATE: struct { + fd: std.posix.fd_t, + id: i64, + price: i64, + quantity: i64, + name_len: u8, + category_len: u8, + name_buf: [NAME_MAX]u8, + category_buf: [CATEGORY_MAX]u8, + }, +}; + +fn jobFd(job: Job) std.posix.fd_t { + return switch (job) { + inline else => |request| request.fd, + }; +} + +/// Close-marked requests must be answered before the handler returns (the +/// engine closes the fd on return). The engine worker spins on done until +/// the shard thread has written the response. +const Completion = struct { + done: std.atomic.Value(bool) = .init(false), +}; + +/// One queued Job plus its optional close-marked completion signal. +const QueuedJob = struct { + job: Job, + completion: ?*Completion = null, +}; + +// --------------------------------------------------------- // + +/// Jobs one shard queue holds (engine workers enqueue, the shard drains). +/// Split across SHARD_COUNT shards this matches the single 8192 queue the +/// unsharded entry ran. +const QUEUE_CAP = 4096; + +/// In-flight Jobs one shard tracks, above its conns times WINDOW ceiling. +const SLOT_CAP = 1024; + +/// Over-cap ASYNC_DB scans one shard can hold before shedding. +const SCAN_HOLD_CAP = 2048; + +/// Client fds one shard can have parked mid-response at once. +const PENDING_CAP = 64; + +const Slot = struct { + job: Job = undefined, + completion: ?*Completion = null, +}; + +/// One stalled client write: the unsent remainder of a response, flushed +/// non-blocking every loop pass. len zero marks the slot free. +const PendingWrite = struct { + fd: std.posix.fd_t = 0, + sent: usize = 0, + len: usize = 0, + buf: [HEAD_MAX + DB_BODY_MAX]u8 = undefined, +}; + +/// One transport shard. Everything except the queue is touched only by the +/// shard's own thread, the queue spinlock serializes the engine workers. +const Shard = struct { + transport: ?*postgrez.Transport = null, + conns: usize = 0, + + q_buf: [QUEUE_CAP]QueuedJob = undefined, + q_head: usize = 0, + q_tail: usize = 0, + q_lock: std.atomic.Value(bool) = .init(false), + + s_slots: [SLOT_CAP]Slot = undefined, + s_free: [SLOT_CAP]usize = undefined, + s_free_count: usize = 0, + + scan_cap: usize = 0, + scan_inflight: usize = 0, + scan_buf: [SCAN_HOLD_CAP]QueuedJob = undefined, + scan_head: usize = 0, + scan_tail: usize = 0, + + pw_slots: [PENDING_CAP]PendingWrite = @splat(.{}), + pw_active: usize = 0, + + fn lock(self: *Shard) void { + while (self.q_lock.swap(true, .acquire)) std.atomic.spinLoopHint(); + } + + fn unlock(self: *Shard) void { + self.q_lock.store(false, .release); + } + + fn enqueue(self: *Shard, item: QueuedJob) bool { + self.lock(); + defer self.unlock(); + + if (self.q_tail -% self.q_head >= QUEUE_CAP) return false; + + self.q_buf[self.q_tail & (QUEUE_CAP - 1)] = item; + self.q_tail +%= 1; + + return true; + } + + fn dequeue(self: *Shard) ?QueuedJob { + self.lock(); + defer self.unlock(); + + if (self.q_head == self.q_tail) return null; + + const item = self.q_buf[self.q_head & (QUEUE_CAP - 1)]; + self.q_head +%= 1; + + return item; + } + + fn slotInit(self: *Shard) void { + for (0..SLOT_CAP) |index| self.s_free[index] = SLOT_CAP - 1 - index; + + self.s_free_count = SLOT_CAP; + } + + fn slotAlloc(self: *Shard, item: QueuedJob) ?u64 { + if (self.s_free_count == 0) return null; + + self.s_free_count -= 1; + const index = self.s_free[self.s_free_count]; + self.s_slots[index] = .{ .job = item.job, .completion = item.completion }; + + return index; + } + + fn slotFree(self: *Shard, tag: u64) void { + self.s_free[self.s_free_count] = @intCast(tag); + self.s_free_count += 1; + } + + /// Take a held scan once an in-flight scan slot is open. + fn scanTake(self: *Shard) ?QueuedJob { + if (self.scan_inflight >= self.scan_cap) return null; + if (self.scan_head == self.scan_tail) return null; + + const item = self.scan_buf[self.scan_head & (SCAN_HOLD_CAP - 1)]; + self.scan_head +%= 1; + + return item; + } + + /// Hold a scan that is over the in-flight cap, false when the ring is full. + fn scanHold(self: *Shard, item: QueuedJob) bool { + if (self.scan_tail -% self.scan_head >= SCAN_HOLD_CAP) return false; + + self.scan_buf[self.scan_tail & (SCAN_HOLD_CAP - 1)] = item; + self.scan_tail +%= 1; + + return true; + } +}; + +// --------------------------------------------------------- // + +// Set once in init before start, read-only afterwards. +var g_io: std.Io = undefined; +var g_config: postgrez.Config = undefined; +var g_enabled: bool = false; +var g_conns: usize = 8; + +var g_shards: [SHARD_COUNT]Shard = @splat(.{}); +var g_open: bool = false; +var g_running: std.atomic.Value(bool) = .init(false); + +/// The shard owned by the current thread, set at transportLoop entry so the +/// send helpers under onReply reach the pending pool without threading a +/// parameter through every renderer. +threadlocal var tl_shard: ?*Shard = null; + +/// Set around a close-marked reply: its delivery must block until fully +/// written because the engine worker closes the fd the moment it returns. +threadlocal var tl_write_blocking: bool = false; + +fn shardOf(fd: std.posix.fd_t) *Shard { + return &g_shards[@intCast(@mod(fd, SHARD_COUNT))]; +} + +// --------------------------------------------------------- // + +/// Read DATABASE_URL and DATABASE_MAX_CONN once at startup. Absent or +/// malformed DATABASE_URL leaves the DB endpoints answering 503. +pub fn init(process: std.process.Init) void { + const url_text = process.environ_map.get("DATABASE_URL") orelse return; + + g_config = postgrez.parseUrl(url_text) catch return; + g_config.tls = .OFF; + g_config.dispatch_model = .URING; + g_io = process.io; + g_enabled = true; + + if (process.environ_map.get("DATABASE_MAX_CONN")) |max_text| { + if (std.fmt.parseInt(usize, max_text, 10)) |parsed| { + if (parsed > 0) g_conns = parsed; + } else |_| {} + } else { + const cpu = std.Thread.getCpuCount() catch 8; + g_conns = std.math.clamp(cpu, 4, 16); + } +} + +pub fn enabled() bool { + return g_enabled; +} + +/// Open one multiplexed transport per shard and spawn its poll-loop thread, +/// splitting the configured connections. Does nothing when DATABASE_URL was +/// absent, so non-DB profiles spawn no extra threads. +pub fn start() void { + if (!g_enabled) return; + + // Pre-encode one Parse plus Sync per named statement, handed to open so the + // transport parses each on every connection before the pipelined loop runs. + var prepare_buf: [4096]u8 = undefined; + var fixed = std.heap.FixedBufferAllocator.init(&prepare_buf); + const allocator = fixed.allocator(); + + var prepares: [STATEMENTS.len][]const u8 = undefined; + inline for (STATEMENTS, 0..) |spec, index| { + var out: std.ArrayList(u8) = .empty; + frontend.parse(allocator, &out, spec.name, spec.sql, &.{}) catch { + g_enabled = false; + return; + }; + frontend.sync(allocator, &out) catch { + g_enabled = false; + return; + }; + + prepares[index] = out.items; + } + + g_running.store(true, .release); + + var opened: usize = 0; + for (&g_shards, 0..) |*shard, index| { + shard.conns = @max(1, g_conns / SHARD_COUNT + @intFromBool(index < g_conns % SHARD_COUNT)); + shard.scan_cap = shard.conns * SCAN_CAP_PER_CONN; + shard.slotInit(); + + shard.transport = postgrez.Transport.open(std.heap.smp_allocator, g_io, g_config, .{ + .model = .URING, + .conns = shard.conns, + .window = WINDOW, + .context = shard, + .on_reply = onReply, + .prepare = &prepares, + }) catch break; + + const thread = std.Thread.spawn(.{}, transportLoop, .{shard}) catch break; + thread.detach(); + + opened += 1; + } + + if (opened < SHARD_COUNT) { + g_enabled = false; + g_running.store(false, .release); + + return; + } + + g_open = true; +} + +/// Queue a Job on its fd's shard thread. +/// +/// Note: +/// - keep_alive false marks a close request: this blocks until the shard +/// thread writes the response (the engine closes the fd on return). +/// +/// Return: +/// - true when the response is owned by the shard thread (or already written) +/// - false when the transport is down or the queue is full (shed 503) +pub fn submit(job: Job, keep_alive: bool) bool { + if (!g_open) return false; + + const shard = shardOf(jobFd(job)); + if (keep_alive) return shard.enqueue(.{ .job = job }); + + var completion: Completion = .{}; + if (!shard.enqueue(.{ .job = job, .completion = &completion })) return false; + + while (!completion.done.load(.acquire)) std.atomic.spinLoopHint(); + + return true; +} + +/// Queue a Job on its fd's shard thread. For a close request dbpg.submit blocks +/// until the response is written, so a deferred write never races the fd close. +pub fn submitJob(head: *const zix.Http1.ParsedHead, job: Job) bool { + return submit(job, head.keep_alive); +} + +// --------------------------------------------------------- // + +/// One shard's thread: drain queued Jobs into the transport, poll for +/// replies, then flush parked client writes. A held-over Job retries before +/// the next dequeue, a held scan re-enters first once a scan slot frees. +fn transportLoop(shard: *Shard) void { + tl_shard = shard; + + const transport = shard.transport.?; + + var holdover: ?QueuedJob = null; + + while (g_running.load(.acquire)) { + var progressed = false; + + while (shard.s_free_count > 0) { + const item = holdover orelse shard.scanTake() orelse shard.dequeue() orelse break; + holdover = null; + + if (item.job == .ASYNC_DB and shard.scan_inflight >= shard.scan_cap) { + if (!shard.scanHold(item)) shed(item); + progressed = true; + continue; + } + + if (serveFromCache(item)) { + progressed = true; + continue; + } + + const tag = shard.slotAlloc(item) orelse { + holdover = item; + break; + }; + + const request = buildRequest(item.job) catch { + shard.slotFree(tag); + shed(item); + progressed = true; + continue; + }; + + if (!transport.submit(request, tag)) { + shard.slotFree(tag); + holdover = item; + break; + } + + if (item.job == .ASYNC_DB) shard.scan_inflight += 1; + progressed = true; + } + + if (transport.pending() > 0) { + _ = transport.poll() catch {}; + progressed = true; + } + + if (shard.pw_active > 0 and flushPendingWrites(shard)) progressed = true; + + if (!progressed) idle(); + } +} + +/// Brief park when nothing is queued and nothing is in flight, so the loop +/// does not busy-spin at idle. Under load the poll above always has work, so +/// this never runs. +fn idle() void { + const req = std.os.linux.timespec{ .sec = 0, .nsec = 200 * std.time.ns_per_us }; + + _ = std.os.linux.nanosleep(&req, null); +} + +/// A crud read may have been filled between the engine-worker miss and this +/// dequeue. Answer from the cache when so, the database is not hit twice. +/// +/// Return: +/// - true when answered from the cache (Job consumed) +/// - false when the Job still needs the database +fn serveFromCache(item: QueuedJob) bool { + tl_write_blocking = item.completion != null; + defer tl_write_blocking = false; + + switch (item.job) { + .CRUD_GET => |request| { + if (crudcache.get(request.id, &db_body_buf)) |len| { + sendCrudBody(request.fd, db_body_buf[0..len], "HIT"); + signal(item); + + return true; + } + }, + .CRUD_LIST => |request| { + const category = request.category_buf[0..request.category_len]; + const key = crudcache.listKey(category, request.page, request.limit); + if (crudcache.listGetFresh(key, &db_body_buf)) |len| { + sendJson(request.fd, db_body_buf[0..len]); + signal(item); + + return true; + } + }, + else => {}, + } + + return false; +} + +/// Shed a Job the transport could not accept: 503 the fd and release any +/// close-request waiter. +fn shed(item: QueuedJob) void { + tl_write_blocking = item.completion != null; + defer tl_write_blocking = false; + + send503(jobFd(item.job)); + signal(item); +} + +/// Release a close-request waiter, no-op for a keep-alive Job. +fn signal(item: QueuedJob) void { + if (item.completion) |completion| completion.done.store(true, .release); +} + +// --------------------------------------------------------- // + +// Per shard-thread scratch. One reply is decoded at a time on each shard +// thread, so a per-thread buffer set is safe. +threadlocal var db_body_buf: [DB_BODY_MAX]u8 = undefined; +threadlocal var request_buf: [8 * 1024]u8 = undefined; +threadlocal var param_scratch: [8][24]u8 = undefined; + +/// Route one Job to its prepared statement name. +fn stmtName(job: Job) []const u8 { + return switch (job) { + .ASYNC_DB => STMT_ASYNC_DB, + .CRUD_LIST => STMT_CRUD_LIST, + .CRUD_GET => STMT_CRUD_GET, + .CRUD_CREATE => STMT_CRUD_UPSERT, + .CRUD_UPDATE => STMT_CRUD_UPDATE, + }; +} + +/// Encode one Job as Bind plus Describe plus Execute plus Sync against its +/// named prepared statement. Params bind as text, results come back binary. +/// The returned slice lives in request_buf, consumed by transport.submit. +/// +/// Return: +/// - []const u8 request bytes +/// - error.OutOfMemory when the encoding overruns request_buf +fn buildRequest(job: Job) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + + var fixed = std.heap.FixedBufferAllocator.init(&request_buf); + const allocator = fixed.allocator(); + + var params: [5]?[]const u8 = undefined; + switch (job) { + .ASYNC_DB => |request| { + params[0] = intParam(0, request.min); + params[1] = intParam(1, request.max); + params[2] = intParam(2, request.limit); + }, + .CRUD_LIST => |request| { + const offset = (request.page - 1) * request.limit; + params[0] = request.category_buf[0..request.category_len]; + params[1] = intParam(1, request.limit); + params[2] = intParam(2, offset); + }, + .CRUD_GET => |request| { + params[0] = intParam(0, request.id); + }, + .CRUD_CREATE => |request| { + params[0] = intParam(0, request.id); + params[1] = request.name_buf[0..request.name_len]; + params[2] = request.category_buf[0..request.category_len]; + params[3] = intParam(3, request.price); + params[4] = intParam(4, request.quantity); + }, + .CRUD_UPDATE => |request| { + params[0] = intParam(0, request.id); + params[1] = request.name_buf[0..request.name_len]; + params[2] = request.category_buf[0..request.category_len]; + params[3] = intParam(3, request.price); + params[4] = intParam(4, request.quantity); + }, + } + + const param_count: usize = switch (job) { + .ASYNC_DB, .CRUD_LIST => 3, + .CRUD_GET => 1, + .CRUD_CREATE, .CRUD_UPDATE => 5, + }; + + try frontend.bind(allocator, &out, "", stmtName(job), &.{}, params[0..param_count], &[_]frontend.Format{.BINARY}); + try frontend.describePortal(allocator, &out, ""); + try frontend.execute(allocator, &out, "", 0); + try frontend.sync(allocator, &out); + + return out.items; +} + +/// Text-encode an i64 parameter into slot `index` of the scratch, returning a +/// slice valid until the next buildRequest on this thread. +fn intParam(index: usize, value: i64) []const u8 { + return std.fmt.bufPrint(¶m_scratch[index], "{d}", .{value}) catch unreachable; +} + +// --------------------------------------------------------- // + +/// Reply sink, fired by the shard's transport once per completed request in +/// submit order. Decodes the backend messages, renders the response, writes +/// it to the fd, then releases the slot (and the scan slot for ASYNC_DB). +fn onReply(context: ?*anyopaque, tag: u64, reply: []const u8) void { + const shard: *Shard = @ptrCast(@alignCast(context.?)); + + const slot = &shard.s_slots[@intCast(tag)]; + const job = slot.job; + const completion = slot.completion; + + tl_write_blocking = completion != null; + defer tl_write_blocking = false; + + switch (job) { + .ASYNC_DB => |request| renderRows(reply, request.fd, .ASYNC_DB, .{}), + .CRUD_LIST => |request| renderRows(reply, request.fd, .CRUD_LIST, .{ + .page = request.page, + .limit = request.limit, + .category = request.category_buf[0..request.category_len], + }), + .CRUD_GET => |request| renderRows(reply, request.fd, .CRUD_GET, .{ .id = request.id }), + .CRUD_CREATE => |request| finishWrite(reply, request.fd, request.id, .CREATE), + .CRUD_UPDATE => |request| finishWrite(reply, request.fd, request.id, .UPDATE), + } + + if (completion) |signal_ptr| signal_ptr.done.store(true, .release); + + if (job == .ASYNC_DB) shard.scan_inflight -= 1; + shard.slotFree(tag); +} + +const RowShape = enum { ASYNC_DB, CRUD_LIST, CRUD_GET }; + +const RenderMeta = struct { + id: i64 = 0, + page: i64 = 0, + limit: i64 = 0, + category: []const u8 = "", +}; + +/// Walk a SELECT-shaped reply: capture the RowDescription columns, render each +/// DataRow with renderDbRow, and write the shaped JSON. A server error sheds +/// 503, a missing single item answers 404. +fn renderRows(reply: []const u8, fd: std.posix.fd_t, shape: RowShape, meta: RenderMeta) void { + var columns: [MAX_COLUMNS]row.ColumnInfo = undefined; + var column_count: usize = 0; + + var cells: [MAX_COLUMNS]?[]const u8 = undefined; + + const prefix = "{\"items\":["; + @memcpy(db_body_buf[0..prefix.len], prefix); + var pos: usize = prefix.len; + + var rows: usize = 0; + var total: i64 = 0; + + var walk = MessageWalk{ .bytes = reply }; + while (walk.next() catch { + send503(fd); + return; + }) |message| { + switch (message) { + .error_response => { + send503(fd); + return; + }, + .row_description => |description| { + column_count = 0; + var it = description.iterator(); + while (it.next() catch null) |column| { + if (column_count >= MAX_COLUMNS) break; + + columns[column_count] = .{ .name = column.name, .type_oid = column.type_oid, .format = column.format }; + column_count += 1; + } + }, + .data_row => |data| { + var count: usize = 0; + var it = data.iterator(); + while (it.next() catch null) |cell| { + if (count >= MAX_COLUMNS) break; + + cells[count] = cell; + count += 1; + } + + if (shape == .CRUD_GET) { + const len = renderDbRow(&db_body_buf, 0, columns[0..column_count], cells[0..count]) catch { + send503(fd); + return; + }; + finishCrudGet(meta.id, db_body_buf[0..len], fd); + + return; + } + + if (rows > 0) { + db_body_buf[pos] = ','; + pos += 1; + } + pos = renderDbRow(&db_body_buf, pos, columns[0..column_count], cells[0..count]) catch { + send503(fd); + return; + }; + if (shape == .CRUD_LIST and count > 9) { + total = cellInt(columns[0..column_count], cells[0..count], 9) catch total; + } + rows += 1; + }, + else => {}, + } + } + + if (shape == .CRUD_GET) { + send404(fd); + return; + } + + pos = appendStr(&db_body_buf, pos, "],"); + if (shape == .CRUD_LIST) { + pos = appendStr(&db_body_buf, pos, "\"total\":"); + pos = appendI64(&db_body_buf, pos, total); + pos = appendStr(&db_body_buf, pos, ",\"page\":"); + pos = appendI64(&db_body_buf, pos, meta.page); + } else { + pos = appendStr(&db_body_buf, pos, "\"count\":"); + pos = appendInt(&db_body_buf, pos, rows); + } + db_body_buf[pos] = '}'; + pos += 1; + + if (shape == .CRUD_LIST) { + const key = crudcache.listKey(meta.category, meta.page, meta.limit); + crudcache.listPut(key, db_body_buf[0..pos]); + } + + sendJson(fd, db_body_buf[0..pos]); +} + +const WriteKind = enum { CREATE, UPDATE }; + +/// Drive a row-less write reply (insert or update) to ReadyForQuery. On +/// success the cached id is invalidated and a small JSON status answers, a +/// server error sheds 503. +fn finishWrite(reply: []const u8, fd: std.posix.fd_t, id: i64, kind: WriteKind) void { + var walk = MessageWalk{ .bytes = reply }; + while (walk.next() catch { + send503(fd); + return; + }) |message| { + if (message == .error_response) { + send503(fd); + return; + } + } + + invalidateCrud(id); + + switch (kind) { + .CREATE => sendStatus(fd, 201, "{\"status\":\"created\"}"), + .UPDATE => sendStatus(fd, 200, "{\"status\":\"ok\"}"), + } +} + +/// Iterator over the backend messages in one framed reply (up to and +/// including ReadyForQuery). +const MessageWalk = struct { + bytes: []const u8, + pos: usize = 0, + + fn next(self: *MessageWalk) !?backend.BackendMessage { + if (self.pos + 5 > self.bytes.len) return null; + + const tag = self.bytes[self.pos]; + const length = std.mem.readInt(u32, self.bytes[self.pos + 1 ..][0..4], .big); + if (length < 4) return error.BadMessage; + + const total = 1 + @as(usize, length); + if (self.pos + total > self.bytes.len) return null; + + const payload = self.bytes[self.pos + 5 .. self.pos + total]; + self.pos += total; + + return try backend.decode(tag, payload); + } +}; + +// --------------------------------------------------------- // + +/// MISS response plus the two cache fills: the in-process slot and the +/// write-behind Redis mirror. +fn finishCrudGet(id: i64, body: []const u8, fd: std.posix.fd_t) void { + crudcache.put(id, body); + dbrd.mirrorSet(id, body); + + sendCrudBody(fd, body, "MISS"); +} + +/// Drop the cached crud body on every write: the in-process slot first (the +/// read path), the Redis mirror write-behind. +fn invalidateCrud(id: i64) void { + crudcache.remove(id); + dbrd.mirrorDel(id); +} + +// --------------------------------------------------------- // + +fn cellInt(columns: []const row.ColumnInfo, cells: []const ?[]const u8, index: usize) !i64 { + const bytes = cells[index] orelse return error.BadCell; + const column = columns[index]; + + return row.rawDecode(i64, @enumFromInt(column.type_oid), column.format, bytes); +} + +fn cellBool(columns: []const row.ColumnInfo, cells: []const ?[]const u8, index: usize) !bool { + const bytes = cells[index] orelse return error.BadCell; + const column = columns[index]; + + return row.rawDecode(bool, @enumFromInt(column.type_oid), column.format, bytes); +} + +fn cellStr(columns: []const row.ColumnInfo, cells: []const ?[]const u8, index: usize) ![]const u8 { + const bytes = cells[index] orelse return error.BadCell; + const column = columns[index]; + + return row.rawDecode([]const u8, @enumFromInt(column.type_oid), column.format, bytes); +} + +fn appendStr(out: []u8, pos: usize, text: []const u8) usize { + @memcpy(out[pos..][0..text.len], text); + + return pos + text.len; +} + +fn appendInt(out: []u8, pos: usize, value: u64) usize { + var tmp: [24]u8 = undefined; + const rendered = std.fmt.bufPrint(&tmp, "{d}", .{value}) catch unreachable; + @memcpy(out[pos..][0..rendered.len], rendered); + + return pos + rendered.len; +} + +fn appendI64(out: []u8, pos: usize, value: i64) usize { + var tmp: [24]u8 = undefined; + const rendered = std.fmt.bufPrint(&tmp, "{d}", .{value}) catch unreachable; + @memcpy(out[pos..][0..rendered.len], rendered); + + return pos + rendered.len; +} + +/// Append a JSON string body (quotes are the caller's), escaped. Worst case +/// is 6x the input, the renderDbRow budget accounts for it. +fn appendJsonStr(out: []u8, begin: usize, value: []const u8) usize { + const HEX = "0123456789abcdef"; + + var pos = begin; + for (value) |char| { + switch (char) { + '"', '\\' => { + out[pos] = '\\'; + out[pos + 1] = char; + pos += 2; + }, + 0x00...0x1f => { + out[pos..][0..4].* = "\\u00".*; + out[pos + 4] = HEX[char >> 4]; + out[pos + 5] = HEX[char & 0xf]; + pos += 6; + }, + else => { + out[pos] = char; + pos += 1; + }, + } + } + + return pos; +} + +/// Render one items row as a JSON object, cells by SQL column order. tags is +/// jsonb text, emitted raw. +fn renderDbRow(out: []u8, begin: usize, columns: []const row.ColumnInfo, cells: []const ?[]const u8) !usize { + const name = try cellStr(columns, cells, 1); + const category = try cellStr(columns, cells, 2); + const tags = try cellStr(columns, cells, 6); + if (begin + name.len * 6 + category.len * 6 + tags.len + 192 > out.len) return error.NoSpaceLeft; + + var pos = begin; + pos = appendStr(out, pos, "{\"id\":"); + pos = appendI64(out, pos, try cellInt(columns, cells, 0)); + pos = appendStr(out, pos, ",\"name\":\""); + pos = appendJsonStr(out, pos, name); + pos = appendStr(out, pos, "\",\"category\":\""); + pos = appendJsonStr(out, pos, category); + pos = appendStr(out, pos, "\",\"price\":"); + pos = appendI64(out, pos, try cellInt(columns, cells, 3)); + pos = appendStr(out, pos, ",\"quantity\":"); + pos = appendI64(out, pos, try cellInt(columns, cells, 4)); + pos = appendStr(out, pos, ",\"active\":"); + pos = appendStr(out, pos, if (try cellBool(columns, cells, 5)) "true" else "false"); + pos = appendStr(out, pos, ",\"tags\":"); + pos = appendStr(out, pos, tags); + pos = appendStr(out, pos, ",\"rating\":{\"score\":"); + pos = appendI64(out, pos, try cellInt(columns, cells, 7)); + pos = appendStr(out, pos, ",\"count\":"); + pos = appendI64(out, pos, try cellInt(columns, cells, 8)); + pos = appendStr(out, pos, "}}"); + + return pos; +} + +// --------------------------------------------------------- // + +/// Bytes one response head may reach (status line, Content-Type, +/// Content-Length, Date). +const HEAD_MAX = 192; + +/// Status lines for the transport-side responses, byte-matching the engine. +fn statusLine(status: u16) []const u8 { + return switch (status) { + 200 => "HTTP/1.1 200 OK\r\n", + 201 => "HTTP/1.1 201 Created\r\n", + 404 => "HTTP/1.1 404 Not Found\r\n", + 503 => "HTTP/1.1 503 Service Unavailable\r\n", + else => "HTTP/1.1 500 Internal Server Error\r\n", + }; +} + +/// Build a response head byte-matching what the engine fd writers emit on a +/// shard thread (Content-Type, Content-Length, Date). +fn buildHead(buf: []u8, status: u16, content_type: []const u8, body_len: usize) []const u8 { + var pos: usize = 0; + pos = appendStr(buf, pos, statusLine(status)); + pos = appendStr(buf, pos, "Content-Type: "); + pos = appendStr(buf, pos, content_type); + pos = appendStr(buf, pos, "\r\nContent-Length: "); + pos = appendInt(buf, pos, body_len); + pos = appendStr(buf, pos, "\r\nDate: "); + pos = appendStr(buf, pos, httpDate()); + pos = appendStr(buf, pos, "\r\n\r\n"); + + return buf[0..pos]; +} + +threadlocal var date_secs: u64 = 0; +threadlocal var date_len: usize = 0; +threadlocal var date_tick: u8 = 0; +threadlocal var date_buf: [40]u8 = undefined; + +/// Cached RFC 7231 date, refreshed at most once per 256 responses (the same +/// pacing as the engine's date cache). +fn httpDate() []const u8 { + date_tick +%= 1; + if (date_tick == 0 or date_len == 0) { + var ts: std.os.linux.timespec = undefined; + _ = std.os.linux.clock_gettime(.REALTIME, &ts); + + const secs: u64 = if (ts.sec >= 0) @intCast(ts.sec) else 0; + if (secs != date_secs or date_len == 0) { + date_len = formatHttpDate(secs, &date_buf).len; + date_secs = secs; + } + } + + return date_buf[0..date_len]; +} + +fn formatHttpDate(secs: u64, buf: []u8) []u8 { + const ep = std.time.epoch; + const es = ep.EpochSeconds{ .secs = secs }; + const epoch_day = es.getEpochDay(); + const year_day = epoch_day.calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + const day_secs = es.getDaySeconds(); + const day_names = [_][]const u8{ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }; + const month_names = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + const dow = (@as(u64, epoch_day.day) % 7 + 4) % 7; + + return std.fmt.bufPrint(buf, "{s}, {d:0>2} {s} {d} {d:0>2}:{d:0>2}:{d:0>2} GMT", .{ + day_names[dow], + @as(u32, month_day.day_index) + 1, + month_names[@intFromEnum(month_day.month) - 1], + year_day.year, + day_secs.getHoursIntoDay(), + day_secs.getMinutesIntoHour(), + day_secs.getSecondsIntoMinute(), + }) catch buf[0..0]; +} + +// --------------------------------------------------------- // + +/// Write one response (head then body) without letting a stalled client +/// block the shard thread: send non-blocking, park the remainder on a full +/// socket buffer, keep per-fd order by appending behind a parked response. +/// +/// Note: +/// - a close-marked reply (tl_write_blocking) writes blocking instead, the +/// engine worker closes the fd the moment it returns. +fn deliver(fd: std.posix.fd_t, head: []const u8, body: []const u8) void { + const shard = tl_shard orelse { + deliverBlocking(fd, head, body, 0); + return; + }; + + if (tl_write_blocking) { + if (shard.pw_active > 0) { + if (findPending(shard, fd)) |slot| flushSlotBlocking(shard, slot); + } + + deliverBlocking(fd, head, body, 0); + + return; + } + + if (shard.pw_active > 0) { + if (findPending(shard, fd)) |slot| { + if (slot.len + head.len + body.len <= slot.buf.len) { + @memcpy(slot.buf[slot.len..][0..head.len], head); + @memcpy(slot.buf[slot.len + head.len ..][0..body.len], body); + slot.len += head.len + body.len; + + return; + } + + flushSlotBlocking(shard, slot); + } + } + + const total = head.len + body.len; + var sent: usize = 0; + while (sent < total) { + var iovs: [2]std.posix.iovec_const = undefined; + var iov_count: usize = 0; + if (sent < head.len) { + iovs[0] = .{ .base = head[sent..].ptr, .len = head.len - sent }; + iovs[1] = .{ .base = body.ptr, .len = body.len }; + iov_count = 2; + } else { + iovs[0] = .{ .base = body[sent - head.len ..].ptr, .len = total - sent }; + iov_count = 1; + } + + const msg = std.os.linux.msghdr_const{ + .name = null, + .namelen = 0, + .iov = &iovs, + .iovlen = iov_count, + .control = null, + .controllen = 0, + .flags = 0, + }; + const rc = std.os.linux.sendmsg(fd, &msg, std.os.linux.MSG.DONTWAIT | std.os.linux.MSG.NOSIGNAL); + switch (std.posix.errno(rc)) { + .SUCCESS => { + const sent_now: usize = @intCast(rc); + if (sent_now == 0) return; + + sent += sent_now; + }, + .INTR => continue, + .AGAIN => { + park(shard, fd, head, body, sent); + return; + }, + else => return, + } + } +} + +/// Blocking tail of a delivery, from `sent` bytes into head plus body. +fn deliverBlocking(fd: std.posix.fd_t, head: []const u8, body: []const u8, sent: usize) void { + if (sent < head.len) { + zix.Http1.writeAllFD(fd, head[sent..]) catch return; + zix.Http1.writeAllFD(fd, body) catch {}; + + return; + } + + zix.Http1.writeAllFD(fd, body[sent - head.len ..]) catch {}; +} + +/// Park the unsent remainder of a response in the shard's pending pool. A +/// full pool finishes blocking instead (the pre-parking behavior). +fn park(shard: *Shard, fd: std.posix.fd_t, head: []const u8, body: []const u8, sent: usize) void { + const slot = allocPending(shard, fd) orelse { + deliverBlocking(fd, head, body, sent); + return; + }; + + var len: usize = 0; + if (sent < head.len) { + const head_rest = head[sent..]; + @memcpy(slot.buf[0..head_rest.len], head_rest); + len = head_rest.len; + @memcpy(slot.buf[len..][0..body.len], body); + len += body.len; + } else { + const body_rest = body[sent - head.len ..]; + @memcpy(slot.buf[0..body_rest.len], body_rest); + len = body_rest.len; + } + + slot.sent = 0; + slot.len = len; + shard.pw_active += 1; +} + +fn findPending(shard: *Shard, fd: std.posix.fd_t) ?*PendingWrite { + for (&shard.pw_slots) |*slot| { + if (slot.len > 0 and slot.fd == fd) return slot; + } + + return null; +} + +fn allocPending(shard: *Shard, fd: std.posix.fd_t) ?*PendingWrite { + for (&shard.pw_slots) |*slot| { + if (slot.len == 0) { + slot.fd = fd; + return slot; + } + } + + return null; +} + +/// Drain one parked slot blocking (order guard before a same-fd write). +fn flushSlotBlocking(shard: *Shard, slot: *PendingWrite) void { + zix.Http1.writeAllFD(slot.fd, slot.buf[slot.sent..slot.len]) catch {}; + + slot.len = 0; + slot.sent = 0; + shard.pw_active -= 1; +} + +/// One non-blocking pass over the shard's parked responses. +/// +/// Return: +/// - true when any parked bytes moved or a slot freed +fn flushPendingWrites(shard: *Shard) bool { + var progressed = false; + + var remaining = shard.pw_active; + for (&shard.pw_slots) |*slot| { + if (remaining == 0) break; + if (slot.len == 0) continue; + + remaining -= 1; + + while (slot.sent < slot.len) { + const rc = std.os.linux.sendto(slot.fd, slot.buf[slot.sent..].ptr, slot.len - slot.sent, std.os.linux.MSG.DONTWAIT | std.os.linux.MSG.NOSIGNAL, null, 0); + switch (std.posix.errno(rc)) { + .SUCCESS => { + const sent_now: usize = @intCast(rc); + if (sent_now == 0) break; + + slot.sent += sent_now; + progressed = true; + }, + .INTR => continue, + .AGAIN => break, + else => { + // client gone: drop the remainder + slot.sent = slot.len; + progressed = true; + }, + } + } + + if (slot.sent >= slot.len) { + slot.len = 0; + slot.sent = 0; + shard.pw_active -= 1; + } + } + + return progressed; +} + +// --------------------------------------------------------- // + +fn sendJson(fd: std.posix.fd_t, body: []const u8) void { + var head_buf: [HEAD_MAX]u8 = undefined; + + deliver(fd, buildHead(&head_buf, 200, "application/json", body.len), body); +} + +fn sendStatus(fd: std.posix.fd_t, status: u16, body: []const u8) void { + var head_buf: [HEAD_MAX]u8 = undefined; + + deliver(fd, buildHead(&head_buf, status, "application/json", body.len), body); +} + +fn send503(fd: std.posix.fd_t) void { + const body = "Service Unavailable"; + var head_buf: [HEAD_MAX]u8 = undefined; + + deliver(fd, buildHead(&head_buf, 503, "text/plain", body.len), body); +} + +fn send404(fd: std.posix.fd_t) void { + const body = "Not Found"; + var head_buf: [HEAD_MAX]u8 = undefined; + + deliver(fd, buildHead(&head_buf, 404, "text/plain", body.len), body); +} + +/// 200 JSON response with the X-Cache header the crud cache check reads. +fn sendCrudBody(fd: std.posix.fd_t, body: []const u8, cache_state: []const u8) void { + var hdr_buf: [128]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Cache: {s}\r\nContent-Length: {d}\r\n\r\n", .{ cache_state, body.len }) catch return; + + deliver(fd, hdr, body); +} + +// --------------------------------------------------------- // +// --------------------------------------------------------- // + +test "3e test: buildHead matches the engine header shape" { + var buf: [HEAD_MAX]u8 = undefined; + const head = buildHead(&buf, 200, "application/json", 5); + + try std.testing.expect(std.mem.startsWith(u8, head, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 5\r\nDate: ")); + try std.testing.expect(std.mem.endsWith(u8, head, " GMT\r\n\r\n")); +} + +test "3e test: scan hold ring admits under cap and holds at cap" { + const shard = try std.testing.allocator.create(Shard); + defer std.testing.allocator.destroy(shard); + + shard.* = .{}; + shard.scan_cap = 2; + + const job: Job = .{ .ASYNC_DB = .{ .fd = 0, .min = 1, .max = 2, .limit = 3 } }; + + try std.testing.expect(shard.scanTake() == null); + try std.testing.expect(shard.scanHold(.{ .job = job })); + try std.testing.expect(shard.scanTake() != null); + + shard.scan_inflight = 2; + try std.testing.expect(shard.scanHold(.{ .job = job })); + try std.testing.expect(shard.scanTake() == null); + + shard.scan_inflight = 1; + try std.testing.expect(shard.scanTake() != null); +} + +test "3e test: deliver parks on a full socket and flush completes it" { + const linux = std.os.linux; + + const shard = try std.testing.allocator.create(Shard); + defer std.testing.allocator.destroy(shard); + + shard.* = .{}; + tl_shard = shard; + defer tl_shard = null; + + var fds: [2]i32 = undefined; + try std.testing.expect(linux.socketpair(linux.AF.UNIX, linux.SOCK.STREAM, 0, &fds) == 0); + defer _ = linux.close(fds[0]); + defer _ = linux.close(fds[1]); + + // Shrink the send buffer so the body cannot fit in one non-blocking send. + const sndbuf: u32 = 4096; + _ = linux.setsockopt(fds[0], linux.SOL.SOCKET, linux.SO.SNDBUF, @ptrCast(&sndbuf), @sizeOf(u32)); + + const body = try std.testing.allocator.alloc(u8, DB_BODY_MAX); + defer std.testing.allocator.free(body); + + @memset(body, 'x'); + + var head_buf: [HEAD_MAX]u8 = undefined; + const head = buildHead(&head_buf, 200, "application/json", body.len); + const total = head.len + body.len; + + deliver(fds[0], head, body); + + try std.testing.expect(shard.pw_active == 1); + + // Drain the peer while flushing until the parked remainder is gone. + var received: usize = 0; + var recv_buf: [8192]u8 = undefined; + var rounds: usize = 0; + while (received < total) : (rounds += 1) { + try std.testing.expect(rounds < 100_000); + + const rc = linux.recvfrom(fds[1], &recv_buf, recv_buf.len, linux.MSG.DONTWAIT, null, null); + switch (std.posix.errno(rc)) { + .SUCCESS => received += @intCast(rc), + .AGAIN => {}, + else => return error.RecvFailed, + } + + _ = flushPendingWrites(shard); + } + + try std.testing.expect(shard.pw_active == 0); + try std.testing.expect(received == total); + try std.testing.expect(recv_buf[0] == 'x'); +} diff --git a/frameworks/zix/src/shared/dbrd.zig b/frameworks/zix/src/shared/dbrd.zig new file mode 100644 index 000000000..333378aed --- /dev/null +++ b/frameworks/zix/src/shared/dbrd.zig @@ -0,0 +1,224 @@ +//! Redis write-behind mirror over the driver-owned multiplexed transport +//! (rediz.Transport, .URING). The read path is the in-process cache +//! (crudcache.zig), Redis only carries a write-behind mirror: crud cache +//! fills SET the key, crud writes DEL it. Replies are never awaited, one +//! transport thread drains queued commands and the reply sink is a no-op. + +const std = @import("std"); +const zix = @import("zix"); + +const rediz = zix.Driver.rediz; + +// --------------------------------------------------------- // + +const CRUD_KEY_PREFIX = "crud:item:"; + +/// Mirror TTL in seconds, the write path additionally deletes the key. +const CRUD_CACHE_TTL = "1"; + +/// Pipeline depth per connection, matches the driver transport default. +const WINDOW = rediz.dispatch.DEFAULT_WINDOW; + +/// One encoded command cap: SET crud:item: EX 1, body <= 256. +const CMD_MAX = 512; + +/// A body larger than this skips the mirror (its command would overrun CMD_MAX). +const BODY_LIMIT = 300; + +// --------------------------------------------------------- // + +// Set once in init before start, read-only afterwards. +var g_io: std.Io = undefined; +var g_config: rediz.Config = undefined; +var g_enabled: bool = false; +var g_conns: usize = 4; + +var g_transport: ?*rediz.Transport = null; +var g_running: std.atomic.Value(bool) = .init(false); + +// --------------------------------------------------------- // + +/// One queued, pre-encoded RESP command. +const Command = struct { + len: u16 = 0, + buf: [CMD_MAX]u8 = undefined, +}; + +/// Bounded queue: the postgrez shard threads enqueue, this thread drains. +const QUEUE_CAP = 8192; + +var q_buf: [QUEUE_CAP]Command = undefined; +var q_head: usize = 0; +var q_tail: usize = 0; +var q_lock: std.atomic.Value(bool) = .init(false); + +fn qLock() void { + while (q_lock.swap(true, .acquire)) std.atomic.spinLoopHint(); +} + +fn qUnlock() void { + q_lock.store(false, .release); +} + +fn enqueue(bytes: []const u8) void { + if (bytes.len > CMD_MAX) return; + + qLock(); + defer qUnlock(); + + if (q_tail -% q_head >= QUEUE_CAP) return; + + const slot = &q_buf[q_tail & (QUEUE_CAP - 1)]; + @memcpy(slot.buf[0..bytes.len], bytes); + slot.len = @intCast(bytes.len); + q_tail +%= 1; +} + +fn dequeue(out: *Command) bool { + qLock(); + defer qUnlock(); + + if (q_head == q_tail) return false; + + out.* = q_buf[q_head & (QUEUE_CAP - 1)]; + q_head +%= 1; + + return true; +} + +// --------------------------------------------------------- // + +/// Read REDIS_URL once at startup. Absent or malformed disables the mirror: +/// the in-process cache still serves x-cache MISS/HIT on its own. +pub fn init(process: std.process.Init) void { + const url_text = process.environ_map.get("REDIS_URL") orelse return; + + g_config = rediz.parseUrl(url_text) catch return; + g_config.tls = .OFF; + g_config.dispatch_model = .URING; + g_io = process.io; + g_enabled = true; + + const cpu = std.Thread.getCpuCount() catch 4; + g_conns = std.math.clamp(cpu, 4, 8); +} + +pub fn enabled() bool { + return g_enabled; +} + +/// Open the multiplexed transport and spawn the thread that owns its poll +/// loop. Does nothing when REDIS_URL was absent. +pub fn start() void { + if (!g_enabled) return; + + g_transport = rediz.Transport.open(std.heap.smp_allocator, g_io, g_config, .{ + .model = .URING, + .conns = g_conns, + .window = WINDOW, + .on_reply = onReply, + }) catch { + g_enabled = false; + return; + }; + + g_running.store(true, .release); + + const thread = std.Thread.spawn(.{}, transportLoop, .{}) catch { + g_enabled = false; + return; + }; + thread.detach(); +} + +/// Mirror a crud item body under its key (cache fill). Fire-and-forget. +pub fn mirrorSet(id: i64, body: []const u8) void { + if (!g_enabled) return; + if (body.len > BODY_LIMIT) return; + + var key_buf: [40]u8 = undefined; + const key = std.fmt.bufPrint(&key_buf, CRUD_KEY_PREFIX ++ "{d}", .{id}) catch return; + + var cmd_buf: [CMD_MAX]u8 = undefined; + const bytes = encode(&cmd_buf, &.{ "SET", key, body, "EX", CRUD_CACHE_TTL }) orelse return; + + enqueue(bytes); +} + +/// Drop a crud item mirror by key (write invalidation). Fire-and-forget. +pub fn mirrorDel(id: i64) void { + if (!g_enabled) return; + + var key_buf: [40]u8 = undefined; + const key = std.fmt.bufPrint(&key_buf, CRUD_KEY_PREFIX ++ "{d}", .{id}) catch return; + + var cmd_buf: [CMD_MAX]u8 = undefined; + const bytes = encode(&cmd_buf, &.{ "DEL", key }) orelse return; + + enqueue(bytes); +} + +/// Encode one RESP command into `scratch`, null when it does not fit. +fn encode(scratch: []u8, args: []const []const u8) ?[]const u8 { + var out: std.ArrayList(u8) = .empty; + + var fixed = std.heap.FixedBufferAllocator.init(scratch); + rediz.resp.encodeCommand(fixed.allocator(), &out, args) catch return null; + + return out.items; +} + +// --------------------------------------------------------- // + +/// The transport thread: drain queued commands into the transport, then poll. +/// A held-over command (the transport was full) is retried before the next +/// dequeue. +fn transportLoop() void { + const transport = g_transport.?; + + var holdover: Command = undefined; + var has_holdover = false; + + while (g_running.load(.acquire)) { + var progressed = false; + + while (true) { + var cmd: Command = undefined; + if (has_holdover) { + cmd = holdover; + has_holdover = false; + } else if (!dequeue(&cmd)) { + break; + } + + if (!transport.submit(cmd.buf[0..cmd.len], 0)) { + holdover = cmd; + has_holdover = true; + break; + } + + progressed = true; + } + + if (transport.pending() > 0) { + _ = transport.poll() catch {}; + progressed = true; + } + + if (!progressed) idle(); + } +} + +fn idle() void { + const req = std.os.linux.timespec{ .sec = 0, .nsec = 200 * std.time.ns_per_us }; + + _ = std.os.linux.nanosleep(&req, null); +} + +/// Reply sink: the mirror is fire-and-forget, so a completed reply only +/// advances the pipeline and is otherwise ignored. +fn onReply(context: ?*anyopaque, tag: u64, reply: []const u8) void { + _ = context; + _ = tag; + _ = reply; +} diff --git a/frameworks/zix/src/shared/encoding.zig b/frameworks/zix/src/shared/encoding.zig new file mode 100644 index 000000000..3fb4fd602 --- /dev/null +++ b/frameworks/zix/src/shared/encoding.zig @@ -0,0 +1,27 @@ +//! Accept-Encoding parsing shared by the static and json handlers: a +//! substring scan for br/gzip tokens, no q-value parsing. + +const std = @import("std"); +const zix = @import("zix"); + +// --------------------------------------------------------- // + +/// Accept-Encoding tokens the client advertised (substring scan, the bench +/// header needs no q-value parsing). +pub const AcceptEncoding = struct { + prefers_br: bool, + accepts_gzip: bool, +}; + +// --------------------------------------------------------- // + +pub fn accept(head: *const zix.Http1.ParsedHead) AcceptEncoding { + const value = + zix.Http1.acceptEncoding(head) orelse + return .{ .prefers_br = false, .accepts_gzip = false }; + + return .{ + .prefers_br = std.mem.indexOf(u8, value, "br") != null, + .accepts_gzip = std.mem.indexOf(u8, value, "gzip") != null, + }; +} diff --git a/frameworks/zix/src/shared/response.zig b/frameworks/zix/src/shared/response.zig new file mode 100644 index 000000000..8113655f1 --- /dev/null +++ b/frameworks/zix/src/shared/response.zig @@ -0,0 +1,22 @@ +//! Shared error responders (400/404/500/503) for the handlers. + +const std = @import("std"); +const zix = @import("zix"); + +// --------------------------------------------------------- // + +pub fn badRequest(fd: std.posix.fd_t) void { + zix.Http1.sendSimpleFD(fd, 400, "text/plain", "Bad Request") catch {}; +} + +pub fn notFound(fd: std.posix.fd_t) void { + zix.Http1.sendSimpleFD(fd, 404, "text/plain", "Not Found") catch {}; +} + +pub fn internalServerError(fd: std.posix.fd_t) void { + zix.Http1.sendSimpleFD(fd, 500, "text/plain", "Internal Server Error") catch {}; +} + +pub fn serviceUnavailable(fd: std.posix.fd_t) void { + zix.Http1.sendSimpleFD(fd, 503, "text/plain", "Service Unavailable") catch {}; +} diff --git a/frameworks/zix/src/shared/util.zig b/frameworks/zix/src/shared/util.zig new file mode 100644 index 000000000..8df9fa381 --- /dev/null +++ b/frameworks/zix/src/shared/util.zig @@ -0,0 +1,17 @@ +//! Tiny byte-buffer appenders (string, integer) shared by the json handler. + +const std = @import("std"); + +// --------------------------------------------------------- // + +pub fn appendStr(out: []u8, pos: usize, s: []const u8) usize { + @memcpy(out[pos..][0..s.len], s); + return pos + s.len; +} + +pub fn appendInt(out: []u8, pos: usize, n: u64) usize { + var tmp: [24]u8 = undefined; + const s = std.fmt.bufPrint(&tmp, "{d}", .{n}) catch unreachable; + @memcpy(out[pos..][0..s.len], s); + return pos + s.len; +} From 9d5b1ffda2b93703dd11cc06fcbba5e8e7bb7253 Mon Sep 17 00:00:00 2001 From: prothegee Date: Tue, 21 Jul 2026 09:52:57 +0700 Subject: [PATCH 24/46] remove stale src file --- frameworks/zix/src/crudcache.zig | 221 ------- frameworks/zix/src/dataset.zig | 165 ----- frameworks/zix/src/dbpg.zig | 141 ----- frameworks/zix/src/dbrd.zig | 51 -- frameworks/zix/src/handler.zig | 1002 ------------------------------ 5 files changed, 1580 deletions(-) delete mode 100644 frameworks/zix/src/crudcache.zig delete mode 100644 frameworks/zix/src/dataset.zig delete mode 100644 frameworks/zix/src/dbpg.zig delete mode 100644 frameworks/zix/src/dbrd.zig delete mode 100644 frameworks/zix/src/handler.zig diff --git a/frameworks/zix/src/crudcache.zig b/frameworks/zix/src/crudcache.zig deleted file mode 100644 index ad1fa6fac..000000000 --- a/frameworks/zix/src/crudcache.zig +++ /dev/null @@ -1,221 +0,0 @@ -//! In-process cache for crud reads. Single-item slots (x-cache MISS/HIT -//! spans workers, invalidated on write) plus rendered list pages -//! (TTL-bounded staleness only, list content is not invalidated on write). -//! Direct-mapped, a lookup verifies the stored key and the TTL. Per-slot -//! spinlock, slots live in BSS. - -const std = @import("std"); - -// --------------------------------------------------------- // - -/// Power of two, covers the 1..50000 bench id keyspace so every id owns its -/// slot (no collision eviction inside the TTL). -const SLOT_COUNT = 65536; -/// Rendered single-item JSON cap, a larger body skips the cache. The seed -/// data bounds a rendered item near 210 bytes (name <= 19, category <= 11, -/// tags <= 48). -const BODY_MAX = 256; - -/// Power of two, list-page slots (the bench mix runs ~10 distinct pages). -const LIST_SLOT_COUNT = 32; -/// Rendered list JSON cap, a larger body skips the cache. -const LIST_BODY_MAX = 4096; - -/// Item TTL is a safety bound only: every write path invalidates its id, so -/// a longer TTL never serves a stale item. Beyond ~5s the write-invalidation -/// rate dominates misses anyway. -const ITEM_TTL_MS: i64 = 5000; -/// List pages are never invalidated on write, this TTL IS the staleness bound. -const LIST_TTL_MS: i64 = 1000; - -const Slot = struct { - lock_flag: std.atomic.Value(bool) = .init(false), - id: i64 = 0, - expires_ms: i64 = 0, - len: u16 = 0, - body: [BODY_MAX]u8 = undefined, -}; - -/// One refresh claim per expired page: the claimer misses (and refills), -/// everyone else serves the stale body meanwhile. A claim not filled within -/// this window may be re-claimed. -const LIST_REFRESH_CLAIM_MS: i64 = 250; - -/// Key-scanned entry (not direct-mapped): the bench runs ~10 distinct -/// pages, a hash-indexed table would let two pages ping-pong one slot and -/// turn every list request into a database job. -const ListEntry = struct { - key: u64 = 0, - expires_ms: i64 = 0, - refresh_at_ms: i64 = 0, - len: u16 = 0, - body: [LIST_BODY_MAX]u8 = undefined, -}; - -var g_slots: [SLOT_COUNT]Slot = @splat(.{}); -var g_list_entries: [LIST_SLOT_COUNT]ListEntry = @splat(.{}); -var g_list_lock: std.atomic.Value(bool) = .init(false); - -fn slotFor(id: i64) ?*Slot { - if (id < 1) return null; - - return &g_slots[@as(usize, @intCast(id)) & (SLOT_COUNT - 1)]; -} - -/// Caller holds g_list_lock. -fn listFind(key: u64) ?*ListEntry { - for (&g_list_entries) |*entry| { - if (entry.key == key) return entry; - } - - return null; -} - -fn lock(flag: *std.atomic.Value(bool)) void { - while (flag.swap(true, .acquire)) std.atomic.spinLoopHint(); -} - -fn unlock(flag: *std.atomic.Value(bool)) void { - flag.store(false, .release); -} - -fn nowMs() i64 { - var ts: std.os.linux.timespec = undefined; - _ = std.os.linux.clock_gettime(.MONOTONIC_COARSE, &ts); - - return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000); -} - -/// Copy a fresh cached body for id into out. -/// -/// Return: -/// - the body length (out[0..len] is the item JSON) -/// - null on a miss (absent, expired, or a colliding id) -pub fn get(id: i64, out: []u8) ?usize { - const slot = slotFor(id) orelse return null; - - lock(&slot.lock_flag); - defer unlock(&slot.lock_flag); - - if (slot.id != id or slot.len == 0) return null; - if (nowMs() >= slot.expires_ms) return null; - - const len: usize = slot.len; - if (len > out.len) return null; - @memcpy(out[0..len], slot.body[0..len]); - - return len; -} - -/// Store a rendered body for id, TTL-bounded. An oversized body is skipped. -pub fn put(id: i64, body: []const u8) void { - if (body.len > BODY_MAX) return; - - const slot = slotFor(id) orelse return; - - lock(&slot.lock_flag); - defer unlock(&slot.lock_flag); - - slot.id = id; - slot.expires_ms = nowMs() + ITEM_TTL_MS; - slot.len = @intCast(body.len); - @memcpy(slot.body[0..body.len], body); -} - -/// Drop the cached body for id (write invalidation). -pub fn remove(id: i64) void { - const slot = slotFor(id) orelse return; - - lock(&slot.lock_flag); - defer unlock(&slot.lock_flag); - - if (slot.id == id) { - slot.len = 0; - slot.expires_ms = 0; - } -} - -/// Cache key of one list page. -pub fn listKey(category: []const u8, page: i64, limit: i64) u64 { - var hasher = std.hash.Wyhash.init(0); - hasher.update(category); - hasher.update(std.mem.asBytes(&page)); - hasher.update(std.mem.asBytes(&limit)); - - return hasher.final(); -} - -/// Copy a cached list body for key into out, single-flight on expiry: the -/// first caller past the TTL misses (claiming the refill), later callers -/// serve the stale body until the refill lands. -/// -/// Return: -/// - the body length (out[0..len] is the list JSON, fresh or stale) -/// - null on a miss (absent, colliding key, or this caller owns the refill) -pub fn listGet(key: u64, out: []u8) ?usize { - lock(&g_list_lock); - defer unlock(&g_list_lock); - - const entry = listFind(key) orelse return null; - if (entry.len == 0) return null; - - const now = nowMs(); - if (now >= entry.expires_ms) { - if (now >= entry.refresh_at_ms) { - entry.refresh_at_ms = now + LIST_REFRESH_CLAIM_MS; - - return null; - } - } - - const len: usize = entry.len; - if (len > out.len) return null; - @memcpy(out[0..len], entry.body[0..len]); - - return len; -} - -/// Fresh-only list lookup for the executor-side re-check: never serves -/// stale and never claims, so a claimed refill always reaches the database. -pub fn listGetFresh(key: u64, out: []u8) ?usize { - lock(&g_list_lock); - defer unlock(&g_list_lock); - - const entry = listFind(key) orelse return null; - if (entry.len == 0) return null; - if (nowMs() >= entry.expires_ms) return null; - - const len: usize = entry.len; - if (len > out.len) return null; - @memcpy(out[0..len], entry.body[0..len]); - - return len; -} - -/// Store a rendered list body for key, TTL-bounded. Reuses the key's entry, -/// else an empty or expired one. An oversized body (or a full table of -/// fresh entries) is skipped. -pub fn listPut(key: u64, body: []const u8) void { - if (body.len > LIST_BODY_MAX) return; - - lock(&g_list_lock); - defer unlock(&g_list_lock); - - const now = nowMs(); - var target: ?*ListEntry = listFind(key); - if (target == null) { - for (&g_list_entries) |*entry| { - if (entry.key == 0 or now >= entry.expires_ms) { - target = entry; - break; - } - } - } - - const entry = target orelse return; - entry.key = key; - entry.expires_ms = now + LIST_TTL_MS; - entry.refresh_at_ms = 0; - entry.len = @intCast(body.len); - @memcpy(entry.body[0..body.len], body); -} diff --git a/frameworks/zix/src/dataset.zig b/frameworks/zix/src/dataset.zig deleted file mode 100644 index dd536615d..000000000 --- a/frameworks/zix/src/dataset.zig +++ /dev/null @@ -1,165 +0,0 @@ -//! Dataset loader for the /json endpoint. -//! -//! Loads the fixed 50-item benchmark dataset once at startup and pre-renders -//! each item as a JSON object fragment (without the closing brace), so the hot -//! path only appends the per-request total and the closing brace. - -const std = @import("std"); - -pub const ItemCount = 50; - -pub const Item = struct { - /// Pre-rendered JSON object for this item, WITHOUT the closing `}`. - /// Caller appends `,"total":}` per request. - prefix: []const u8, - /// price * quantity, pre-multiplied so per-request work is one *m - /// followed by an integer-to-decimal print. - pq: u64, -}; - -pub const Dataset = struct { - items: []Item, - arena: std.heap.ArenaAllocator, - - pub fn deinit(self: *Dataset) void { - self.arena.deinit(); - } -}; - -pub fn load(gpa: std.mem.Allocator, path: []const u8) !Dataset { - var arena = std.heap.ArenaAllocator.init(gpa); - errdefer arena.deinit(); - const aa = arena.allocator(); - - const raw = try readFileAlloc(aa, path, 4 * 1024 * 1024); - - var parsed = try std.json.parseFromSlice(std.json.Value, aa, raw, .{}); - defer parsed.deinit(); - - const arr = switch (parsed.value) { - .array => |a| a, - else => return error.BadDataset, - }; - if (arr.items.len != ItemCount) return error.BadDataset; - - const items = try aa.alloc(Item, ItemCount); - for (arr.items, 0..) |elem, i| { - const obj = switch (elem) { - .object => |o| o, - else => return error.BadDataset, - }; - const price = jsonInt(obj.get("price") orelse return error.BadDataset); - const quantity = jsonInt(obj.get("quantity") orelse return error.BadDataset); - - var buf: std.ArrayList(u8) = .empty; - try renderItemPrefix(&buf, aa, obj); - items[i] = .{ - .prefix = try buf.toOwnedSlice(aa), - .pq = @as(u64, @intCast(price)) * @as(u64, @intCast(quantity)), - }; - } - - return .{ .items = items, .arena = arena }; -} - -fn readFileAlloc(aa: std.mem.Allocator, path: []const u8, max: usize) ![]u8 { - var path_z: [std.posix.PATH_MAX]u8 = undefined; - if (path.len >= path_z.len) return error.NameTooLong; - @memcpy(path_z[0..path.len], path); - path_z[path.len] = 0; - const fd = try std.posix.openatZ(std.posix.AT.FDCWD, @ptrCast(&path_z), .{ .ACCMODE = .RDONLY }, 0); - defer _ = std.posix.system.close(fd); - - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(aa); - try buf.ensureTotalCapacity(aa, 64 * 1024); - while (buf.items.len < max) { - try buf.ensureUnusedCapacity(aa, 32 * 1024); - const dst = buf.unusedCapacitySlice(); - const n = try std.posix.read(fd, dst); - if (n == 0) break; - buf.items.len += n; - } - return buf.toOwnedSlice(aa); -} - -fn jsonInt(v: std.json.Value) i64 { - return switch (v) { - .integer => |n| n, - .float => |f| @intFromFloat(f), - else => 0, - }; -} - -fn renderItemPrefix(buf: *std.ArrayList(u8), aa: std.mem.Allocator, obj: std.json.ObjectMap) !void { - try buf.append(aa, '{'); - var first = true; - var it = obj.iterator(); - while (it.next()) |kv| { - if (!first) try buf.append(aa, ','); - first = false; - try writeString(buf, aa, kv.key_ptr.*); - try buf.append(aa, ':'); - try writeValue(buf, aa, kv.value_ptr.*); - } - // Intentionally no closing `}`: the caller appends `,"total":N}`. -} - -fn writeValue(buf: *std.ArrayList(u8), aa: std.mem.Allocator, v: std.json.Value) !void { - switch (v) { - .null => try buf.appendSlice(aa, "null"), - .bool => |b| try buf.appendSlice(aa, if (b) "true" else "false"), - .integer => |n| try writeInt(buf, aa, n), - .float => |f| { - var tmp: [32]u8 = undefined; - const s = std.fmt.bufPrint(&tmp, "{d}", .{f}) catch unreachable; - try buf.appendSlice(aa, s); - }, - .number_string => |ns| try buf.appendSlice(aa, ns), - .string => |s| try writeString(buf, aa, s), - .array => |arr| { - try buf.append(aa, '['); - for (arr.items, 0..) |e, i| { - if (i > 0) try buf.append(aa, ','); - try writeValue(buf, aa, e); - } - try buf.append(aa, ']'); - }, - .object => |o| { - try buf.append(aa, '{'); - var first = true; - var it = o.iterator(); - while (it.next()) |kv| { - if (!first) try buf.append(aa, ','); - first = false; - try writeString(buf, aa, kv.key_ptr.*); - try buf.append(aa, ':'); - try writeValue(buf, aa, kv.value_ptr.*); - } - try buf.append(aa, '}'); - }, - } -} - -fn writeInt(buf: *std.ArrayList(u8), aa: std.mem.Allocator, n: i64) !void { - var tmp: [24]u8 = undefined; - const s = std.fmt.bufPrint(&tmp, "{d}", .{n}) catch unreachable; - try buf.appendSlice(aa, s); -} - -fn writeString(buf: *std.ArrayList(u8), aa: std.mem.Allocator, s: []const u8) !void { - try buf.append(aa, '"'); - for (s) |c| { - switch (c) { - '"' => try buf.appendSlice(aa, "\\\""), - '\\' => try buf.appendSlice(aa, "\\\\"), - 0x00...0x1f => { - var esc: [6]u8 = undefined; - _ = std.fmt.bufPrint(&esc, "\\u{x:0>4}", .{c}) catch unreachable; - try buf.appendSlice(aa, esc[0..6]); - }, - else => try buf.append(aa, c), - } - } - try buf.append(aa, '"'); -} diff --git a/frameworks/zix/src/dbpg.zig b/frameworks/zix/src/dbpg.zig deleted file mode 100644 index e7375dac3..000000000 --- a/frameworks/zix/src/dbpg.zig +++ /dev/null @@ -1,141 +0,0 @@ -//! PostgreSQL for the DB endpoints: parses DATABASE_URL and -//! DATABASE_MAX_CONN, then owns the postgrez.Executor that runs every round -//! trip off the engine workers. The batching, pooling, and prepared-statement -//! caching all live in the driver (postgrez.Executor), this module only wires -//! it to the entry's Job type and the request routes. - -const std = @import("std"); -const zix = @import("zix"); - -const postgrez = zix.Driver.postgrez; - -// --------------------------------------------------------- // - -pub const NAME_MAX = 96; -pub const CATEGORY_MAX = 48; - -/// One prepared SQL slot per distinct statement the entry runs. The enum -/// value indexes the executor's per-connection statement cache. -pub const StatementId = enum(usize) { - ASYNC_DB, - CRUD_LIST, - CRUD_GET, - CRUD_UPSERT, - CRUD_UPDATE, -}; - -pub const STATEMENT_COUNT = 5; - -/// Jobs one batch drains, matches the driver max_pending_replies default. -pub const BATCH_MAX = 16; - -/// The cache slot for a StatementId, for Batch.statement. -pub fn slot(id: StatementId) usize { - return @intFromEnum(id); -} - -/// One parsed DB request. Strings are copied into fixed buffers, the engine's -/// receive buffer is reused the moment the handler returns. -pub const Job = union(enum) { - ASYNC_DB: struct { - fd: std.posix.fd_t, - min: i64, - max: i64, - limit: i64, - }, - CRUD_LIST: struct { - fd: std.posix.fd_t, - page: i64, - limit: i64, - category_len: u8, - category_buf: [CATEGORY_MAX]u8, - }, - CRUD_GET: struct { - fd: std.posix.fd_t, - id: i64, - }, - CRUD_CREATE: struct { - fd: std.posix.fd_t, - id: i64, - price: i64, - quantity: i64, - name_len: u8, - category_len: u8, - name_buf: [NAME_MAX]u8, - category_buf: [CATEGORY_MAX]u8, - }, - CRUD_UPDATE: struct { - fd: std.posix.fd_t, - id: i64, - price: i64, - quantity: i64, - name_len: u8, - category_len: u8, - name_buf: [NAME_MAX]u8, - category_buf: [CATEGORY_MAX]u8, - }, -}; - -/// The batching executor specialized to the entry's Job and statement set. -pub const DbExecutor = postgrez.Executor(Job, STATEMENT_COUNT); - -// Set once in init before startExecutor, read-only afterwards. -var g_io: std.Io = undefined; -var g_config: postgrez.Config = undefined; -var g_enabled: bool = false; -var g_max_conn: usize = 0; -var g_executor: ?*DbExecutor = null; - -/// Read DATABASE_URL and DATABASE_MAX_CONN once at startup. Absent or -/// malformed DATABASE_URL leaves the DB endpoints answering 503. -pub fn init(process: std.process.Init) void { - const url_text = process.environ_map.get("DATABASE_URL") orelse return; - - g_config = postgrez.parseUrl(url_text) catch return; - g_config.max_pending_replies = BATCH_MAX; - g_io = process.io; - g_enabled = true; - - if (process.environ_map.get("DATABASE_MAX_CONN")) |max_text| { - g_max_conn = std.fmt.parseInt(usize, max_text, 10) catch 0; - } -} - -pub fn enabled() bool { - return g_enabled; -} - -/// Build the executor over the parsed config. Does nothing when DATABASE_URL -/// was absent, so non-DB profiles spawn no worker threads. -pub fn startExecutor(run_batch: *const fn (*DbExecutor.Batch, []const Job) void) void { - if (!g_enabled) return; - - g_executor = DbExecutor.init(std.heap.smp_allocator, g_io, g_config, .{ - .run_batch = run_batch, - .max_conn_hint = g_max_conn, - .batch_max = BATCH_MAX, - }) catch null; -} - -/// Queue a job on the executor. -/// -/// Return: -/// - true when queued (a worker owns the response) -/// - false when the executor is down or the queue is full (shed 503) -pub fn submit(job: Job) bool { - const executor = g_executor orelse return false; - - return executor.submit(job); -} - -/// Run a job synchronously on the calling thread (for a request whose -/// connection is about to close, where a deferred write would race the -/// close). -/// -/// Return: -/// - true when the executor ran it, false when it is down -pub fn runInline(job: Job) bool { - const executor = g_executor orelse return false; - - return executor.runInline(job); -} diff --git a/frameworks/zix/src/dbrd.zig b/frameworks/zix/src/dbrd.zig deleted file mode 100644 index c9c12d7f8..000000000 --- a/frameworks/zix/src/dbrd.zig +++ /dev/null @@ -1,51 +0,0 @@ -//! Redis state for the crud cache mirror. The read path is the in-process -//! cache (crudcache.zig), Redis carries a write-behind mirror of it through -//! the rediz deferred path (replies never awaited on the hot path). One -//! connection per executor thread, connected lazily, dropped on failure. - -const std = @import("std"); -const zix = @import("zix"); - -const rediz = zix.Driver.rediz; - -// --------------------------------------------------------- // - -const MAX_PENDING_REPLIES = 32; - -// Set once in init before server.run, read-only afterwards. -var g_io: std.Io = undefined; -var g_config: rediz.Config = undefined; -var g_enabled: bool = false; - -threadlocal var tl_conn: ?*rediz.Conn = null; - -/// Read REDIS_URL once at startup. Absent or malformed disables the -/// mirror: the in-process cache still serves x-cache MISS/HIT on its own. -pub fn init(process: std.process.Init) void { - const url_text = process.environ_map.get("REDIS_URL") orelse return; - - g_config = rediz.parseUrl(url_text) catch return; - g_config.max_pending_replies = MAX_PENDING_REPLIES; - g_io = process.io; - g_enabled = true; -} - -/// The calling worker's connection, connecting on first use. -/// Null when REDIS_URL is absent or the connect failed. -pub fn conn() ?*rediz.Conn { - if (!g_enabled) return null; - if (tl_conn) |existing| return existing; - - const fresh = rediz.Conn.connect(std.heap.smp_allocator, g_io, g_config) catch return null; - tl_conn = fresh; - - return fresh; -} - -/// Drop the worker's connection after a transport failure, so the next -/// request reconnects instead of reusing a broken stream. -pub fn drop() void { - if (tl_conn) |broken| broken.deinit(); - - tl_conn = null; -} diff --git a/frameworks/zix/src/handler.zig b/frameworks/zix/src/handler.zig deleted file mode 100644 index 192ca561d..000000000 --- a/frameworks/zix/src/handler.zig +++ /dev/null @@ -1,1002 +0,0 @@ -//! HttpArena: zix -//! -//! Route handlers: zero-copy static serving, br/gzip negotiation, cached -//! JSON. DB endpoints queue a job on the postgrez.Executor (owned by -//! dbpg.zig), which batches and pipelines the round trips off the engine -//! workers, run_batch below renders each result and writes the response raw -//! to the fd. Single-item crud reads serve from the in-process cache -//! (crudcache.zig), rediz mirrors fills and invalidations write-behind. - -const std = @import("std"); -const zix = @import("zix"); - -const postgrez = zix.Driver.postgrez; -const dataset = @import("dataset.zig"); -const crudcache = @import("crudcache.zig"); -const dbpg = @import("dbpg.zig"); -const dbrd = @import("dbrd.zig"); - -// --------------------------------------------------------- // - -// Precomputed response for the pipeline endpoint. -const PIPELINE_RESP: []const u8 = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nok"; - -/// Static cache name cap. Fixture names are short, anything longer is a 404. -pub const STATIC_NAME_MAX: usize = 96; -/// 20 fixtures times their (.br, .gz, identity) candidates plus headroom. -pub const STATIC_CACHE_MAX: usize = 128; - -/// Long TTL: the dataset is immutable, each key is built once per run. -pub const CACHE_TTL_MS: u32 = 60 * 1000; - -/// Accept-Encoding tokens the client advertised (substring scan, the bench -/// header needs no q-value parsing). -const AcceptEncoding = struct { - prefers_br: bool, - accepts_gzip: bool, -}; - -/// One servable static variant: a process-lifetime fd, its size, and the -/// pre-rendered response header. -const StaticVariant = struct { - fd: std.posix.fd_t, - size: u64, - hdr_len: u16, - hdr_buf: [192]u8, -}; - -/// Cache slot for one resolved static name. A null variant caches a miss -/// so a bad name is probed only once. -const StaticEntry = struct { - name_len: u16, - // rel plus a 3-char precompressed suffix (".br" or ".gz") - name_buf: [STATIC_NAME_MAX + 3]u8, - variant: ?StaticVariant, -}; - -/// Content type plus content encoding for a cached static name. -const StaticMeta = struct { - content_type: []const u8, - content_encoding: []const u8, -}; - -// --------------------------------------------------------- // - -// Per-worker scratch, the JSON body (count up to 50) tops out near 12 KiB. -threadlocal var json_body_buf: [32 * 1024]u8 = undefined; -threadlocal var json_resp_buf: [32 * 1024]u8 = undefined; - -// Append-only cache: readers scan 0..count lock-free, the spinlock only -// serializes inserts. -var g_static_entries: [STATIC_CACHE_MAX]StaticEntry = undefined; -var g_static_count: usize = 0; -var g_static_lock: std.atomic.Value(bool) = .init(false); - -// Data directory (default /data, the container mount point). -pub var g_static_base: []const u8 = "/data/static/"; -pub var g_static_base_buf: [256]u8 = undefined; - -// --------------------------------------------------------- // - -/// Must initialize in init main. -pub var g_dataset: dataset.Dataset = undefined; - -// --------------------------------------------------------- // - -fn sumQuery(query: []const u8) i64 { - var sum: i64 = 0; - var it = std.mem.tokenizeScalar(u8, query, '&'); - while (it.next()) |pair| { - if (std.mem.indexOfScalar(u8, pair, '=')) |eq| { - sum += std.fmt.parseInt(i64, pair[eq + 1 ..], 10) catch 0; - } - } - return sum; -} - -fn parseIntLoose(s: []const u8) i64 { - var i: usize = 0; - while (i < s.len and (s[i] == ' ' or s[i] == '\t' or s[i] == '\r' or s[i] == '\n')) i += 1; - - var neg = false; - if (i < s.len and s[i] == '-') { - neg = true; - i += 1; - } - - var n: i64 = 0; - while (i < s.len and s[i] >= '0' and s[i] <= '9') : (i += 1) { - n = n * 10 + (s[i] - '0'); - } - - return if (neg) -n else n; -} - -fn appendStr(out: []u8, pos: usize, s: []const u8) usize { - @memcpy(out[pos..][0..s.len], s); - return pos + s.len; -} - -fn appendInt(out: []u8, pos: usize, n: u64) usize { - var tmp: [24]u8 = undefined; - const s = std.fmt.bufPrint(&tmp, "{d}", .{n}) catch unreachable; - @memcpy(out[pos..][0..s.len], s); - return pos + s.len; -} - -// --------------------------------------------------------- // - -fn notFound(fd: std.posix.fd_t) void { - zix.Http1.sendSimpleFD(fd, 404, "text/plain", "Not Found") catch {}; -} - -fn badRequest(fd: std.posix.fd_t) void { - zix.Http1.sendSimpleFD(fd, 400, "text/plain", "Bad Request") catch {}; -} - -fn acceptEncoding(head: *const zix.Http1.ParsedHead) AcceptEncoding { - const value = zix.Http1.getHeader(head, "accept-encoding") orelse return .{ .prefers_br = false, .accepts_gzip = false }; - - return .{ - .prefers_br = std.mem.indexOf(u8, value, "br") != null, - .accepts_gzip = std.mem.indexOf(u8, value, "gzip") != null, - }; -} - -fn contentType(rel: []const u8) []const u8 { - if (std.mem.endsWith(u8, rel, ".css")) return "text/css"; - if (std.mem.endsWith(u8, rel, ".js")) return "application/javascript"; - if (std.mem.endsWith(u8, rel, ".json")) return "application/json"; - if (std.mem.endsWith(u8, rel, ".html")) return "text/html"; - if (std.mem.endsWith(u8, rel, ".svg")) return "image/svg+xml"; - if (std.mem.endsWith(u8, rel, ".woff2")) return "font/woff2"; - if (std.mem.endsWith(u8, rel, ".webp")) return "image/webp"; - - return "application/octet-stream"; -} - -fn staticLookup(name: []const u8, count: usize) ?*const StaticEntry { - for (g_static_entries[0..count]) |*e| { - if (std.mem.eql(u8, e.name_buf[0..e.name_len], name)) return e; - } - - return null; -} - -pub fn staticMeta(name: []const u8) StaticMeta { - if (std.mem.endsWith(u8, name, ".br")) { - return .{ .content_type = contentType(name[0 .. name.len - ".br".len]), .content_encoding = "br" }; - } - if (std.mem.endsWith(u8, name, ".gz")) { - return .{ .content_type = contentType(name[0 .. name.len - ".gz".len]), .content_encoding = "gzip" }; - } - - return .{ .content_type = contentType(name), .content_encoding = "" }; -} - -/// Build the process-lifetime path for a static name and open it read-only. Returns null when absent. -fn openStatic(name: []const u8) ?std.posix.fd_t { - var path_buf: [512]u8 = undefined; - const path = std.fmt.bufPrint(&path_buf, "{s}{s}", .{ g_static_base, name }) catch return null; - if (path.len >= path_buf.len) return null; - - path_buf[path.len] = 0; - - return std.posix.openatZ(std.posix.AT.FDCWD, @ptrCast(&path_buf), .{ .ACCMODE = .RDONLY }, 0) catch null; -} - -/// Probe one static name on disk and build its cache record: open, fstat, and pre-render the header -/// (content type and encoding from staticMeta) so serving it later is send + sendfile only. -fn buildVariant(name: []const u8) ?StaticVariant { - const file_fd = openStatic(name) orelse return null; - - var stx: std.os.linux.Statx = undefined; - const stat_rc = std.os.linux.statx(file_fd, "", std.os.linux.AT.EMPTY_PATH, .{ .SIZE = true }, &stx); - if (std.posix.errno(stat_rc) != .SUCCESS) { - _ = std.posix.system.close(file_fd); - return null; - } - - const size: u64 = stx.size; - const meta = staticMeta(name); - - var v: StaticVariant = .{ .fd = file_fd, .size = size, .hdr_len = 0, .hdr_buf = undefined }; - const hdr = (if (meta.content_encoding.len > 0) - std.fmt.bufPrint(&v.hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nContent-Encoding: {s}\r\n\r\n", .{ meta.content_type, size, meta.content_encoding }) - else - std.fmt.bufPrint(&v.hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\n\r\n", .{ meta.content_type, size })) catch { - _ = std.posix.system.close(file_fd); - return null; - }; - v.hdr_len = @intCast(hdr.len); - - return v; -} - -/// Probe + cache a static name on first request, then return the slot. -/// Caches a null variant so a bad name is probed only once. -/// Returns null only when the cache is full. -fn staticInsert(name: []const u8) ?*const StaticEntry { - while (g_static_lock.swap(true, .acquire)) std.atomic.spinLoopHint(); - defer g_static_lock.store(false, .release); - - const count = @atomicLoad(usize, &g_static_count, .acquire); - if (staticLookup(name, count)) |e| return e; - if (count == STATIC_CACHE_MAX) return null; - - const e = &g_static_entries[count]; - e.name_len = @intCast(name.len); - @memcpy(e.name_buf[0..name.len], name); - e.variant = buildVariant(name); - - @atomicStore(usize, &g_static_count, count + 1, .release); - - return e; -} - -/// Resolve a static name through the cache (lookup, then insert on a miss). -/// Returns the slot only when the file exists on disk, -/// so a caller can fall through to the next candidate on a missing variant. -pub fn resolveStatic(name: []const u8) ?*const StaticEntry { - const count = @atomicLoad(usize, &g_static_count, .acquire); - const entry = staticLookup(name, count) orelse staticInsert(name) orelse return null; - if (entry.variant == null) return null; - - return entry; -} - -/// Block until fd is writable again. Used by the static send path to ride -/// out a full socket buffer, mirroring writeAllFD's EAGAIN handling. -fn waitWritable(fd: std.posix.fd_t) error{BrokenPipe}!void { - var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.OUT, .revents = 0 }}; - - _ = std.posix.poll(&pfd, -1) catch return error.BrokenPipe; -} - -/// Send with MSG_MORE so the header coalesces into the same packets as the -/// sendfile body that follows instead of leaving as its own small packet. -fn sendMoreFD(fd: std.posix.fd_t, data: []const u8) error{BrokenPipe}!void { - const linux = std.os.linux; - - var rem = data; - while (rem.len > 0) { - const rc = linux.sendto(fd, rem.ptr, rem.len, linux.MSG.MORE, null, 0); - switch (std.posix.errno(rc)) { - .SUCCESS => { - const n: usize = @intCast(rc); - if (n == 0) return error.BrokenPipe; - - rem = rem[n..]; - }, - .INTR => {}, - .AGAIN => try waitWritable(fd), - else => return error.BrokenPipe, - } - } -} - -/// Zero-copy file body: kernel pages straight to the socket, no userspace -/// bounce buffer. A local offset keeps the shared cached fd position -/// untouched, so one fd serves all workers concurrently. -fn sendfileAll(sock: std.posix.fd_t, file_fd: std.posix.fd_t, size: u64) error{BrokenPipe}!void { - const linux = std.os.linux; - - var off: i64 = 0; - while (@as(u64, @intCast(off)) < size) { - const remaining: usize = @intCast(size - @as(u64, @intCast(off))); - const rc = linux.sendfile(sock, file_fd, &off, remaining); - switch (std.posix.errno(rc)) { - .SUCCESS => { - if (rc == 0) return error.BrokenPipe; - }, - .INTR => {}, - .AGAIN => try waitWritable(sock), - else => return error.BrokenPipe, - } - } -} - -// json-comp: gzip a JSON body through the engine's per-(key, encoding) -// cache, a repeat request replays the compressed bytes. -fn sendJsonGzipFD(head: *const zix.Http1.ParsedHead, fd: std.posix.fd_t, json: []const u8) void { - zix.Http1.sendGzipCachedFD(fd, head, 200, "application/json", json, zix.Http1.cacheTtl()) catch {}; -} - -// --------------------------------------------------------- // - -// GET/POST /baseline11?a=..&b=.. : sum the query values, plus the POST body as -// an integer. Returns the sum as text/plain. -pub fn baseline(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { - var sum: i64 = sumQuery(head.query); - - if (std.mem.eql(u8, head.method, "POST") and body.len > 0) { - sum += parseIntLoose(body); - } - - var body_buf: [32]u8 = undefined; - const out = std.fmt.bufPrint(&body_buf, "{d}", .{sum}) catch return; - - zix.Http1.sendSimpleFD(fd, 200, "text/plain", out) catch {}; -} - -// GET /pipeline : fixed tiny response. writeAllFD appends to the engine's -// staged sink, a pipelined batch leaves in request order as one send. -pub fn pipeline(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { - _ = head; - _ = body; - - zix.Http1.writeAllFD(fd, PIPELINE_RESP) catch {}; -} - -// POST /upload : return the received byte count. Content-Length is -// authoritative, the engine drains oversized bodies. -pub fn upload(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { - const n: u64 = if (head.content_length > 0) head.content_length else body.len; - - var body_buf: [24]u8 = undefined; - const out = std.fmt.bufPrint(&body_buf, "{d}", .{n}) catch return; - - zix.Http1.sendSimpleFD(fd, 200, "text/plain", out) catch {}; -} - -/// GET /json/{count}?m=M : render count dataset items, total = price*qty*M. -/// The body is deterministic in (count, m), so each distinct path caches -/// under its own slot: a hit replays, a miss builds and stores. -pub fn jsonResp(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { - _ = body; - - // json-comp: a gzip-accepting client gets the per-(key, encoding) - // cache, the plain json test uses the identity cache. - const accept = zix.Http1.getHeader(head, "accept-encoding") orelse ""; - const want_gzip = std.mem.indexOf(u8, accept, "gzip") != null; - - if (want_gzip) { - if (zix.Http1.cacheLookupEncoded(head, "gzip")) |cached| { - zix.Http1.writeAllFD(fd, cached) catch {}; - return; - } - } else { - if (zix.Http1.cacheLookup(head)) |cached| { - zix.Http1.writeAllFD(fd, cached) catch {}; - return; - } - } - - // The PREFIX route also matches a bare /json (no trailing slash), which - // would slice out of bounds below. - if (head.path.len < "/json/".len) return badRequest(fd); - - const count_str = head.path["/json/".len..]; - const count = std.fmt.parseInt(u8, count_str, 10) catch return badRequest(fd); - if (count < 1 or count > dataset.ItemCount) return badRequest(fd); - - const m: u64 = if (zix.Http1.queryParam(head, "m")) |s| std.fmt.parseInt(u64, s, 10) catch 1 else 1; - - const buf = &json_body_buf; - var pos: usize = 0; - - pos = appendStr(buf, pos, "{\"items\":["); - var i: usize = 0; - while (i < count) : (i += 1) { - if (i > 0) { - buf[pos] = ','; - pos += 1; - } - const item = g_dataset.items[i]; - @memcpy(buf[pos..][0..item.prefix.len], item.prefix); - pos += item.prefix.len; - pos = appendStr(buf, pos, ",\"total\":"); - pos = appendInt(buf, pos, item.pq * m); - buf[pos] = '}'; - pos += 1; - } - pos = appendStr(buf, pos, "],\"count\":"); - pos = appendInt(buf, pos, count); - buf[pos] = '}'; - pos += 1; - - if (want_gzip) { - sendJsonGzipFD(head, fd, buf[0..pos]); - return; - } - - // Assemble the full response so it caches and replays verbatim (the - // header matches the engine's sendJsonFD output). - const resp = &json_resp_buf; - const hdr = std.fmt.bufPrint(resp, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{pos}) catch { - zix.Http1.sendJsonFD(fd, 200, buf[0..pos]) catch {}; - return; - }; - @memcpy(resp[hdr.len..][0..pos], buf[0..pos]); - - zix.Http1.sendWithCacheFD(fd, head, resp[0 .. hdr.len + pos], CACHE_TTL_MS) catch {}; -} - -// GET /static/{file} : serve from /data/static. Negotiates .br then .gz -// when accepted, else identity. One header send coalesced with a zero-copy -// sendfile body. -pub fn static(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { - _ = body; - - // The PREFIX route also matches a bare /static (no trailing slash), which - // would slice out of bounds below. - if (head.path.len < "/static/".len) return notFound(fd); - - const rel = head.path["/static/".len..]; - if (rel.len == 0 or rel.len > STATIC_NAME_MAX or std.mem.indexOf(u8, rel, "..") != null or rel[0] == '/') return notFound(fd); - - const accept = acceptEncoding(head); - - // Candidates "{rel}.br" / "{rel}.gz" / "{rel}". - var cand_buf: [STATIC_NAME_MAX + 3]u8 = undefined; - var entry: ?*const StaticEntry = null; - - if (accept.prefers_br) { - const cand = std.fmt.bufPrint(&cand_buf, "{s}.br", .{rel}) catch return notFound(fd); - entry = resolveStatic(cand); - } - if (entry == null and accept.accepts_gzip) { - const cand = std.fmt.bufPrint(&cand_buf, "{s}.gz", .{rel}) catch return notFound(fd); - entry = resolveStatic(cand); - } - if (entry == null) { - entry = resolveStatic(rel); - } - - const served = entry orelse return notFound(fd); - const variant: *const StaticVariant = if (served.variant) |*v| v else return notFound(fd); - - // Raw fd writes below: flush engine-staged responses first so the wire - // order matches the request order under pipelining. - zix.Http1.flushPending(fd); - - sendMoreFD(fd, variant.hdr_buf[0..variant.hdr_len]) catch return; - sendfileAll(fd, variant.fd, variant.size) catch {}; -} - -// --------------------------------------------------------- // - -// DB endpoints. Column order is fixed by the SQL constants, the renderers -// decode cells by position with rawDecode. -const SQL_ASYNC_DB = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3"; -const SQL_CRUD_LIST = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count, count(*) OVER() AS total FROM items WHERE category = $1 ORDER BY id LIMIT $2 OFFSET $3"; -const SQL_CRUD_GET = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE id = $1"; -const SQL_CRUD_UPSERT = "INSERT INTO items (id, name, category, price, quantity, active, tags, rating_score, rating_count) VALUES ($1, $2, $3, $4, $5, true, '[]', 0, 0) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, category = EXCLUDED.category, price = EXCLUDED.price, quantity = EXCLUDED.quantity"; -const SQL_CRUD_UPDATE = "UPDATE items SET name = $2, category = $3, price = $4, quantity = $5 WHERE id = $1"; - -const CRUD_KEY_PREFIX = "crud:item:"; -/// Mirror TTL, the write path additionally deletes the key. -const CRUD_CACHE_TTL_S: u64 = 1; - -const ASYNC_DB_LIMIT_MAX: i64 = 50; -const CRUD_LIST_LIMIT_DEFAULT: i64 = 10; -const CRUD_LIST_LIMIT_MAX: i64 = 100; - -// Per-executor scratch for the rendered DB bodies, the renderDbRow budget -// guard rejects an overflow with 503. -threadlocal var db_body_buf: [32 * 1024]u8 = undefined; - -// Per-engine-worker arena for the crud POST/PUT JSON body parse. -threadlocal var tl_crud_arena: ?std.heap.ArenaAllocator = null; - -/// Fields of a crud create/update body. PUT bodies carry no id (the id is -/// in the path), so every field defaults. -const CrudBody = struct { - id: i64 = 0, - name: []const u8 = "", - category: []const u8 = "", - price: i64 = 0, - quantity: i64 = 0, -}; - -// --------------------------------------------------------- // - -fn serviceUnavailable(fd: std.posix.fd_t) void { - zix.Http1.sendSimpleFD(fd, 503, "text/plain", "Service Unavailable") catch {}; -} - -/// 503 the request after a PostgreSQL failure. A server-reported error -/// keeps the connection, anything else marks the batch broken for discard. -fn failDb(batch: *dbpg.DbExecutor.Batch, fd: std.posix.fd_t, err: anyerror) void { - if (err != error.ServerError) batch.markBroken(); - - serviceUnavailable(fd); -} - -/// Drop the redis connection on a transport-shaped failure, keep it on a -/// server-reported error. -fn failCache(err: anyerror) void { - if (err != error.ServerError) dbrd.drop(); -} - -fn cellInt(columns: []const postgrez.row.ColumnInfo, cells: []const ?[]const u8, index: usize) !i64 { - const bytes = cells[index] orelse return error.BadCell; - const column = columns[index]; - - return postgrez.row.rawDecode(i64, @enumFromInt(column.type_oid), column.format, bytes); -} - -fn cellBool(columns: []const postgrez.row.ColumnInfo, cells: []const ?[]const u8, index: usize) !bool { - const bytes = cells[index] orelse return error.BadCell; - const column = columns[index]; - - return postgrez.row.rawDecode(bool, @enumFromInt(column.type_oid), column.format, bytes); -} - -/// Raw cell bytes (text and jsonb columns). The slice points into the -/// receive buffer, valid until the next result.next(). -fn cellStr(columns: []const postgrez.row.ColumnInfo, cells: []const ?[]const u8, index: usize) ![]const u8 { - const bytes = cells[index] orelse return error.BadCell; - const column = columns[index]; - - return postgrez.row.rawDecode([]const u8, @enumFromInt(column.type_oid), column.format, bytes); -} - -fn appendI64(out: []u8, pos: usize, value: i64) usize { - var tmp: [24]u8 = undefined; - const rendered = std.fmt.bufPrint(&tmp, "{d}", .{value}) catch unreachable; - @memcpy(out[pos..][0..rendered.len], rendered); - - return pos + rendered.len; -} - -/// Append a JSON string body (quotes are the caller's), escaped. Worst -/// case is 6x the input, the renderDbRow budget accounts for it. -fn appendJsonStr(out: []u8, start: usize, value: []const u8) usize { - const HEX = "0123456789abcdef"; - - var pos = start; - for (value) |ch| { - switch (ch) { - '"', '\\' => { - out[pos] = '\\'; - out[pos + 1] = ch; - pos += 2; - }, - 0x00...0x1f => { - out[pos..][0..4].* = "\\u00".*; - out[pos + 4] = HEX[ch >> 4]; - out[pos + 5] = HEX[ch & 0xf]; - pos += 6; - }, - else => { - out[pos] = ch; - pos += 1; - }, - } - } - - return pos; -} - -/// Render one items row as a JSON object, cells by SQL column order. tags -/// is jsonb text, emitted raw. -fn renderDbRow(out: []u8, start: usize, columns: []const postgrez.row.ColumnInfo, cells: []const ?[]const u8) !usize { - const name = try cellStr(columns, cells, 1); - const category = try cellStr(columns, cells, 2); - const tags = try cellStr(columns, cells, 6); - if (start + name.len * 6 + category.len * 6 + tags.len + 192 > out.len) return error.NoSpaceLeft; - - var pos = start; - pos = appendStr(out, pos, "{\"id\":"); - pos = appendI64(out, pos, try cellInt(columns, cells, 0)); - pos = appendStr(out, pos, ",\"name\":\""); - pos = appendJsonStr(out, pos, name); - pos = appendStr(out, pos, "\",\"category\":\""); - pos = appendJsonStr(out, pos, category); - pos = appendStr(out, pos, "\",\"price\":"); - pos = appendI64(out, pos, try cellInt(columns, cells, 3)); - pos = appendStr(out, pos, ",\"quantity\":"); - pos = appendI64(out, pos, try cellInt(columns, cells, 4)); - pos = appendStr(out, pos, ",\"active\":"); - pos = appendStr(out, pos, if (try cellBool(columns, cells, 5)) "true" else "false"); - pos = appendStr(out, pos, ",\"tags\":"); - pos = appendStr(out, pos, tags); - pos = appendStr(out, pos, ",\"rating\":{\"score\":"); - pos = appendI64(out, pos, try cellInt(columns, cells, 7)); - pos = appendStr(out, pos, ",\"count\":"); - pos = appendI64(out, pos, try cellInt(columns, cells, 8)); - pos = appendStr(out, pos, "}}"); - - return pos; -} - -/// Parse a crud create/update JSON body into CrudBody. Returned slices live -/// in the per-worker arena, valid until the next parse on this worker. -fn parseCrudBody(body: []const u8) ?CrudBody { - if (tl_crud_arena == null) tl_crud_arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator); - - const arena = &tl_crud_arena.?; - _ = arena.reset(.retain_capacity); - - return std.json.parseFromSliceLeaky(CrudBody, arena.allocator(), body, .{ - .ignore_unknown_fields = true, - }) catch null; -} - -// --------------------------------------------------------- // - -/// Streamed render of the async-db result, rows go straight into out. -fn renderAsyncDb(result: *postgrez.Result, out: []u8) !usize { - var pos: usize = 0; - pos = appendStr(out, pos, "{\"items\":["); - - var count: usize = 0; - while (try result.next()) |row_view| { - if (count > 0) { - out[pos] = ','; - pos += 1; - } - pos = try renderDbRow(out, pos, result.columns, row_view.cells); - count += 1; - } - - pos = appendStr(out, pos, "],\"count\":"); - pos = appendInt(out, pos, count); - out[pos] = '}'; - pos += 1; - - return pos; -} - -/// Streamed render of the crud list page. total rides every row as a -/// count(*) OVER() column, one pass yields items and total. -fn renderCrudList(result: *postgrez.Result, page: i64, out: []u8) !usize { - var pos: usize = 0; - pos = appendStr(out, pos, "{\"items\":["); - - var total: i64 = 0; - var count: usize = 0; - while (try result.next()) |row_view| { - if (count > 0) { - out[pos] = ','; - pos += 1; - } - pos = try renderDbRow(out, pos, result.columns, row_view.cells); - total = try cellInt(result.columns, row_view.cells, 9); - count += 1; - } - - pos = appendStr(out, pos, "],\"total\":"); - pos = appendI64(out, pos, total); - pos = appendStr(out, pos, ",\"page\":"); - pos = appendI64(out, pos, page); - out[pos] = '}'; - pos += 1; - - return pos; -} - -/// Render one crud item, null when the row does not exist. -fn renderCrudItem(result: *postgrez.Result, out: []u8) !?usize { - const row_view = (try result.next()) orelse return null; - - return try renderDbRow(out, 0, result.columns, row_view.cells); -} - -/// 200 JSON response with the X-Cache header the crud cache check reads. -fn sendCrudBody(fd: std.posix.fd_t, body: []const u8, cache_state: []const u8) void { - var hdr_buf: [128]u8 = undefined; - const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Cache: {s}\r\nContent-Length: {d}\r\n\r\n", .{ cache_state, body.len }) catch return; - - zix.Http1.writeAllFD(fd, hdr) catch return; - zix.Http1.writeAllFD(fd, body) catch {}; -} - -/// Drop the cached crud body on every write: the in-process slot first -/// (the read path), the Redis mirror write-behind. -fn invalidateCrud(id: i64) void { - crudcache.remove(id); - - const cache = dbrd.conn() orelse return; - - var key_buf: [40]u8 = undefined; - const key = std.fmt.bufPrint(&key_buf, CRUD_KEY_PREFIX ++ "{d}", .{id}) catch return; - - cache.delDeferred(&.{key}) catch |err| failCache(err); -} - -// --------------------------------------------------------- // - -// Route handlers below run on the engine worker: parse, queue the job on -// the executor fleet, return without a response. A full queue sheds 503. - -/// GET /async-db?min=A&max=B&limit=N : items with price BETWEEN A AND B, -/// LIMIT N clamped to 1..50. -pub fn asyncDb(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { - _ = body; - - const min: i64 = if (zix.Http1.queryParam(head, "min")) |raw| std.fmt.parseInt(i64, raw, 10) catch 0 else 0; - const max: i64 = if (zix.Http1.queryParam(head, "max")) |raw| std.fmt.parseInt(i64, raw, 10) catch 0 else 0; - var limit: i64 = if (zix.Http1.queryParam(head, "limit")) |raw| std.fmt.parseInt(i64, raw, 10) catch 10 else 10; - if (limit < 1) limit = 1; - if (limit > ASYNC_DB_LIMIT_MAX) limit = ASYNC_DB_LIMIT_MAX; - - const queued = submitJob(head, .{ .ASYNC_DB = .{ - .fd = fd, - .min = min, - .max = max, - .limit = limit, - } }); - if (!queued) serviceUnavailable(fd); -} - -/// Queue a job on the fleet, or run it inline for a close-marked request: -/// the engine closes the fd right after the handler returns, so a deferred -/// executor write would race the close and drop the response. -fn submitJob(head: *const zix.Http1.ParsedHead, job: dbpg.Job) bool { - if (head.keep_alive) return dbpg.submit(job); - - return dbpg.runInline(job); -} - -fn crudList(head: *const zix.Http1.ParsedHead, fd: std.posix.fd_t) void { - const category = zix.Http1.queryParam(head, "category") orelse ""; - var page: i64 = if (zix.Http1.queryParam(head, "page")) |raw| std.fmt.parseInt(i64, raw, 10) catch 1 else 1; - var limit: i64 = if (zix.Http1.queryParam(head, "limit")) |raw| std.fmt.parseInt(i64, raw, 10) catch CRUD_LIST_LIMIT_DEFAULT else CRUD_LIST_LIMIT_DEFAULT; - if (page < 1) page = 1; - if (limit < 1) limit = CRUD_LIST_LIMIT_DEFAULT; - if (limit > CRUD_LIST_LIMIT_MAX) limit = CRUD_LIST_LIMIT_MAX; - if (category.len > dbpg.CATEGORY_MAX) return badRequest(fd); - - // list-page cache first: a hit answers on the engine worker - const list_key = crudcache.listKey(category, page, limit); - if (crudcache.listGet(list_key, &db_body_buf)) |len| { - zix.Http1.sendJsonFD(fd, 200, db_body_buf[0..len]) catch {}; - - return; - } - - var job: dbpg.Job = .{ .CRUD_LIST = .{ - .fd = fd, - .page = page, - .limit = limit, - .category_len = @intCast(category.len), - .category_buf = undefined, - } }; - @memcpy(job.CRUD_LIST.category_buf[0..category.len], category); - - if (!submitJob(head, job)) serviceUnavailable(fd); -} - -fn crudCreate(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { - const item = parseCrudBody(body) orelse return badRequest(fd); - if (item.id < 1 or item.name.len == 0) return badRequest(fd); - if (item.name.len > dbpg.NAME_MAX or item.category.len > dbpg.CATEGORY_MAX) return badRequest(fd); - - var job: dbpg.Job = .{ .CRUD_CREATE = .{ - .fd = fd, - .id = item.id, - .price = item.price, - .quantity = item.quantity, - .name_len = @intCast(item.name.len), - .category_len = @intCast(item.category.len), - .name_buf = undefined, - .category_buf = undefined, - } }; - @memcpy(job.CRUD_CREATE.name_buf[0..item.name.len], item.name); - @memcpy(job.CRUD_CREATE.category_buf[0..item.category.len], item.category); - - if (!submitJob(head, job)) serviceUnavailable(fd); -} - -fn crudUpdate(head: *const zix.Http1.ParsedHead, id: i64, body: []const u8, fd: std.posix.fd_t) void { - const item = parseCrudBody(body) orelse return badRequest(fd); - if (item.name.len > dbpg.NAME_MAX or item.category.len > dbpg.CATEGORY_MAX) return badRequest(fd); - - var job: dbpg.Job = .{ .CRUD_UPDATE = .{ - .fd = fd, - .id = id, - .price = item.price, - .quantity = item.quantity, - .name_len = @intCast(item.name.len), - .category_len = @intCast(item.category.len), - .name_buf = undefined, - .category_buf = undefined, - } }; - @memcpy(job.CRUD_UPDATE.name_buf[0..item.name.len], item.name); - @memcpy(job.CRUD_UPDATE.category_buf[0..item.category.len], item.category); - - if (!submitJob(head, job)) serviceUnavailable(fd); -} - -/// /crud/items dispatcher: list and create on the collection, read and -/// update on /crud/items/{id}. -pub fn crudItems(head: *const zix.Http1.ParsedHead, body: []const u8, fd: std.posix.fd_t) void { - const sub = head.path["/crud/items".len..]; - - if (sub.len == 0) { - if (std.mem.eql(u8, head.method, "GET")) return crudList(head, fd); - if (std.mem.eql(u8, head.method, "POST")) return crudCreate(head, body, fd); - - return notFound(fd); - } - - if (sub[0] != '/' or sub.len == 1) return notFound(fd); - const id = std.fmt.parseInt(i64, sub[1..], 10) catch return badRequest(fd); - - if (std.mem.eql(u8, head.method, "GET")) { - // in-process cache first: a HIT answers on the engine worker and - // never becomes a job - if (crudcache.get(id, &db_body_buf)) |len| { - sendCrudBody(fd, db_body_buf[0..len], "HIT"); - - return; - } - - if (!submitJob(head, .{ .CRUD_GET = .{ .fd = fd, .id = id } })) serviceUnavailable(fd); - - return; - } - if (std.mem.eql(u8, head.method, "PUT")) return crudUpdate(head, id, body, fd); - - return notFound(fd); -} - -// --------------------------------------------------------- // - -// Batch execution below runs on an executor thread, never on an engine -// worker. One batch pipelines every job on the held connection: statements -// resolve first (a prepare must precede any queued execution), then every -// execution queues (sendRows), then results render in order (awaitRows). - -fn jobFd(job: dbpg.Job) std.posix.fd_t { - return switch (job) { - .ASYNC_DB => |request| request.fd, - .CRUD_LIST => |request| request.fd, - .CRUD_GET => |request| request.fd, - .CRUD_CREATE => |request| request.fd, - .CRUD_UPDATE => |request| request.fd, - }; -} - -/// Execute one drained batch and write every response. Registered with the -/// postgrez.Executor as its run function: batch hands out prepared statements -/// on the held connection, this pipelines and renders them. -pub fn runBatch(batch: *dbpg.DbExecutor.Batch, jobs: []const dbpg.Job) void { - var statements: [dbpg.BATCH_MAX]?*postgrez.Statement = @splat(null); - var done: [dbpg.BATCH_MAX]bool = @splat(false); - - // pass 1: cache hits answer directly, everything else resolves its - // prepared statement on the held connection - for (jobs, 0..) |job, index| { - switch (job) { - .ASYNC_DB => statements[index] = batch.statement(dbpg.slot(.ASYNC_DB), SQL_ASYNC_DB), - .CRUD_LIST => |request| { - // an earlier batch may have filled the page after the - // engine-side miss - const category = request.category_buf[0..request.category_len]; - const list_key = crudcache.listKey(category, request.page, request.limit); - if (crudcache.listGetFresh(list_key, &db_body_buf)) |len| { - zix.Http1.sendJsonFD(request.fd, 200, db_body_buf[0..len]) catch {}; - done[index] = true; - } else { - statements[index] = batch.statement(dbpg.slot(.CRUD_LIST), SQL_CRUD_LIST); - } - }, - .CRUD_GET => |request| { - // an earlier batch may have filled the cache after the - // engine-side miss - if (crudcache.get(request.id, &db_body_buf)) |len| { - sendCrudBody(request.fd, db_body_buf[0..len], "HIT"); - done[index] = true; - } else { - statements[index] = batch.statement(dbpg.slot(.CRUD_GET), SQL_CRUD_GET); - } - }, - .CRUD_CREATE => statements[index] = batch.statement(dbpg.slot(.CRUD_UPSERT), SQL_CRUD_UPSERT), - .CRUD_UPDATE => statements[index] = batch.statement(dbpg.slot(.CRUD_UPDATE), SQL_CRUD_UPDATE), - } - - if (!done[index] and statements[index] == null) { - serviceUnavailable(jobFd(job)); - done[index] = true; - } - } - - // pass 2: queue every execution on the held connection - for (jobs, 0..) |job, index| { - if (done[index]) continue; - - queueJob(statements[index].?, job) catch |err| { - failDb(batch, jobFd(job), err); - done[index] = true; - }; - } - - // pass 3: results render in sendRows order - for (jobs, 0..) |job, index| { - if (done[index]) continue; - - awaitJob(batch, statements[index].?, job); - } -} - -/// Queue one execution (Bind + Execute into the connection send buffer, -/// nothing on the wire yet). -fn queueJob(statement: *postgrez.Statement, job: dbpg.Job) !void { - switch (job) { - .ASYNC_DB => |request| try statement.sendRows(.{ request.min, request.max, request.limit }), - .CRUD_LIST => |request| { - const category = request.category_buf[0..request.category_len]; - const offset = (request.page - 1) * request.limit; - - try statement.sendRows(.{ category, request.limit, offset }); - }, - .CRUD_GET => |request| try statement.sendRows(.{request.id}), - .CRUD_CREATE => |request| { - const name = request.name_buf[0..request.name_len]; - const category = request.category_buf[0..request.category_len]; - - try statement.sendRows(.{ request.id, name, category, request.price, request.quantity }); - }, - .CRUD_UPDATE => |request| { - const name = request.name_buf[0..request.name_len]; - const category = request.category_buf[0..request.category_len]; - - try statement.sendRows(.{ request.id, name, category, request.price, request.quantity }); - }, - } -} - -/// Take the next queued result, render it, and write the response. -fn awaitJob(batch: *dbpg.DbExecutor.Batch, statement: *postgrez.Statement, job: dbpg.Job) void { - var result = statement.awaitRows() catch |err| return failDb(batch, jobFd(job), err); - defer result.deinit(); - - switch (job) { - .ASYNC_DB => |request| { - const len = renderAsyncDb(&result, &db_body_buf) catch |err| return failDb(batch, request.fd, err); - - zix.Http1.sendJsonFD(request.fd, 200, db_body_buf[0..len]) catch {}; - }, - .CRUD_LIST => |request| { - const len = renderCrudList(&result, request.page, &db_body_buf) catch |err| return failDb(batch, request.fd, err); - - const category = request.category_buf[0..request.category_len]; - crudcache.listPut(crudcache.listKey(category, request.page, request.limit), db_body_buf[0..len]); - zix.Http1.sendJsonFD(request.fd, 200, db_body_buf[0..len]) catch {}; - }, - .CRUD_GET => |request| { - const maybe_len = renderCrudItem(&result, &db_body_buf) catch |err| return failDb(batch, request.fd, err); - const len = maybe_len orelse return notFound(request.fd); - - finishCrudGet(request.id, db_body_buf[0..len], request.fd); - }, - .CRUD_CREATE => |request| { - drainResult(&result) catch |err| return failDb(batch, request.fd, err); - - // The upsert may replace an already-cached row, so a create - // invalidates too. - invalidateCrud(request.id); - zix.Http1.sendSimpleFD(request.fd, 201, "application/json", "{\"status\":\"created\"}") catch {}; - }, - .CRUD_UPDATE => |request| { - drainResult(&result) catch |err| return failDb(batch, request.fd, err); - - invalidateCrud(request.id); - zix.Http1.sendSimpleFD(request.fd, 200, "application/json", "{\"status\":\"ok\"}") catch {}; - }, - } -} - -/// Drive a row-less result (create/update) to completion so a server error -/// surfaces before the response commits. -fn drainResult(result: *postgrez.Result) !void { - while (try result.next()) |_| {} -} - -/// MISS response plus the two cache fills: the in-process slot and the -/// write-behind Redis mirror. -fn finishCrudGet(id: i64, body: []const u8, fd: std.posix.fd_t) void { - crudcache.put(id, body); - - if (dbrd.conn()) |cache| { - var key_buf: [40]u8 = undefined; - if (std.fmt.bufPrint(&key_buf, CRUD_KEY_PREFIX ++ "{d}", .{id})) |key| { - cache.setDeferred(key, body, .{ .ex_s = CRUD_CACHE_TTL_S }) catch |err| failCache(err); - } else |_| {} - } - - sendCrudBody(fd, body, "MISS"); -} From 1709d74a26c265bfecc74e40139a9897dd62dc42 Mon Sep 17 00:00:00 2001 From: prothegee Date: Tue, 21 Jul 2026 09:54:16 +0700 Subject: [PATCH 25/46] re-align minimal approach in main --- frameworks/zix/src/main.zig | 124 +++++++++++++++++------------------- 1 file changed, 58 insertions(+), 66 deletions(-) diff --git a/frameworks/zix/src/main.zig b/frameworks/zix/src/main.zig index 1247c0cd0..ef957b3d2 100644 --- a/frameworks/zix/src/main.zig +++ b/frameworks/zix/src/main.zig @@ -1,95 +1,87 @@ //! HttpArena: zix //! //! zix.Http1 (.URING), Router-only: every request goes through the engine's -//! parser and the comptime Router. json-tls rides config.tls_port (dual -//! listener, one worker fleet). async-db and crud run on the postgrez.Executor -//! (owned by dbpg.zig) over one shared pool, single-item crud reads serve -//! from the in-process cache (crudcache.zig), rediz mirrors write-behind. +//! parser and the comptime Router, one handler module per route +//! (src/handlers/). TLS rides tls_port (dual listener, one worker fleet). +//! async-db and crud run over the driver-owned multiplexed transport +//! (dbpg.zig, sharded), crud reads also check the in-process cache +//! (crudcache.zig) mirrored to Redis (dbrd.zig). const std = @import("std"); const zix = @import("zix"); -const dataset = @import("dataset.zig"); -const handler = @import("handler.zig"); -const dbpg = @import("dbpg.zig"); -const dbrd = @import("dbrd.zig"); +const baseline = @import("handlers/baseline.zig"); +const pipeline = @import("handlers/pipeline.zig"); +const upload = @import("handlers/upload.zig"); +const static = @import("handlers/static.zig"); +const json = @import("handlers/json.zig"); +const asyncdb = @import("handlers/asyncdb.zig"); +const crud = @import("handlers/crud.zig"); -// --------------------------------------------------------- // - -const IP: []const u8 = "::"; -const PORT: u16 = 8080; -const DISPATCH_MODEL: zix.Http1.DispatchModel = .URING; - -const MAX_HEADERS: u8 = 8; -const WORKERS: usize = 0; - -const SEND_DATE_HEADER: bool = false; -const RESPONSE_CACHE: bool = true; -const CACHE_MAX_ENTRIES: u32 = 64; -const CACHE_MAX_VALUE_BYTES: u32 = 32 * 1024; - -const TLS_PORT: u16 = 8081; -const TLS_CERT_DEFAULT: []const u8 = "/etc/zix-tls/server.crt"; -const TLS_KEY_DEFAULT: []const u8 = "/etc/zix-tls/server.key"; +const cache = @import("shared/cache.zig"); +const dbpg = @import("shared/dbpg.zig"); +const dbrd = @import("shared/dbrd.zig"); // --------------------------------------------------------- // const Routes = zix.Http1.Router(&[_]zix.Http1.Route{ - .{ .path = "/baseline11", .handler = handler.baseline }, - .{ .path = "/pipeline", .handler = handler.pipeline }, - .{ .path = "/upload", .handler = handler.upload }, - .{ .path = "/async-db", .handler = handler.asyncDb }, - .{ .path = "/json", .handler = handler.jsonResp, .kind = .PREFIX }, - .{ .path = "/static", .handler = handler.static, .kind = .PREFIX }, - .{ .path = "/crud/items", .handler = handler.crudItems, .kind = .PREFIX }, + .{ .path = baseline.PATH, .handler = baseline.RESPONSE }, + .{ .path = pipeline.PATH, .handler = pipeline.RESPONSE }, + .{ .path = upload.PATH, .handler = upload.RESPONSE }, + .{ .path = asyncdb.PATH, .handler = asyncdb.RESPONSE }, + .{ .path = static.PATH, .handler = static.RESPONSE, .kind = .PREFIX }, + .{ .path = json.PATH, .handler = json.RESPONSE, .kind = .PREFIX }, + .{ .path = crud.PATH, .handler = crud.RESPONSE, .kind = .PREFIX }, }); pub fn main(process: std.process.Init) !void { - // DB endpoints: the executor spawns nothing when DATABASE_URL is absent, - // so non-DB profiles run zero extra threads and the DB routes answer 503. - dbpg.init(process); - dbrd.init(process); - dbpg.startExecutor(handler.runBatch); + var json_alloc = std.heap.ArenaAllocator.init(std.heap.smp_allocator); + defer json_alloc.deinit(); - var allocator_dataset = std.heap.ArenaAllocator.init(std.heap.smp_allocator); - defer allocator_dataset.deinit(); + var tls_alloc = std.heap.ArenaAllocator.init(std.heap.smp_allocator); + defer tls_alloc.deinit(); - var dataset_path_buf: [512]u8 = undefined; - const data_dir = "/data"; - const dataset_path = try std.fmt.bufPrint(&dataset_path_buf, "{s}/dataset.json", .{data_dir}); - handler.g_dataset = try dataset.load(allocator_dataset.allocator(), dataset_path); - handler.g_static_base = std.fmt.bufPrint(&handler.g_static_base_buf, "{s}/static/", .{data_dir}) catch "/data/static/"; + try json.init(json_alloc.allocator()); - var allocator_tls = std.heap.ArenaAllocator.init(std.heap.smp_allocator); - defer allocator_tls.deinit(); + // DB endpoints: each driver's transport threads spawn only when its URL + // is present (non-DB profiles run zero extra threads, DB routes answer + // 503). Redis starts first, the postgrez shard threads mirror to it. + dbpg.init(process); + dbrd.init(process); + dbrd.start(); + dbpg.start(); - // json-tls https side. A failed cert load leaves tls_ctx null and the - // cleartext listener keeps serving. - var tls_ctx: ?zix.Tls.Context = zix.Tls.Context.init(allocator_tls.allocator(), process.io, .{ - .cert_path = TLS_CERT_DEFAULT, - .key_path = TLS_KEY_DEFAULT, + var tls = zix.Tls.Context.init(tls_alloc.allocator(), process.io, .{ + .cert_path = "/etc/zix-tls/server.cert", + .key_path = "/etc/zix-tls/server.key", .alpn = &.{.HTTP_1_1}, .min_version = .TLS_1_3, - }) catch null; + }) catch |e| { + return e; + }; - // Dual listener: one server serves cleartext on PORT and https on - // TLS_PORT from the same .URING worker fleet. var server = zix.Http1.Server.init(Routes.dispatch, .{ .io = process.io, - .ip = IP, - .port = PORT, - .tls = if (tls_ctx) |*tls| tls else null, - .tls_port = TLS_PORT, - .dispatch_model = DISPATCH_MODEL, - .max_headers = MAX_HEADERS, - .workers = WORKERS, - .send_date_header = SEND_DATE_HEADER, - .response_cache = RESPONSE_CACHE, - .cache_max_entries = CACHE_MAX_ENTRIES, - .cache_max_value_bytes = CACHE_MAX_VALUE_BYTES, - .cache_ttl_ms = handler.CACHE_TTL_MS, + .ip = "::", + .port = 8080, + .workers = 0, + .dispatch_model = .URING, + .tls = &tls, + .tls_port = 8081, + // + .send_date_header = false, + .max_response_headers = .{ .CUSTOM = 8 }, + // + .compress = true, + // + .response_cache = true, + .cache_max_entries = 1 * 1024 / 2, + .cache_max_value_bytes = 32 * 1024, + .cache_ttl_ms = cache.TTL_MS, + // .kernel_backlog = 16 * 1024, .max_recv_buf = 8 * 1024, + // .uring_send_buf_size = 16 * 1024, .uring_idle_pool_floor = 1 * 1024 / 4, .uring_idle_pool_ceiling = 1 * 1024, From 71da3efe182999a550eb7716f9f01beb1e197da7 Mon Sep 17 00:00:00 2001 From: prothegee Date: Tue, 21 Jul 2026 18:40:27 +0700 Subject: [PATCH 26/46] re-adjusting entry 0.5.x-rc2 entry for zix --- frameworks/zix/src/main.zig | 9 ++- frameworks/zix/src/shared/dbpg.zig | 91 ++++++++++++++++++++++++------ 2 files changed, 80 insertions(+), 20 deletions(-) diff --git a/frameworks/zix/src/main.zig b/frameworks/zix/src/main.zig index ef957b3d2..d75b61412 100644 --- a/frameworks/zix/src/main.zig +++ b/frameworks/zix/src/main.zig @@ -60,6 +60,11 @@ pub fn main(process: std.process.Init) !void { return e; }; + // Park ring sized to peak conns per worker: 16384c is the deepest + // scenario and workers = 0 spawns one worker per CPU. + const cpus = std.Thread.getCpuCount() catch 8; + const park_len = @max(512, 16 * 1024 / cpus); + var server = zix.Http1.Server.init(Routes.dispatch, .{ .io = process.io, .ip = "::", @@ -83,9 +88,9 @@ pub fn main(process: std.process.Init) !void { .max_recv_buf = 8 * 1024, // .uring_send_buf_size = 16 * 1024, - .uring_idle_pool_floor = 1 * 1024 / 4, + .uring_idle_pool_floor = 16, // 0:256 1:16 .uring_idle_pool_ceiling = 1 * 1024, - .process_queue_len = 8192, + .process_queue_len = park_len, // 0:8192 1:16384 / workers, floor 512 }); defer server.deinit(); diff --git a/frameworks/zix/src/shared/dbpg.zig b/frameworks/zix/src/shared/dbpg.zig index 898ad339d..2cf861703 100644 --- a/frameworks/zix/src/shared/dbpg.zig +++ b/frameworks/zix/src/shared/dbpg.zig @@ -1,10 +1,10 @@ //! PostgreSQL for the DB endpoints (async-db, crud) over the driver-owned -//! multiplexed transport (postgrez.Transport, .URING), sharded: SHARD_COUNT -//! transport threads each own their slice of the pipelined connections, so -//! reply decode, render, and the client write are not serialized on one -//! thread. Jobs route to a shard by fd (per-fd order). Handlers build a Job -//! and call submitJob, replies render and write from onReply on the shard -//! thread. +//! multiplexed transport (postgrez.Transport, .URING), sharded: the active +//! shard count (scaled from the CPU budget at init) transport threads each +//! own their slice of the pipelined connections, so reply decode, render, +//! and the client write are not serialized on one thread. Jobs route to a +//! shard by fd (per-fd order). Handlers build a Job and call submitJob, +//! replies render and write from onReply on the shard thread. const std = @import("std"); const zix = @import("zix"); @@ -33,9 +33,10 @@ const WINDOW = postgrez.dispatch.DEFAULT_WINDOW; /// list of 100 rows tops out near 21 KiB. const DB_BODY_MAX = 32 * 1024; -/// Transport shards. Each runs its own thread and Transport, splitting the -/// configured connections, so total DB connections stay the same. -const SHARD_COUNT = 2; +/// Transport shard ceiling. Each shard runs its own thread and Transport, +/// splitting the configured connections. The active count comes from +/// shardCountFor at init. +const MAX_SHARDS = 8; // 0:2 1:8 /// In-flight ASYNC_DB scans one shard allows: shard connections times this. /// Caps how many concurrent price-range scans can over-feed the server. @@ -137,8 +138,8 @@ const QueuedJob = struct { // --------------------------------------------------------- // /// Jobs one shard queue holds (engine workers enqueue, the shard drains). -/// Split across SHARD_COUNT shards this matches the single 8192 queue the -/// unsharded entry ran. +/// Per shard: at the two-shard floor this matches the single 8192 queue the +/// unsharded entry ran, more shards raise the fleet total. const QUEUE_CAP = 4096; /// In-flight Jobs one shard tracks, above its conns times WINDOW ceiling. @@ -270,8 +271,9 @@ var g_io: std.Io = undefined; var g_config: postgrez.Config = undefined; var g_enabled: bool = false; var g_conns: usize = 8; +var g_shard_count: usize = 2; -var g_shards: [SHARD_COUNT]Shard = @splat(.{}); +var g_shards: [MAX_SHARDS]Shard = @splat(.{}); var g_open: bool = false; var g_running: std.atomic.Value(bool) = .init(false); @@ -285,7 +287,27 @@ threadlocal var tl_shard: ?*Shard = null; threadlocal var tl_write_blocking: bool = false; fn shardOf(fd: std.posix.fd_t) *Shard { - return &g_shards[@intCast(@mod(fd, SHARD_COUNT))]; + const key: usize = @intCast(fd); + + return &g_shards[key % g_shard_count]; +} + +/// Active transport shards for a CPU budget: one per four CPUs, floored at +/// the sharded baseline of two, capped at MAX_SHARDS. +fn shardCountFor(cpu: usize) usize { + return std.math.clamp(cpu / 4, 2, MAX_SHARDS); // 0:2 1:clamp(cpu / 4, 2, 8) +} + +/// Total DB connections for a CPU budget when DATABASE_MAX_CONN is absent. +/// The arena postgres runs max_connections=256, 64 stays well inside it. +fn connsFor(cpu: usize) usize { + return std.math.clamp(cpu, 4, 64); // 0:clamp(cpu, 4, 16) 1:clamp(cpu, 4, 64) +} + +/// One shard's slice of the conn budget: an even split, the first +/// total mod count shards take the remainder, never below one. +fn shardConns(total: usize, count: usize, index: usize) usize { + return @max(1, total / count + @intFromBool(index < total % count)); } // --------------------------------------------------------- // @@ -301,13 +323,15 @@ pub fn init(process: std.process.Init) void { g_io = process.io; g_enabled = true; + const cpu = std.Thread.getCpuCount() catch 8; + g_shard_count = shardCountFor(cpu); + if (process.environ_map.get("DATABASE_MAX_CONN")) |max_text| { if (std.fmt.parseInt(usize, max_text, 10)) |parsed| { if (parsed > 0) g_conns = parsed; } else |_| {} } else { - const cpu = std.Thread.getCpuCount() catch 8; - g_conns = std.math.clamp(cpu, 4, 16); + g_conns = connsFor(cpu); } } @@ -345,8 +369,8 @@ pub fn start() void { g_running.store(true, .release); var opened: usize = 0; - for (&g_shards, 0..) |*shard, index| { - shard.conns = @max(1, g_conns / SHARD_COUNT + @intFromBool(index < g_conns % SHARD_COUNT)); + for (g_shards[0..g_shard_count], 0..) |*shard, index| { + shard.conns = shardConns(g_conns, g_shard_count, index); shard.scan_cap = shard.conns * SCAN_CAP_PER_CONN; shard.slotInit(); @@ -365,7 +389,7 @@ pub fn start() void { opened += 1; } - if (opened < SHARD_COUNT) { + if (opened < g_shard_count) { g_enabled = false; g_running.store(false, .release); @@ -1259,6 +1283,37 @@ test "3e test: scan hold ring admits under cap and holds at cap" { try std.testing.expect(shard.scanTake() != null); } +test "shardCountFor scales with the cpu budget inside the clamp" { + try std.testing.expectEqual(@as(usize, 2), shardCountFor(1)); + try std.testing.expectEqual(@as(usize, 2), shardCountFor(6)); + try std.testing.expectEqual(@as(usize, 4), shardCountFor(16)); + try std.testing.expectEqual(@as(usize, 8), shardCountFor(64)); + try std.testing.expectEqual(@as(usize, 8), shardCountFor(128)); +} + +test "connsFor scales with the cpu budget inside the clamp" { + try std.testing.expectEqual(@as(usize, 4), connsFor(2)); + try std.testing.expectEqual(@as(usize, 6), connsFor(6)); + try std.testing.expectEqual(@as(usize, 16), connsFor(16)); + try std.testing.expectEqual(@as(usize, 64), connsFor(64)); + try std.testing.expectEqual(@as(usize, 64), connsFor(128)); +} + +test "shardConns splits the conn budget without loss" { + var sum: usize = 0; + for (0..8) |index| sum += shardConns(64, 8, index); + try std.testing.expectEqual(@as(usize, 64), sum); + + sum = 0; + for (0..2) |index| sum += shardConns(7, 2, index); + try std.testing.expectEqual(@as(usize, 7), sum); + try std.testing.expectEqual(@as(usize, 4), shardConns(7, 2, 0)); + try std.testing.expectEqual(@as(usize, 3), shardConns(7, 2, 1)); + + // A budget smaller than the shard count still gives every shard one conn. + try std.testing.expectEqual(@as(usize, 1), shardConns(1, 2, 1)); +} + test "3e test: deliver parks on a full socket and flush completes it" { const linux = std.os.linux; From 4945a651fd09500473a27548a4fc4d8125d80307 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Jul 2026 17:59:48 +0000 Subject: [PATCH 27/46] Benchmark results: zix --- site/data/api-16-1024.json | 26 ++++++++++++++++++++++++ site/data/api-4-256.json | 26 ++++++++++++++++++++++++ site/data/async-db-1024.json | 20 ++++++++++++++++++ site/data/baseline-4096.json | 16 +++++++-------- site/data/baseline-512.json | 16 +++++++-------- site/data/crud-4096.json | 20 ++++++++++++++++++ site/data/frameworks.json | 4 ++-- site/data/json-4096.json | 16 +++++++-------- site/data/json-comp-16384.json | 20 ++++++++++++++++++ site/data/json-comp-4096.json | 20 ++++++++++++++++++ site/data/json-comp-512.json | 20 ++++++++++++++++++ site/data/json-tls-4096.json | 19 +++++++++++++++++ site/data/limited-conn-4096.json | 18 ++++++++-------- site/data/limited-conn-512.json | 18 ++++++++-------- site/data/pipelined-4096.json | 14 ++++++------- site/data/pipelined-512.json | 14 ++++++------- site/data/static-1024.json | 14 ++++++------- site/data/static-4096.json | 12 +++++------ site/data/static-6800.json | 14 ++++++------- site/data/upload-256.json | 16 +++++++-------- site/data/upload-32.json | 18 ++++++++-------- site/static/logs/api-16/1024/zix.log | 0 site/static/logs/api-4/256/zix.log | 0 site/static/logs/async-db/1024/zix.log | 0 site/static/logs/crud/4096/zix.log | 0 site/static/logs/json-comp/16384/zix.log | 0 site/static/logs/json-comp/4096/zix.log | 0 site/static/logs/json-comp/512/zix.log | 0 site/static/logs/json-tls/4096/zix.log | 0 29 files changed, 266 insertions(+), 95 deletions(-) create mode 100644 site/static/logs/api-16/1024/zix.log create mode 100644 site/static/logs/api-4/256/zix.log create mode 100644 site/static/logs/async-db/1024/zix.log create mode 100644 site/static/logs/crud/4096/zix.log create mode 100644 site/static/logs/json-comp/16384/zix.log create mode 100644 site/static/logs/json-comp/4096/zix.log create mode 100644 site/static/logs/json-comp/512/zix.log create mode 100644 site/static/logs/json-tls/4096/zix.log diff --git a/site/data/api-16-1024.json b/site/data/api-16-1024.json index 1e4486c4c..7ec384a49 100644 --- a/site/data/api-16-1024.json +++ b/site/data/api-16-1024.json @@ -1632,5 +1632,31 @@ "tpl_upload": 0, "tpl_static": 0, "tpl_async_db": 462555 + }, + { + "framework": "zix", + "language": "Zig", + "rps": 257170, + "avg_latency": "1.78ms", + "p99_latency": "13.80ms", + "cpu": "984.8%", + "memory": "79MiB", + "connections": 1024, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "1.26GB/s", + "input_bw": "14.47MB/s", + "reconnects": 771518, + "status_2xx": 3857563, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0, + "tpl_baseline": 1448966, + "tpl_json": 1443619, + "tpl_db": 0, + "tpl_upload": 0, + "tpl_static": 0, + "tpl_async_db": 964977 } ] \ No newline at end of file diff --git a/site/data/api-4-256.json b/site/data/api-4-256.json index 240b22219..a28293b1e 100644 --- a/site/data/api-4-256.json +++ b/site/data/api-4-256.json @@ -1632,5 +1632,31 @@ "tpl_upload": 0, "tpl_static": 0, "tpl_async_db": 148717 + }, + { + "framework": "zix", + "language": "Zig", + "rps": 67637, + "avg_latency": "1.22ms", + "p99_latency": "9.45ms", + "cpu": "223.7%", + "memory": "38MiB", + "connections": 256, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "339.05MB/s", + "input_bw": "3.81MB/s", + "reconnects": 202910, + "status_2xx": 1014569, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0, + "tpl_baseline": 381285, + "tpl_json": 379909, + "tpl_db": 0, + "tpl_upload": 0, + "tpl_static": 0, + "tpl_async_db": 253374 } ] \ No newline at end of file diff --git a/site/data/async-db-1024.json b/site/data/async-db-1024.json index 65ce6a807..49314e0a2 100644 --- a/site/data/async-db-1024.json +++ b/site/data/async-db-1024.json @@ -1334,5 +1334,25 @@ "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 + }, + { + "framework": "zix", + "language": "Zig", + "rps": 199403, + "avg_latency": "4.59ms", + "p99_latency": "6.87ms", + "cpu": "1126.9%", + "memory": "159MiB", + "connections": 1024, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "768.24MB/s", + "input_bw": "13.31MB/s", + "reconnects": 79530, + "status_2xx": 1994031, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 } ] \ No newline at end of file diff --git a/site/data/baseline-4096.json b/site/data/baseline-4096.json index c90e96cee..cfea4357e 100644 --- a/site/data/baseline-4096.json +++ b/site/data/baseline-4096.json @@ -1870,19 +1870,19 @@ { "framework": "zix", "language": "Zig", - "rps": 4492613, - "avg_latency": "911us", - "p99_latency": "1.19ms", - "cpu": "6413.1%", - "memory": "186MiB", + "rps": 4479858, + "avg_latency": "913us", + "p99_latency": "1.20ms", + "cpu": "6415.9%", + "memory": "188MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "282.68MB/s", - "input_bw": "347.04MB/s", + "bandwidth": "281.89MB/s", + "input_bw": "346.06MB/s", "reconnects": 0, - "status_2xx": 22463065, + "status_2xx": 22399294, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/baseline-512.json b/site/data/baseline-512.json index d59b781b5..775766d79 100644 --- a/site/data/baseline-512.json +++ b/site/data/baseline-512.json @@ -1870,19 +1870,19 @@ { "framework": "zix", "language": "Zig", - "rps": 4217982, - "avg_latency": "120us", - "p99_latency": "231us", - "cpu": "6348.6%", - "memory": "130MiB", + "rps": 4254791, + "avg_latency": "119us", + "p99_latency": "209us", + "cpu": "6433.1%", + "memory": "138MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "265.42MB/s", - "input_bw": "325.83MB/s", + "bandwidth": "267.69MB/s", + "input_bw": "328.67MB/s", "reconnects": 0, - "status_2xx": 21089911, + "status_2xx": 21273956, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/crud-4096.json b/site/data/crud-4096.json index 1855e56ca..bfb971609 100644 --- a/site/data/crud-4096.json +++ b/site/data/crud-4096.json @@ -617,5 +617,25 @@ "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 + }, + { + "framework": "zix", + "language": "Zig", + "rps": 963289, + "avg_latency": "3.43ms", + "p99_latency": "16.60ms", + "cpu": "2686.4%", + "memory": "232MiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "283.72MB/s", + "input_bw": "82.68MB/s", + "reconnects": 70386, + "status_2xx": 14449337, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 } ] \ No newline at end of file diff --git a/site/data/frameworks.json b/site/data/frameworks.json index 309e33343..0479e840a 100644 --- a/site/data/frameworks.json +++ b/site/data/frameworks.json @@ -1049,10 +1049,10 @@ }, "zix": { "dir": "zix", - "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine (no std.http). Shared-nothing: each worker runs its own SO_REUSEPORT multishot accept plus io_uring completion loop and owns its connections. The /json endpoint serves from the per-worker response cache, and request bodies larger than the read buffer are drained rather than buffered.", + "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine, .URING dispatch: per-worker SO_REUSEPORT io_uring loops, comptime Router, staged send sink. Dual listener: cleartext 8080, TLS 1.3 on 8081. DB endpoints ride the driver-owned multiplexed transports (postgrez plus rediz, .URING): fd-routed shard threads, named prepared statements, cache-aside crud with a Redis write-behind mirror.", "repo": "https://github.com/prothegee/zix", "type": "engine", - "engine": "zix", + "engine": "zix.Http1 URING Dispatch Model", "variants": [ { "dir": "zix-http2", diff --git a/site/data/json-4096.json b/site/data/json-4096.json index 2a785f54f..48e8775ff 100644 --- a/site/data/json-4096.json +++ b/site/data/json-4096.json @@ -1578,19 +1578,19 @@ { "framework": "zix", "language": "Zig", - "rps": 2348626, - "avg_latency": "614us", + "rps": 2364532, + "avg_latency": "627us", "p99_latency": "2.75ms", - "cpu": "5365.1%", - "memory": "290MiB", + "cpu": "5475.6%", + "memory": "181MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "7.86GB/s", - "input_bw": "111.99MB/s", - "reconnects": 468990, - "status_2xx": 11743130, + "bandwidth": "7.91GB/s", + "input_bw": "112.75MB/s", + "reconnects": 472585, + "status_2xx": 11822661, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/json-comp-16384.json b/site/data/json-comp-16384.json index 836dee211..0b5061f73 100644 --- a/site/data/json-comp-16384.json +++ b/site/data/json-comp-16384.json @@ -1298,5 +1298,25 @@ "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 + }, + { + "framework": "zix", + "language": "Zig", + "rps": 2893193, + "avg_latency": "4.31ms", + "p99_latency": "7.72ms", + "cpu": "6401.9%", + "memory": "453MiB", + "connections": 16384, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "3.64GB/s", + "input_bw": "215.21MB/s", + "reconnects": 570773, + "status_2xx": 14465965, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 } ] \ No newline at end of file diff --git a/site/data/json-comp-4096.json b/site/data/json-comp-4096.json index a571feb5c..f0715e65a 100644 --- a/site/data/json-comp-4096.json +++ b/site/data/json-comp-4096.json @@ -1298,5 +1298,25 @@ "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 + }, + { + "framework": "zix", + "language": "Zig", + "rps": 3133503, + "avg_latency": "574us", + "p99_latency": "2.24ms", + "cpu": "6320.1%", + "memory": "204MiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "3.95GB/s", + "input_bw": "233.09MB/s", + "reconnects": 626407, + "status_2xx": 15667517, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 } ] \ No newline at end of file diff --git a/site/data/json-comp-512.json b/site/data/json-comp-512.json index 0c46d3393..78c88f90f 100644 --- a/site/data/json-comp-512.json +++ b/site/data/json-comp-512.json @@ -1298,5 +1298,25 @@ "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 + }, + { + "framework": "zix", + "language": "Zig", + "rps": 2204553, + "avg_latency": "98us", + "p99_latency": "365us", + "cpu": "5272.3%", + "memory": "143MiB", + "connections": 512, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "2.78GB/s", + "input_bw": "163.99MB/s", + "reconnects": 440902, + "status_2xx": 11022766, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 } ] \ No newline at end of file diff --git a/site/data/json-tls-4096.json b/site/data/json-tls-4096.json index 26197ff35..7b96ba7ca 100644 --- a/site/data/json-tls-4096.json +++ b/site/data/json-tls-4096.json @@ -922,5 +922,24 @@ "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 + }, + { + "framework": "zix", + "language": "Zig", + "rps": 1937152, + "avg_latency": "1.12ms", + "p99_latency": "37.91ms", + "cpu": "4903.9%", + "memory": "278MiB", + "connections": 4096, + "threads": 64, + "duration": "5s", + "pipeline": 1, + "bandwidth": "6.48GB", + "reconnects": 0, + "status_2xx": 9880616, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0 } ] \ No newline at end of file diff --git a/site/data/limited-conn-4096.json b/site/data/limited-conn-4096.json index 6a121eb6f..343698263 100644 --- a/site/data/limited-conn-4096.json +++ b/site/data/limited-conn-4096.json @@ -1870,19 +1870,19 @@ { "framework": "zix", "language": "Zig", - "rps": 2751069, - "avg_latency": "1.35ms", - "p99_latency": "1.89ms", - "cpu": "5783.9%", - "memory": "237MiB", + "rps": 2711205, + "avg_latency": "1.37ms", + "p99_latency": "1.87ms", + "cpu": "5804.3%", + "memory": "227MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "173.09MB/s", - "input_bw": "212.51MB/s", - "reconnects": 1375655, - "status_2xx": 13755345, + "bandwidth": "170.58MB/s", + "input_bw": "209.43MB/s", + "reconnects": 1355814, + "status_2xx": 13556028, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/limited-conn-512.json b/site/data/limited-conn-512.json index 6b3c09d94..485ae813d 100644 --- a/site/data/limited-conn-512.json +++ b/site/data/limited-conn-512.json @@ -1870,19 +1870,19 @@ { "framework": "zix", "language": "Zig", - "rps": 2740199, - "avg_latency": "170us", - "p99_latency": "403us", - "cpu": "5474.7%", - "memory": "150MiB", + "rps": 2683926, + "avg_latency": "174us", + "p99_latency": "419us", + "cpu": "5516.2%", + "memory": "128MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "172.36MB/s", - "input_bw": "211.67MB/s", - "reconnects": 1369714, - "status_2xx": 13700995, + "bandwidth": "168.90MB/s", + "input_bw": "207.33MB/s", + "reconnects": 1341978, + "status_2xx": 13419631, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/pipelined-4096.json b/site/data/pipelined-4096.json index 52725ecf0..21a98781a 100644 --- a/site/data/pipelined-4096.json +++ b/site/data/pipelined-4096.json @@ -1787,18 +1787,18 @@ { "framework": "zix", "language": "Zig", - "rps": 55979164, - "avg_latency": "1.17ms", - "p99_latency": "1.79ms", - "cpu": "6454.4%", - "memory": "186MiB", + "rps": 57817404, + "avg_latency": "1.13ms", + "p99_latency": "1.52ms", + "cpu": "6241.2%", + "memory": "189MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 16, - "bandwidth": "3.44GB/s", + "bandwidth": "3.55GB/s", "reconnects": 0, - "status_2xx": 279895824, + "status_2xx": 289087021, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/pipelined-512.json b/site/data/pipelined-512.json index 03ac6b812..32319194e 100644 --- a/site/data/pipelined-512.json +++ b/site/data/pipelined-512.json @@ -1787,18 +1787,18 @@ { "framework": "zix", "language": "Zig", - "rps": 53609380, - "avg_latency": "152us", - "p99_latency": "344us", - "cpu": "6399.9%", - "memory": "134MiB", + "rps": 56016444, + "avg_latency": "145us", + "p99_latency": "254us", + "cpu": "6368.3%", + "memory": "132MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 16, - "bandwidth": "3.29GB/s", + "bandwidth": "3.44GB/s", "reconnects": 0, - "status_2xx": 268046903, + "status_2xx": 280082224, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/static-1024.json b/site/data/static-1024.json index 7af204830..f17d67118 100644 --- a/site/data/static-1024.json +++ b/site/data/static-1024.json @@ -1484,18 +1484,18 @@ { "framework": "zix", "language": "Zig", - "rps": 2025521, - "avg_latency": "300.92us", - "p99_latency": "3.21ms", - "cpu": "5488.4%", - "memory": "140MiB", + "rps": 2005632, + "avg_latency": "303.81us", + "p99_latency": "3.57ms", + "cpu": "5478.0%", + "memory": "142MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "30.86GB", + "bandwidth": "30.55GB", "reconnects": 0, - "status_2xx": 10331115, + "status_2xx": 10228661, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/static-4096.json b/site/data/static-4096.json index 30c866648..cdc4184a8 100644 --- a/site/data/static-4096.json +++ b/site/data/static-4096.json @@ -1484,18 +1484,18 @@ { "framework": "zix", "language": "Zig", - "rps": 2036680, + "rps": 2023494, "avg_latency": "1.05ms", - "p99_latency": "7.16ms", - "cpu": "5503.2%", - "memory": "195MiB", + "p99_latency": "4.11ms", + "cpu": "5454.7%", + "memory": "216MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "31.03GB", + "bandwidth": "30.83GB", "reconnects": 0, - "status_2xx": 10386093, + "status_2xx": 10321137, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/static-6800.json b/site/data/static-6800.json index 6e5718ef2..84a862416 100644 --- a/site/data/static-6800.json +++ b/site/data/static-6800.json @@ -1484,18 +1484,18 @@ { "framework": "zix", "language": "Zig", - "rps": 1986456, - "avg_latency": "1.75ms", - "p99_latency": "5.18ms", - "cpu": "5269.1%", - "memory": "241MiB", + "rps": 2005733, + "avg_latency": "1.74ms", + "p99_latency": "7.49ms", + "cpu": "5415.0%", + "memory": "275MiB", "connections": 6800, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "30.26GB", + "bandwidth": "30.55GB", "reconnects": 0, - "status_2xx": 10111205, + "status_2xx": 10198345, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/upload-256.json b/site/data/upload-256.json index 3c66d6358..c63cc667c 100644 --- a/site/data/upload-256.json +++ b/site/data/upload-256.json @@ -1441,19 +1441,19 @@ { "framework": "zix", "language": "Zig", - "rps": 6661, - "avg_latency": "38.12ms", - "p99_latency": "45.10ms", - "cpu": "943.2%", + "rps": 6726, + "avg_latency": "37.84ms", + "p99_latency": "44.20ms", + "cpu": "911.8%", "memory": "130MiB", "connections": 256, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "463.46KB/s", - "input_bw": "52.83GB/s", - "reconnects": 6688, - "status_2xx": 33441, + "bandwidth": "467.76KB/s", + "input_bw": "53.35GB/s", + "reconnects": 6756, + "status_2xx": 33766, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/upload-32.json b/site/data/upload-32.json index 843c16c90..90041277d 100644 --- a/site/data/upload-32.json +++ b/site/data/upload-32.json @@ -1441,19 +1441,19 @@ { "framework": "zix", "language": "Zig", - "rps": 8185, - "avg_latency": "3.88ms", - "p99_latency": "10.90ms", - "cpu": "1065.4%", - "memory": "124MiB", + "rps": 8232, + "avg_latency": "3.86ms", + "p99_latency": "11.00ms", + "cpu": "1112.3%", + "memory": "125MiB", "connections": 32, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "569.93KB/s", - "input_bw": "64.92GB/s", - "reconnects": 8199, - "status_2xx": 41007, + "bandwidth": "573.29KB/s", + "input_bw": "65.29GB/s", + "reconnects": 8250, + "status_2xx": 41245, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/static/logs/api-16/1024/zix.log b/site/static/logs/api-16/1024/zix.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/api-4/256/zix.log b/site/static/logs/api-4/256/zix.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/async-db/1024/zix.log b/site/static/logs/async-db/1024/zix.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/crud/4096/zix.log b/site/static/logs/crud/4096/zix.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/json-comp/16384/zix.log b/site/static/logs/json-comp/16384/zix.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/json-comp/4096/zix.log b/site/static/logs/json-comp/4096/zix.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/json-comp/512/zix.log b/site/static/logs/json-comp/512/zix.log new file mode 100644 index 000000000..e69de29bb diff --git a/site/static/logs/json-tls/4096/zix.log b/site/static/logs/json-tls/4096/zix.log new file mode 100644 index 000000000..e69de29bb From af9c11b2d0c8fe0be2e338ba2c6191d19f531614 Mon Sep 17 00:00:00 2001 From: prothegee Date: Wed, 22 Jul 2026 16:53:42 +0700 Subject: [PATCH 28/46] use 16 shards --- frameworks/zix/src/shared/dbpg.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frameworks/zix/src/shared/dbpg.zig b/frameworks/zix/src/shared/dbpg.zig index 2cf861703..7fc05cdb1 100644 --- a/frameworks/zix/src/shared/dbpg.zig +++ b/frameworks/zix/src/shared/dbpg.zig @@ -36,7 +36,7 @@ const DB_BODY_MAX = 32 * 1024; /// Transport shard ceiling. Each shard runs its own thread and Transport, /// splitting the configured connections. The active count comes from /// shardCountFor at init. -const MAX_SHARDS = 8; // 0:2 1:8 +const MAX_SHARDS = 16; // 0:2 1:8 2:16 /// In-flight ASYNC_DB scans one shard allows: shard connections times this. /// Caps how many concurrent price-range scans can over-feed the server. From a20ac7a834e5a0e8a6e5f820e1a4c1d1d1ac0fd8 Mon Sep 17 00:00:00 2001 From: prothegee Date: Wed, 22 Jul 2026 17:15:34 +0700 Subject: [PATCH 29/46] re-arange and remove local test --- frameworks/zix/src/shared/dbpg.zig | 299 +++++++++-------------------- 1 file changed, 90 insertions(+), 209 deletions(-) diff --git a/frameworks/zix/src/shared/dbpg.zig b/frameworks/zix/src/shared/dbpg.zig index 7fc05cdb1..027d03680 100644 --- a/frameworks/zix/src/shared/dbpg.zig +++ b/frameworks/zix/src/shared/dbpg.zig @@ -312,122 +312,6 @@ fn shardConns(total: usize, count: usize, index: usize) usize { // --------------------------------------------------------- // -/// Read DATABASE_URL and DATABASE_MAX_CONN once at startup. Absent or -/// malformed DATABASE_URL leaves the DB endpoints answering 503. -pub fn init(process: std.process.Init) void { - const url_text = process.environ_map.get("DATABASE_URL") orelse return; - - g_config = postgrez.parseUrl(url_text) catch return; - g_config.tls = .OFF; - g_config.dispatch_model = .URING; - g_io = process.io; - g_enabled = true; - - const cpu = std.Thread.getCpuCount() catch 8; - g_shard_count = shardCountFor(cpu); - - if (process.environ_map.get("DATABASE_MAX_CONN")) |max_text| { - if (std.fmt.parseInt(usize, max_text, 10)) |parsed| { - if (parsed > 0) g_conns = parsed; - } else |_| {} - } else { - g_conns = connsFor(cpu); - } -} - -pub fn enabled() bool { - return g_enabled; -} - -/// Open one multiplexed transport per shard and spawn its poll-loop thread, -/// splitting the configured connections. Does nothing when DATABASE_URL was -/// absent, so non-DB profiles spawn no extra threads. -pub fn start() void { - if (!g_enabled) return; - - // Pre-encode one Parse plus Sync per named statement, handed to open so the - // transport parses each on every connection before the pipelined loop runs. - var prepare_buf: [4096]u8 = undefined; - var fixed = std.heap.FixedBufferAllocator.init(&prepare_buf); - const allocator = fixed.allocator(); - - var prepares: [STATEMENTS.len][]const u8 = undefined; - inline for (STATEMENTS, 0..) |spec, index| { - var out: std.ArrayList(u8) = .empty; - frontend.parse(allocator, &out, spec.name, spec.sql, &.{}) catch { - g_enabled = false; - return; - }; - frontend.sync(allocator, &out) catch { - g_enabled = false; - return; - }; - - prepares[index] = out.items; - } - - g_running.store(true, .release); - - var opened: usize = 0; - for (g_shards[0..g_shard_count], 0..) |*shard, index| { - shard.conns = shardConns(g_conns, g_shard_count, index); - shard.scan_cap = shard.conns * SCAN_CAP_PER_CONN; - shard.slotInit(); - - shard.transport = postgrez.Transport.open(std.heap.smp_allocator, g_io, g_config, .{ - .model = .URING, - .conns = shard.conns, - .window = WINDOW, - .context = shard, - .on_reply = onReply, - .prepare = &prepares, - }) catch break; - - const thread = std.Thread.spawn(.{}, transportLoop, .{shard}) catch break; - thread.detach(); - - opened += 1; - } - - if (opened < g_shard_count) { - g_enabled = false; - g_running.store(false, .release); - - return; - } - - g_open = true; -} - -/// Queue a Job on its fd's shard thread. -/// -/// Note: -/// - keep_alive false marks a close request: this blocks until the shard -/// thread writes the response (the engine closes the fd on return). -/// -/// Return: -/// - true when the response is owned by the shard thread (or already written) -/// - false when the transport is down or the queue is full (shed 503) -pub fn submit(job: Job, keep_alive: bool) bool { - if (!g_open) return false; - - const shard = shardOf(jobFd(job)); - if (keep_alive) return shard.enqueue(.{ .job = job }); - - var completion: Completion = .{}; - if (!shard.enqueue(.{ .job = job, .completion = &completion })) return false; - - while (!completion.done.load(.acquire)) std.atomic.spinLoopHint(); - - return true; -} - -/// Queue a Job on its fd's shard thread. For a close request dbpg.submit blocks -/// until the response is written, so a deferred write never races the fd close. -pub fn submitJob(head: *const zix.Http1.ParsedHead, job: Job) bool { - return submit(job, head.keep_alive); -} - // --------------------------------------------------------- // /// One shard's thread: drain queued Jobs into the transport, poll for @@ -1025,8 +909,6 @@ fn formatHttpDate(secs: u64, buf: []u8) []u8 { }) catch buf[0..0]; } -// --------------------------------------------------------- // - /// Write one response (head then body) without letting a stalled client /// block the shard thread: send non-blocking, park the remainder on a full /// socket buffer, keep per-fd order by appending behind a parked response. @@ -1215,8 +1097,6 @@ fn flushPendingWrites(shard: *Shard) bool { return progressed; } -// --------------------------------------------------------- // - fn sendJson(fd: std.posix.fd_t, body: []const u8) void { var head_buf: [HEAD_MAX]u8 = undefined; @@ -1252,118 +1132,119 @@ fn sendCrudBody(fd: std.posix.fd_t, body: []const u8, cache_state: []const u8) v } // --------------------------------------------------------- // -// --------------------------------------------------------- // - -test "3e test: buildHead matches the engine header shape" { - var buf: [HEAD_MAX]u8 = undefined; - const head = buildHead(&buf, 200, "application/json", 5); - - try std.testing.expect(std.mem.startsWith(u8, head, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 5\r\nDate: ")); - try std.testing.expect(std.mem.endsWith(u8, head, " GMT\r\n\r\n")); -} - -test "3e test: scan hold ring admits under cap and holds at cap" { - const shard = try std.testing.allocator.create(Shard); - defer std.testing.allocator.destroy(shard); - - shard.* = .{}; - shard.scan_cap = 2; - const job: Job = .{ .ASYNC_DB = .{ .fd = 0, .min = 1, .max = 2, .limit = 3 } }; +/// Read DATABASE_URL and DATABASE_MAX_CONN once at startup. Absent or +/// malformed DATABASE_URL leaves the DB endpoints answering 503. +pub fn init(process: std.process.Init) void { + const url_text = process.environ_map.get("DATABASE_URL") orelse return; - try std.testing.expect(shard.scanTake() == null); - try std.testing.expect(shard.scanHold(.{ .job = job })); - try std.testing.expect(shard.scanTake() != null); + g_config = postgrez.parseUrl(url_text) catch return; + g_config.tls = .OFF; + g_config.dispatch_model = .URING; + g_io = process.io; + g_enabled = true; - shard.scan_inflight = 2; - try std.testing.expect(shard.scanHold(.{ .job = job })); - try std.testing.expect(shard.scanTake() == null); + const cpu = std.Thread.getCpuCount() catch 8; + g_shard_count = shardCountFor(cpu); - shard.scan_inflight = 1; - try std.testing.expect(shard.scanTake() != null); + if (process.environ_map.get("DATABASE_MAX_CONN")) |max_text| { + if (std.fmt.parseInt(usize, max_text, 10)) |parsed| { + if (parsed > 0) g_conns = parsed; + } else |_| {} + } else { + g_conns = connsFor(cpu); + } } -test "shardCountFor scales with the cpu budget inside the clamp" { - try std.testing.expectEqual(@as(usize, 2), shardCountFor(1)); - try std.testing.expectEqual(@as(usize, 2), shardCountFor(6)); - try std.testing.expectEqual(@as(usize, 4), shardCountFor(16)); - try std.testing.expectEqual(@as(usize, 8), shardCountFor(64)); - try std.testing.expectEqual(@as(usize, 8), shardCountFor(128)); +pub fn enabled() bool { + return g_enabled; } -test "connsFor scales with the cpu budget inside the clamp" { - try std.testing.expectEqual(@as(usize, 4), connsFor(2)); - try std.testing.expectEqual(@as(usize, 6), connsFor(6)); - try std.testing.expectEqual(@as(usize, 16), connsFor(16)); - try std.testing.expectEqual(@as(usize, 64), connsFor(64)); - try std.testing.expectEqual(@as(usize, 64), connsFor(128)); -} +/// Open one multiplexed transport per shard and spawn its poll-loop thread, +/// splitting the configured connections. Does nothing when DATABASE_URL was +/// absent, so non-DB profiles spawn no extra threads. +pub fn start() void { + if (!g_enabled) return; -test "shardConns splits the conn budget without loss" { - var sum: usize = 0; - for (0..8) |index| sum += shardConns(64, 8, index); - try std.testing.expectEqual(@as(usize, 64), sum); + // Pre-encode one Parse plus Sync per named statement, handed to open so the + // transport parses each on every connection before the pipelined loop runs. + var prepare_buf: [4096]u8 = undefined; + var fixed = std.heap.FixedBufferAllocator.init(&prepare_buf); + const allocator = fixed.allocator(); - sum = 0; - for (0..2) |index| sum += shardConns(7, 2, index); - try std.testing.expectEqual(@as(usize, 7), sum); - try std.testing.expectEqual(@as(usize, 4), shardConns(7, 2, 0)); - try std.testing.expectEqual(@as(usize, 3), shardConns(7, 2, 1)); + var prepares: [STATEMENTS.len][]const u8 = undefined; + inline for (STATEMENTS, 0..) |spec, index| { + var out: std.ArrayList(u8) = .empty; + frontend.parse(allocator, &out, spec.name, spec.sql, &.{}) catch { + g_enabled = false; + return; + }; + frontend.sync(allocator, &out) catch { + g_enabled = false; + return; + }; - // A budget smaller than the shard count still gives every shard one conn. - try std.testing.expectEqual(@as(usize, 1), shardConns(1, 2, 1)); -} + prepares[index] = out.items; + } -test "3e test: deliver parks on a full socket and flush completes it" { - const linux = std.os.linux; + g_running.store(true, .release); - const shard = try std.testing.allocator.create(Shard); - defer std.testing.allocator.destroy(shard); + var opened: usize = 0; + for (g_shards[0..g_shard_count], 0..) |*shard, index| { + shard.conns = shardConns(g_conns, g_shard_count, index); + shard.scan_cap = shard.conns * SCAN_CAP_PER_CONN; + shard.slotInit(); - shard.* = .{}; - tl_shard = shard; - defer tl_shard = null; + shard.transport = postgrez.Transport.open(std.heap.smp_allocator, g_io, g_config, .{ + .model = .URING, + .conns = shard.conns, + .window = WINDOW, + .context = shard, + .on_reply = onReply, + .prepare = &prepares, + }) catch break; - var fds: [2]i32 = undefined; - try std.testing.expect(linux.socketpair(linux.AF.UNIX, linux.SOCK.STREAM, 0, &fds) == 0); - defer _ = linux.close(fds[0]); - defer _ = linux.close(fds[1]); + const thread = std.Thread.spawn(.{}, transportLoop, .{shard}) catch break; + thread.detach(); - // Shrink the send buffer so the body cannot fit in one non-blocking send. - const sndbuf: u32 = 4096; - _ = linux.setsockopt(fds[0], linux.SOL.SOCKET, linux.SO.SNDBUF, @ptrCast(&sndbuf), @sizeOf(u32)); + opened += 1; + } - const body = try std.testing.allocator.alloc(u8, DB_BODY_MAX); - defer std.testing.allocator.free(body); + if (opened < g_shard_count) { + g_enabled = false; + g_running.store(false, .release); - @memset(body, 'x'); + return; + } - var head_buf: [HEAD_MAX]u8 = undefined; - const head = buildHead(&head_buf, 200, "application/json", body.len); - const total = head.len + body.len; + g_open = true; +} - deliver(fds[0], head, body); +/// Queue a Job on its fd's shard thread. +/// +/// Note: +/// - keep_alive false marks a close request: this blocks until the shard +/// thread writes the response (the engine closes the fd on return). +/// +/// Return: +/// - true when the response is owned by the shard thread (or already written) +/// - false when the transport is down or the queue is full (shed 503) +pub fn submit(job: Job, keep_alive: bool) bool { + if (!g_open) return false; - try std.testing.expect(shard.pw_active == 1); + const shard = shardOf(jobFd(job)); + if (keep_alive) return shard.enqueue(.{ .job = job }); - // Drain the peer while flushing until the parked remainder is gone. - var received: usize = 0; - var recv_buf: [8192]u8 = undefined; - var rounds: usize = 0; - while (received < total) : (rounds += 1) { - try std.testing.expect(rounds < 100_000); + var completion: Completion = .{}; + if (!shard.enqueue(.{ .job = job, .completion = &completion })) return false; - const rc = linux.recvfrom(fds[1], &recv_buf, recv_buf.len, linux.MSG.DONTWAIT, null, null); - switch (std.posix.errno(rc)) { - .SUCCESS => received += @intCast(rc), - .AGAIN => {}, - else => return error.RecvFailed, - } + while (!completion.done.load(.acquire)) std.atomic.spinLoopHint(); - _ = flushPendingWrites(shard); - } + return true; +} - try std.testing.expect(shard.pw_active == 0); - try std.testing.expect(received == total); - try std.testing.expect(recv_buf[0] == 'x'); +/// Queue a Job on its fd's shard thread. For a close request dbpg.submit blocks +/// until the response is written, so a deferred write never races the fd close. +pub fn submitJob(head: *const zix.Http1.ParsedHead, job: Job) bool { + return submit(job, head.keep_alive); } From 936c488ae59ce100de79ca605eafef9f8e47c5e3 Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 30/46] upload returns counted received body bytes --- frameworks/zix/src/handlers/upload.zig | 28 ++++++-------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/frameworks/zix/src/handlers/upload.zig b/frameworks/zix/src/handlers/upload.zig index 1f6f47c10..cd7f4b1a8 100644 --- a/frameworks/zix/src/handlers/upload.zig +++ b/frameworks/zix/src/handlers/upload.zig @@ -1,5 +1,5 @@ -//! POST /upload : return the received byte count. Content-Length is -//! authoritative, the engine drains oversized bodies. +//! POST /upload : return how many body bytes were read off the socket +//! (bodyReceived), not the Content-Length value. const std = @import("std"); const zix = @import("zix"); @@ -10,31 +10,15 @@ pub const PATH = "/upload"; // --------------------------------------------------------- // -pub fn RESPONSE( - req: *zix.Http1.Request, - _: *zix.Http1.Response, - _: *zix.Http1.Context -) !void { - const head = req.head; - const body = try req.body(); +pub fn RESPONSE(req: *zix.Http1.Request, _: *zix.Http1.Response, _: *zix.Http1.Context) !void { const fd = req.fd; - const n: u64 = if (head.content_length > 0) head.content_length else body.len; + const n: u64 = req.bodyReceived(); var body_buf: [24]u8 = undefined; const out = std.fmt.bufPrint(&body_buf, "{d}", .{n}) catch return; - zix.Http1.sendSimpleFD( - fd, - @intFromEnum(zix.Http1.Status.Code.OK), - zix.Http1.Content.Type.TEXT_PLAIN.asString(), - out - ) catch { - try zix.Http1.sendSimpleFD( - fd, - @intFromEnum(zix.Http1.Status.Code.INTERNAL_SERVER_ERROR), - zix.Http1.Content.Type.TEXT_PLAIN.asString(), - zix.Http1.Status.Code.INTERNAL_SERVER_ERROR.asString() - ); + zix.Http1.sendSimpleFD(fd, @intFromEnum(zix.Http1.Status.Code.OK), zix.Http1.Content.Type.TEXT_PLAIN.asString(), out) catch { + try zix.Http1.sendSimpleFD(fd, @intFromEnum(zix.Http1.Status.Code.INTERNAL_SERVER_ERROR), zix.Http1.Content.Type.TEXT_PLAIN.asString(), zix.Http1.Status.Code.INTERNAL_SERVER_ERROR.asString()); }; } From bee61fbd4e0d2e4a10a121f96a2b22a30c3339cb Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 31/46] serialize and compress json per request edited: - replay caches dropped, gzip runs per request - header reserve sized from the largest render --- frameworks/zix/src/handlers/json.zig | 109 +++++++++++++-------------- 1 file changed, 54 insertions(+), 55 deletions(-) diff --git a/frameworks/zix/src/handlers/json.zig b/frameworks/zix/src/handlers/json.zig index 39f4410f3..3e1e2c2f6 100644 --- a/frameworks/zix/src/handlers/json.zig +++ b/frameworks/zix/src/handlers/json.zig @@ -1,6 +1,6 @@ //! GET /json/{count}?m=M : render count dataset items, total = price*qty*m. -//! The body is deterministic in (count, m), cached by key: identity via the -//! engine's response cache, gzip via the per-(key, encoding) cache. +//! Every response serializes per request from the loaded dataset, gzip +//! compresses per request when the client accepts it. const std = @import("std"); const zix = @import("zix"); @@ -8,7 +8,6 @@ const zix = @import("zix"); const response = @import("../shared/response.zig"); const dataset = @import("../shared/dataset.zig"); const util = @import("../shared/util.zig"); -const cache = @import("../shared/cache.zig"); // --------------------------------------------------------- // @@ -17,50 +16,67 @@ pub const PATH = "/json"; /// Must initialize in main (the loaded dataset the bodies render from). pub var g_dataset: dataset.Dataset = undefined; -// Reserve for the identity JSON header, rendered right-aligned so it ends -// exactly where the body starts (reserve-prefix assembly, no body copy). +// Space in front of the body for the header, so header plus body go out as +// one slice. const JSON_HDR_RESERVE: usize = 96; threadlocal var json_resp_buf: [JSON_HDR_RESERVE + 32 * 1024]u8 = undefined; // --------------------------------------------------------- // -fn sendJsonGzipFD(head: *const zix.Http1.ParsedHead, fd: std.posix.fd_t, json_body: []const u8) void { - zix.Http1.sendGzipCachedFD(fd, head, 200, "application/json", json_body, zix.Http1.cacheTtl()) catch {}; +fn sendJsonGzipFD(fd: std.posix.fd_t, json_body: []const u8) void { + zix.Http1.sendGzipFD(fd, 200, "application/json", json_body) catch {}; } // --------------------------------------------------------- // +/// Largest body one render can produce, measured at init. Sizes the reserve. +var g_body_max: usize = 0; + pub fn init(aa: std.mem.Allocator) !void { var dataset_path_buf: [512]u8 = undefined; const data_dir = "/data"; const dataset_path = try std.fmt.bufPrint(&dataset_path_buf, "{s}/dataset.json", .{data_dir}); g_dataset = try dataset.load(aa, dataset_path); + + const full = renderItems(json_resp_buf[JSON_HDR_RESERVE..], dataset.ItemCount, 1); + g_body_max = full + (dataset.ItemCount + 1) * 20; +} + +/// Render the items body for (count, m) into buf, returning the body length. +fn renderItems(buf: []u8, count: u8, m: u64) usize { + var pos: usize = 0; + + pos = util.appendStr(buf, pos, "{\"items\":["); + var i: usize = 0; + while (i < count) : (i += 1) { + if (i > 0) { + buf[pos] = ','; + pos += 1; + } + const item = g_dataset.items[i]; + @memcpy(buf[pos..][0..item.prefix.len], item.prefix); + pos += item.prefix.len; + pos = util.appendStr(buf, pos, ",\"total\":"); + pos = util.appendInt(buf, pos, item.pq * m); + buf[pos] = '}'; + pos += 1; + } + pos = util.appendStr(buf, pos, "],\"count\":"); + pos = util.appendInt(buf, pos, count); + buf[pos] = '}'; + pos += 1; + + return pos; } -pub fn RESPONSE( - req: *zix.Http1.Request, - _: *zix.Http1.Response, - _: *zix.Http1.Context -) !void { +pub fn RESPONSE(req: *zix.Http1.Request, _: *zix.Http1.Response, _: *zix.Http1.Context) !void { const head = req.head; const fd = req.fd; const accept = zix.Http1.acceptEncoding(head) orelse ""; const want_gzip = std.mem.indexOf(u8, accept, "gzip") != null; - if (want_gzip) { - if (zix.Http1.cacheLookupEncoded(head, "gzip")) |cached| { - zix.Http1.writeAllFD(fd, cached) catch {}; - return; - } - } else { - if (zix.Http1.cacheLookup(head)) |cached| { - zix.Http1.writeAllFD(fd, cached) catch {}; - return; - } - } - // The PREFIX route also matches a bare /json (no trailing slash), which // would slice out of bounds below. if (head.path.len < "/json/".len) return response.badRequest(fd); @@ -74,37 +90,26 @@ pub fn RESPONSE( if (zix.Http1.queryParam(head, "m")) |s| std.fmt.parseInt(u64, s, 10) catch 1 else 1; - const buf = json_resp_buf[JSON_HDR_RESERVE..]; - var pos: usize = 0; + // No-gzip path: render the body straight into the engine's send buffer, + // the engine puts the header in front. The body is written once, no copy. + if (!want_gzip) { + if (zix.Http1.responseReserve(fd, g_body_max)) |region| { + const body_len = renderItems(region, count, m); - pos = util.appendStr(buf, pos, "{\"items\":["); - var i: usize = 0; - while (i < count) : (i += 1) { - if (i > 0) { - buf[pos] = ','; - pos += 1; + try zix.Http1.responseCommit(fd, 200, "application/json", body_len); + return; } - const item = g_dataset.items[i]; - @memcpy(buf[pos..][0..item.prefix.len], item.prefix); - pos += item.prefix.len; - pos = util.appendStr(buf, pos, ",\"total\":"); - pos = util.appendInt(buf, pos, item.pq * m); - buf[pos] = '}'; - pos += 1; } - pos = util.appendStr(buf, pos, "],\"count\":"); - pos = util.appendInt(buf, pos, count); - buf[pos] = '}'; - pos += 1; + + const buf = json_resp_buf[JSON_HDR_RESERVE..]; + const pos = renderItems(buf, count, m); if (want_gzip) { - sendJsonGzipFD(head, fd, buf[0..pos]); + sendJsonGzipFD(fd, buf[0..pos]); return; } - // The header renders right-aligned into the reserve so it ends exactly - // where the body starts: the full response caches and replays verbatim - // (the header matches the engine's sendJsonFD output) with no body copy. + // The header is built right before the body, so both go out as one slice. var hdr_buf: [JSON_HDR_RESERVE]u8 = undefined; const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\n\r\n", .{pos}) catch { zix.Http1.sendJsonFD(fd, 200, buf[0..pos]) catch {}; @@ -113,13 +118,7 @@ pub fn RESPONSE( const start = JSON_HDR_RESERVE - hdr.len; @memcpy(json_resp_buf[start..JSON_HDR_RESERVE], hdr); - zix.Http1.sendWithCacheFD(fd, head, json_resp_buf[start .. JSON_HDR_RESERVE + pos], cache.TTL_MS) catch { - try zix.Http1.sendSimpleFD( - fd, - @intFromEnum(zix.Http1.Status.Code.INTERNAL_SERVER_ERROR), - zix.Http1.Content.Type.TEXT_PLAIN.asString(), - zix.Http1.Status.Code.INTERNAL_SERVER_ERROR.asString() - ); + zix.Http1.writeAllFD(fd, json_resp_buf[start .. JSON_HDR_RESERVE + pos]) catch { + try zix.Http1.sendSimpleFD(fd, @intFromEnum(zix.Http1.Status.Code.INTERNAL_SERVER_ERROR), zix.Http1.Content.Type.TEXT_PLAIN.asString(), zix.Http1.Status.Code.INTERNAL_SERVER_ERROR.asString()); }; } - From 927b0bee1e3642cc962e48a55459efc25765f60f Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 32/46] item cache only, absolute 200 ms ttl edited: - ITEM_TTL_MS 5000 -> 200 per the crud profile removed: - list-page slots, ttl refresh, list body cap --- frameworks/zix/src/shared/crudcache.zig | 133 +----------------------- 1 file changed, 5 insertions(+), 128 deletions(-) diff --git a/frameworks/zix/src/shared/crudcache.zig b/frameworks/zix/src/shared/crudcache.zig index ab7e6465a..8e57aa6bf 100644 --- a/frameworks/zix/src/shared/crudcache.zig +++ b/frameworks/zix/src/shared/crudcache.zig @@ -1,7 +1,5 @@ -//! In-process cache for crud reads. Direct-mapped item slots (x-cache -//! MISS/HIT, invalidated on write) plus scanned list-page slots (TTL-bounded -//! staleness only, single-flight refresh past the TTL). Per-slot and -//! per-list spinlocks, slots live in BSS. +//! In-process cache for crud single-item reads: direct-mapped slots with +//! per-slot spinlocks, X-Cache MISS/HIT, writes invalidate their id. const std = @import("std"); @@ -15,17 +13,9 @@ const SLOT_COUNT = 65536; /// tags <= 48). const BODY_MAX = 256; -/// Power of two, list-page slots (the bench mix runs ~10 distinct pages). -const LIST_SLOT_COUNT = 32; -/// Rendered list JSON cap, a larger body skips the cache. -const LIST_BODY_MAX = 4096; - -/// Item TTL is a safety bound only: every write path invalidates its id, so -/// a longer TTL never serves a stale item. Beyond ~5s the write-invalidation -/// rate dominates misses anyway. -const ITEM_TTL_MS: i64 = 5000; -/// List pages are never invalidated on write, this TTL IS the staleness bound. -const LIST_TTL_MS: i64 = 1000; +/// Absolute item TTL, the crud profile specifies 200 ms. Writes also +/// invalidate their id. +const ITEM_TTL_MS: i64 = 200; const Slot = struct { lock_flag: std.atomic.Value(bool) = .init(false), @@ -35,25 +25,7 @@ const Slot = struct { body: [BODY_MAX]u8 = undefined, }; -/// One refresh claim per expired page: the claimer misses (and refills), -/// everyone else serves the stale body meanwhile. A claim not filled within -/// this window may be re-claimed. -const LIST_REFRESH_CLAIM_MS: i64 = 250; - -/// Key-scanned entry (not direct-mapped): the bench runs ~10 distinct -/// pages, a hash-indexed table would let two pages ping-pong one slot and -/// turn every list request into a database job. -const ListEntry = struct { - key: u64 = 0, - expires_ms: i64 = 0, - refresh_at_ms: i64 = 0, - len: u16 = 0, - body: [LIST_BODY_MAX]u8 = undefined, -}; - var g_slots: [SLOT_COUNT]Slot = @splat(.{}); -var g_list_entries: [LIST_SLOT_COUNT]ListEntry = @splat(.{}); -var g_list_lock: std.atomic.Value(bool) = .init(false); fn slotFor(id: i64) ?*Slot { if (id < 1) return null; @@ -61,15 +33,6 @@ fn slotFor(id: i64) ?*Slot { return &g_slots[@as(usize, @intCast(id)) & (SLOT_COUNT - 1)]; } -/// Caller holds g_list_lock. -fn listFind(key: u64) ?*ListEntry { - for (&g_list_entries) |*entry| { - if (entry.key == key) return entry; - } - - return null; -} - fn lock(flag: *std.atomic.Value(bool)) void { while (flag.swap(true, .acquire)) std.atomic.spinLoopHint(); } @@ -133,89 +96,3 @@ pub fn remove(id: i64) void { slot.expires_ms = 0; } } - -/// Cache key of one list page. -pub fn listKey(category: []const u8, page: i64, limit: i64) u64 { - var hasher = std.hash.Wyhash.init(0); - hasher.update(category); - hasher.update(std.mem.asBytes(&page)); - hasher.update(std.mem.asBytes(&limit)); - - return hasher.final(); -} - -/// Copy a cached list body for key into out, single-flight on expiry: the -/// first caller past the TTL misses (claiming the refill), later callers -/// serve the stale body until the refill lands. -/// -/// Return: -/// - the body length (out[0..len] is the list JSON, fresh or stale) -/// - null on a miss (absent, colliding key, or this caller owns the refill) -pub fn listGet(key: u64, out: []u8) ?usize { - lock(&g_list_lock); - defer unlock(&g_list_lock); - - const entry = listFind(key) orelse return null; - if (entry.len == 0) return null; - - const now = nowMs(); - if (now >= entry.expires_ms) { - if (now >= entry.refresh_at_ms) { - entry.refresh_at_ms = now + LIST_REFRESH_CLAIM_MS; - - return null; - } - } - - const len: usize = entry.len; - if (len > out.len) return null; - @memcpy(out[0..len], entry.body[0..len]); - - return len; -} - -/// Fresh-only list lookup for the executor-side re-check: never serves -/// stale and never claims, so a claimed refill always reaches the database. -pub fn listGetFresh(key: u64, out: []u8) ?usize { - lock(&g_list_lock); - defer unlock(&g_list_lock); - - const entry = listFind(key) orelse return null; - if (entry.len == 0) return null; - if (nowMs() >= entry.expires_ms) return null; - - const len: usize = entry.len; - if (len > out.len) return null; - @memcpy(out[0..len], entry.body[0..len]); - - return len; -} - -/// Store a rendered list body for key, TTL-bounded. Reuses the key's entry, -/// else an empty or expired one. An oversized body (or a full table of -/// fresh entries) is skipped. -pub fn listPut(key: u64, body: []const u8) void { - if (body.len > LIST_BODY_MAX) return; - - lock(&g_list_lock); - defer unlock(&g_list_lock); - - const now = nowMs(); - var target: ?*ListEntry = listFind(key); - if (target == null) { - for (&g_list_entries) |*entry| { - if (entry.key == 0 or now >= entry.expires_ms) { - target = entry; - break; - } - } - } - - const entry = target orelse return; - entry.key = key; - entry.expires_ms = now + LIST_TTL_MS; - entry.refresh_at_ms = 0; - entry.len = @intCast(body.len); - @memcpy(entry.body[0..body.len], body); -} - From e7da520d517c01d5df4daa7f762ad4c676b2ff3b Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 33/46] list requests always query the database --- frameworks/zix/src/handlers/crud.zig | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/frameworks/zix/src/handlers/crud.zig b/frameworks/zix/src/handlers/crud.zig index d62b10a9d..e9a12c867 100644 --- a/frameworks/zix/src/handlers/crud.zig +++ b/frameworks/zix/src/handlers/crud.zig @@ -1,6 +1,6 @@ //! /crud/items : GET lists (paged, category filter) or reads one id, POST -//! creates, PUT updates. A cache hit (crudcache.zig) answers on the engine -//! worker, otherwise queues a Job on the fd's postgrez shard thread +//! creates, PUT updates. A single-item cache hit (crudcache.zig) answers +//! right away, everything else queues a Job on the worker's postgres lane //! (dbpg.zig). const std = @import("std"); @@ -43,14 +43,6 @@ fn crudList(head: *const zix.Http1.ParsedHead, fd: std.posix.fd_t) void { if (limit > CRUD_LIST_LIMIT_MAX) limit = CRUD_LIST_LIMIT_MAX; if (category.len > dbpg.CATEGORY_MAX) return response.badRequest(fd); - // list-page cache first: a hit answers on the engine worker - const list_key = crudcache.listKey(category, page, limit); - if (crudcache.listGet(list_key, &tl_db_body_buf)) |len| { - zix.Http1.sendJsonFD(fd, 200, tl_db_body_buf[0..len]) catch {}; - - return; - } - var job: dbpg.Job = .{ .CRUD_LIST = .{ .fd = fd, .page = page, @@ -124,14 +116,9 @@ fn crudUpdate(head: *const zix.Http1.ParsedHead, id: i64, body: []const u8, fd: if (!dbpg.submitJob(head, job)) response.serviceUnavailable(fd); } - // --------------------------------------------------------- // -pub fn RESPONSE( - req: *zix.Http1.Request, - _: *zix.Http1.Response, - _: *zix.Http1.Context -) !void { +pub fn RESPONSE(req: *zix.Http1.Request, _: *zix.Http1.Response, _: *zix.Http1.Context) !void { const head = req.head; const body = try req.body(); const fd = req.fd; From e8c46ff938468c96a2b76342f827854d9b10c929 Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 34/46] move db endpoints to engine-worker lanes added: - one pipelined lane per worker via dispatch Line and the engine fd watch - WritePool for deferred client writes edited: - SCAN_CAP_PER_CONN 4 -> 8 - list total = page size, no count(*) OVER() removed: - shard threads and fd routing --- frameworks/zix/src/shared/dbpg.zig | 711 ++++++++++++++--------------- 1 file changed, 336 insertions(+), 375 deletions(-) diff --git a/frameworks/zix/src/shared/dbpg.zig b/frameworks/zix/src/shared/dbpg.zig index 027d03680..1f76828ae 100644 --- a/frameworks/zix/src/shared/dbpg.zig +++ b/frameworks/zix/src/shared/dbpg.zig @@ -1,16 +1,14 @@ -//! PostgreSQL for the DB endpoints (async-db, crud) over the driver-owned -//! multiplexed transport (postgrez.Transport, .URING), sharded: the active -//! shard count (scaled from the CPU budget at init) transport threads each -//! own their slice of the pipelined connections, so reply decode, render, -//! and the client write are not serialized on one thread. Jobs route to a -//! shard by fd (per-fd order). Handlers build a Job and call submitJob, -//! replies render and write from onReply on the shard thread. +//! PostgreSQL for the DB endpoints (async-db, crud) over engine-worker +//! lanes: each engine worker owns one pipelined connection +//! (postgrez.dispatch.Line) pumped by its own ring (zix.Http1.uringWatchFd), +//! so a reply decodes, renders, and writes on the core that owns the client +//! socket. Handlers build a Job and call submitJob, replies come back +//! through onLaneReply on the same worker. const std = @import("std"); const zix = @import("zix"); const crudcache = @import("crudcache.zig"); -const dbrd = @import("dbrd.zig"); const postgrez = zix.Driver.postgrez; const frontend = postgrez.frontend; @@ -22,37 +20,30 @@ const row = postgrez.row; pub const NAME_MAX = 96; pub const CATEGORY_MAX = 48; -/// Widest result set: the crud list adds a count(*) OVER() column to the nine -/// item columns, so ten columns bound the row decode scratch. +/// Every query returns the nine item columns, 16 bounds the decode scratch. const MAX_COLUMNS = 16; -/// Pipeline depth per connection, matches the driver transport default. +/// Pipeline depth per connection (driver default). const WINDOW = postgrez.dispatch.DEFAULT_WINDOW; /// Bytes one rendered DB body may reach before renderDbRow sheds it. A crud /// list of 100 rows tops out near 21 KiB. const DB_BODY_MAX = 32 * 1024; -/// Transport shard ceiling. Each shard runs its own thread and Transport, -/// splitting the configured connections. The active count comes from -/// shardCountFor at init. -const MAX_SHARDS = 16; // 0:2 1:8 2:16 - -/// In-flight ASYNC_DB scans one shard allows: shard connections times this. -/// Caps how many concurrent price-range scans can over-feed the server. -/// Swept on the isolate bench: 2 under-feeds (43.3K), 4 is the knee (47.1K), -/// 8 thrashes (45.9K, p99 up). Keep 4 while the scan stays sequential. -const SCAN_CAP_PER_CONN = 4; +/// In-flight ASYNC_DB scans one lane allows: line count times this. Caps how +/// many concurrent price-range scans can pile onto the server at once. +/// Swept on the isolate bench: 8 reads better than 4 on both DB cells. +const SCAN_CAP_PER_CONN = 8; // SQL: column order is fixed here, the renderers decode cells by position. const SQL_ASYNC_DB = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3"; -const SQL_CRUD_LIST = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count, count(*) OVER() AS total FROM items WHERE category = $1 ORDER BY id LIMIT $2 OFFSET $3"; +const SQL_CRUD_LIST = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE category = $1 ORDER BY id LIMIT $2 OFFSET $3"; // total = returned page size per spec, no count(*) OVER() const SQL_CRUD_GET = "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE id = $1"; const SQL_CRUD_UPSERT = "INSERT INTO items (id, name, category, price, quantity, active, tags, rating_score, rating_count) VALUES ($1, $2, $3, $4, $5, true, '[]', 0, 0) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, category = EXCLUDED.category, price = EXCLUDED.price, quantity = EXCLUDED.quantity"; const SQL_CRUD_UPDATE = "UPDATE items SET name = $2, category = $3, price = $4, quantity = $5 WHERE id = $1"; -// Named prepared statements, one per SQL. Created on every connection at open -// (Transport prepare warm-up), so per request only Bind plus Execute is sent. +// Named prepared statements, one per SQL. Prepared on every connection at +// open, so per request only Bind plus Execute is sent. const STMT_ASYNC_DB = "zix_async_db"; const STMT_CRUD_LIST = "zix_crud_list"; const STMT_CRUD_GET = "zix_crud_get"; @@ -123,8 +114,7 @@ fn jobFd(job: Job) std.posix.fd_t { } /// Close-marked requests must be answered before the handler returns (the -/// engine closes the fd on return). The engine worker spins on done until -/// the shard thread has written the response. +/// engine closes the fd on return). submit pumps the lane until done is set. const Completion = struct { done: std.atomic.Value(bool) = .init(false), }; @@ -137,18 +127,10 @@ const QueuedJob = struct { // --------------------------------------------------------- // -/// Jobs one shard queue holds (engine workers enqueue, the shard drains). -/// Per shard: at the two-shard floor this matches the single 8192 queue the -/// unsharded entry ran, more shards raise the fleet total. -const QUEUE_CAP = 4096; - -/// In-flight Jobs one shard tracks, above its conns times WINDOW ceiling. +/// In-flight Jobs one lane tracks, above its lines times WINDOW ceiling. const SLOT_CAP = 1024; -/// Over-cap ASYNC_DB scans one shard can hold before shedding. -const SCAN_HOLD_CAP = 2048; - -/// Client fds one shard can have parked mid-response at once. +/// Client fds one worker can have parked mid-response at once. const PENDING_CAP = 64; const Slot = struct { @@ -165,223 +147,51 @@ const PendingWrite = struct { buf: [HEAD_MAX + DB_BODY_MAX]u8 = undefined, }; -/// One transport shard. Everything except the queue is touched only by the -/// shard's own thread, the queue spinlock serializes the engine workers. -const Shard = struct { - transport: ?*postgrez.Transport = null, - conns: usize = 0, - - q_buf: [QUEUE_CAP]QueuedJob = undefined, - q_head: usize = 0, - q_tail: usize = 0, - q_lock: std.atomic.Value(bool) = .init(false), - - s_slots: [SLOT_CAP]Slot = undefined, - s_free: [SLOT_CAP]usize = undefined, - s_free_count: usize = 0, - - scan_cap: usize = 0, - scan_inflight: usize = 0, - scan_buf: [SCAN_HOLD_CAP]QueuedJob = undefined, - scan_head: usize = 0, - scan_tail: usize = 0, - +/// Parked client writes for one worker: the unsent remainder of responses +/// whose socket was full, flushed non-blocking on every lane turn. +const WritePool = struct { pw_slots: [PENDING_CAP]PendingWrite = @splat(.{}), pw_active: usize = 0, - - fn lock(self: *Shard) void { - while (self.q_lock.swap(true, .acquire)) std.atomic.spinLoopHint(); - } - - fn unlock(self: *Shard) void { - self.q_lock.store(false, .release); - } - - fn enqueue(self: *Shard, item: QueuedJob) bool { - self.lock(); - defer self.unlock(); - - if (self.q_tail -% self.q_head >= QUEUE_CAP) return false; - - self.q_buf[self.q_tail & (QUEUE_CAP - 1)] = item; - self.q_tail +%= 1; - - return true; - } - - fn dequeue(self: *Shard) ?QueuedJob { - self.lock(); - defer self.unlock(); - - if (self.q_head == self.q_tail) return null; - - const item = self.q_buf[self.q_head & (QUEUE_CAP - 1)]; - self.q_head +%= 1; - - return item; - } - - fn slotInit(self: *Shard) void { - for (0..SLOT_CAP) |index| self.s_free[index] = SLOT_CAP - 1 - index; - - self.s_free_count = SLOT_CAP; - } - - fn slotAlloc(self: *Shard, item: QueuedJob) ?u64 { - if (self.s_free_count == 0) return null; - - self.s_free_count -= 1; - const index = self.s_free[self.s_free_count]; - self.s_slots[index] = .{ .job = item.job, .completion = item.completion }; - - return index; - } - - fn slotFree(self: *Shard, tag: u64) void { - self.s_free[self.s_free_count] = @intCast(tag); - self.s_free_count += 1; - } - - /// Take a held scan once an in-flight scan slot is open. - fn scanTake(self: *Shard) ?QueuedJob { - if (self.scan_inflight >= self.scan_cap) return null; - if (self.scan_head == self.scan_tail) return null; - - const item = self.scan_buf[self.scan_head & (SCAN_HOLD_CAP - 1)]; - self.scan_head +%= 1; - - return item; - } - - /// Hold a scan that is over the in-flight cap, false when the ring is full. - fn scanHold(self: *Shard, item: QueuedJob) bool { - if (self.scan_tail -% self.scan_head >= SCAN_HOLD_CAP) return false; - - self.scan_buf[self.scan_tail & (SCAN_HOLD_CAP - 1)] = item; - self.scan_tail +%= 1; - - return true; - } }; -// --------------------------------------------------------- // - // Set once in init before start, read-only afterwards. var g_io: std.Io = undefined; var g_config: postgrez.Config = undefined; var g_enabled: bool = false; var g_conns: usize = 8; -var g_shard_count: usize = 2; - -var g_shards: [MAX_SHARDS]Shard = @splat(.{}); var g_open: bool = false; -var g_running: std.atomic.Value(bool) = .init(false); -/// The shard owned by the current thread, set at transportLoop entry so the -/// send helpers under onReply reach the pending pool without threading a -/// parameter through every renderer. -threadlocal var tl_shard: ?*Shard = null; +// Prepared-statement bytes, encoded once in start(). Module scope because the +// lane mode opens connections lazily per worker, long after start() returned. +var g_prepare_buf: [4096]u8 = undefined; +var g_prepares: [STATEMENTS.len][]const u8 = undefined; + +/// The pending-write pool owned by the current worker, set at lane creation +/// so the send helpers reach it without threading a parameter through every +/// renderer. +threadlocal var tl_pool: ?*WritePool = null; /// Set around a close-marked reply: its delivery must block until fully /// written because the engine worker closes the fd the moment it returns. threadlocal var tl_write_blocking: bool = false; -fn shardOf(fd: std.posix.fd_t) *Shard { - const key: usize = @intCast(fd); - - return &g_shards[key % g_shard_count]; -} +// Per-worker scratch. One reply is decoded at a time on a worker, so a +// per-thread buffer set is safe. +threadlocal var db_body_buf: [DB_BODY_MAX]u8 = undefined; +threadlocal var request_buf: [8 * 1024]u8 = undefined; +threadlocal var param_scratch: [8][24]u8 = undefined; -/// Active transport shards for a CPU budget: one per four CPUs, floored at -/// the sharded baseline of two, capped at MAX_SHARDS. -fn shardCountFor(cpu: usize) usize { - return std.math.clamp(cpu / 4, 2, MAX_SHARDS); // 0:2 1:clamp(cpu / 4, 2, 8) -} +// --------------------------------------------------------- // /// Total DB connections for a CPU budget when DATABASE_MAX_CONN is absent. /// The arena postgres runs max_connections=256, 64 stays well inside it. +/// Small CPU budgets (the api profiles) double up: one postgres backend per +/// worker serializes the scans there, two keeps them parallel. Larger budgets +/// stay at one per worker, where two contends (isolate-swept). fn connsFor(cpu: usize) usize { - return std.math.clamp(cpu, 4, 64); // 0:clamp(cpu, 4, 16) 1:clamp(cpu, 4, 64) -} + if (cpu <= 4) return cpu * 2; -/// One shard's slice of the conn budget: an even split, the first -/// total mod count shards take the remainder, never below one. -fn shardConns(total: usize, count: usize, index: usize) usize { - return @max(1, total / count + @intFromBool(index < total % count)); -} - -// --------------------------------------------------------- // - -// --------------------------------------------------------- // - -/// One shard's thread: drain queued Jobs into the transport, poll for -/// replies, then flush parked client writes. A held-over Job retries before -/// the next dequeue, a held scan re-enters first once a scan slot frees. -fn transportLoop(shard: *Shard) void { - tl_shard = shard; - - const transport = shard.transport.?; - - var holdover: ?QueuedJob = null; - - while (g_running.load(.acquire)) { - var progressed = false; - - while (shard.s_free_count > 0) { - const item = holdover orelse shard.scanTake() orelse shard.dequeue() orelse break; - holdover = null; - - if (item.job == .ASYNC_DB and shard.scan_inflight >= shard.scan_cap) { - if (!shard.scanHold(item)) shed(item); - progressed = true; - continue; - } - - if (serveFromCache(item)) { - progressed = true; - continue; - } - - const tag = shard.slotAlloc(item) orelse { - holdover = item; - break; - }; - - const request = buildRequest(item.job) catch { - shard.slotFree(tag); - shed(item); - progressed = true; - continue; - }; - - if (!transport.submit(request, tag)) { - shard.slotFree(tag); - holdover = item; - break; - } - - if (item.job == .ASYNC_DB) shard.scan_inflight += 1; - progressed = true; - } - - if (transport.pending() > 0) { - _ = transport.poll() catch {}; - progressed = true; - } - - if (shard.pw_active > 0 and flushPendingWrites(shard)) progressed = true; - - if (!progressed) idle(); - } -} - -/// Brief park when nothing is queued and nothing is in flight, so the loop -/// does not busy-spin at idle. Under load the poll above always has work, so -/// this never runs. -fn idle() void { - const req = std.os.linux.timespec{ .sec = 0, .nsec = 200 * std.time.ns_per_us }; - - _ = std.os.linux.nanosleep(&req, null); + return std.math.clamp(cpu, 4, 64); } /// A crud read may have been filled between the engine-worker miss and this @@ -403,23 +213,13 @@ fn serveFromCache(item: QueuedJob) bool { return true; } }, - .CRUD_LIST => |request| { - const category = request.category_buf[0..request.category_len]; - const key = crudcache.listKey(category, request.page, request.limit); - if (crudcache.listGetFresh(key, &db_body_buf)) |len| { - sendJson(request.fd, db_body_buf[0..len]); - signal(item); - - return true; - } - }, else => {}, } return false; } -/// Shed a Job the transport could not accept: 503 the fd and release any +/// Shed a Job the lane could not accept: 503 the fd and release any /// close-request waiter. fn shed(item: QueuedJob) void { tl_write_blocking = item.completion != null; @@ -434,14 +234,6 @@ fn signal(item: QueuedJob) void { if (item.completion) |completion| completion.done.store(true, .release); } -// --------------------------------------------------------- // - -// Per shard-thread scratch. One reply is decoded at a time on each shard -// thread, so a per-thread buffer set is safe. -threadlocal var db_body_buf: [DB_BODY_MAX]u8 = undefined; -threadlocal var request_buf: [8 * 1024]u8 = undefined; -threadlocal var param_scratch: [8][24]u8 = undefined; - /// Route one Job to its prepared statement name. fn stmtName(job: Job) []const u8 { return switch (job) { @@ -455,7 +247,7 @@ fn stmtName(job: Job) []const u8 { /// Encode one Job as Bind plus Describe plus Execute plus Sync against its /// named prepared statement. Params bind as text, results come back binary. -/// The returned slice lives in request_buf, consumed by transport.submit. +/// The returned slice lives in request_buf, consumed by line.submit. /// /// Return: /// - []const u8 request bytes @@ -520,37 +312,6 @@ fn intParam(index: usize, value: i64) []const u8 { // --------------------------------------------------------- // -/// Reply sink, fired by the shard's transport once per completed request in -/// submit order. Decodes the backend messages, renders the response, writes -/// it to the fd, then releases the slot (and the scan slot for ASYNC_DB). -fn onReply(context: ?*anyopaque, tag: u64, reply: []const u8) void { - const shard: *Shard = @ptrCast(@alignCast(context.?)); - - const slot = &shard.s_slots[@intCast(tag)]; - const job = slot.job; - const completion = slot.completion; - - tl_write_blocking = completion != null; - defer tl_write_blocking = false; - - switch (job) { - .ASYNC_DB => |request| renderRows(reply, request.fd, .ASYNC_DB, .{}), - .CRUD_LIST => |request| renderRows(reply, request.fd, .CRUD_LIST, .{ - .page = request.page, - .limit = request.limit, - .category = request.category_buf[0..request.category_len], - }), - .CRUD_GET => |request| renderRows(reply, request.fd, .CRUD_GET, .{ .id = request.id }), - .CRUD_CREATE => |request| finishWrite(reply, request.fd, request.id, .CREATE), - .CRUD_UPDATE => |request| finishWrite(reply, request.fd, request.id, .UPDATE), - } - - if (completion) |signal_ptr| signal_ptr.done.store(true, .release); - - if (job == .ASYNC_DB) shard.scan_inflight -= 1; - shard.slotFree(tag); -} - const RowShape = enum { ASYNC_DB, CRUD_LIST, CRUD_GET }; const RenderMeta = struct { @@ -574,7 +335,6 @@ fn renderRows(reply: []const u8, fd: std.posix.fd_t, shape: RowShape, meta: Rend var pos: usize = prefix.len; var rows: usize = 0; - var total: i64 = 0; var walk = MessageWalk{ .bytes = reply }; while (walk.next() catch { @@ -624,9 +384,6 @@ fn renderRows(reply: []const u8, fd: std.posix.fd_t, shape: RowShape, meta: Rend send503(fd); return; }; - if (shape == .CRUD_LIST and count > 9) { - total = cellInt(columns[0..column_count], cells[0..count], 9) catch total; - } rows += 1; }, else => {}, @@ -641,7 +398,7 @@ fn renderRows(reply: []const u8, fd: std.posix.fd_t, shape: RowShape, meta: Rend pos = appendStr(&db_body_buf, pos, "],"); if (shape == .CRUD_LIST) { pos = appendStr(&db_body_buf, pos, "\"total\":"); - pos = appendI64(&db_body_buf, pos, total); + pos = appendInt(&db_body_buf, pos, rows); pos = appendStr(&db_body_buf, pos, ",\"page\":"); pos = appendI64(&db_body_buf, pos, meta.page); } else { @@ -651,11 +408,6 @@ fn renderRows(reply: []const u8, fd: std.posix.fd_t, shape: RowShape, meta: Rend db_body_buf[pos] = '}'; pos += 1; - if (shape == .CRUD_LIST) { - const key = crudcache.listKey(meta.category, meta.page, meta.limit); - crudcache.listPut(key, db_body_buf[0..pos]); - } - sendJson(fd, db_body_buf[0..pos]); } @@ -709,20 +461,16 @@ const MessageWalk = struct { // --------------------------------------------------------- // -/// MISS response plus the two cache fills: the in-process slot and the -/// write-behind Redis mirror. +/// MISS response plus the in-process cache fill. fn finishCrudGet(id: i64, body: []const u8, fd: std.posix.fd_t) void { crudcache.put(id, body); - dbrd.mirrorSet(id, body); sendCrudBody(fd, body, "MISS"); } -/// Drop the cached crud body on every write: the in-process slot first (the -/// read path), the Redis mirror write-behind. +/// Drop the cached crud body on every write. fn invalidateCrud(id: i64) void { crudcache.remove(id); - dbrd.mirrorDel(id); } // --------------------------------------------------------- // @@ -837,7 +585,7 @@ fn renderDbRow(out: []u8, begin: usize, columns: []const row.ColumnInfo, cells: /// Content-Length, Date). const HEAD_MAX = 192; -/// Status lines for the transport-side responses, byte-matching the engine. +/// Status lines for the lane-side responses, byte-matching the engine. fn statusLine(status: u16) []const u8 { return switch (status) { 200 => "HTTP/1.1 200 OK\r\n", @@ -848,8 +596,8 @@ fn statusLine(status: u16) []const u8 { }; } -/// Build a response head byte-matching what the engine fd writers emit on a -/// shard thread (Content-Type, Content-Length, Date). +/// Build a response head byte-matching what the engine fd writers emit +/// (Content-Type, Content-Length, Date). fn buildHead(buf: []u8, status: u16, content_type: []const u8, body_len: usize) []const u8 { var pos: usize = 0; pos = appendStr(buf, pos, statusLine(status)); @@ -910,21 +658,21 @@ fn formatHttpDate(secs: u64, buf: []u8) []u8 { } /// Write one response (head then body) without letting a stalled client -/// block the shard thread: send non-blocking, park the remainder on a full +/// block the worker: send non-blocking, park the remainder on a full /// socket buffer, keep per-fd order by appending behind a parked response. /// /// Note: /// - a close-marked reply (tl_write_blocking) writes blocking instead, the /// engine worker closes the fd the moment it returns. fn deliver(fd: std.posix.fd_t, head: []const u8, body: []const u8) void { - const shard = tl_shard orelse { + const pool = tl_pool orelse { deliverBlocking(fd, head, body, 0); return; }; if (tl_write_blocking) { - if (shard.pw_active > 0) { - if (findPending(shard, fd)) |slot| flushSlotBlocking(shard, slot); + if (pool.pw_active > 0) { + if (findPending(pool, fd)) |slot| flushSlotBlocking(pool, slot); } deliverBlocking(fd, head, body, 0); @@ -932,8 +680,8 @@ fn deliver(fd: std.posix.fd_t, head: []const u8, body: []const u8) void { return; } - if (shard.pw_active > 0) { - if (findPending(shard, fd)) |slot| { + if (pool.pw_active > 0) { + if (findPending(pool, fd)) |slot| { if (slot.len + head.len + body.len <= slot.buf.len) { @memcpy(slot.buf[slot.len..][0..head.len], head); @memcpy(slot.buf[slot.len + head.len ..][0..body.len], body); @@ -942,7 +690,7 @@ fn deliver(fd: std.posix.fd_t, head: []const u8, body: []const u8) void { return; } - flushSlotBlocking(shard, slot); + flushSlotBlocking(pool, slot); } } @@ -979,7 +727,7 @@ fn deliver(fd: std.posix.fd_t, head: []const u8, body: []const u8) void { }, .INTR => continue, .AGAIN => { - park(shard, fd, head, body, sent); + park(pool, fd, head, body, sent); return; }, else => return, @@ -999,10 +747,10 @@ fn deliverBlocking(fd: std.posix.fd_t, head: []const u8, body: []const u8, sent: zix.Http1.writeAllFD(fd, body[sent - head.len ..]) catch {}; } -/// Park the unsent remainder of a response in the shard's pending pool. A +/// Park the unsent remainder of a response in the worker's pending pool. A /// full pool finishes blocking instead (the pre-parking behavior). -fn park(shard: *Shard, fd: std.posix.fd_t, head: []const u8, body: []const u8, sent: usize) void { - const slot = allocPending(shard, fd) orelse { +fn park(pool: *WritePool, fd: std.posix.fd_t, head: []const u8, body: []const u8, sent: usize) void { + const slot = allocPending(pool, fd) orelse { deliverBlocking(fd, head, body, sent); return; }; @@ -1022,19 +770,19 @@ fn park(shard: *Shard, fd: std.posix.fd_t, head: []const u8, body: []const u8, s slot.sent = 0; slot.len = len; - shard.pw_active += 1; + pool.pw_active += 1; } -fn findPending(shard: *Shard, fd: std.posix.fd_t) ?*PendingWrite { - for (&shard.pw_slots) |*slot| { +fn findPending(pool: *WritePool, fd: std.posix.fd_t) ?*PendingWrite { + for (&pool.pw_slots) |*slot| { if (slot.len > 0 and slot.fd == fd) return slot; } return null; } -fn allocPending(shard: *Shard, fd: std.posix.fd_t) ?*PendingWrite { - for (&shard.pw_slots) |*slot| { +fn allocPending(pool: *WritePool, fd: std.posix.fd_t) ?*PendingWrite { + for (&pool.pw_slots) |*slot| { if (slot.len == 0) { slot.fd = fd; return slot; @@ -1045,23 +793,23 @@ fn allocPending(shard: *Shard, fd: std.posix.fd_t) ?*PendingWrite { } /// Drain one parked slot blocking (order guard before a same-fd write). -fn flushSlotBlocking(shard: *Shard, slot: *PendingWrite) void { +fn flushSlotBlocking(pool: *WritePool, slot: *PendingWrite) void { zix.Http1.writeAllFD(slot.fd, slot.buf[slot.sent..slot.len]) catch {}; slot.len = 0; slot.sent = 0; - shard.pw_active -= 1; + pool.pw_active -= 1; } -/// One non-blocking pass over the shard's parked responses. +/// One non-blocking pass over the worker's parked responses. /// /// Return: /// - true when any parked bytes moved or a slot freed -fn flushPendingWrites(shard: *Shard) bool { +fn flushPendingWrites(pool: *WritePool) bool { var progressed = false; - var remaining = shard.pw_active; - for (&shard.pw_slots) |*slot| { + var remaining = pool.pw_active; + for (&pool.pw_slots) |*slot| { if (remaining == 0) break; if (slot.len == 0) continue; @@ -1090,7 +838,7 @@ fn flushPendingWrites(shard: *Shard) bool { if (slot.sent >= slot.len) { slot.len = 0; slot.sent = 0; - shard.pw_active -= 1; + pool.pw_active -= 1; } } @@ -1145,7 +893,6 @@ pub fn init(process: std.process.Init) void { g_enabled = true; const cpu = std.Thread.getCpuCount() catch 8; - g_shard_count = shardCountFor(cpu); if (process.environ_map.get("DATABASE_MAX_CONN")) |max_text| { if (std.fmt.parseInt(usize, max_text, 10)) |parsed| { @@ -1156,23 +903,17 @@ pub fn init(process: std.process.Init) void { } } -pub fn enabled() bool { - return g_enabled; -} - -/// Open one multiplexed transport per shard and spawn its poll-loop thread, -/// splitting the configured connections. Does nothing when DATABASE_URL was -/// absent, so non-DB profiles spawn no extra threads. +/// Encode the prepared-statement bytes and mark the lanes ready to open. +/// Does nothing when DATABASE_URL was absent, so non-DB profiles touch +/// nothing. pub fn start() void { if (!g_enabled) return; - // Pre-encode one Parse plus Sync per named statement, handed to open so the - // transport parses each on every connection before the pipelined loop runs. - var prepare_buf: [4096]u8 = undefined; - var fixed = std.heap.FixedBufferAllocator.init(&prepare_buf); + // Pre-encode one Parse plus Sync per named statement, handed to Line.open + // so every connection prepares them before the pipelined loop runs. + var fixed = std.heap.FixedBufferAllocator.init(&g_prepare_buf); const allocator = fixed.allocator(); - var prepares: [STATEMENTS.len][]const u8 = undefined; inline for (STATEMENTS, 0..) |spec, index| { var out: std.ArrayList(u8) = .empty; frontend.parse(allocator, &out, spec.name, spec.sql, &.{}) catch { @@ -1184,67 +925,287 @@ pub fn start() void { return; }; - prepares[index] = out.items; + g_prepares[index] = out.items; } - g_running.store(true, .release); + g_open = true; +} + +// Engine-worker lanes: one Lane per worker, single-threaded (every field is +// touched only by the owning worker). The embedded pool supplies the +// pending-write slots the shared send helpers park into through tl_pool. +const LANE_LINES_MAX = 4; + +const Lane = struct { + pool: WritePool = .{}, + lines: [LANE_LINES_MAX]*postgrez.dispatch.Line = undefined, + line_count: usize = 0, + slots: [SLOT_CAP]Slot = undefined, + slot_line: [SLOT_CAP]u8 = undefined, + slot_live: [SLOT_CAP]bool = @splat(false), + free: [SLOT_CAP]usize = undefined, + free_count: usize = 0, + queue: [SLOT_CAP]QueuedJob = undefined, + q_head: usize = 0, + q_tail: usize = 0, + scan_inflight: usize = 0, + scan_cap: usize = 0, +}; + +threadlocal var tl_lane: ?*Lane = null; + +/// This worker's lane, opened lazily on its first DB request (blocking +/// connect + prepare warm-up, once per worker). +fn laneGet() ?*Lane { + if (tl_lane) |lane| return lane; + if (!g_open) return null; + + const lane = std.heap.smp_allocator.create(Lane) catch return null; + lane.* = .{}; + + const cpu = std.Thread.getCpuCount() catch 8; + const want = std.math.clamp(g_conns / cpu, 1, LANE_LINES_MAX); var opened: usize = 0; - for (g_shards[0..g_shard_count], 0..) |*shard, index| { - shard.conns = shardConns(g_conns, g_shard_count, index); - shard.scan_cap = shard.conns * SCAN_CAP_PER_CONN; - shard.slotInit(); - - shard.transport = postgrez.Transport.open(std.heap.smp_allocator, g_io, g_config, .{ - .model = .URING, - .conns = shard.conns, + while (opened < want) : (opened += 1) { + lane.lines[opened] = postgrez.dispatch.Line.open(std.heap.smp_allocator, g_io, g_config, .{ + .conns = 1, .window = WINDOW, - .context = shard, - .on_reply = onReply, - .prepare = &prepares, + .context = lane, + .on_reply = onLaneReply, + .prepare = &g_prepares, }) catch break; + } - const thread = std.Thread.spawn(.{}, transportLoop, .{shard}) catch break; - thread.detach(); + if (opened == 0) { + std.heap.smp_allocator.destroy(lane); - opened += 1; + return null; } - if (opened < g_shard_count) { - g_enabled = false; - g_running.store(false, .release); + lane.line_count = opened; + lane.scan_cap = opened * SCAN_CAP_PER_CONN; + for (0..SLOT_CAP) |index| lane.free[index] = SLOT_CAP - 1 - index; + lane.free_count = SLOT_CAP; - return; + tl_lane = lane; + tl_pool = &lane.pool; + + zix.Http1.setExternalHandler(onLaneReadable); + var armed = false; + for (lane.lines[0..opened]) |line| { + if (zix.Http1.uringWatchFd(line.fd())) armed = true; } - g_open = true; + if (!armed) { + for (lane.lines[0..opened]) |line| line.deinit(); + std.heap.smp_allocator.destroy(lane); + tl_lane = null; + tl_pool = null; + + return null; + } + + return lane; } -/// Queue a Job on its fd's shard thread. -/// -/// Note: -/// - keep_alive false marks a close request: this blocks until the shard -/// thread writes the response (the engine closes the fd on return). -/// -/// Return: -/// - true when the response is owned by the shard thread (or already written) -/// - false when the transport is down or the queue is full (shed 503) -pub fn submit(job: Job, keep_alive: bool) bool { - if (!g_open) return false; +fn laneQueueFull(lane: *const Lane) bool { + return lane.q_tail -% lane.q_head >= SLOT_CAP; +} + +fn lanePush(lane: *Lane, item: QueuedJob) void { + lane.queue[lane.q_tail & (SLOT_CAP - 1)] = item; + lane.q_tail +%= 1; +} + +/// The least loaded line takes the next request. +fn laneLineFor(lane: *const Lane) usize { + var best: usize = 0; + for (lane.lines[1..lane.line_count], 1..) |line, index| { + if (line.pending() < lane.lines[best].pending()) best = index; + } + + return best; +} + +/// Read replies from every line that owes some. A dead connection sheds +/// everything it owes and leaves the rotation. +fn lanePump(lane: *Lane) void { + var index: usize = 0; + while (index < lane.line_count) { + const line = lane.lines[index]; + + if (line.pending() > 0) { + _ = line.pump() catch { + laneDropLine(lane, index); + continue; + }; + } + + index += 1; + } +} + +/// Shed every request a dead connection owes (503) and drop it from the +/// rotation, so a lost backend degrades to shedding instead of hangs. +fn laneDropLine(lane: *Lane, index: usize) void { + const line = lane.lines[index]; + + for (0..SLOT_CAP) |slot_index| { + if (!lane.slot_live[slot_index] or lane.slot_line[slot_index] != index) continue; + + const slot = lane.slots[slot_index]; + if (slot.job == .ASYNC_DB) lane.scan_inflight -= 1; + shed(.{ .job = slot.job, .completion = slot.completion }); + lane.slot_live[slot_index] = false; + lane.free[lane.free_count] = slot_index; + lane.free_count += 1; + } + + const last = lane.line_count - 1; + lane.lines[index] = lane.lines[last]; + for (0..SLOT_CAP) |slot_index| { + if (lane.slot_live[slot_index] and lane.slot_line[slot_index] == last) { + lane.slot_line[slot_index] = @intCast(index); + } + } - const shard = shardOf(jobFd(job)); - if (keep_alive) return shard.enqueue(.{ .job = job }); + lane.line_count -= 1; + line.deinit(); +} + +/// Drain queued jobs into the lines until a window fills, then flush any +/// parked client writes. Runs on the owning engine worker only. +fn laneDrain(lane: *Lane) void { + while (lane.line_count > 0 and lane.q_head != lane.q_tail) { + const item = lane.queue[lane.q_head & (SLOT_CAP - 1)]; + + if (item.job == .ASYNC_DB and lane.scan_inflight >= lane.scan_cap) break; + if (lane.free_count == 0) break; + + if (serveFromCache(item)) { + lane.q_head +%= 1; + continue; + } + + lane.free_count -= 1; + const tag = lane.free[lane.free_count]; + lane.slots[tag] = .{ .job = item.job, .completion = item.completion }; + + const request = buildRequest(item.job) catch { + lane.free_count += 1; + lane.q_head +%= 1; + shed(item); + + continue; + }; + + const line_index = laneLineFor(lane); + if (!lane.lines[line_index].submit(request, tag)) { + lane.free_count += 1; + + break; + } + + lane.slot_line[tag] = @intCast(line_index); + lane.slot_live[tag] = true; + if (item.job == .ASYNC_DB) lane.scan_inflight += 1; + lane.q_head +%= 1; + } + + for (lane.lines[0..lane.line_count]) |line| line.flush(); + + if (lane.pool.pw_active > 0) _ = flushPendingWrites(&lane.pool); +} + +/// Reply sink for the lane: render and send, then release the slot. +fn onLaneReply(context: ?*anyopaque, tag: u64, reply: []const u8) void { + const lane: *Lane = @ptrCast(@alignCast(context.?)); + + const slot = &lane.slots[@intCast(tag)]; + const job = slot.job; + const completion = slot.completion; + + tl_write_blocking = completion != null; + defer tl_write_blocking = false; + + switch (job) { + .ASYNC_DB => |request| renderRows(reply, request.fd, .ASYNC_DB, .{}), + .CRUD_LIST => |request| renderRows(reply, request.fd, .CRUD_LIST, .{ + .page = request.page, + .limit = request.limit, + .category = request.category_buf[0..request.category_len], + }), + .CRUD_GET => |request| renderRows(reply, request.fd, .CRUD_GET, .{ .id = request.id }), + .CRUD_CREATE => |request| finishWrite(reply, request.fd, request.id, .CREATE), + .CRUD_UPDATE => |request| finishWrite(reply, request.fd, request.id, .UPDATE), + } + + if (completion) |signal_ptr| signal_ptr.done.store(true, .release); + + if (job == .ASYNC_DB) lane.scan_inflight -= 1; + lane.slot_live[@intCast(tag)] = false; + lane.free[lane.free_count] = @intCast(tag); + lane.free_count += 1; +} + +/// Ring readable callback for a lane fd: pump and refill. The watch is +/// multishot, the engine keeps it armed. +fn onLaneReadable(fd: std.posix.fd_t) void { + _ = fd; + + const lane = tl_lane orelse return; + + lanePump(lane); + laneDrain(lane); +} + +/// Lane-mode submit. For a close request the same thread owns the pump, so +/// waiting means pumping. +fn laneSubmit(job: Job, keep_alive: bool) bool { + const lane = laneGet() orelse return false; + if (lane.line_count == 0) return false; + + lanePump(lane); + + if (keep_alive) { + if (laneQueueFull(lane)) return false; + + lanePush(lane, .{ .job = job }); + laneDrain(lane); + + return true; + } var completion: Completion = .{}; - if (!shard.enqueue(.{ .job = job, .completion = &completion })) return false; + if (laneQueueFull(lane)) return false; - while (!completion.done.load(.acquire)) std.atomic.spinLoopHint(); + lanePush(lane, .{ .job = job, .completion = &completion }); + laneDrain(lane); + while (!completion.done.load(.acquire)) { + lanePump(lane); + laneDrain(lane); + } return true; } -/// Queue a Job on its fd's shard thread. For a close request dbpg.submit blocks -/// until the response is written, so a deferred write never races the fd close. +/// Queue a Job on this worker's lane. +/// +/// Note: +/// - keep_alive false marks a close request: the call pumps the lane until +/// the response is written (the engine closes the fd on return). +/// +/// Return: +/// - true when the response is written or owned by the lane +/// - false when the lane is down or full (caller sheds 503) +pub fn submit(job: Job, keep_alive: bool) bool { + return laneSubmit(job, keep_alive); +} + +/// Queue a Job on this worker's lane. For a close request submit blocks +/// until the response is written, so a deferred write never races the fd +/// close. pub fn submitJob(head: *const zix.Http1.ParsedHead, job: Job) bool { return submit(job, head.keep_alive); } From 711566d0b25bf89a4b797c2c95dae55e10890f88 Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 35/46] update lane wording, drop dead comments --- frameworks/zix/src/handlers/asyncdb.zig | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/frameworks/zix/src/handlers/asyncdb.zig b/frameworks/zix/src/handlers/asyncdb.zig index 59ef993da..73f722871 100644 --- a/frameworks/zix/src/handlers/asyncdb.zig +++ b/frameworks/zix/src/handlers/asyncdb.zig @@ -1,5 +1,5 @@ //! GET /async-db?min=..&max=..&limit=.. : price-range item scan. Queues a -//! Job on the fd's postgrez shard thread (dbpg.zig), answered async. +//! Job on the worker's postgres lane (dbpg.zig), answered async. const std = @import("std"); const zix = @import("zix"); @@ -15,19 +15,7 @@ const ASYNC_DB_LIMIT_MAX: i64 = 50; // --------------------------------------------------------- // -// /// Queue a Job on its fd's shard thread. For a close request dbpg.submit blocks -// /// until the response is written, so a deferred write never races the fd close. -// fn submitJob(head: *const zix.Http1.ParsedHead, job: dbpg.Job) bool { -// return dbpg.submit(job, head.keep_alive); -// } - -// --------------------------------------------------------- // - -pub fn RESPONSE( - req: *zix.Http1.Request, - _: *zix.Http1.Response, - _: *zix.Http1.Context -) !void { +pub fn RESPONSE(req: *zix.Http1.Request, _: *zix.Http1.Response, _: *zix.Http1.Context) !void { const head = req.head; const fd = req.fd; From 0e5fb08eb4e2046e8eeeb6fc8790ec5e53e9c89c Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 36/46] clean dead comments, collapse signatures --- frameworks/zix/src/handlers/baseline.zig | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/frameworks/zix/src/handlers/baseline.zig b/frameworks/zix/src/handlers/baseline.zig index 5c047b11c..2b9de88c1 100644 --- a/frameworks/zix/src/handlers/baseline.zig +++ b/frameworks/zix/src/handlers/baseline.zig @@ -41,11 +41,7 @@ fn parseIntLoose(s: []const u8) i64 { // --------------------------------------------------------- // -pub fn RESPONSE( - req: *zix.Http1.Request, - _: *zix.Http1.Response, - _: *zix.Http1.Context -) !void { +pub fn RESPONSE(req: *zix.Http1.Request, _: *zix.Http1.Response, _: *zix.Http1.Context) !void { const head = req.head; const body = try req.body(); const fd = req.fd; @@ -53,7 +49,6 @@ pub fn RESPONSE( var sum: i64 = sumQuery(head.query); if (req.method() == .POST and body.len > 0) { - // if (std.mem.eql(u8, head.method, "POST") and body.len > 0) { sum += parseIntLoose(body); } @@ -61,11 +56,6 @@ pub fn RESPONSE( const out = std.fmt.bufPrint(&body_buf, "{d}", .{sum}) catch return; zix.Http1.sendSimpleFD(fd, 200, "text/plain", out) catch { - try zix.Http1.sendSimpleFD( - fd, - @intFromEnum(zix.Http1.Status.Code.INTERNAL_SERVER_ERROR), - zix.Http1.Content.Type.TEXT_PLAIN.asString(), - zix.Http1.Status.Code.INTERNAL_SERVER_ERROR.asString() - ); + try zix.Http1.sendSimpleFD(fd, @intFromEnum(zix.Http1.Status.Code.INTERNAL_SERVER_ERROR), zix.Http1.Content.Type.TEXT_PLAIN.asString(), zix.Http1.Status.Code.INTERNAL_SERVER_ERROR.asString()); }; } From 6d8071fd8e1eaecbf0bb78c29e5cd699d8372933 Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 37/46] collapse handler signatures and sends --- frameworks/zix/src/handlers/pipeline.zig | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/frameworks/zix/src/handlers/pipeline.zig b/frameworks/zix/src/handlers/pipeline.zig index 42f34112a..9e6381340 100644 --- a/frameworks/zix/src/handlers/pipeline.zig +++ b/frameworks/zix/src/handlers/pipeline.zig @@ -12,19 +12,10 @@ const PIPELINE_RESP: []const u8 = // --------------------------------------------------------- // -pub fn RESPONSE( - req: *zix.Http1.Request, - _: *zix.Http1.Response, - _: *zix.Http1.Context -) !void { +pub fn RESPONSE(req: *zix.Http1.Request, _: *zix.Http1.Response, _: *zix.Http1.Context) !void { const fd = req.fd; zix.Http1.writeAllFD(req.fd, PIPELINE_RESP) catch { - try zix.Http1.sendSimpleFD( - fd, - @intFromEnum(zix.Http1.Status.Code.INTERNAL_SERVER_ERROR), - zix.Http1.Content.Type.TEXT_PLAIN.asString(), - zix.Http1.Status.Code.INTERNAL_SERVER_ERROR.asString() - ); + try zix.Http1.sendSimpleFD(fd, @intFromEnum(zix.Http1.Status.Code.INTERNAL_SERVER_ERROR), zix.Http1.Content.Type.TEXT_PLAIN.asString(), zix.Http1.Status.Code.INTERNAL_SERVER_ERROR.asString()); }; } From 771dd5ef1b8ff2d45e8a97ea82e67382ddf9f643 Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 38/46] simplify static index comments --- frameworks/zix/src/handlers/static.zig | 40 ++++++++------------------ 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/frameworks/zix/src/handlers/static.zig b/frameworks/zix/src/handlers/static.zig index 6cef5cac6..f604c5d6c 100644 --- a/frameworks/zix/src/handlers/static.zig +++ b/frameworks/zix/src/handlers/static.zig @@ -1,7 +1,7 @@ -//! GET /static/{file} : serve from /data/static. Negotiates .br then .gz -//! when accepted, else identity. One header send coalesced with a zero-copy -//! sendfile body. Resolved names cache in a lock-free wyhash index over an -//! append-only entry table (process-lifetime fds, pre-rendered headers). +//! GET /static/{file} : serve from /data/static. Picks .br then .gz when the +//! client accepts them, else the plain file. One header send coalesced with +//! a zero-copy sendfile body. Resolved names are cached: open fd, size, and +//! a pre-built header per name. const std = @import("std"); const zix = @import("zix"); @@ -17,12 +17,9 @@ pub const PATH = "/static"; pub const STATIC_NAME_MAX: usize = 96; /// 20 fixtures times their (.br, .gz, identity) candidates plus headroom. pub const STATIC_CACHE_MAX: usize = 128; -/// Open-addressed name index over g_static_entries: wyhash of the name to -/// (entry index + 1), 0 = empty. Twice the entry capacity, so a probe always -/// reaches an empty slot and a lookup is a couple of compares instead of a -/// linear scan of every cached name per request. Entries are never removed, -/// inserts publish a slot last (release) so lock-free readers always see the -/// entry bytes behind a visible slot. +/// Name index over g_static_entries: name hash to (entry index + 1), 0 = +/// empty. Twice the entry capacity so a probe always ends at its entry or an +/// empty slot. Entries are never removed. const STATIC_INDEX_SLOTS: usize = STATIC_CACHE_MAX * 2; var g_static_index: [STATIC_INDEX_SLOTS]u16 = @splat(0); @@ -77,13 +74,12 @@ fn openStatic(name: []const u8) ?std.posix.fd_t { var path_buf: [512]u8 = undefined; const file_path = std.fmt.bufPrint(&path_buf, "{s}{s}", .{ g_static_base, name }) catch - return null; + return null; if (file_path.len >= path_buf.len) return null; path_buf[file_path.len] = 0; - return std.posix.openatZ(std.posix.AT.FDCWD, @ptrCast(&path_buf), - .{ .ACCMODE = .RDONLY }, 0) catch null; + return std.posix.openatZ(std.posix.AT.FDCWD, @ptrCast(&path_buf), .{ .ACCMODE = .RDONLY }, 0) catch null; } fn buildVariant(name: []const u8) ?StaticVariant { @@ -100,12 +96,7 @@ fn buildVariant(name: []const u8) ?StaticVariant { const size: u64 = stx.size; const meta = staticMeta(name); - var v: StaticVariant = .{ - .fd = file_fd, - .size = size, - .hdr_len = 0, - .hdr_buf = undefined - }; + var v: StaticVariant = .{ .fd = file_fd, .size = size, .hdr_len = 0, .hdr_buf = undefined }; const hdr = (if (meta.content_encoding.len > 0) std.fmt.bufPrint(&v.hdr_buf, "HTTP/1.1 200 OK\r\nContent-Type: {s}\r\nContent-Length: {d}\r\nContent-Encoding: {s}\r\n\r\n", .{ meta.content_type, size, meta.content_encoding }) else @@ -156,7 +147,6 @@ fn waitWritable(fd: std.posix.fd_t) error{BrokenPipe}!void { _ = std.posix.poll(&pfd, -1) catch return error.BrokenPipe; } - fn sendMoreFD(fd: std.posix.fd_t, data: []const u8) error{BrokenPipe}!void { const linux = std.os.linux; @@ -206,16 +196,10 @@ pub fn resolveStatic(name: []const u8) ?*const StaticEntry { pub fn staticMeta(name: []const u8) StaticMeta { if (std.mem.endsWith(u8, name, ".br")) { - return .{ - .content_type = contentType(name[0 .. name.len - ".br".len]), - .content_encoding = "br" - }; + return .{ .content_type = contentType(name[0 .. name.len - ".br".len]), .content_encoding = "br" }; } if (std.mem.endsWith(u8, name, ".gz")) { - return .{ - .content_type = contentType(name[0 .. name.len - ".gz".len]), - .content_encoding = "gzip" - }; + return .{ .content_type = contentType(name[0 .. name.len - ".gz".len]), .content_encoding = "gzip" }; } return .{ .content_type = contentType(name), .content_encoding = "" }; From 8609f179bc4653a262c186f7bd7cef6e635ae4bc Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 39/46] remove unused internalServerError --- frameworks/zix/src/shared/response.zig | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frameworks/zix/src/shared/response.zig b/frameworks/zix/src/shared/response.zig index 8113655f1..18a0c6288 100644 --- a/frameworks/zix/src/shared/response.zig +++ b/frameworks/zix/src/shared/response.zig @@ -1,4 +1,4 @@ -//! Shared error responders (400/404/500/503) for the handlers. +//! Shared error responders (400/404/503) for the handlers. const std = @import("std"); const zix = @import("zix"); @@ -13,10 +13,6 @@ pub fn notFound(fd: std.posix.fd_t) void { zix.Http1.sendSimpleFD(fd, 404, "text/plain", "Not Found") catch {}; } -pub fn internalServerError(fd: std.posix.fd_t) void { - zix.Http1.sendSimpleFD(fd, 500, "text/plain", "Internal Server Error") catch {}; -} - pub fn serviceUnavailable(fd: std.posix.fd_t) void { zix.Http1.sendSimpleFD(fd, 503, "text/plain", "Service Unavailable") catch {}; } From bdd42449e34b4697b436506f0d3313b41bb0f931 Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 40/46] remove unused Dataset deinit --- frameworks/zix/src/shared/dataset.zig | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frameworks/zix/src/shared/dataset.zig b/frameworks/zix/src/shared/dataset.zig index f6f0ff2d3..e3d94d56d 100644 --- a/frameworks/zix/src/shared/dataset.zig +++ b/frameworks/zix/src/shared/dataset.zig @@ -20,10 +20,6 @@ pub const Item = struct { pub const Dataset = struct { items: []Item, arena: std.heap.ArenaAllocator, - - pub fn deinit(self: *Dataset) void { - self.arena.deinit(); - } }; pub fn load(gpa: std.mem.Allocator, path: []const u8) !Dataset { From b8028a9c5d5062f2f43b1c2d83d0221b5f572acd Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 41/46] wire db lanes, drop cache and mirror setup edited: - db endpoints open lanes lazily per worker removed: - cache and dbrd imports and startup - response_cache config --- frameworks/zix/src/main.zig | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/frameworks/zix/src/main.zig b/frameworks/zix/src/main.zig index d75b61412..950753dbb 100644 --- a/frameworks/zix/src/main.zig +++ b/frameworks/zix/src/main.zig @@ -3,9 +3,9 @@ //! zix.Http1 (.URING), Router-only: every request goes through the engine's //! parser and the comptime Router, one handler module per route //! (src/handlers/). TLS rides tls_port (dual listener, one worker fleet). -//! async-db and crud run over the driver-owned multiplexed transport -//! (dbpg.zig, sharded), crud reads also check the in-process cache -//! (crudcache.zig) mirrored to Redis (dbrd.zig). +//! async-db and crud run over engine-worker lanes (dbpg.zig): each worker +//! owns a pipelined postgres connection on its own ring. crud reads also +//! check the in-process cache (crudcache.zig). const std = @import("std"); const zix = @import("zix"); @@ -18,9 +18,7 @@ const json = @import("handlers/json.zig"); const asyncdb = @import("handlers/asyncdb.zig"); const crud = @import("handlers/crud.zig"); -const cache = @import("shared/cache.zig"); const dbpg = @import("shared/dbpg.zig"); -const dbrd = @import("shared/dbrd.zig"); // --------------------------------------------------------- // @@ -43,12 +41,9 @@ pub fn main(process: std.process.Init) !void { try json.init(json_alloc.allocator()); - // DB endpoints: each driver's transport threads spawn only when its URL - // is present (non-DB profiles run zero extra threads, DB routes answer - // 503). Redis starts first, the postgrez shard threads mirror to it. + // DB endpoints: lanes open lazily per engine worker once DATABASE_URL is + // present (non-DB profiles open nothing, DB routes answer 503). dbpg.init(process); - dbrd.init(process); - dbrd.start(); dbpg.start(); var tls = zix.Tls.Context.init(tls_alloc.allocator(), process.io, .{ @@ -79,18 +74,13 @@ pub fn main(process: std.process.Init) !void { // .compress = true, // - .response_cache = true, - .cache_max_entries = 1 * 1024 / 2, - .cache_max_value_bytes = 32 * 1024, - .cache_ttl_ms = cache.TTL_MS, - // .kernel_backlog = 16 * 1024, .max_recv_buf = 8 * 1024, // .uring_send_buf_size = 16 * 1024, - .uring_idle_pool_floor = 16, // 0:256 1:16 + .uring_idle_pool_floor = 16, .uring_idle_pool_ceiling = 1 * 1024, - .process_queue_len = park_len, // 0:8192 1:16384 / workers, floor 512 + .process_queue_len = park_len, }); defer server.deinit(); From 0b6185a2e9fb219e82cf10ca4d0e51a3c2a76790 Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 42/46] remove response cache module --- frameworks/zix/src/shared/cache.zig | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 frameworks/zix/src/shared/cache.zig diff --git a/frameworks/zix/src/shared/cache.zig b/frameworks/zix/src/shared/cache.zig deleted file mode 100644 index a9c46dd5a..000000000 --- a/frameworks/zix/src/shared/cache.zig +++ /dev/null @@ -1,7 +0,0 @@ -//! Engine response-cache TTL (cache_ttl_ms), long-lived since the dataset is -//! immutable and each key renders once per run. - -const std = @import("std"); - -/// Long TTL: the dataset is immutable, each key is built once per run. -pub const TTL_MS: u32 = (60 * 1_000) * 6; From 7d9f111eaa1e38d5c578f6e0caccb0c694000966 Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 43/46] remove redis write-behind mirror --- frameworks/zix/src/shared/dbrd.zig | 224 ----------------------------- 1 file changed, 224 deletions(-) delete mode 100644 frameworks/zix/src/shared/dbrd.zig diff --git a/frameworks/zix/src/shared/dbrd.zig b/frameworks/zix/src/shared/dbrd.zig deleted file mode 100644 index 333378aed..000000000 --- a/frameworks/zix/src/shared/dbrd.zig +++ /dev/null @@ -1,224 +0,0 @@ -//! Redis write-behind mirror over the driver-owned multiplexed transport -//! (rediz.Transport, .URING). The read path is the in-process cache -//! (crudcache.zig), Redis only carries a write-behind mirror: crud cache -//! fills SET the key, crud writes DEL it. Replies are never awaited, one -//! transport thread drains queued commands and the reply sink is a no-op. - -const std = @import("std"); -const zix = @import("zix"); - -const rediz = zix.Driver.rediz; - -// --------------------------------------------------------- // - -const CRUD_KEY_PREFIX = "crud:item:"; - -/// Mirror TTL in seconds, the write path additionally deletes the key. -const CRUD_CACHE_TTL = "1"; - -/// Pipeline depth per connection, matches the driver transport default. -const WINDOW = rediz.dispatch.DEFAULT_WINDOW; - -/// One encoded command cap: SET crud:item: EX 1, body <= 256. -const CMD_MAX = 512; - -/// A body larger than this skips the mirror (its command would overrun CMD_MAX). -const BODY_LIMIT = 300; - -// --------------------------------------------------------- // - -// Set once in init before start, read-only afterwards. -var g_io: std.Io = undefined; -var g_config: rediz.Config = undefined; -var g_enabled: bool = false; -var g_conns: usize = 4; - -var g_transport: ?*rediz.Transport = null; -var g_running: std.atomic.Value(bool) = .init(false); - -// --------------------------------------------------------- // - -/// One queued, pre-encoded RESP command. -const Command = struct { - len: u16 = 0, - buf: [CMD_MAX]u8 = undefined, -}; - -/// Bounded queue: the postgrez shard threads enqueue, this thread drains. -const QUEUE_CAP = 8192; - -var q_buf: [QUEUE_CAP]Command = undefined; -var q_head: usize = 0; -var q_tail: usize = 0; -var q_lock: std.atomic.Value(bool) = .init(false); - -fn qLock() void { - while (q_lock.swap(true, .acquire)) std.atomic.spinLoopHint(); -} - -fn qUnlock() void { - q_lock.store(false, .release); -} - -fn enqueue(bytes: []const u8) void { - if (bytes.len > CMD_MAX) return; - - qLock(); - defer qUnlock(); - - if (q_tail -% q_head >= QUEUE_CAP) return; - - const slot = &q_buf[q_tail & (QUEUE_CAP - 1)]; - @memcpy(slot.buf[0..bytes.len], bytes); - slot.len = @intCast(bytes.len); - q_tail +%= 1; -} - -fn dequeue(out: *Command) bool { - qLock(); - defer qUnlock(); - - if (q_head == q_tail) return false; - - out.* = q_buf[q_head & (QUEUE_CAP - 1)]; - q_head +%= 1; - - return true; -} - -// --------------------------------------------------------- // - -/// Read REDIS_URL once at startup. Absent or malformed disables the mirror: -/// the in-process cache still serves x-cache MISS/HIT on its own. -pub fn init(process: std.process.Init) void { - const url_text = process.environ_map.get("REDIS_URL") orelse return; - - g_config = rediz.parseUrl(url_text) catch return; - g_config.tls = .OFF; - g_config.dispatch_model = .URING; - g_io = process.io; - g_enabled = true; - - const cpu = std.Thread.getCpuCount() catch 4; - g_conns = std.math.clamp(cpu, 4, 8); -} - -pub fn enabled() bool { - return g_enabled; -} - -/// Open the multiplexed transport and spawn the thread that owns its poll -/// loop. Does nothing when REDIS_URL was absent. -pub fn start() void { - if (!g_enabled) return; - - g_transport = rediz.Transport.open(std.heap.smp_allocator, g_io, g_config, .{ - .model = .URING, - .conns = g_conns, - .window = WINDOW, - .on_reply = onReply, - }) catch { - g_enabled = false; - return; - }; - - g_running.store(true, .release); - - const thread = std.Thread.spawn(.{}, transportLoop, .{}) catch { - g_enabled = false; - return; - }; - thread.detach(); -} - -/// Mirror a crud item body under its key (cache fill). Fire-and-forget. -pub fn mirrorSet(id: i64, body: []const u8) void { - if (!g_enabled) return; - if (body.len > BODY_LIMIT) return; - - var key_buf: [40]u8 = undefined; - const key = std.fmt.bufPrint(&key_buf, CRUD_KEY_PREFIX ++ "{d}", .{id}) catch return; - - var cmd_buf: [CMD_MAX]u8 = undefined; - const bytes = encode(&cmd_buf, &.{ "SET", key, body, "EX", CRUD_CACHE_TTL }) orelse return; - - enqueue(bytes); -} - -/// Drop a crud item mirror by key (write invalidation). Fire-and-forget. -pub fn mirrorDel(id: i64) void { - if (!g_enabled) return; - - var key_buf: [40]u8 = undefined; - const key = std.fmt.bufPrint(&key_buf, CRUD_KEY_PREFIX ++ "{d}", .{id}) catch return; - - var cmd_buf: [CMD_MAX]u8 = undefined; - const bytes = encode(&cmd_buf, &.{ "DEL", key }) orelse return; - - enqueue(bytes); -} - -/// Encode one RESP command into `scratch`, null when it does not fit. -fn encode(scratch: []u8, args: []const []const u8) ?[]const u8 { - var out: std.ArrayList(u8) = .empty; - - var fixed = std.heap.FixedBufferAllocator.init(scratch); - rediz.resp.encodeCommand(fixed.allocator(), &out, args) catch return null; - - return out.items; -} - -// --------------------------------------------------------- // - -/// The transport thread: drain queued commands into the transport, then poll. -/// A held-over command (the transport was full) is retried before the next -/// dequeue. -fn transportLoop() void { - const transport = g_transport.?; - - var holdover: Command = undefined; - var has_holdover = false; - - while (g_running.load(.acquire)) { - var progressed = false; - - while (true) { - var cmd: Command = undefined; - if (has_holdover) { - cmd = holdover; - has_holdover = false; - } else if (!dequeue(&cmd)) { - break; - } - - if (!transport.submit(cmd.buf[0..cmd.len], 0)) { - holdover = cmd; - has_holdover = true; - break; - } - - progressed = true; - } - - if (transport.pending() > 0) { - _ = transport.poll() catch {}; - progressed = true; - } - - if (!progressed) idle(); - } -} - -fn idle() void { - const req = std.os.linux.timespec{ .sec = 0, .nsec = 200 * std.time.ns_per_us }; - - _ = std.os.linux.nanosleep(&req, null); -} - -/// Reply sink: the mirror is fire-and-forget, so a completed reply only -/// advances the pipeline and is otherwise ignored. -fn onReply(context: ?*anyopaque, tag: u64, reply: []const u8) void { - _ = context; - _ = tag; - _ = reply; -} From 7bb02d89618c9e166e3db7dac1fd7c648069138d Mon Sep 17 00:00:00 2001 From: prothegee Date: Thu, 23 Jul 2026 07:12:45 +0700 Subject: [PATCH 44/46] describe the lane design in meta --- frameworks/zix/meta.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frameworks/zix/meta.json b/frameworks/zix/meta.json index c063ab36f..1b18a76bf 100644 --- a/frameworks/zix/meta.json +++ b/frameworks/zix/meta.json @@ -3,7 +3,7 @@ "language": "Zig", "type": "engine", "engine": "zix.Http1 URING Dispatch Model", - "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine, .URING dispatch: per-worker SO_REUSEPORT io_uring loops, comptime Router, staged send sink. Dual listener: cleartext 8080, TLS 1.3 on 8081. DB endpoints ride the driver-owned multiplexed transports (postgrez plus rediz, .URING): fd-routed shard threads, named prepared statements, cache-aside crud with a Redis write-behind mirror.", + "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine, .URING dispatch: per-worker SO_REUSEPORT io_uring loops, comptime Router, staged send sink. Dual listener: cleartext 8080, TLS 1.3 on 8081. DB endpoints run on engine-worker lanes: each worker pumps its own pipelined postgrez connections on its ring, named prepared statements, cache-aside single-item crud reads (200 ms TTL).", "repo": "https://github.com/prothegee/zix", "enabled": true, "tests": [ From 0160520b3240f718d9f41e6972f00a4c7652c81a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Jul 2026 18:02:33 +0000 Subject: [PATCH 45/46] Benchmark results: zix --- site/data/api-16-1024.json | 24 ++++++++++++------------ site/data/api-4-256.json | 24 ++++++++++++------------ site/data/async-db-1024.json | 18 +++++++++--------- site/data/baseline-4096.json | 14 +++++++------- site/data/baseline-512.json | 16 ++++++++-------- site/data/crud-4096.json | 18 +++++++++--------- site/data/frameworks.json | 2 +- site/data/json-4096.json | 18 +++++++++--------- site/data/json-comp-16384.json | 18 +++++++++--------- site/data/json-comp-4096.json | 18 +++++++++--------- site/data/json-comp-512.json | 18 +++++++++--------- site/data/json-tls-4096.json | 10 +++++----- site/data/limited-conn-4096.json | 18 +++++++++--------- site/data/limited-conn-512.json | 18 +++++++++--------- site/data/pipelined-4096.json | 12 ++++++------ site/data/pipelined-512.json | 14 +++++++------- site/data/static-1024.json | 14 +++++++------- site/data/static-4096.json | 14 +++++++------- site/data/static-6800.json | 14 +++++++------- site/data/upload-256.json | 18 +++++++++--------- site/data/upload-32.json | 18 +++++++++--------- 21 files changed, 169 insertions(+), 169 deletions(-) diff --git a/site/data/api-16-1024.json b/site/data/api-16-1024.json index 7ec384a49..4b719f2eb 100644 --- a/site/data/api-16-1024.json +++ b/site/data/api-16-1024.json @@ -1636,27 +1636,27 @@ { "framework": "zix", "language": "Zig", - "rps": 257170, - "avg_latency": "1.78ms", - "p99_latency": "13.80ms", - "cpu": "984.8%", - "memory": "79MiB", + "rps": 249200, + "avg_latency": "1.36ms", + "p99_latency": "14.00ms", + "cpu": "969.6%", + "memory": "120MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "1.26GB/s", - "input_bw": "14.47MB/s", - "reconnects": 771518, - "status_2xx": 3857563, + "bandwidth": "1.22GB/s", + "input_bw": "14.02MB/s", + "reconnects": 747596, + "status_2xx": 3738000, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "tpl_baseline": 1448966, - "tpl_json": 1443619, + "tpl_baseline": 1401751, + "tpl_json": 1402864, "tpl_db": 0, "tpl_upload": 0, "tpl_static": 0, - "tpl_async_db": 964977 + "tpl_async_db": 933375 } ] \ No newline at end of file diff --git a/site/data/api-4-256.json b/site/data/api-4-256.json index a28293b1e..9591346c3 100644 --- a/site/data/api-4-256.json +++ b/site/data/api-4-256.json @@ -1636,27 +1636,27 @@ { "framework": "zix", "language": "Zig", - "rps": 67637, - "avg_latency": "1.22ms", - "p99_latency": "9.45ms", - "cpu": "223.7%", - "memory": "38MiB", + "rps": 69172, + "avg_latency": "1.18ms", + "p99_latency": "5.38ms", + "cpu": "227.1%", + "memory": "41MiB", "connections": 256, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "339.05MB/s", - "input_bw": "3.81MB/s", - "reconnects": 202910, - "status_2xx": 1014569, + "bandwidth": "347.19MB/s", + "input_bw": "3.89MB/s", + "reconnects": 207508, + "status_2xx": 1037589, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "tpl_baseline": 381285, - "tpl_json": 379909, + "tpl_baseline": 389094, + "tpl_json": 389483, "tpl_db": 0, "tpl_upload": 0, "tpl_static": 0, - "tpl_async_db": 253374 + "tpl_async_db": 259010 } ] \ No newline at end of file diff --git a/site/data/async-db-1024.json b/site/data/async-db-1024.json index 49314e0a2..29a23c0cf 100644 --- a/site/data/async-db-1024.json +++ b/site/data/async-db-1024.json @@ -1338,19 +1338,19 @@ { "framework": "zix", "language": "Zig", - "rps": 199403, - "avg_latency": "4.59ms", - "p99_latency": "6.87ms", - "cpu": "1126.9%", - "memory": "159MiB", + "rps": 407196, + "avg_latency": "1.87ms", + "p99_latency": "18.90ms", + "cpu": "3235.6%", + "memory": "325MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "768.24MB/s", - "input_bw": "13.31MB/s", - "reconnects": 79530, - "status_2xx": 1994031, + "bandwidth": "1.53GB/s", + "input_bw": "27.18MB/s", + "reconnects": 162419, + "status_2xx": 4071966, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/baseline-4096.json b/site/data/baseline-4096.json index cfea4357e..478bd9b43 100644 --- a/site/data/baseline-4096.json +++ b/site/data/baseline-4096.json @@ -1870,19 +1870,19 @@ { "framework": "zix", "language": "Zig", - "rps": 4479858, - "avg_latency": "913us", - "p99_latency": "1.20ms", - "cpu": "6415.9%", + "rps": 4455412, + "avg_latency": "919us", + "p99_latency": "1.25ms", + "cpu": "6415.3%", "memory": "188MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "281.89MB/s", - "input_bw": "346.06MB/s", + "bandwidth": "280.35MB/s", + "input_bw": "344.17MB/s", "reconnects": 0, - "status_2xx": 22399294, + "status_2xx": 22277061, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/baseline-512.json b/site/data/baseline-512.json index 775766d79..9d5ae9315 100644 --- a/site/data/baseline-512.json +++ b/site/data/baseline-512.json @@ -1870,19 +1870,19 @@ { "framework": "zix", "language": "Zig", - "rps": 4254791, - "avg_latency": "119us", - "p99_latency": "209us", - "cpu": "6433.1%", - "memory": "138MiB", + "rps": 4067485, + "avg_latency": "125us", + "p99_latency": "263us", + "cpu": "6443.9%", + "memory": "127MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "267.69MB/s", - "input_bw": "328.67MB/s", + "bandwidth": "255.94MB/s", + "input_bw": "314.20MB/s", "reconnects": 0, - "status_2xx": 21273956, + "status_2xx": 20337429, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/crud-4096.json b/site/data/crud-4096.json index bfb971609..46deac609 100644 --- a/site/data/crud-4096.json +++ b/site/data/crud-4096.json @@ -621,19 +621,19 @@ { "framework": "zix", "language": "Zig", - "rps": 963289, - "avg_latency": "3.43ms", - "p99_latency": "16.60ms", - "cpu": "2686.4%", - "memory": "232MiB", + "rps": 837446, + "avg_latency": "4.56ms", + "p99_latency": "22.50ms", + "cpu": "3310.6%", + "memory": "395MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "283.72MB/s", - "input_bw": "82.68MB/s", - "reconnects": 70386, - "status_2xx": 14449337, + "bandwidth": "244.20MB/s", + "input_bw": "71.88MB/s", + "reconnects": 60974, + "status_2xx": 12561702, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/frameworks.json b/site/data/frameworks.json index 0479e840a..bb9fb2604 100644 --- a/site/data/frameworks.json +++ b/site/data/frameworks.json @@ -1049,7 +1049,7 @@ }, "zix": { "dir": "zix", - "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine, .URING dispatch: per-worker SO_REUSEPORT io_uring loops, comptime Router, staged send sink. Dual listener: cleartext 8080, TLS 1.3 on 8081. DB endpoints ride the driver-owned multiplexed transports (postgrez plus rediz, .URING): fd-routed shard threads, named prepared statements, cache-aside crud with a Redis write-behind mirror.", + "description": "Zig HTTP/1.1 server on the zix.Http1 raw engine, .URING dispatch: per-worker SO_REUSEPORT io_uring loops, comptime Router, staged send sink. Dual listener: cleartext 8080, TLS 1.3 on 8081. DB endpoints run on engine-worker lanes: each worker pumps its own pipelined postgrez connections on its ring, named prepared statements, cache-aside single-item crud reads (200 ms TTL).", "repo": "https://github.com/prothegee/zix", "type": "engine", "engine": "zix.Http1 URING Dispatch Model", diff --git a/site/data/json-4096.json b/site/data/json-4096.json index 48e8775ff..38f0ff3c0 100644 --- a/site/data/json-4096.json +++ b/site/data/json-4096.json @@ -1578,19 +1578,19 @@ { "framework": "zix", "language": "Zig", - "rps": 2364532, - "avg_latency": "627us", - "p99_latency": "2.75ms", - "cpu": "5475.6%", - "memory": "181MiB", + "rps": 2420229, + "avg_latency": "734us", + "p99_latency": "2.95ms", + "cpu": "6110.8%", + "memory": "244MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "7.91GB/s", - "input_bw": "112.75MB/s", - "reconnects": 472585, - "status_2xx": 11822661, + "bandwidth": "8.10GB/s", + "input_bw": "115.41MB/s", + "reconnects": 484774, + "status_2xx": 12101149, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/json-comp-16384.json b/site/data/json-comp-16384.json index 0b5061f73..19e40bb57 100644 --- a/site/data/json-comp-16384.json +++ b/site/data/json-comp-16384.json @@ -1302,19 +1302,19 @@ { "framework": "zix", "language": "Zig", - "rps": 2893193, - "avg_latency": "4.31ms", - "p99_latency": "7.72ms", - "cpu": "6401.9%", - "memory": "453MiB", + "rps": 1110719, + "avg_latency": "14.66ms", + "p99_latency": "21.60ms", + "cpu": "6827.6%", + "memory": "522MiB", "connections": 16384, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "3.64GB/s", - "input_bw": "215.21MB/s", - "reconnects": 570773, - "status_2xx": 14465965, + "bandwidth": "1.90GB/s", + "input_bw": "82.62MB/s", + "reconnects": 214285, + "status_2xx": 5553598, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/json-comp-4096.json b/site/data/json-comp-4096.json index f0715e65a..df6344773 100644 --- a/site/data/json-comp-4096.json +++ b/site/data/json-comp-4096.json @@ -1302,19 +1302,19 @@ { "framework": "zix", "language": "Zig", - "rps": 3133503, - "avg_latency": "574us", - "p99_latency": "2.24ms", - "cpu": "6320.1%", - "memory": "204MiB", + "rps": 1139164, + "avg_latency": "3.54ms", + "p99_latency": "7.80ms", + "cpu": "6839.4%", + "memory": "206MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "3.95GB/s", - "input_bw": "233.09MB/s", - "reconnects": 626407, - "status_2xx": 15667517, + "bandwidth": "1.95GB/s", + "input_bw": "84.74MB/s", + "reconnects": 226926, + "status_2xx": 5695820, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/json-comp-512.json b/site/data/json-comp-512.json index 78c88f90f..b88fe1c12 100644 --- a/site/data/json-comp-512.json +++ b/site/data/json-comp-512.json @@ -1302,19 +1302,19 @@ { "framework": "zix", "language": "Zig", - "rps": 2204553, - "avg_latency": "98us", - "p99_latency": "365us", - "cpu": "5272.3%", - "memory": "143MiB", + "rps": 1028610, + "avg_latency": "468us", + "p99_latency": "1.88ms", + "cpu": "6112.8%", + "memory": "123MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "2.78GB/s", - "input_bw": "163.99MB/s", - "reconnects": 440902, - "status_2xx": 11022766, + "bandwidth": "1.76GB/s", + "input_bw": "76.51MB/s", + "reconnects": 205708, + "status_2xx": 5143054, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/json-tls-4096.json b/site/data/json-tls-4096.json index 7b96ba7ca..36b787841 100644 --- a/site/data/json-tls-4096.json +++ b/site/data/json-tls-4096.json @@ -926,18 +926,18 @@ { "framework": "zix", "language": "Zig", - "rps": 1937152, + "rps": 1938324, "avg_latency": "1.12ms", - "p99_latency": "37.91ms", - "cpu": "4903.9%", - "memory": "278MiB", + "p99_latency": "36.66ms", + "cpu": "4996.3%", + "memory": "273MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, "bandwidth": "6.48GB", "reconnects": 0, - "status_2xx": 9880616, + "status_2xx": 9880751, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/limited-conn-4096.json b/site/data/limited-conn-4096.json index 343698263..c72cbdbc0 100644 --- a/site/data/limited-conn-4096.json +++ b/site/data/limited-conn-4096.json @@ -1870,19 +1870,19 @@ { "framework": "zix", "language": "Zig", - "rps": 2711205, - "avg_latency": "1.37ms", - "p99_latency": "1.87ms", - "cpu": "5804.3%", - "memory": "227MiB", + "rps": 2703646, + "avg_latency": "1.38ms", + "p99_latency": "1.85ms", + "cpu": "5845.0%", + "memory": "231MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "170.58MB/s", - "input_bw": "209.43MB/s", - "reconnects": 1355814, - "status_2xx": 13556028, + "bandwidth": "170.11MB/s", + "input_bw": "208.85MB/s", + "reconnects": 1351326, + "status_2xx": 13518234, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/limited-conn-512.json b/site/data/limited-conn-512.json index 485ae813d..ed199791c 100644 --- a/site/data/limited-conn-512.json +++ b/site/data/limited-conn-512.json @@ -1870,19 +1870,19 @@ { "framework": "zix", "language": "Zig", - "rps": 2683926, - "avg_latency": "174us", - "p99_latency": "419us", - "cpu": "5516.2%", - "memory": "128MiB", + "rps": 2640088, + "avg_latency": "178us", + "p99_latency": "441us", + "cpu": "5615.1%", + "memory": "122MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "168.90MB/s", - "input_bw": "207.33MB/s", - "reconnects": 1341978, - "status_2xx": 13419631, + "bandwidth": "166.11MB/s", + "input_bw": "203.94MB/s", + "reconnects": 1320040, + "status_2xx": 13200443, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/pipelined-4096.json b/site/data/pipelined-4096.json index 21a98781a..603c481b0 100644 --- a/site/data/pipelined-4096.json +++ b/site/data/pipelined-4096.json @@ -1787,18 +1787,18 @@ { "framework": "zix", "language": "Zig", - "rps": 57817404, - "avg_latency": "1.13ms", + "rps": 57643456, + "avg_latency": "1.14ms", "p99_latency": "1.52ms", - "cpu": "6241.2%", - "memory": "189MiB", + "cpu": "6223.0%", + "memory": "188MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 16, - "bandwidth": "3.55GB/s", + "bandwidth": "3.54GB/s", "reconnects": 0, - "status_2xx": 289087021, + "status_2xx": 288217280, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/pipelined-512.json b/site/data/pipelined-512.json index 32319194e..9c35f6a5a 100644 --- a/site/data/pipelined-512.json +++ b/site/data/pipelined-512.json @@ -1787,18 +1787,18 @@ { "framework": "zix", "language": "Zig", - "rps": 56016444, - "avg_latency": "145us", - "p99_latency": "254us", - "cpu": "6368.3%", - "memory": "132MiB", + "rps": 53352112, + "avg_latency": "152us", + "p99_latency": "325us", + "cpu": "6435.5%", + "memory": "124MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 16, - "bandwidth": "3.44GB/s", + "bandwidth": "3.28GB/s", "reconnects": 0, - "status_2xx": 280082224, + "status_2xx": 266760560, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/static-1024.json b/site/data/static-1024.json index f17d67118..3698b30b9 100644 --- a/site/data/static-1024.json +++ b/site/data/static-1024.json @@ -1484,18 +1484,18 @@ { "framework": "zix", "language": "Zig", - "rps": 2005632, - "avg_latency": "303.81us", - "p99_latency": "3.57ms", - "cpu": "5478.0%", - "memory": "142MiB", + "rps": 2026656, + "avg_latency": "299.95us", + "p99_latency": "3.66ms", + "cpu": "5612.5%", + "memory": "141MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "30.55GB", + "bandwidth": "30.87GB", "reconnects": 0, - "status_2xx": 10228661, + "status_2xx": 10335759, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/static-4096.json b/site/data/static-4096.json index cdc4184a8..48ef91c4e 100644 --- a/site/data/static-4096.json +++ b/site/data/static-4096.json @@ -1484,18 +1484,18 @@ { "framework": "zix", "language": "Zig", - "rps": 2023494, - "avg_latency": "1.05ms", - "p99_latency": "4.11ms", - "cpu": "5454.7%", - "memory": "216MiB", + "rps": 2026878, + "avg_latency": "1.06ms", + "p99_latency": "4.91ms", + "cpu": "5598.9%", + "memory": "205MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "30.83GB", + "bandwidth": "30.88GB", "reconnects": 0, - "status_2xx": 10321137, + "status_2xx": 10338506, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/static-6800.json b/site/data/static-6800.json index 84a862416..d8ce53abd 100644 --- a/site/data/static-6800.json +++ b/site/data/static-6800.json @@ -1484,18 +1484,18 @@ { "framework": "zix", "language": "Zig", - "rps": 2005733, - "avg_latency": "1.74ms", - "p99_latency": "7.49ms", - "cpu": "5415.0%", - "memory": "275MiB", + "rps": 1988793, + "avg_latency": "1.76ms", + "p99_latency": "6.55ms", + "cpu": "5367.8%", + "memory": "273MiB", "connections": 6800, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "30.55GB", + "bandwidth": "30.30GB", "reconnects": 0, - "status_2xx": 10198345, + "status_2xx": 10117275, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/upload-256.json b/site/data/upload-256.json index c63cc667c..3ba84fb6c 100644 --- a/site/data/upload-256.json +++ b/site/data/upload-256.json @@ -1441,19 +1441,19 @@ { "framework": "zix", "language": "Zig", - "rps": 6726, - "avg_latency": "37.84ms", - "p99_latency": "44.20ms", - "cpu": "911.8%", - "memory": "130MiB", + "rps": 6460, + "avg_latency": "34.70ms", + "p99_latency": "79.20ms", + "cpu": "891.1%", + "memory": "126MiB", "connections": 256, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "467.76KB/s", - "input_bw": "53.35GB/s", - "reconnects": 6756, - "status_2xx": 33766, + "bandwidth": "449.27KB/s", + "input_bw": "51.24GB/s", + "reconnects": 6471, + "status_2xx": 32433, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 diff --git a/site/data/upload-32.json b/site/data/upload-32.json index 90041277d..4289837e4 100644 --- a/site/data/upload-32.json +++ b/site/data/upload-32.json @@ -1441,19 +1441,19 @@ { "framework": "zix", "language": "Zig", - "rps": 8232, - "avg_latency": "3.86ms", - "p99_latency": "11.00ms", - "cpu": "1112.3%", - "memory": "125MiB", + "rps": 8872, + "avg_latency": "3.58ms", + "p99_latency": "9.89ms", + "cpu": "1039.4%", + "memory": "124MiB", "connections": 32, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "573.29KB/s", - "input_bw": "65.29GB/s", - "reconnects": 8250, - "status_2xx": 41245, + "bandwidth": "617.79KB/s", + "input_bw": "70.37GB/s", + "reconnects": 8889, + "status_2xx": 44451, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 From f087ee9b466824d01ee12d0c7d6547e659831bab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 26 Jul 2026 09:27:23 +0000 Subject: [PATCH 46/46] Benchmark results: zix --- site/data/results/zix.json | 312 ++++++++++++++++++------------------- 1 file changed, 156 insertions(+), 156 deletions(-) diff --git a/site/data/results/zix.json b/site/data/results/zix.json index 6637b3d5d..e25c59358 100644 --- a/site/data/results/zix.json +++ b/site/data/results/zix.json @@ -4,71 +4,71 @@ "api-16-1024": { "framework": "zix", "language": "Zig", - "rps": 249200, - "avg_latency": "1.36ms", - "p99_latency": "14.00ms", - "cpu": "969.6%", - "memory": "120MiB", + "rps": 243615, + "avg_latency": "1.25ms", + "p99_latency": "13.50ms", + "cpu": "958.6%", + "memory": "118MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "1.22GB/s", - "input_bw": "14.02MB/s", - "reconnects": 747596, - "status_2xx": 3738000, + "bandwidth": "1.19GB/s", + "input_bw": "13.71MB/s", + "reconnects": 730807, + "status_2xx": 3654227, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "tpl_baseline": 1401751, - "tpl_json": 1402864, + "tpl_baseline": 1370460, + "tpl_json": 1371308, "tpl_db": 0, "tpl_upload": 0, "tpl_static": 0, - "tpl_async_db": 933375 + "tpl_async_db": 912459 }, "api-4-256": { "framework": "zix", "language": "Zig", - "rps": 69172, - "avg_latency": "1.18ms", - "p99_latency": "5.38ms", - "cpu": "227.1%", + "rps": 67725, + "avg_latency": "1.15ms", + "p99_latency": "4.55ms", + "cpu": "215.0%", "memory": "41MiB", "connections": 256, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "347.19MB/s", - "input_bw": "3.89MB/s", - "reconnects": 207508, - "status_2xx": 1037589, + "bandwidth": "339.91MB/s", + "input_bw": "3.81MB/s", + "reconnects": 203172, + "status_2xx": 1015889, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0, - "tpl_baseline": 389094, - "tpl_json": 389483, + "tpl_baseline": 380995, + "tpl_json": 381218, "tpl_db": 0, "tpl_upload": 0, "tpl_static": 0, - "tpl_async_db": 259010 + "tpl_async_db": 253676 }, "async-db-1024": { "framework": "zix", "language": "Zig", - "rps": 407196, - "avg_latency": "1.87ms", - "p99_latency": "18.90ms", - "cpu": "3235.6%", + "rps": 406353, + "avg_latency": "1.94ms", + "p99_latency": "18.00ms", + "cpu": "3230.5%", "memory": "325MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, "bandwidth": "1.53GB/s", - "input_bw": "27.18MB/s", - "reconnects": 162419, - "status_2xx": 4071966, + "input_bw": "27.13MB/s", + "reconnects": 162078, + "status_2xx": 4063536, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -76,19 +76,19 @@ "baseline-4096": { "framework": "zix", "language": "Zig", - "rps": 4455412, - "avg_latency": "919us", - "p99_latency": "1.25ms", - "cpu": "6415.3%", - "memory": "188MiB", + "rps": 4398710, + "avg_latency": "930us", + "p99_latency": "1.26ms", + "cpu": "6426.3%", + "memory": "186MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "280.35MB/s", - "input_bw": "344.17MB/s", + "bandwidth": "276.74MB/s", + "input_bw": "339.79MB/s", "reconnects": 0, - "status_2xx": 22277061, + "status_2xx": 21993554, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -96,19 +96,19 @@ "baseline-512": { "framework": "zix", "language": "Zig", - "rps": 4067485, - "avg_latency": "125us", - "p99_latency": "263us", - "cpu": "6443.9%", - "memory": "127MiB", + "rps": 4018752, + "avg_latency": "126us", + "p99_latency": "300us", + "cpu": "6491.1%", + "memory": "128MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "255.94MB/s", - "input_bw": "314.20MB/s", + "bandwidth": "252.88MB/s", + "input_bw": "310.44MB/s", "reconnects": 0, - "status_2xx": 20337429, + "status_2xx": 20093764, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -135,19 +135,19 @@ "crud-4096": { "framework": "zix", "language": "Zig", - "rps": 837446, - "avg_latency": "4.56ms", - "p99_latency": "22.50ms", - "cpu": "3310.6%", - "memory": "395MiB", + "rps": 834511, + "avg_latency": "4.64ms", + "p99_latency": "22.70ms", + "cpu": "3304.5%", + "memory": "394MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "244.20MB/s", - "input_bw": "71.88MB/s", - "reconnects": 60974, - "status_2xx": 12561702, + "bandwidth": "244.17MB/s", + "input_bw": "71.63MB/s", + "reconnects": 60650, + "status_2xx": 12517672, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -155,19 +155,19 @@ "json-4096": { "framework": "zix", "language": "Zig", - "rps": 2420229, - "avg_latency": "734us", - "p99_latency": "2.95ms", - "cpu": "6110.8%", - "memory": "244MiB", + "rps": 2405756, + "avg_latency": "719us", + "p99_latency": "2.97ms", + "cpu": "6061.2%", + "memory": "242MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "8.10GB/s", - "input_bw": "115.41MB/s", - "reconnects": 484774, - "status_2xx": 12101149, + "bandwidth": "8.05GB/s", + "input_bw": "114.72MB/s", + "reconnects": 481346, + "status_2xx": 12028784, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -175,19 +175,19 @@ "json-comp-16384": { "framework": "zix", "language": "Zig", - "rps": 1110719, - "avg_latency": "14.66ms", - "p99_latency": "21.60ms", - "cpu": "6827.6%", - "memory": "522MiB", + "rps": 1110217, + "avg_latency": "14.63ms", + "p99_latency": "21.70ms", + "cpu": "6821.1%", + "memory": "519MiB", "connections": 16384, "threads": 64, "duration": "5s", "pipeline": 1, "bandwidth": "1.90GB/s", - "input_bw": "82.62MB/s", - "reconnects": 214285, - "status_2xx": 5553598, + "input_bw": "82.59MB/s", + "reconnects": 214287, + "status_2xx": 5551085, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -195,19 +195,19 @@ "json-comp-4096": { "framework": "zix", "language": "Zig", - "rps": 1139164, - "avg_latency": "3.54ms", - "p99_latency": "7.80ms", - "cpu": "6839.4%", - "memory": "206MiB", + "rps": 1139676, + "avg_latency": "3.55ms", + "p99_latency": "7.79ms", + "cpu": "6627.7%", + "memory": "208MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, "bandwidth": "1.95GB/s", - "input_bw": "84.74MB/s", - "reconnects": 226926, - "status_2xx": 5695820, + "input_bw": "84.78MB/s", + "reconnects": 226110, + "status_2xx": 5698383, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -215,19 +215,19 @@ "json-comp-512": { "framework": "zix", "language": "Zig", - "rps": 1028610, + "rps": 1024427, "avg_latency": "468us", - "p99_latency": "1.88ms", - "cpu": "6112.8%", - "memory": "123MiB", + "p99_latency": "1.85ms", + "cpu": "6249.5%", + "memory": "118MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "1.76GB/s", - "input_bw": "76.51MB/s", - "reconnects": 205708, - "status_2xx": 5143054, + "bandwidth": "1.75GB/s", + "input_bw": "76.20MB/s", + "reconnects": 204897, + "status_2xx": 5122139, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -235,18 +235,18 @@ "json-tls-4096": { "framework": "zix", "language": "Zig", - "rps": 1938324, - "avg_latency": "1.12ms", - "p99_latency": "36.66ms", - "cpu": "4996.3%", + "rps": 1922974, + "avg_latency": "1.13ms", + "p99_latency": "40.39ms", + "cpu": "5024.6%", "memory": "273MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "6.48GB", + "bandwidth": "6.43GB", "reconnects": 0, - "status_2xx": 9880751, + "status_2xx": 9804595, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -254,19 +254,19 @@ "limited-conn-4096": { "framework": "zix", "language": "Zig", - "rps": 2703646, + "rps": 2698000, "avg_latency": "1.38ms", - "p99_latency": "1.85ms", - "cpu": "5845.0%", - "memory": "231MiB", + "p99_latency": "1.87ms", + "cpu": "5831.0%", + "memory": "229MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "170.11MB/s", - "input_bw": "208.85MB/s", - "reconnects": 1351326, - "status_2xx": 13518234, + "bandwidth": "169.75MB/s", + "input_bw": "208.41MB/s", + "reconnects": 1349624, + "status_2xx": 13490000, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -274,19 +274,19 @@ "limited-conn-512": { "framework": "zix", "language": "Zig", - "rps": 2640088, + "rps": 2640887, "avg_latency": "178us", - "p99_latency": "441us", - "cpu": "5615.1%", + "p99_latency": "438us", + "cpu": "5649.7%", "memory": "122MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 1, "bandwidth": "166.11MB/s", - "input_bw": "203.94MB/s", - "reconnects": 1320040, - "status_2xx": 13200443, + "input_bw": "204.00MB/s", + "reconnects": 1320451, + "status_2xx": 13204438, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -294,18 +294,18 @@ "pipelined-4096": { "framework": "zix", "language": "Zig", - "rps": 57643456, - "avg_latency": "1.14ms", - "p99_latency": "1.52ms", - "cpu": "6223.0%", - "memory": "188MiB", + "rps": 57794272, + "avg_latency": "1.13ms", + "p99_latency": "1.59ms", + "cpu": "6420.1%", + "memory": "186MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 16, - "bandwidth": "3.54GB/s", + "bandwidth": "3.55GB/s", "reconnects": 0, - "status_2xx": 288217280, + "status_2xx": 288971360, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -313,18 +313,18 @@ "pipelined-512": { "framework": "zix", "language": "Zig", - "rps": 53352112, - "avg_latency": "152us", - "p99_latency": "325us", - "cpu": "6435.5%", + "rps": 53079958, + "avg_latency": "153us", + "p99_latency": "317us", + "cpu": "6387.0%", "memory": "124MiB", "connections": 512, "threads": 64, "duration": "5s", "pipeline": 16, - "bandwidth": "3.28GB/s", + "bandwidth": "3.26GB/s", "reconnects": 0, - "status_2xx": 266760560, + "status_2xx": 265399791, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -332,18 +332,18 @@ "static-1024": { "framework": "zix", "language": "Zig", - "rps": 2026656, - "avg_latency": "299.95us", - "p99_latency": "3.66ms", - "cpu": "5612.5%", + "rps": 2022673, + "avg_latency": "303.60us", + "p99_latency": "2.79ms", + "cpu": "5577.6%", "memory": "141MiB", "connections": 1024, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "30.87GB", + "bandwidth": "30.81GB", "reconnects": 0, - "status_2xx": 10335759, + "status_2xx": 10315518, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -351,18 +351,18 @@ "static-4096": { "framework": "zix", "language": "Zig", - "rps": 2026878, + "rps": 2018326, "avg_latency": "1.06ms", - "p99_latency": "4.91ms", - "cpu": "5598.9%", + "p99_latency": "4.74ms", + "cpu": "5590.9%", "memory": "205MiB", "connections": 4096, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "30.88GB", + "bandwidth": "30.75GB", "reconnects": 0, - "status_2xx": 10338506, + "status_2xx": 10293028, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -370,18 +370,18 @@ "static-6800": { "framework": "zix", "language": "Zig", - "rps": 1988793, + "rps": 1976393, "avg_latency": "1.76ms", - "p99_latency": "6.55ms", - "cpu": "5367.8%", - "memory": "273MiB", + "p99_latency": "7.78ms", + "cpu": "5443.8%", + "memory": "265MiB", "connections": 6800, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "30.30GB", + "bandwidth": "30.11GB", "reconnects": 0, - "status_2xx": 10117275, + "status_2xx": 10080305, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -408,19 +408,19 @@ "upload-256": { "framework": "zix", "language": "Zig", - "rps": 6460, - "avg_latency": "34.70ms", - "p99_latency": "79.20ms", - "cpu": "891.1%", - "memory": "126MiB", + "rps": 6469, + "avg_latency": "34.83ms", + "p99_latency": "78.10ms", + "cpu": "889.0%", + "memory": "128MiB", "connections": 256, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "449.27KB/s", - "input_bw": "51.24GB/s", - "reconnects": 6471, - "status_2xx": 32433, + "bandwidth": "450.10KB/s", + "input_bw": "51.31GB/s", + "reconnects": 6519, + "status_2xx": 32479, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0 @@ -428,19 +428,19 @@ "upload-32": { "framework": "zix", "language": "Zig", - "rps": 8872, - "avg_latency": "3.58ms", - "p99_latency": "9.89ms", - "cpu": "1039.4%", - "memory": "124MiB", + "rps": 8944, + "avg_latency": "3.55ms", + "p99_latency": "9.98ms", + "cpu": "1048.6%", + "memory": "121MiB", "connections": 32, "threads": 64, "duration": "5s", "pipeline": 1, - "bandwidth": "617.79KB/s", - "input_bw": "70.37GB/s", - "reconnects": 8889, - "status_2xx": 44451, + "bandwidth": "622.86KB/s", + "input_bw": "70.94GB/s", + "reconnects": 8961, + "status_2xx": 44812, "status_3xx": 0, "status_4xx": 0, "status_5xx": 0