diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 91d838d..845cd97 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -39,10 +39,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Zig 0.15.2 + - name: Install Zig 0.16.0 uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Install Python deps run: pip install pymongo psycopg2-binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bcbfac..1357a50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,10 +17,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Zig 0.15 + - name: Install Zig 0.16 uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Build (Debug) run: zig build @@ -53,10 +53,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Zig 0.15 + - name: Install Zig 0.16 uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Build shared library run: zig build @@ -78,10 +78,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install Zig 0.15 + - name: Install Zig 0.16 uses: mlugg/setup-zig@v2 with: - version: 0.15.2 + version: 0.16.0 - name: Build (ReleaseFast) run: zig build -Doptimize=ReleaseFast diff --git a/.gitignore b/.gitignore index 3059984..cb386a7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,27 @@ zig-out/ +zig-out-ffi/ +zig-out-*/ .zig-cache/ python/dist/ *.egg-info/ codedb.snapshot __pycache__/ *.pyc +.DS_Store +.claude/ +.zigrep_archive +run_status.txt +turbodb_data/ +charts.png # ClickHouse local data access/ preprocessed_configs/ -# Optimization docs (tracked) -# optimizations/ +# Local agent/MCP workspaces +optimizations/ +MCP/ +architecutre/ +LOCAL_README.md +local-agent-readme.md +benchmark-results/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0b5630a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# Agent Runtime Rules + +## Container-Only Runtime + +Do not run TurboDB, `turbodb-gateway`, database services, long-lived servers, +stress tests, or deployment simulations directly on the host macOS machine. + +Runtime work must use Apple `container`: + +```sh +container --help +``` + +Reference: https://github.com/apple/container + +Allowed on the host: + +- Editing files. +- Inspecting files. +- Building binaries when no server is started. +- One-shot commands that do not bind ports, create launch agents, or start + persistent services. + +Not allowed on the host: + +- `launchctl` service installation for TurboDB or its gateway. +- Starting `turbodb` or `turbodb-gateway` directly on macOS. +- Binding TurboDB, gateway, or test services to host ports. +- Moving live database data into host-managed service directories. + +Before any database runtime, create or use an Apple `container` environment and +mount the intended data directory explicitly. Database updates must preserve +existing data: take a backup/snapshot first, reuse the existing data directory, +and never delete or reinitialize data unless the user explicitly asks for that. diff --git a/README.md b/README.md index 9fc8864..ea300f6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

PyPI npm - Zig 0.15 + Zig 0.16 MIT Alpha

@@ -118,7 +118,7 @@ npm install turbodatabase # Node.js ### Build from source ```bash -# Requires Zig 0.15+ +# Requires Zig 0.16+ git clone https://github.com/justrach/turbodb cd turbodb zig build @@ -353,7 +353,7 @@ bash bench/setup_shard_bench.sh # Full shard comparison (needs Docker + ## Contributing -Contributions welcome! TurboDB is written in Zig 0.15 with no external dependencies. +Contributions welcome! TurboDB is written in Zig 0.16 with no external dependencies. ## License diff --git a/bench/README_apple_container_matrix.md b/bench/README_apple_container_matrix.md new file mode 100644 index 0000000..81c64a9 --- /dev/null +++ b/bench/README_apple_container_matrix.md @@ -0,0 +1,109 @@ +# Apple Container Benchmark Matrix + +This harness compares TurboDB against PostgreSQL 18, MySQL, and optionally +TigerBeetle using the same generated application shape. TurboDB is measured two +ways: over HTTP and through the embedded C ABI/FFI bridge. + +It must be run through Apple `container`. The runner creates a private container +network, starts each database in a container, runs the Python benchmark client in +another container, and does not publish database ports to macOS. + +## Workload Shape + +- `users`: keyed user records with an email and JSON profile. +- `orders`: keyed order records with `user_id`, amount, status, and JSON payload. +- TurboDB stores `bench_users`, `bench_orders`, and a materialized + `bench_user_orders` edge collection. The benchmark client uses persistent + HTTP plus durable `POST /db/:col/bulk` ingestion by default. Use + `--turbodb-bulk-mode binary` to benchmark length-prefixed + `POST /db/:col/bulk_binary` ingestion. It uses + `POST /db/:col/batch_get?mode=count` for relationship fetches, so + lookup-heavy workloads do not pay to serialize document bodies they do not + consume. For the join-like, update, and delete workloads it assumes the + faster HTTP shapes exist and uses `POST /db/:edge_col/join?mode=count`, + `POST /db/:col/batch_update`, and `POST /db/:col/batch_delete`. +- `turbodb_ffi` loads `/work/zig-out-ffi/lib/libturbodb.so` with Python `ctypes`. + This is an embedded/raw-engine path, not a network database path, and exists + to show the cost of HTTP/client transport separately from storage operations. + Bulk inserts, updates, deletes, and batched reads use C ABI batch exports so + the benchmark does not hide storage behavior behind one Python FFI call per + row. +- PostgreSQL 18 and MySQL use normalized `users` and `orders` tables with a + secondary index on `orders.user_id`. +- TigerBeetle uses accounts and transfers, so relationship lookups are modeled + as account transfer queries rather than SQL/document joins. Updates and + deletes are reported as not applicable because transfers are immutable. + +## Workloads + +- `ingest`: create all users and orders. TurboDB HTTP uses a collection-level + bulk insert path that writes committed WAL entries as a batch and flushes the + WAL before acknowledging the bulk request. +- `point_get`: read users by primary key. +- `relationship_lookup`: fetch all orders/transfers for a user. TurboDB HTTP + keeps this as a materialized edge read plus compact `batch_get`. +- `join_or_join_like`: SQL join for PostgreSQL/MySQL, materialized edge fetches + for TurboDB FFI, compact `POST /db/:edge_col/join` for TurboDB HTTP, account + transfer query for TigerBeetle. +- `update_orders`: update order status where the engine supports mutation. +- `delete_orders`: delete orders where the engine supports deletion. + +TurboDB HTTP update/delete latency is reported as amortized row latency when +the benchmark uses the batch endpoints; throughput remains row operations per +second. + +TurboDB also exposes a gRPC-compatible bridge for binary bulk mutations on the +same server: + +- `POST /grpc/:col/BulkInsert` +- `POST /grpc/:col/BulkUpdate` / `BulkUpsert` +- `POST /grpc/:col/BulkDelete` + +The bridge accepts one uncompressed gRPC message frame for `application/grpc` +requests, or the raw binary payload directly. It is not a full HTTP/2/protobuf +server yet; it is a fast route shape for clients/gateways that already speak in +gRPC-style frames. + +## Run + +Small smoke run: + +```sh +python3 bench/run_apple_container_bench.py \ + --users 50 \ + --orders-per-user 3 \ + --samples 25 \ + --skip-tigerbeetle +``` + +Larger run with all engines: + +```sh +python3 bench/run_apple_container_bench.py \ + --users 10000 \ + --orders-per-user 5 \ + --samples 2000 +``` + +Useful flags: + +- `--skip-turbodb`, `--skip-postgres`, `--skip-mysql`, `--skip-tigerbeetle` +- `--skip-turbodb-ffi` +- `--turbodb-bulk-mode ndjson|binary` +- `--batch-size 16384` to align with TurboDB's larger default durable bulk + chunk and reduce per-request/WAL-flush overhead on large ingests +- `--turbodb-ffi-target aarch64-linux-gnu` +- `--turbodb-ffi-prefix zig-out-ffi` +- `--turbodb-ffi-lib /work/zig-out-ffi/lib/libturbodb.so` +- `--mysql-image mysql:8.4` +- `--postgres-image postgres:18` +- `--tigerbeetle-image ghcr.io/tigerbeetle/tigerbeetle:latest` +- `--tigerbeetle-memory 2G` because the replica journal needs more than the + Apple container 1 GiB default in practice. +- `--keep` to leave containers, network, and volumes behind for inspection. + +Results are written under ignored `benchmark-results/`. + +PostgreSQL 18 is mounted at `/var/lib/postgresql`, not +`/var/lib/postgresql/data`, because the official image stores data in +versioned subdirectories for upgrade compatibility. diff --git a/bench/agentic_nanoapi_client.py b/bench/agentic_nanoapi_client.py new file mode 100644 index 0000000..504092f --- /dev/null +++ b/bench/agentic_nanoapi_client.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Concurrent agent-shaped client workload for the nanoapi TurboDB proxy.""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import http.client +import json +import random +import statistics +import string +import time +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class OpStats: + latencies_ms: list[float] = field(default_factory=list) + ok: int = 0 + errors: int = 0 + statuses: dict[int, int] = field(default_factory=dict) + + def add(self, latency_ms: float, status: int, ok: bool) -> None: + self.latencies_ms.append(latency_ms) + self.statuses[status] = self.statuses.get(status, 0) + 1 + if ok: + self.ok += 1 + else: + self.errors += 1 + + +def parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser(description="Pound the nanoapi agent proxy through agent-shaped calls") + ap.add_argument("--host", required=True) + ap.add_argument("--port", type=int, default=28080) + ap.add_argument("--agents", type=int, default=32) + ap.add_argument("--rounds", type=int, default=20) + ap.add_argument("--events-per-round", type=int, default=3) + ap.add_argument("--context-limit", type=int, default=20) + ap.add_argument("--timeout", type=float, default=10.0) + ap.add_argument("--output", required=True) + ap.add_argument("--run-id", default=f"agentic-nanoapi-{int(time.time())}") + ap.add_argument("--seed", type=int, default=7) + return ap.parse_args() + + +class AgentClient: + def __init__(self, host: str, port: int, timeout: float): + self.host = host + self.port = port + self.timeout = timeout + self.conn = http.client.HTTPConnection(host, port, timeout=timeout) + + def close(self) -> None: + self.conn.close() + + def request(self, method: str, path: str, body: str = "") -> tuple[int, bytes]: + headers = {"Content-Type": "application/json"} + try: + self.conn.request(method, path, body=body.encode("utf-8"), headers=headers) + resp = self.conn.getresponse() + data = resp.read() + return resp.status, data + except (http.client.HTTPException, OSError): + self.conn.close() + self.conn = http.client.HTTPConnection(self.host, self.port, timeout=self.timeout) + raise + + +def random_text(rng: random.Random, words: int = 24) -> str: + parts: list[str] = [] + alphabet = string.ascii_lowercase + for _ in range(words): + parts.append("".join(rng.choice(alphabet) for _ in range(rng.randint(3, 10)))) + return " ".join(parts) + + +def timed(stats: dict[str, OpStats], name: str, fn) -> None: + started = time.perf_counter() + status = 0 + ok = False + try: + status, body = fn() + ok = 200 <= status < 300 and body + except Exception: + status = 0 + finally: + stats.setdefault(name, OpStats()).add((time.perf_counter() - started) * 1000.0, status, ok) + + +def run_agent(agent_id: int, args: argparse.Namespace) -> dict[str, OpStats]: + rng = random.Random(args.seed + agent_id) + client = AgentClient(args.host, args.port, args.timeout) + stats: dict[str, OpStats] = {} + try: + for round_id in range(args.rounds): + base_key = f"{args.run_id}-a{agent_id:03d}-r{round_id:03d}" + message = { + "run_id": args.run_id, + "agent": agent_id, + "round": round_id, + "kind": "message", + "thought_summary": random_text(rng, 12), + "message": random_text(rng, 32), + "tool_calls": [ + {"name": "ziggrep", "args": {"pattern": "agent", "path": "MCP/harness"}}, + {"name": "chat_post", "args": {"topic": "agentic-load", "role": f"agent-{agent_id:03d}"}}, + ], + } + + timed(stats, "write_event", lambda m=message, k=base_key: client.request("POST", f"/agent/event/{k}", json.dumps(m, separators=(",", ":")))) + + tool = { + "run_id": args.run_id, + "agent": agent_id, + "round": round_id, + "kind": "tool_result", + "status": "ok", + "latency_ms": rng.randint(10, 400), + "preview": random_text(rng, 18), + } + timed(stats, "write_tool", lambda t=tool, k=base_key: client.request("POST", f"/agent/tool/{k}", json.dumps(t, separators=(",", ":")))) + + batch_lines: list[str] = [] + for event_id in range(args.events_per_round): + key = f"{base_key}-batch-{event_id:02d}" + value = { + "run_id": args.run_id, + "agent": agent_id, + "round": round_id, + "event": event_id, + "kind": "trace", + "content": random_text(rng, 16), + } + batch_lines.append(json.dumps({"key": key, "value": value}, separators=(",", ":"))) + timed(stats, "batch_trace", lambda body="\n".join(batch_lines) + "\n": client.request("POST", "/agent/batch", body)) + + if round_id > 0: + prev_key = f"{args.run_id}-a{agent_id:03d}-r{round_id - 1:03d}" + timed(stats, "read_previous", lambda k=prev_key: client.request("GET", f"/agent/event/{k}")) + + if round_id % 3 == 0: + timed(stats, "context_scan", lambda: client.request("GET", f"/agent/context?limit={args.context_limit}")) + finally: + client.close() + return stats + + +def merge_stats(results: list[dict[str, OpStats]]) -> dict[str, OpStats]: + merged: dict[str, OpStats] = {} + for result in results: + for name, stats in result.items(): + dst = merged.setdefault(name, OpStats()) + dst.latencies_ms.extend(stats.latencies_ms) + dst.ok += stats.ok + dst.errors += stats.errors + for status, count in stats.statuses.items(): + dst.statuses[status] = dst.statuses.get(status, 0) + count + return merged + + +def percentile(values: list[float], pct: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + idx = min(len(ordered) - 1, int((len(ordered) - 1) * pct)) + return ordered[idx] + + +def summarize(stats: dict[str, OpStats], elapsed_s: float, args: argparse.Namespace, health: dict[str, Any]) -> dict[str, Any]: + ops: dict[str, Any] = {} + total_ok = 0 + total_errors = 0 + total_ops = 0 + for name, item in sorted(stats.items()): + values = item.latencies_ms + total_ok += item.ok + total_errors += item.errors + total_ops += len(values) + ops[name] = { + "ops": len(values), + "ok": item.ok, + "errors": item.errors, + "statuses": item.statuses, + "p50_ms": statistics.median(values) if values else 0.0, + "p95_ms": percentile(values, 0.95), + "p99_ms": percentile(values, 0.99), + } + return { + "run_id": args.run_id, + "config": { + "agents": args.agents, + "rounds": args.rounds, + "events_per_round": args.events_per_round, + "context_limit": args.context_limit, + }, + "elapsed_s": elapsed_s, + "ops_sec": total_ops / elapsed_s if elapsed_s > 0 else 0.0, + "total_ops": total_ops, + "ok": total_ok, + "errors": total_errors, + "operations": ops, + "post_health": health, + } + + +def gateway_health(host: str, port: int, timeout: float) -> dict[str, Any]: + client = AgentClient(host, port, timeout) + try: + status, body = client.request("GET", "/health") + turbo_status, turbo_body = client.request("GET", "/turbodb/health") + return { + "gateway_status": status, + "gateway_body": body.decode("utf-8", "replace")[:500], + "turbodb_status": turbo_status, + "turbodb_body": turbo_body.decode("utf-8", "replace")[:500], + } + except Exception as exc: + return {"error": repr(exc)} + finally: + client.close() + + +def main() -> int: + args = parse_args() + started = time.perf_counter() + with concurrent.futures.ThreadPoolExecutor(max_workers=args.agents) as pool: + futures = [pool.submit(run_agent, i, args) for i in range(args.agents)] + results = [future.result() for future in concurrent.futures.as_completed(futures)] + elapsed = time.perf_counter() - started + health = gateway_health(args.host, args.port, args.timeout) + summary = summarize(merge_stats(results), elapsed, args, health) + + with open(args.output, "w", encoding="utf-8") as f: + json.dump(summary, f, indent=2, sort_keys=True) + + print(json.dumps({ + "run_id": args.run_id, + "ops_sec": round(summary["ops_sec"], 2), + "total_ops": summary["total_ops"], + "errors": summary["errors"], + "post_health": summary["post_health"], + }, indent=2)) + return 0 if summary["errors"] == 0 and "error" not in summary["post_health"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/container_shape_bench.py b/bench/container_shape_bench.py new file mode 100644 index 0000000..d05b3df --- /dev/null +++ b/bench/container_shape_bench.py @@ -0,0 +1,931 @@ +#!/usr/bin/env python3 +""" +Container runner for keyed document / relational / transfer-shaped benchmarks. + +This script is intended to run inside an Apple container network created by +run_apple_container_bench.py. It connects only to private container IPs. +""" +from __future__ import annotations + +import argparse +import ctypes +import http.client +import json +import math +import os +import random +import shutil +import struct +import sys +import time +from dataclasses import dataclass +from typing import Any, Callable + + +def now() -> float: + return time.perf_counter() + + +def percentile(values: list[float], p: float) -> float: + if not values: + return 0.0 + values = sorted(values) + k = (len(values) - 1) * (p / 100.0) + lo = int(math.floor(k)) + hi = min(lo + 1, len(values) - 1) + return values[lo] + (values[hi] - values[lo]) * (k - lo) + + +def metric(elapsed: float, ops: int, latencies: list[float] | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]: + latencies = latencies or [] + result = { + "ops": ops, + "seconds": elapsed, + "ops_sec": ops / elapsed if elapsed > 0 else 0, + } + if latencies: + result.update({ + "p50_ms": percentile(latencies, 50), + "p95_ms": percentile(latencies, 95), + "p99_ms": percentile(latencies, 99), + }) + if extra: + result.update(extra) + return result + + +def timed(fn: Callable[[], Any], ops: int, extra: dict[str, Any] | None = None) -> dict[str, Any]: + start = now() + fn() + return metric(now() - start, ops, extra=extra) + + +def timed_loop(items: list[Any], fn: Callable[[Any], Any], extra: dict[str, Any] | None = None) -> dict[str, Any]: + latencies: list[float] = [] + start = now() + for item in items: + op_start = now() + fn(item) + latencies.append((now() - op_start) * 1000.0) + return metric(now() - start, len(items), latencies, extra) + + +def timed_batch_loop(batches: list[list[Any]], fn: Callable[[list[Any]], Any], extra: dict[str, Any] | None = None) -> dict[str, Any]: + latencies: list[float] = [] + ops = sum(len(batch) for batch in batches) + start = now() + for batch in batches: + op_start = now() + fn(batch) + batch_elapsed_ms = (now() - op_start) * 1000.0 + latencies.extend([batch_elapsed_ms / len(batch)] * len(batch)) + return metric(now() - start, ops, latencies, extra) + + +def user_doc(user_id: int) -> dict[str, Any]: + return { + "id": user_id, + "email": f"user{user_id}@example.test", + "profile": { + "tier": user_id % 7, + "region": f"r{user_id % 16}", + "active": True, + }, + } + + +def order_doc(order_id: int, user_id: int) -> dict[str, Any]: + return { + "id": order_id, + "user_id": user_id, + "amount": 100 + (order_id % 10_000), + "status": "open" if order_id % 3 else "settled", + "payload": { + "sku": f"sku-{order_id % 97}", + "memo": f"order memo {order_id}", + }, + } + + +@dataclass +class Shape: + users: int + orders_per_user: int + + @property + def orders(self) -> int: + return self.users * self.orders_per_user + + def user_ids(self) -> range: + return range(1, self.users + 1) + + def order_id(self, user_id: int, ordinal: int) -> int: + return (user_id - 1) * self.orders_per_user + ordinal + 1 + + def order_ids_for_user(self, user_id: int) -> list[int]: + return [self.order_id(user_id, i) for i in range(self.orders_per_user)] + + +class TurboDBBench: + name = "turbodb" + + def __init__(self, host: str, port: int, shape: Shape, batch_size: int, bulk_mode: str = "ndjson"): + self.host = host + self.port = port + self.shape = shape + self.batch_size = batch_size + self.bulk_mode = bulk_mode + self.conn = http.client.HTTPConnection(host, port, timeout=10) + + def request(self, method: str, path: str, body: bytes | None = None, content_type: str = "application/json") -> bytes: + headers = {"content-type": content_type} + try: + self.conn.request(method, path, body=body, headers=headers) + resp = self.conn.getresponse() + data = resp.read() + if resp.status >= 400: + raise RuntimeError(f"{method} {path} failed: {resp.status} {data[:200]!r}") + return data + except (http.client.HTTPException, OSError): + self.conn.close() + self.conn = http.client.HTTPConnection(self.host, self.port, timeout=10) + self.conn.request(method, path, body=body, headers=headers) + resp = self.conn.getresponse() + data = resp.read() + if resp.status >= 400: + raise RuntimeError(f"{method} {path} failed: {resp.status} {data[:200]!r}") + return data + + def wait(self) -> None: + deadline = time.time() + 30 + while time.time() < deadline: + try: + self.request("GET", "/health") + return + except Exception: + time.sleep(0.25) + raise RuntimeError("TurboDB did not become healthy") + + def drop(self) -> None: + for col in ("bench_users", "bench_orders", "bench_user_orders"): + try: + self.request("DELETE", f"/db/{col}") + except Exception: + pass + + def bulk(self, collection: str, rows: list[tuple[str, dict[str, Any]]]) -> None: + if self.bulk_mode == "binary": + body = bytearray() + for key, value in rows: + key_b = key.encode() + value_b = json.dumps(value, separators=(",", ":")).encode() + if len(key_b) > 0xFFFF: + raise ValueError("key too large for binary bulk format") + body += struct.pack(" list[list[Any]]: + batch_size = max(self.batch_size, 1) + return [values[i:i + batch_size] for i in range(0, len(values), batch_size)] + + def batch_update(self, collection: str, rows: list[tuple[str, dict[str, Any]]]) -> None: + lines = [] + for key, value in rows: + lines.append(json.dumps({"key": key, "value": value}, separators=(",", ":"))) + self.request("POST", f"/db/{collection}/batch_update", ("\n".join(lines) + "\n").encode(), "application/x-ndjson") + + def batch_delete(self, collection: str, keys: list[int]) -> None: + body = ("\n".join(str(key) for key in keys) + "\n").encode() + self.request("POST", f"/db/{collection}/batch_delete", body, "text/plain") + + def ingest(self) -> dict[str, Any]: + self.drop() + + def run() -> None: + rows: list[tuple[str, dict[str, Any]]] = [] + for uid in self.shape.user_ids(): + rows.append((str(uid), user_doc(uid))) + if len(rows) >= self.batch_size: + self.bulk("bench_users", rows) + rows.clear() + if rows: + self.bulk("bench_users", rows) + + rows.clear() + for uid in self.shape.user_ids(): + for oid in self.shape.order_ids_for_user(uid): + rows.append((str(oid), order_doc(oid, uid))) + if len(rows) >= self.batch_size: + self.bulk("bench_orders", rows) + rows.clear() + if rows: + self.bulk("bench_orders", rows) + + rows.clear() + for uid in self.shape.user_ids(): + rows.append((str(uid), {"user_id": uid, "order_ids": self.shape.order_ids_for_user(uid)})) + if len(rows) >= self.batch_size: + self.bulk("bench_user_orders", rows) + rows.clear() + if rows: + self.bulk("bench_user_orders", rows) + + logical = self.shape.users + self.shape.orders + physical = logical + self.shape.users + return timed(run, logical, {"physical_writes": physical}) + + def get_doc(self, collection: str, key: int) -> dict[str, Any]: + return json.loads(self.request("GET", f"/db/{collection}/{key}").decode()) + + def batch_get_docs(self, collection: str, keys: list[int]) -> list[dict[str, Any]]: + body = json.dumps({"keys": [str(key) for key in keys]}, separators=(",", ":")).encode() + return json.loads(self.request("POST", f"/db/{collection}/batch_get", body).decode())["docs"] + + def batch_get_count(self, collection: str, keys: list[int]) -> None: + body = json.dumps({"keys": [str(key) for key in keys]}, separators=(",", ":")).encode() + self.request("POST", f"/db/{collection}/batch_get?mode=count", body) + + def point_get(self, samples: list[int]) -> dict[str, Any]: + return timed_loop(samples, lambda uid: self.get_doc("bench_users", uid)) + + def order_lookup(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + edge = self.get_doc("bench_user_orders", uid)["value"] + self.batch_get_count("bench_orders", edge["order_ids"]) + + return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user, "mode": "materialized_edge_batch_get_count"}) + + def join_like(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + body = json.dumps({ + "key": str(uid), + "target_collection": "bench_orders", + "field": "order_ids", + }, separators=(",", ":")).encode() + self.request("POST", "/db/bench_user_orders/join?mode=count", body) + + return timed_loop(samples, op, {"mode": "server_join_edge_field_count"}) + + def update_orders(self, samples: list[int]) -> dict[str, Any]: + def op(batch: list[int]) -> None: + rows = [] + for oid in batch: + value = order_doc(oid, ((oid - 1) // self.shape.orders_per_user) + 1) + value["status"] = "updated" + rows.append((str(oid), value)) + self.batch_update("bench_orders", rows) + + return timed_batch_loop(self.batches(samples), op, {"mode": "http_ndjson_batch_update", "latency_unit": "amortized_row"}) + + def delete_orders(self, samples: list[int]) -> dict[str, Any]: + return timed_batch_loop( + self.batches(samples), + lambda keys: self.batch_delete("bench_orders", keys), + {"mode": "http_newline_batch_delete", "latency_unit": "amortized_row"}, + ) + + +class TurboDocResult(ctypes.Structure): + _fields_ = [ + ("key_ptr", ctypes.c_void_p), + ("key_len", ctypes.c_size_t), + ("val_ptr", ctypes.c_void_p), + ("val_len", ctypes.c_size_t), + ("doc_id", ctypes.c_uint64), + ("version", ctypes.c_uint8), + ("_pad", ctypes.c_uint8 * 7), + ] + + +class TurboDBFFIBench: + name = "turbodb_ffi" + + def __init__(self, lib_path: str, data_dir: str, shape: Shape, batch_size: int): + self.shape = shape + self.batch_size = batch_size + self.data_dir = data_dir + shutil.rmtree(data_dir, ignore_errors=True) + os.makedirs(data_dir, exist_ok=True) + + self.lib = ctypes.CDLL(lib_path) + self._bind() + data_dir_b = data_dir.encode() + self.db = self.lib.turbodb_open(data_dir_b, len(data_dir_b)) + if not self.db: + raise RuntimeError(f"failed to open TurboDB FFI database at {data_dir}") + self.collections: dict[str, ctypes.c_void_p] = {} + + def _bind(self) -> None: + lib = self.lib + lib.turbodb_open.argtypes = [ctypes.c_char_p, ctypes.c_size_t] + lib.turbodb_open.restype = ctypes.c_void_p + lib.turbodb_close.argtypes = [ctypes.c_void_p] + lib.turbodb_close.restype = None + lib.turbodb_collection.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t] + lib.turbodb_collection.restype = ctypes.c_void_p + lib.turbodb_drop_collection.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t] + lib.turbodb_drop_collection.restype = None + lib.turbodb_insert.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, + ctypes.c_char_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_uint64), + ] + lib.turbodb_insert.restype = ctypes.c_int + lib.turbodb_insert_bulk_ndjson.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, + ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), + ] + lib.turbodb_insert_bulk_ndjson.restype = ctypes.c_int + lib.turbodb_get.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, ctypes.POINTER(TurboDocResult)] + lib.turbodb_get.restype = ctypes.c_int + lib.turbodb_get_many_keys.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, + ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_size_t), + ] + lib.turbodb_get_many_keys.restype = ctypes.c_int + lib.turbodb_update.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, ctypes.c_char_p, ctypes.c_size_t] + lib.turbodb_update.restype = ctypes.c_int + lib.turbodb_update_bulk_ndjson.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, + ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), + ] + lib.turbodb_update_bulk_ndjson.restype = ctypes.c_int + lib.turbodb_delete.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t] + lib.turbodb_delete.restype = ctypes.c_int + lib.turbodb_delete_many_keys.argtypes = [ + ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t, + ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), + ] + lib.turbodb_delete_many_keys.restype = ctypes.c_int + + def close(self) -> None: + if getattr(self, "db", None): + self.lib.turbodb_close(self.db) + self.db = None + + def wait(self) -> None: + pass + + def collection(self, name: str) -> ctypes.c_void_p: + if name in self.collections: + return self.collections[name] + name_b = name.encode() + handle = self.lib.turbodb_collection(self.db, name_b, len(name_b)) + if not handle: + raise RuntimeError(f"failed to open FFI collection {name}") + self.collections[name] = handle + return handle + + def drop(self) -> None: + self.collections.clear() + for col in ("bench_users", "bench_orders", "bench_user_orders"): + col_b = col.encode() + self.lib.turbodb_drop_collection(self.db, col_b, len(col_b)) + + def bulk(self, collection: str, rows: list[tuple[str, dict[str, Any]]]) -> tuple[int, int]: + lines = [] + for key, value in rows: + lines.append(json.dumps({"key": key, "value": value}, separators=(",", ":"))) + body = ("\n".join(lines) + "\n").encode() + inserted = ctypes.c_uint32(0) + errors = ctypes.c_uint32(0) + rc = self.lib.turbodb_insert_bulk_ndjson( + self.collection(collection), body, len(body), ctypes.byref(inserted), ctypes.byref(errors) + ) + if rc != 0: + raise RuntimeError(f"FFI bulk insert failed for {collection}") + return int(inserted.value), int(errors.value) + + def ingest(self) -> dict[str, Any]: + self.drop() + + def run() -> None: + rows: list[tuple[str, dict[str, Any]]] = [] + for uid in self.shape.user_ids(): + rows.append((str(uid), user_doc(uid))) + if len(rows) >= self.batch_size: + self.bulk("bench_users", rows) + rows.clear() + if rows: + self.bulk("bench_users", rows) + + rows.clear() + for uid in self.shape.user_ids(): + for oid in self.shape.order_ids_for_user(uid): + rows.append((str(oid), order_doc(oid, uid))) + if len(rows) >= self.batch_size: + self.bulk("bench_orders", rows) + rows.clear() + if rows: + self.bulk("bench_orders", rows) + + rows.clear() + for uid in self.shape.user_ids(): + rows.append((str(uid), {"user_id": uid, "order_ids": self.shape.order_ids_for_user(uid)})) + if len(rows) >= self.batch_size: + self.bulk("bench_user_orders", rows) + rows.clear() + if rows: + self.bulk("bench_user_orders", rows) + + logical = self.shape.users + self.shape.orders + physical = logical + self.shape.users + return timed(run, logical, {"physical_writes": physical, "mode": "embedded_c_abi"}) + + def get_key(self, collection: str, key: int) -> TurboDocResult: + key_b = str(key).encode() + out = TurboDocResult() + rc = self.lib.turbodb_get(self.collection(collection), key_b, len(key_b), ctypes.byref(out)) + if rc != 0: + raise RuntimeError(f"FFI get failed for {collection}/{key}") + return out + + def batch_get_keys(self, collection: str, keys: list[int]) -> tuple[int, int, int]: + body = ("\n".join(str(key) for key in keys) + "\n").encode() + found = ctypes.c_uint32(0) + missing = ctypes.c_uint32(0) + bytes_read = ctypes.c_size_t(0) + rc = self.lib.turbodb_get_many_keys( + self.collection(collection), body, len(body), + ctypes.byref(found), ctypes.byref(missing), ctypes.byref(bytes_read), + ) + if rc != 0: + raise RuntimeError(f"FFI batch get failed for {collection}") + return int(found.value), int(missing.value), int(bytes_read.value) + + def point_get(self, samples: list[int]) -> dict[str, Any]: + return timed_loop(samples, lambda uid: self.get_key("bench_users", uid), {"mode": "embedded_c_abi"}) + + def order_lookup(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + self.get_key("bench_user_orders", uid) + self.batch_get_keys("bench_orders", self.shape.order_ids_for_user(uid)) + + return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user, "mode": "embedded_c_abi_batch_get"}) + + def join_like(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + self.get_key("bench_users", uid) + self.get_key("bench_user_orders", uid) + self.batch_get_keys("bench_orders", self.shape.order_ids_for_user(uid)) + + return timed_loop(samples, op, {"mode": "embedded_c_abi_materialized_edge_batch_get"}) + + def update_orders(self, samples: list[int]) -> dict[str, Any]: + def op(batch: list[int]) -> None: + lines = [] + for oid in batch: + value = order_doc(oid, ((oid - 1) // self.shape.orders_per_user) + 1) + value["status"] = "updated" + lines.append(json.dumps({"key": str(oid), "value": value}, separators=(",", ":"))) + body = ("\n".join(lines) + "\n").encode() + updated = ctypes.c_uint32(0) + inserted = ctypes.c_uint32(0) + errors = ctypes.c_uint32(0) + rc = self.lib.turbodb_update_bulk_ndjson( + self.collection("bench_orders"), body, len(body), + ctypes.byref(updated), ctypes.byref(inserted), ctypes.byref(errors), + ) + if rc != 0: + raise RuntimeError("FFI bulk update failed for orders") + if errors.value: + raise RuntimeError(f"FFI bulk update had {errors.value} row errors") + + return timed_batch_loop(self.batches(samples), op, {"mode": "embedded_c_abi_bulk_update", "latency_unit": "amortized_row"}) + + def delete_orders(self, samples: list[int]) -> dict[str, Any]: + def op(batch: list[int]) -> None: + body = ("\n".join(str(oid) for oid in batch) + "\n").encode() + deleted = ctypes.c_uint32(0) + missing = ctypes.c_uint32(0) + errors = ctypes.c_uint32(0) + rc = self.lib.turbodb_delete_many_keys( + self.collection("bench_orders"), body, len(body), + ctypes.byref(deleted), ctypes.byref(missing), ctypes.byref(errors), + ) + if rc != 0: + raise RuntimeError("FFI bulk delete failed for orders") + if errors.value: + raise RuntimeError(f"FFI bulk delete had {errors.value} row errors") + + return timed_batch_loop(self.batches(samples), op, {"mode": "embedded_c_abi_bulk_delete", "latency_unit": "amortized_row"}) + + +class PostgresBench: + name = "postgresql18" + + def __init__(self, host: str, shape: Shape, batch_size: int): + import psycopg + + self.conn = psycopg.connect(host=host, port=5432, dbname="bench", user="postgres", password="postgres", autocommit=True) + self.shape = shape + self.batch_size = batch_size + + def wait(self) -> None: + with self.conn.cursor() as cur: + cur.execute("select 1") + + def drop(self) -> None: + with self.conn.cursor() as cur: + cur.execute("drop table if exists orders") + cur.execute("drop table if exists users") + cur.execute("create table users (id bigint primary key, email text not null, profile jsonb not null)") + cur.execute("create table orders (id bigint primary key, user_id bigint not null references users(id), amount bigint not null, status text not null, payload jsonb not null)") + cur.execute("create index orders_user_id_idx on orders(user_id)") + + def ingest(self) -> dict[str, Any]: + self.drop() + + def run() -> None: + with self.conn.cursor() as cur: + cur.executemany( + "insert into users (id,email,profile) values (%s,%s,%s::jsonb)", + [(uid, user_doc(uid)["email"], json.dumps(user_doc(uid)["profile"])) for uid in self.shape.user_ids()], + ) + rows = [] + for uid in self.shape.user_ids(): + for oid in self.shape.order_ids_for_user(uid): + doc = order_doc(oid, uid) + rows.append((oid, uid, doc["amount"], doc["status"], json.dumps(doc["payload"]))) + cur.executemany( + "insert into orders (id,user_id,amount,status,payload) values (%s,%s,%s,%s,%s::jsonb)", + rows, + ) + + return timed(run, self.shape.users + self.shape.orders) + + def point_get(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("select id,email,profile from users where id=%s", (uid,)) + cur.fetchone() + + return timed_loop(samples, op) + + def order_lookup(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("select id,amount,status,payload from orders where user_id=%s", (uid,)) + cur.fetchall() + + return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user}) + + def join_like(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + with self.conn.cursor() as cur: + cur.execute( + "select u.email,o.id,o.amount,o.payload from users u join orders o on o.user_id=u.id where u.id=%s", + (uid,), + ) + cur.fetchall() + + return timed_loop(samples, op, {"mode": "sql_join"}) + + def update_orders(self, samples: list[int]) -> dict[str, Any]: + def op(oid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("update orders set status='updated' where id=%s", (oid,)) + + return timed_loop(samples, op) + + def delete_orders(self, samples: list[int]) -> dict[str, Any]: + def op(oid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("delete from orders where id=%s", (oid,)) + + return timed_loop(samples, op) + + +class MySQLBench: + name = "mysql" + + def __init__(self, host: str, shape: Shape, batch_size: int): + import pymysql + + self.conn = pymysql.connect(host=host, port=3306, user="root", password="mysql", database="bench", autocommit=True) + self.shape = shape + self.batch_size = batch_size + + def wait(self) -> None: + with self.conn.cursor() as cur: + cur.execute("select 1") + + def drop(self) -> None: + with self.conn.cursor() as cur: + cur.execute("drop table if exists orders") + cur.execute("drop table if exists users") + cur.execute("create table users (id bigint primary key, email varchar(255) not null, profile json not null)") + cur.execute("create table orders (id bigint primary key, user_id bigint not null, amount bigint not null, status varchar(32) not null, payload json not null, index orders_user_id_idx(user_id))") + + def ingest(self) -> dict[str, Any]: + self.drop() + + def run() -> None: + with self.conn.cursor() as cur: + cur.executemany( + "insert into users (id,email,profile) values (%s,%s,%s)", + [(uid, user_doc(uid)["email"], json.dumps(user_doc(uid)["profile"])) for uid in self.shape.user_ids()], + ) + rows = [] + for uid in self.shape.user_ids(): + for oid in self.shape.order_ids_for_user(uid): + doc = order_doc(oid, uid) + rows.append((oid, uid, doc["amount"], doc["status"], json.dumps(doc["payload"]))) + cur.executemany( + "insert into orders (id,user_id,amount,status,payload) values (%s,%s,%s,%s,%s)", + rows, + ) + + return timed(run, self.shape.users + self.shape.orders) + + def point_get(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("select id,email,profile from users where id=%s", (uid,)) + cur.fetchone() + + return timed_loop(samples, op) + + def order_lookup(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("select id,amount,status,payload from orders where user_id=%s", (uid,)) + cur.fetchall() + + return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user}) + + def join_like(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + with self.conn.cursor() as cur: + cur.execute( + "select u.email,o.id,o.amount,o.payload from users u join orders o on o.user_id=u.id where u.id=%s", + (uid,), + ) + cur.fetchall() + + return timed_loop(samples, op, {"mode": "sql_join"}) + + def update_orders(self, samples: list[int]) -> dict[str, Any]: + def op(oid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("update orders set status='updated' where id=%s", (oid,)) + + return timed_loop(samples, op) + + def delete_orders(self, samples: list[int]) -> dict[str, Any]: + def op(oid: int) -> None: + with self.conn.cursor() as cur: + cur.execute("delete from orders where id=%s", (oid,)) + + return timed_loop(samples, op) + + +class TigerBeetleBench: + name = "tigerbeetle" + + def __init__(self, host: str, shape: Shape, batch_size: int): + import tigerbeetle as tb + + self.tb = tb + self.client = tb.ClientSync(cluster_id=0, replica_addresses=f"{host}:3000") + self.shape = shape + # Keep TigerBeetle client batches conservative; the wire limit is based + # on encoded message size, not just object count. + self.batch_size = min(batch_size, 128) + self.merchant_id = 9_000_000_000_000 + + def wait(self) -> None: + deadline = time.time() + 30 + while time.time() < deadline: + try: + self.client.lookup_accounts([1]) + return + except Exception: + time.sleep(0.25) + raise RuntimeError("TigerBeetle did not become ready") + + def batches(self, values: list[Any]) -> list[list[Any]]: + return [values[i:i + self.batch_size] for i in range(0, len(values), self.batch_size)] + + def ingest(self) -> dict[str, Any]: + tb = self.tb + + def run() -> None: + accounts = [ + tb.Account( + id=uid, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=uid, + user_data_64=0, + user_data_32=0, + ledger=1, + code=100, + flags=0, + timestamp=0, + ) + for uid in self.shape.user_ids() + ] + accounts.append(tb.Account( + id=self.merchant_id, + debits_pending=0, + debits_posted=0, + credits_pending=0, + credits_posted=0, + user_data_128=0, + user_data_64=0, + user_data_32=0, + ledger=1, + code=200, + flags=0, + timestamp=0, + )) + for batch in self.batches(accounts): + self.client.create_accounts(batch) + + transfers = [] + for uid in self.shape.user_ids(): + for oid in self.shape.order_ids_for_user(uid): + transfers.append(tb.Transfer( + id=oid, + debit_account_id=uid, + credit_account_id=self.merchant_id, + amount=100 + (oid % 10_000), + pending_id=0, + user_data_128=uid, + user_data_64=oid, + user_data_32=0, + timeout=0, + ledger=1, + code=300, + flags=0, + timestamp=0, + )) + for batch in self.batches(transfers): + self.client.create_transfers(batch) + + return timed(run, self.shape.users + self.shape.orders, {"model": "accounts_and_transfers"}) + + def point_get(self, samples: list[int]) -> dict[str, Any]: + def op(uid: int) -> None: + self.client.lookup_accounts([uid]) + + return timed_loop(samples, op) + + def order_lookup(self, samples: list[int]) -> dict[str, Any]: + tb = self.tb + flags = tb.AccountFilterFlags.DEBITS | tb.AccountFilterFlags.CREDITS + + def op(uid: int) -> None: + account_filter = tb.AccountFilter( + account_id=uid, + user_data_128=0, + user_data_64=0, + user_data_32=0, + code=0, + timestamp_min=0, + timestamp_max=0, + limit=max(self.shape.orders_per_user, 1), + flags=flags, + ) + self.client.get_account_transfers(account_filter) + + return timed_loop(samples, op, {"orders_per_lookup": self.shape.orders_per_user, "model": "get_account_transfers"}) + + def join_like(self, samples: list[int]) -> dict[str, Any]: + result = self.order_lookup(samples) + result["mode"] = "account_transfer_query_not_sql_join" + return result + + def update_orders(self, samples: list[int]) -> dict[str, Any]: + return {"not_applicable": True, "reason": "TigerBeetle accounts/transfers are immutable after creation"} + + def delete_orders(self, samples: list[int]) -> dict[str, Any]: + return {"not_applicable": True, "reason": "TigerBeetle does not delete accounts/transfers"} + + +def run_engine(engine: Any, shape: Shape, samples: int) -> dict[str, Any]: + rng = random.Random(42) + user_samples = [rng.randint(1, shape.users) for _ in range(samples)] + order_samples = [rng.randint(1, shape.orders) for _ in range(samples)] + delete_order_samples = rng.sample(range(1, shape.orders + 1), min(samples, shape.orders)) + return { + "ingest": engine.ingest(), + "point_get": engine.point_get(user_samples), + "relationship_lookup": engine.order_lookup(user_samples), + "join_or_join_like": engine.join_like(user_samples), + "update_orders": engine.update_orders(order_samples), + "delete_orders": engine.delete_orders(delete_order_samples), + } + + +def print_summary(results: dict[str, Any]) -> None: + workloads = [ + "ingest", + "point_get", + "relationship_lookup", + "join_or_join_like", + "update_orders", + "delete_orders", + ] + print("\nContainer Shape Benchmark") + print("=" * 96) + print(f"{'workload':<24} {'engine':<14} {'ops/sec':>12} {'p50 ms':>10} {'p95 ms':>10} {'notes'}") + print("-" * 96) + for workload in workloads: + for engine, engine_results in results["engines"].items(): + row = engine_results.get(workload) + if not row: + continue + if row.get("not_applicable"): + print(f"{workload:<24} {engine:<14} {'n/a':>12} {'':>10} {'':>10} {row.get('reason', '')}") + continue + notes = row.get("mode") or row.get("model") or "" + print( + f"{workload:<24} {engine:<14} {row.get('ops_sec', 0):>12.0f} " + f"{row.get('p50_ms', 0):>10.3f} {row.get('p95_ms', 0):>10.3f} {notes}" + ) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--users", type=int, default=1000) + ap.add_argument("--orders-per-user", type=int, default=3) + ap.add_argument("--samples", type=int, default=500) + ap.add_argument("--batch-size", type=int, default=500) + ap.add_argument("--output", default="/work/benchmark-results/container-shape-bench.json") + ap.add_argument("--turbodb-host") + ap.add_argument("--turbodb-port", type=int, default=27017) + ap.add_argument("--turbodb-bulk-mode", choices=("ndjson", "binary"), default="ndjson") + ap.add_argument("--turbodb-ffi-lib") + ap.add_argument("--turbodb-ffi-dir", default="/tmp/turbodb_ffi_shape_bench") + ap.add_argument("--postgres-host") + ap.add_argument("--mysql-host") + ap.add_argument("--tigerbeetle-host") + args = ap.parse_args() + if args.users < 1: + raise SystemExit("--users must be at least 1") + if args.orders_per_user < 1: + raise SystemExit("--orders-per-user must be at least 1") + if args.samples < 1: + raise SystemExit("--samples must be at least 1") + + shape = Shape(args.users, args.orders_per_user) + results: dict[str, Any] = { + "config": { + "users": shape.users, + "orders": shape.orders, + "orders_per_user": shape.orders_per_user, + "samples": args.samples, + "batch_size": args.batch_size, + "turbodb_bulk_mode": args.turbodb_bulk_mode, + }, + "engines": {}, + "errors": {}, + } + + engines: list[Any] = [] + if args.turbodb_host: + engines.append(TurboDBBench(args.turbodb_host, args.turbodb_port, shape, args.batch_size, args.turbodb_bulk_mode)) + if args.turbodb_ffi_lib: + engines.append(TurboDBFFIBench(args.turbodb_ffi_lib, args.turbodb_ffi_dir, shape, args.batch_size)) + if args.postgres_host: + engines.append(PostgresBench(args.postgres_host, shape, args.batch_size)) + if args.mysql_host: + engines.append(MySQLBench(args.mysql_host, shape, args.batch_size)) + if args.tigerbeetle_host: + engines.append(TigerBeetleBench(args.tigerbeetle_host, shape, args.batch_size)) + + for engine in engines: + try: + engine.wait() + results["engines"][engine.name] = run_engine(engine, shape, min(args.samples, shape.users, max(shape.orders, 1))) + except Exception as exc: + results["errors"][engine.name] = repr(exc) + print(f"{engine.name} failed: {exc}", file=sys.stderr) + finally: + close = getattr(engine, "close", None) + if callable(close): + close() + + print_summary(results) + + import os + os.makedirs(os.path.dirname(args.output), exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + json.dump(results, f, indent=2) + print(f"\nwrote {args.output}") + return 0 if results["engines"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/nanoapi_agent_proxy.zig b/bench/nanoapi_agent_proxy.zig new file mode 100644 index 0000000..dc594b6 --- /dev/null +++ b/bench/nanoapi_agent_proxy.zig @@ -0,0 +1,292 @@ +const std = @import("std"); +const nano = @import("nanoapi"); + +extern "c" fn socket(domain: c_uint, sock_type: c_uint, protocol: c_uint) c_int; +extern "c" fn close(fd: std.c.fd_t) c_int; + +const Config = struct { + proxy_port: u16 = 28080, + turbodb_host: []const u8 = "127.0.0.1", + turbodb_host_owned: bool = false, + turbodb_port: u16 = 27017, + runtime: nano.server.Runtime = .auto, + worker_threads: usize = 0, + check_only: bool = false, + + fn deinit(self: *Config, allocator: std.mem.Allocator) void { + if (self.turbodb_host_owned) allocator.free(self.turbodb_host); + } +}; + +var g_config: *const Config = undefined; + +pub fn main(init: std.process.Init) !void { + const allocator = std.heap.smp_allocator; + var config = try configFromArgs(init.minimal.args, allocator); + defer config.deinit(allocator); + g_config = &config; + + var api = try nano.NanoAPI.init(allocator, .{ + .title = "TurboDB Agentic NanoAPI Proxy", + .docs_url = null, + .redoc_url = null, + .openapi_url = null, + }); + defer api.deinit(); + + try api.getStaticJson("/health", "{\"status\":\"ok\",\"gateway\":\"nanoapi-agent-proxy\"}", .{}); + try api.get("/turbodb/health", turboHealth, .{}); + try api.get("/turbodb/metrics", turboMetrics, .{}); + try api.post("/agent/event/{key}", writeEvent, .{}); + try api.post("/agent/tool/{key}", writeTool, .{}); + try api.get("/agent/event/{key}", readEvent, .{}); + try api.post("/agent/batch", batchEvents, .{}); + try api.get("/agent/context", contextScan, .{}); + + if (config.check_only) return; + + std.debug.print( + "nanoapi agent proxy listening on :{d}; turbodb={s}:{d}; runtime={t}; workers={d}\n", + .{ config.proxy_port, config.turbodb_host, config.turbodb_port, config.runtime, config.worker_threads }, + ); + try nano.server.serve(&api, allocator, .{ + .host = .{ 0, 0, 0, 0 }, + .port = config.proxy_port, + .runtime = config.runtime, + .worker_threads = config.worker_threads, + .read_buffer_size = 128 * 1024, + .write_buffer_size = 128 * 1024, + }); +} + +fn turboHealth(req: *nano.Request) anyerror!nano.Response { + return turboJson(req, "GET", "/health", ""); +} + +fn turboMetrics(req: *nano.Request) anyerror!nano.Response { + return turboJson(req, "GET", "/metrics", ""); +} + +fn writeEvent(req: *nano.Request) anyerror!nano.Response { + const key = req.pathParam("key") orelse return jsonError(req, 400, "missing key"); + const path = try std.fmt.allocPrint(req.allocator, "/db/agent_events/{s}", .{key}); + return turboJson(req, "PUT", path, req.body); +} + +fn writeTool(req: *nano.Request) anyerror!nano.Response { + const key = req.pathParam("key") orelse return jsonError(req, 400, "missing key"); + const path = try std.fmt.allocPrint(req.allocator, "/db/agent_tools/{s}", .{key}); + return turboJson(req, "PUT", path, req.body); +} + +fn readEvent(req: *nano.Request) anyerror!nano.Response { + const key = req.pathParam("key") orelse return jsonError(req, 400, "missing key"); + const path = try std.fmt.allocPrint(req.allocator, "/db/agent_events/{s}", .{key}); + return turboJson(req, "GET", path, ""); +} + +fn batchEvents(req: *nano.Request) anyerror!nano.Response { + return turboJson(req, "POST", "/db/agent_events/bulk", req.body); +} + +fn contextScan(req: *nano.Request) anyerror!nano.Response { + const limit_raw = req.queryParam("limit") orelse "20"; + const path = try std.fmt.allocPrint(req.allocator, "/db/agent_events?limit={s}", .{limit_raw}); + return turboJson(req, "GET", path, ""); +} + +fn turboJson(req: *nano.Request, method: []const u8, path: []const u8, body: []const u8) anyerror!nano.Response { + const bytes = callTurbo(req.allocator, method, path, body) catch |err| { + const msg = try std.fmt.allocPrint(req.allocator, "{{\"error\":\"turbodb proxy failed\",\"detail\":\"{t}\"}}", .{err}); + return nano.Response.fromOwnedBody(req.allocator, msg, .{ .status_code = 502, .media_type = "application/json" }); + }; + return nano.Response.fromOwnedBody(req.allocator, bytes, .{ .media_type = "application/json" }); +} + +fn jsonError(req: *nano.Request, code: u16, msg: []const u8) !nano.Response { + const bytes = try std.fmt.allocPrint(req.allocator, "{{\"error\":\"{s}\"}}", .{msg}); + return nano.Response.fromOwnedBody(req.allocator, bytes, .{ .status_code = code, .media_type = "application/json" }); +} + +fn callTurbo(allocator: std.mem.Allocator, method: []const u8, path: []const u8, body: []const u8) ![]u8 { + const cfg = g_config; + const fd = try connectTcp(cfg.turbodb_host, cfg.turbodb_port); + defer _ = close(fd); + + var req_bytes: std.ArrayList(u8) = .empty; + defer req_bytes.deinit(allocator); + try req_bytes.print( + allocator, + "{s} {s} HTTP/1.1\r\nHost: turbodb\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{ method, path, body.len }, + ); + try req_bytes.appendSlice(allocator, body); + try sendAll(fd, req_bytes.items); + + var response: std.ArrayList(u8) = .empty; + defer response.deinit(allocator); + var scratch: [8192]u8 = undefined; + while (response.items.len < 1024 * 1024) { + const n = recvOnce(fd, &scratch) catch |err| switch (err) { + error.ConnectionClosed => break, + else => return err, + }; + if (n == 0) break; + try response.appendSlice(allocator, scratch[0..n]); + if (responseBodyComplete(response.items)) break; + } + + const body_bytes = responseBody(response.items) orelse return error.BadHttpResponse; + return try allocator.dupe(u8, body_bytes); +} + +fn responseBodyComplete(bytes: []const u8) bool { + const frame = inspectResponse(bytes) orelse return false; + return bytes.len >= frame.header_end + frame.content_length; +} + +fn responseBody(bytes: []const u8) ?[]const u8 { + const frame = inspectResponse(bytes) orelse return null; + if (bytes.len < frame.header_end + frame.content_length) return null; + return bytes[frame.header_end .. frame.header_end + frame.content_length]; +} + +const HttpFrame = struct { + header_end: usize, + content_length: usize, +}; + +fn inspectResponse(bytes: []const u8) ?HttpFrame { + const header_end = if (std.mem.indexOf(u8, bytes, "\r\n\r\n")) |p| p + 4 else return null; + return .{ .header_end = header_end, .content_length = extractContentLength(bytes[0..header_end]) }; +} + +fn extractContentLength(headers: []const u8) usize { + var lines = std.mem.splitSequence(u8, headers, "\r\n"); + while (lines.next()) |line_raw| { + const line = std.mem.trim(u8, line_raw, " \t\r\n"); + const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue; + const name = std.mem.trim(u8, line[0..colon], " \t"); + if (!std.ascii.eqlIgnoreCase(name, "content-length")) continue; + const value = std.mem.trim(u8, line[colon + 1 ..], " \t"); + return std.fmt.parseInt(usize, value, 10) catch 0; + } + return 0; +} + +fn connectTcp(host: []const u8, port: u16) !std.c.fd_t { + const fd = socket(std.c.AF.INET, std.c.SOCK.STREAM, 0); + switch (std.c.errno(fd)) { + .SUCCESS => {}, + else => |err| return errnoError(err), + } + errdefer _ = close(fd); + + var addr: std.c.sockaddr.in = .{ + .port = std.mem.nativeToBig(u16, port), + .addr = @bitCast(try parseIpv4(host)), + }; + const rc = std.c.connect(fd, @ptrCast(&addr), @sizeOf(std.c.sockaddr.in)); + switch (std.c.errno(rc)) { + .SUCCESS => {}, + else => |err| return errnoError(err), + } + return fd; +} + +fn parseIpv4(ip: []const u8) ![4]u8 { + var out: [4]u8 = undefined; + var it = std.mem.splitScalar(u8, ip, '.'); + var i: usize = 0; + while (it.next()) |part| { + if (i >= out.len or part.len == 0) return error.InvalidIpAddress; + out[i] = try std.fmt.parseInt(u8, part, 10); + i += 1; + } + if (i != out.len) return error.InvalidIpAddress; + return out; +} + +fn recvOnce(fd: std.c.fd_t, buf: []u8) !usize { + while (true) { + const n = std.c.recv(fd, buf.ptr, buf.len, 0); + switch (std.c.errno(n)) { + .SUCCESS => return @intCast(n), + .INTR => continue, + .AGAIN => continue, + .CONNRESET => return error.ConnectionClosed, + else => |err| return errnoError(err), + } + } +} + +fn sendAll(fd: std.c.fd_t, bytes: []const u8) !void { + var written: usize = 0; + while (written < bytes.len) { + const flags = if (@hasDecl(std.c.MSG, "NOSIGNAL")) std.c.MSG.NOSIGNAL else 0; + const n = std.c.send(fd, bytes[written..].ptr, bytes.len - written, flags); + switch (std.c.errno(n)) { + .SUCCESS => { + if (n == 0) return error.ConnectionClosed; + written += @intCast(n); + }, + .INTR => continue, + .AGAIN => continue, + else => |err| return errnoError(err), + } + } +} + +fn errnoError(err: std.c.E) error{ + BadHttpResponse, + ConnectionClosed, + ConnectFailed, + InvalidIpAddress, + Unexpected, +} { + switch (err) { + .CONNREFUSED, .HOSTUNREACH, .NETUNREACH, .TIMEDOUT => return error.ConnectFailed, + .CONNRESET, .PIPE, .NOTCONN => return error.ConnectionClosed, + else => return error.Unexpected, + } +} + +fn configFromArgs(args_state: std.process.Args, allocator: std.mem.Allocator) !Config { + var args = std.process.Args.Iterator.init(args_state); + defer args.deinit(); + _ = args.next(); + + var config: Config = .{}; + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "--check")) { + config.check_only = true; + } else if (std.mem.eql(u8, arg, "--port")) { + config.proxy_port = try std.fmt.parseInt(u16, args.next() orelse return error.InvalidArgument, 10); + } else if (std.mem.eql(u8, arg, "--turbodb-host")) { + config.turbodb_host = try allocator.dupe(u8, args.next() orelse return error.InvalidArgument); + config.turbodb_host_owned = true; + } else if (std.mem.eql(u8, arg, "--turbodb-port")) { + config.turbodb_port = try std.fmt.parseInt(u16, args.next() orelse return error.InvalidArgument, 10); + } else if (std.mem.eql(u8, arg, "--runtime")) { + config.runtime = try parseRuntime(args.next() orelse return error.InvalidArgument); + } else if (std.mem.eql(u8, arg, "--workers")) { + config.worker_threads = try std.fmt.parseInt(usize, args.next() orelse return error.InvalidArgument, 10); + } else { + return error.InvalidArgument; + } + } + return config; +} + +fn parseRuntime(raw: []const u8) !nano.server.Runtime { + if (std.mem.eql(u8, raw, "auto")) return .auto; + if (std.mem.eql(u8, raw, "io_uring")) return .io_uring; + if (std.mem.eql(u8, raw, "thread_per_connection")) return .thread_per_connection; + if (std.mem.eql(u8, raw, "event_loop")) return .event_loop; + return error.InvalidRuntime; +} + +test "parse response body" { + const resp = "HTTP/1.1 200 OK\r\nContent-Length: 7\r\n\r\n{\"x\":1}"; + try std.testing.expectEqualStrings("{\"x\":1}", responseBody(resp).?); +} diff --git a/bench/run_apple_container_bench.py b/bench/run_apple_container_bench.py new file mode 100644 index 0000000..c1cffaf --- /dev/null +++ b/bench/run_apple_container_bench.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +""" +Run the document/relational/transfer benchmark matrix inside Apple containers. + +The script keeps every database service on a private Apple container network and +runs the benchmark client from a Python container. It does not publish database +ports to the macOS host. +""" +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] + + +def shlex_join(argv: list[str]) -> str: + import shlex + + return " ".join(shlex.quote(part) for part in argv) + + +def run(argv: list[str], *, check: bool = True, capture: bool = False, cwd: Path = ROOT, timeout: int | None = None) -> subprocess.CompletedProcess[str]: + print(f"+ {shlex_join(argv)}", flush=True) + return subprocess.run( + argv, + cwd=cwd, + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.STDOUT if capture else None, + check=check, + timeout=timeout, + ) + + +def container_exists(name: str) -> bool: + proc = run(["container", "inspect", name], check=False, capture=True) + return proc.returncode == 0 + + +def stop_container(name: str) -> None: + if not container_exists(name): + return + run(["container", "stop", name], check=False, capture=True) + + +def delete_container(name: str) -> None: + if not container_exists(name): + return + run(["container", "stop", name], check=False, capture=True) + run(["container", "rm", name], check=False, capture=True) + + +def network_exists(name: str) -> bool: + proc = run(["container", "network", "list"], check=False, capture=True) + if proc.returncode != 0 or proc.stdout is None: + return False + return any(line.split(maxsplit=1)[0] == name for line in proc.stdout.splitlines()[1:]) + + +def create_network(name: str) -> None: + if not network_exists(name): + run(["container", "network", "create", name]) + + +def delete_network(name: str) -> None: + if network_exists(name): + run(["container", "network", "delete", name], check=False, capture=True) + + +def volume_exists(name: str) -> bool: + proc = run(["container", "volume", "list"], check=False, capture=True) + if proc.returncode != 0 or proc.stdout is None: + return False + return any(line.split(maxsplit=1)[0] == name for line in proc.stdout.splitlines()[1:]) + + +def create_volume(name: str) -> None: + if not volume_exists(name): + run(["container", "volume", "create", name]) + + +def delete_volume(name: str) -> None: + if volume_exists(name): + run(["container", "volume", "delete", name], check=False, capture=True) + + +def inspect_json(name: str) -> Any: + proc = run(["container", "inspect", name], capture=True) + assert proc.stdout is not None + return json.loads(proc.stdout) + + +def container_ip(name: str) -> str: + data = inspect_json(name) + candidates: list[Any] + if isinstance(data, list): + candidates = data + else: + candidates = [data] + + for item in candidates: + networks = item.get("networks") if isinstance(item, dict) else None + if not networks: + continue + for net in networks: + address = net.get("ipv4Address") or net.get("address") + if address: + return str(address).split("/", 1)[0] + raise RuntimeError(f"could not find IPv4 address for container {name}") + + +def wait_http(network: str, url: str, timeout_s: int = 45) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + proc = run( + ["container", "run", "--rm", "--network", network, "--entrypoint", "wget", "alpine:3.20", "-qO-", url], + check=False, + capture=True, + timeout=20, + ) + if proc.returncode == 0: + return + time.sleep(0.5) + raise RuntimeError(f"timed out waiting for {url}") + + +def wait_exec(name: str, command: list[str], timeout_s: int = 60) -> None: + deadline = time.time() + timeout_s + last_output = "" + while time.time() < deadline: + proc = run(["container", "exec", name, *command], check=False, capture=True, timeout=20) + last_output = (proc.stdout or "").strip() + if proc.returncode == 0: + return + time.sleep(0.75) + suffix = f": {last_output}" if last_output else "" + raise RuntimeError(f"timed out waiting for {name}: {' '.join(command)}{suffix}") + + +def logs_tail(name: str, lines: int = 120) -> str: + proc = run(["container", "logs", "-n", str(lines), name], check=False, capture=True) + return (proc.stdout or "").strip() + + +def build_turbodb(target: str, prefix: str | None = None) -> None: + argv = ["zig", "build"] + if prefix: + argv.extend(["--prefix", prefix]) + argv.append(f"-Dtarget={target}") + run(argv) + + +@dataclass +class Service: + name: str + host: str + error: str | None = None + + +class BenchRun: + def __init__(self, args: argparse.Namespace): + self.args = args + self.run_id = args.run_id or f"shape-{uuid.uuid4().hex[:10]}" + self.network = args.network or f"tdb-bench-{self.run_id}" + self.output = Path(args.output or ROOT / "benchmark-results" / f"{self.run_id}.json").resolve() + self.containers: list[str] = [] + self.volumes: list[str] = [] + + def name(self, suffix: str) -> str: + return f"tdb-{self.run_id}-{suffix}" + + def volume(self, suffix: str) -> str: + name = self.name(suffix) + delete_volume(name) + create_volume(name) + self.volumes.append(name) + return name + + def start(self) -> dict[str, Service]: + create_network(self.network) + services: dict[str, Service] = {} + + if not self.args.skip_turbodb: + build_turbodb(self.args.turbodb_target) + if not self.args.skip_turbodb_ffi: + build_turbodb(self.args.turbodb_ffi_target, self.args.turbodb_ffi_prefix) + if not self.args.skip_turbodb: + services["turbodb"] = self.start_turbodb() + if not self.args.skip_postgres: + services["postgres"] = self.start_postgres() + if not self.args.skip_mysql: + services["mysql"] = self.start_mysql() + if not self.args.skip_tigerbeetle: + services["tigerbeetle"] = self.start_tigerbeetle() + return services + + def start_turbodb(self) -> Service: + name = self.name("turbodb") + data_volume = self.volume("turbodb-data") + delete_container(name) + cmd = [ + "container", "run", "-d", + "--name", name, + "--network", self.network, + "--mount", f"type=bind,source={ROOT},target=/work", + "--volume", f"{data_volume}:/data", + "--entrypoint", "/work/zig-out/bin/turbodb", + "alpine:3.20", + "--data", "/data", + "--port", "27017", + "--http", + ] + if self.args.turbodb_http_runtime: + cmd.extend(["--http-runtime", self.args.turbodb_http_runtime]) + run(cmd) + self.containers.append(name) + host = container_ip(name) + wait_http(self.network, f"http://{host}:27017/health", timeout_s=60) + return Service(name=name, host=host) + + def start_postgres(self) -> Service: + name = self.name("postgres") + data_volume = self.volume("postgres-data") + delete_container(name) + run([ + "container", "run", "-d", + "--name", name, + "--network", self.network, + "-e", "POSTGRES_PASSWORD=postgres", + "-e", "POSTGRES_DB=bench", + "--volume", f"{data_volume}:/var/lib/postgresql", + self.args.postgres_image, + ]) + self.containers.append(name) + try: + wait_exec(name, ["pg_isready", "-U", "postgres", "-d", "bench"], timeout_s=90) + except Exception as exc: + raise RuntimeError(f"{exc}\npostgres logs:\n{logs_tail(name)}") from exc + return Service(name=name, host=container_ip(name)) + + def start_mysql(self) -> Service: + name = self.name("mysql") + data_volume = self.volume("mysql-data") + delete_container(name) + run([ + "container", "run", "-d", + "--name", name, + "--network", self.network, + "-e", "MYSQL_ROOT_PASSWORD=mysql", + "-e", "MYSQL_DATABASE=bench", + "--volume", f"{data_volume}:/var/lib/mysql", + self.args.mysql_image, + ]) + self.containers.append(name) + try: + wait_exec(name, ["mysqladmin", "ping", "-uroot", "-pmysql", "--silent"], timeout_s=120) + except Exception as exc: + raise RuntimeError(f"{exc}\nmysql logs:\n{logs_tail(name)}") from exc + return Service(name=name, host=container_ip(name)) + + def start_tigerbeetle(self) -> Service: + name = self.name("tigerbeetle") + data_volume = self.volume("tigerbeetle-data") + data_file = "/data/0_0.tigerbeetle" + delete_container(name) + + format_proc = run([ + "container", "run", "--rm", + "--volume", f"{data_volume}:/data", + self.args.tigerbeetle_image, + "format", + "--cluster=0", + "--replica=0", + "--replica-count=1", + "--development", + data_file, + ], check=False, capture=True, timeout=120) + if format_proc.returncode != 0: + message = format_proc.stdout.strip() if format_proc.stdout else "format failed" + if self.args.require_tigerbeetle: + raise RuntimeError(message) + return Service(name=name, host="", error=message) + + start_proc = run([ + "container", "run", "-d", + "--name", name, + "--network", self.network, + "--memory", self.args.tigerbeetle_memory, + "--ulimit", "memlock=-1:-1", + "--volume", f"{data_volume}:/data", + self.args.tigerbeetle_image, + "start", + "--addresses=0.0.0.0:3000", + "--development", + data_file, + ], check=False, capture=True, timeout=120) + if start_proc.returncode != 0: + message = start_proc.stdout.strip() if start_proc.stdout else "start failed" + if self.args.require_tigerbeetle: + raise RuntimeError(message) + return Service(name=name, host="", error=message) + + self.containers.append(name) + time.sleep(2.0) + try: + host = container_ip(name) + except Exception as exc: + message = logs_tail(name) or str(exc) + if self.args.require_tigerbeetle: + raise RuntimeError(f"TigerBeetle did not stay running:\n{message}") from exc + return Service(name=name, host="", error=message) + return Service(name=name, host=host) + + def run_client(self, services: dict[str, Service]) -> int: + self.output.parent.mkdir(parents=True, exist_ok=True) + deps: list[str] = [] + args = [ + "python", "bench/container_shape_bench.py", + "--users", str(self.args.users), + "--orders-per-user", str(self.args.orders_per_user), + "--samples", str(self.args.samples), + "--batch-size", str(self.args.batch_size), + "--output", f"/work/{self.output.relative_to(ROOT)}", + "--turbodb-bulk-mode", self.args.turbodb_bulk_mode, + ] + + if (svc := services.get("turbodb")) and not svc.error: + args.extend(["--turbodb-host", svc.host]) + if not self.args.skip_turbodb_ffi: + args.extend([ + "--turbodb-ffi-lib", self.args.turbodb_ffi_lib, + "--turbodb-ffi-dir", self.args.turbodb_ffi_dir, + ]) + if (svc := services.get("postgres")) and not svc.error: + deps.append("psycopg[binary]") + args.extend(["--postgres-host", svc.host]) + if (svc := services.get("mysql")) and not svc.error: + deps.append("pymysql") + deps.append("cryptography") + args.extend(["--mysql-host", svc.host]) + if (svc := services.get("tigerbeetle")) and not svc.error: + deps.append("tigerbeetle") + args.extend(["--tigerbeetle-host", svc.host]) + + has_service_engine = any(services.get(engine) and not services[engine].error for engine in services) + if not has_service_engine and self.args.skip_turbodb_ffi: + raise RuntimeError("no benchmark services started successfully") + + client_name = self.name("client") + delete_container(client_name) + pip_cmd = "python -m pip install --quiet --disable-pip-version-check --root-user-action=ignore " + " ".join(deps) if deps else "true" + bench_cmd = shlex_join(args) + command = f"{pip_cmd} && {bench_cmd}" + proc = run([ + "container", "run", "--rm", + "--name", client_name, + "--network", self.network, + "--mount", f"type=bind,source={ROOT},target=/work", + "-w", "/work", + "--entrypoint", "/bin/sh", + self.args.python_image, + "-lc", command, + ], check=False) + return proc.returncode + + def cleanup(self) -> None: + if self.args.keep: + print(f"keeping containers/network/volumes for run {self.run_id}", flush=True) + return + for name in reversed(self.containers): + stop_container(name) + delete_container(name) + delete_network(self.network) + for name in reversed(self.volumes): + delete_volume(name) + + +def parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser(description="Run TurboDB/PostgreSQL/MySQL/TigerBeetle benchmarks in Apple containers") + ap.add_argument("--users", type=int, default=1000) + ap.add_argument("--orders-per-user", type=int, default=3) + ap.add_argument("--samples", type=int, default=500) + ap.add_argument("--batch-size", type=int, default=500) + ap.add_argument("--output") + ap.add_argument("--run-id") + ap.add_argument("--network") + ap.add_argument("--turbodb-target", default="aarch64-linux-musl") + ap.add_argument("--turbodb-ffi-target", default="aarch64-linux-gnu") + ap.add_argument("--turbodb-ffi-prefix", default="zig-out-ffi") + ap.add_argument("--turbodb-ffi-lib", default="/work/zig-out-ffi/lib/libturbodb.so") + ap.add_argument("--turbodb-ffi-dir", default="/tmp/turbodb_ffi_shape_bench") + ap.add_argument("--turbodb-bulk-mode", choices=("ndjson", "binary"), default="ndjson") + ap.add_argument("--turbodb-http-runtime", choices=("threaded", "nanoapi-raw"), default=None) + ap.add_argument("--python-image", default="python:3.12-slim") + ap.add_argument("--postgres-image", default="postgres:18") + ap.add_argument("--mysql-image", default="mysql:8.4") + ap.add_argument("--tigerbeetle-image", default="ghcr.io/tigerbeetle/tigerbeetle:latest") + ap.add_argument("--tigerbeetle-memory", default="2G") + ap.add_argument("--skip-turbodb", action="store_true") + ap.add_argument("--skip-turbodb-ffi", action="store_true") + ap.add_argument("--skip-postgres", action="store_true") + ap.add_argument("--skip-mysql", action="store_true") + ap.add_argument("--skip-tigerbeetle", action="store_true") + ap.add_argument("--require-tigerbeetle", action="store_true") + ap.add_argument("--keep", action="store_true", help="leave containers, network, and volumes in place") + return ap.parse_args() + + +def main() -> int: + if not shutil.which("container"): + print("Apple container CLI is required: https://github.com/apple/container", file=sys.stderr) + return 2 + + args = parse_args() + bench = BenchRun(args) + services: dict[str, Service] = {} + try: + services = bench.start() + errors = {name: svc.error for name, svc in services.items() if svc.error} + if errors: + print("optional services failed:") + for name, error in errors.items(): + print(f" {name}: {error}") + rc = bench.run_client(services) + print(f"benchmark output: {bench.output}") + return rc + finally: + bench.cleanup() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/run_container_agentic_nanoapi_bench.py b/bench/run_container_agentic_nanoapi_bench.py new file mode 100644 index 0000000..134b0c3 --- /dev/null +++ b/bench/run_container_agentic_nanoapi_bench.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Run TurboDB behind a nanoapi proxy and pound only the proxy from a client container.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def shlex_join(argv: list[str]) -> str: + import shlex + + return " ".join(shlex.quote(part) for part in argv) + + +def run(argv: list[str], *, check: bool = True, capture: bool = False, timeout: int | None = None) -> subprocess.CompletedProcess[str]: + print(f"+ {shlex_join(argv)}", flush=True) + return subprocess.run( + argv, + cwd=ROOT, + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.STDOUT if capture else None, + check=check, + timeout=timeout, + ) + + +def container_exists(name: str) -> bool: + return run(["container", "inspect", name], check=False, capture=True).returncode == 0 + + +def delete_container(name: str) -> None: + if container_exists(name): + run(["container", "stop", name], check=False, capture=True) + run(["container", "rm", name], check=False, capture=True) + + +def network_exists(name: str) -> bool: + proc = run(["container", "network", "list"], check=False, capture=True) + return proc.returncode == 0 and proc.stdout is not None and any(line.split(maxsplit=1)[0] == name for line in proc.stdout.splitlines()[1:]) + + +def create_network(name: str) -> None: + if not network_exists(name): + run(["container", "network", "create", name]) + + +def delete_network(name: str) -> None: + if network_exists(name): + run(["container", "network", "delete", name], check=False, capture=True) + + +def volume_exists(name: str) -> bool: + proc = run(["container", "volume", "list"], check=False, capture=True) + return proc.returncode == 0 and proc.stdout is not None and any(line.split(maxsplit=1)[0] == name for line in proc.stdout.splitlines()[1:]) + + +def create_volume(name: str) -> None: + if not volume_exists(name): + run(["container", "volume", "create", name]) + + +def delete_volume(name: str) -> None: + if volume_exists(name): + run(["container", "volume", "delete", name], check=False, capture=True) + + +def inspect_json(name: str) -> object: + proc = run(["container", "inspect", name], capture=True) + assert proc.stdout is not None + return json.loads(proc.stdout) + + +def container_ip(name: str) -> str: + data = inspect_json(name) + candidates = data if isinstance(data, list) else [data] + for item in candidates: + if not isinstance(item, dict): + continue + for net in item.get("networks") or []: + address = net.get("ipv4Address") or net.get("address") + if address: + return str(address).split("/", 1)[0] + raise RuntimeError(f"could not find IPv4 address for {name}") + + +def wait_http(network: str, url: str, timeout_s: int = 45) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + proc = run( + ["container", "run", "--rm", "--network", network, "--entrypoint", "wget", "alpine:3.20", "-qO-", url], + check=False, + capture=True, + timeout=20, + ) + if proc.returncode == 0: + return + time.sleep(0.5) + raise RuntimeError(f"timed out waiting for {url}") + + +def logs_tail(name: str, lines: int = 160) -> str: + proc = run(["container", "logs", "-n", str(lines), name], check=False, capture=True) + return (proc.stdout or "").strip() + + +def parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser(description="Agentic nanoapi-over-TurboDB container stress harness") + ap.add_argument("--run-id", default=f"agentic-nanoapi-{int(time.time())}") + ap.add_argument("--network") + ap.add_argument("--output") + ap.add_argument("--target", default="aarch64-linux-musl") + ap.add_argument("--optimize", default="ReleaseFast") + ap.add_argument("--agents", type=int, default=32) + ap.add_argument("--rounds", type=int, default=20) + ap.add_argument("--events-per-round", type=int, default=3) + ap.add_argument("--context-limit", type=int, default=20) + ap.add_argument("--proxy-runtime", choices=("auto", "io_uring", "thread_per_connection"), default="io_uring") + ap.add_argument("--proxy-workers", type=int, default=1) + ap.add_argument("--turbodb-runtime", choices=("threaded", "nanoapi-raw"), default="threaded") + ap.add_argument("--python-image", default="python:3.12-slim") + ap.add_argument("--keep", action="store_true") + return ap.parse_args() + + +def main() -> int: + if not shutil.which("container"): + print("Apple container CLI is required", file=sys.stderr) + return 2 + + args = parse_args() + run_id = args.run_id + network = args.network or f"tdb-agentic-{run_id}" + output = Path(args.output or ROOT / "benchmark-results" / f"{run_id}.json").resolve() + output.parent.mkdir(parents=True, exist_ok=True) + + turbodb_name = f"tdb-{run_id}-db" + proxy_name = f"tdb-{run_id}-proxy" + client_name = f"tdb-{run_id}-client" + volume = f"tdb-{run_id}-data" + + try: + create_network(network) + create_volume(volume) + run(["zig", "build", f"-Dtarget={args.target}", f"-Doptimize={args.optimize}"]) + + delete_container(turbodb_name) + run([ + "container", "run", "-d", + "--name", turbodb_name, + "--network", network, + "--mount", f"type=bind,source={ROOT},target=/work,readonly", + "--volume", f"{volume}:/data", + "--entrypoint", "/work/zig-out/bin/turbodb", + "alpine:3.20", + "--data", "/data", + "--port", "27017", + "--http", + "--http-runtime", args.turbodb_runtime, + ]) + turbodb_host = container_ip(turbodb_name) + wait_http(network, f"http://{turbodb_host}:27017/health", timeout_s=60) + + delete_container(proxy_name) + run([ + "container", "run", "-d", + "--name", proxy_name, + "--network", network, + "--mount", f"type=bind,source={ROOT},target=/work,readonly", + "--entrypoint", "/work/zig-out/bin/nanoapi-agent-proxy", + "alpine:3.20", + "--port", "28080", + "--turbodb-host", turbodb_host, + "--turbodb-port", "27017", + "--runtime", args.proxy_runtime, + "--workers", str(args.proxy_workers), + ]) + proxy_host = container_ip(proxy_name) + wait_http(network, f"http://{proxy_host}:28080/health", timeout_s=60) + wait_http(network, f"http://{proxy_host}:28080/turbodb/health", timeout_s=60) + + delete_container(client_name) + proc = run([ + "container", "run", "--rm", + "--name", client_name, + "--network", network, + "--mount", f"type=bind,source={ROOT},target=/work", + "-w", "/work", + "--entrypoint", "python", + args.python_image, + "bench/agentic_nanoapi_client.py", + "--host", proxy_host, + "--port", "28080", + "--agents", str(args.agents), + "--rounds", str(args.rounds), + "--events-per-round", str(args.events_per_round), + "--context-limit", str(args.context_limit), + "--run-id", run_id, + "--output", f"/work/{output.relative_to(ROOT)}", + ], check=False) + + if proc.returncode != 0: + print("\nproxy logs:\n" + logs_tail(proxy_name), file=sys.stderr) + print("\nturbodb logs:\n" + logs_tail(turbodb_name), file=sys.stderr) + print(f"benchmark output: {output}") + return proc.returncode + finally: + if args.keep: + print(f"keeping network={network} db={turbodb_name} proxy={proxy_name} volume={volume}") + else: + delete_container(client_name) + delete_container(proxy_name) + delete_container(turbodb_name) + delete_network(network) + delete_volume(volume) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/wire2_perf_client.zig b/bench/wire2_perf_client.zig new file mode 100644 index 0000000..bb8ee8c --- /dev/null +++ b/bench/wire2_perf_client.zig @@ -0,0 +1,690 @@ +const std = @import("std"); +const compat = @import("compat"); + +extern "c" fn socket(domain: c_uint, sock_type: c_uint, protocol: c_uint) c_int; +extern "c" fn close(fd: std.c.fd_t) c_int; + +const OUTER_HDR: usize = 5; +const WIRE2_HDR: usize = 16; +const FRAME_HDR: usize = OUTER_HDR + WIRE2_HDR; +const MAX_SERVER_RESPONSE: usize = 4 * 1024 * 1024; +const MAX_WIRE2_FRAME: usize = 64 * 1024 * 1024 + OUTER_HDR; + +const OP_WIRE2: u8 = 0x20; +const WIRE2_VERSION: u8 = 2; +const WIRE2_OP_HELLO: u8 = 0x00; +const WIRE2_OP_GET: u8 = 0x02; +const WIRE2_OP_PUT: u8 = 0x03; +const WIRE2_OP_BATCH_GET: u8 = 0x06; +const WIRE2_OP_BULK_INSERT: u8 = 0x07; +const WIRE2_OP_AUTH: u8 = 0x0A; + +const STATUS_OK: u8 = 0x00; +const STATUS_NOT_FOUND: u8 = 0x01; + +const Config = struct { + host: []const u8 = "127.0.0.1", + host_owned: bool = false, + port: u16 = 27017, + collection: []const u8 = "wire2_perf", + collection_owned: bool = false, + rows: usize = 1_000_000, + batch_size: usize = 10_000, + get_rounds: usize = 100, + get_batch_size: usize = 0, + pipeline_window: usize = 4, + auth_key: []const u8 = "", + auth_key_owned: bool = false, + output: []const u8 = "", + output_owned: bool = false, + + fn deinit(self: *Config, allocator: std.mem.Allocator) void { + if (self.host_owned) allocator.free(self.host); + if (self.collection_owned) allocator.free(self.collection); + if (self.auth_key_owned) allocator.free(self.auth_key); + if (self.output_owned) allocator.free(self.output); + } +}; + +const HelloInfo = struct { + max_frame: u32 = 0, + max_response: u32 = 0, + features: u32 = 0, +}; + +const Wire2Response = struct { + request_id: u64, + op: u8, + status: u8, + body: []const u8, +}; + +const Wire2Stats = struct { + inserted: u64 = 0, + updated: u64 = 0, + deleted: u64 = 0, + missing: u64 = 0, + errors: u64 = 0, + bytes: u64 = 0, +}; + +const PhaseMetrics = struct { + operations: u64 = 0, + seconds: f64 = 0, + per_second: f64 = 0, + bytes: u64 = 0, + batches: u64 = 0, + inserted: u64 = 0, + found: u64 = 0, + missing: u64 = 0, + errors: u64 = 0, +}; + +const BenchResult = struct { + hello: HelloInfo, + probe_seconds: f64, + bulk: PhaseMetrics, + batch_get: PhaseMetrics, +}; + +pub fn main(init: std.process.Init) !void { + const allocator = std.heap.smp_allocator; + const args = try compat.argsAlloc(allocator, init); + defer compat.argsFree(allocator, args); + + var config = try parseArgs(args, allocator); + defer config.deinit(allocator); + validateConfig(config) catch |err| { + usage(); + return err; + }; + + const result = try runBenchmark(allocator, config); + try emitResult(config, result); +} + +fn parseArgs(args: [][:0]const u8, allocator: std.mem.Allocator) !Config { + var config: Config = .{}; + var i: usize = 1; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (std.mem.eql(u8, arg, "--host")) { + i += 1; + config.host = try dupArg(args, i, allocator); + config.host_owned = true; + } else if (std.mem.eql(u8, arg, "--port")) { + i += 1; + config.port = try parseArg(u16, args, i); + } else if (std.mem.eql(u8, arg, "--collection")) { + i += 1; + config.collection = try dupArg(args, i, allocator); + config.collection_owned = true; + } else if (std.mem.eql(u8, arg, "--rows")) { + i += 1; + config.rows = try parseArg(usize, args, i); + } else if (std.mem.eql(u8, arg, "--batch-size")) { + i += 1; + config.batch_size = try parseArg(usize, args, i); + } else if (std.mem.eql(u8, arg, "--get-rounds")) { + i += 1; + config.get_rounds = try parseArg(usize, args, i); + } else if (std.mem.eql(u8, arg, "--get-batch-size")) { + i += 1; + config.get_batch_size = try parseArg(usize, args, i); + } else if (std.mem.eql(u8, arg, "--pipeline-window")) { + i += 1; + config.pipeline_window = try parseArg(usize, args, i); + } else if (std.mem.eql(u8, arg, "--auth-key")) { + i += 1; + config.auth_key = try dupArg(args, i, allocator); + config.auth_key_owned = true; + } else if (std.mem.eql(u8, arg, "--output")) { + i += 1; + config.output = try dupArg(args, i, allocator); + config.output_owned = true; + } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) { + usage(); + std.process.exit(0); + } else { + return error.InvalidArgument; + } + } + if (config.get_batch_size == 0) config.get_batch_size = config.batch_size; + return config; +} + +fn dupArg(args: [][:0]const u8, i: usize, allocator: std.mem.Allocator) ![]const u8 { + if (i >= args.len) return error.InvalidArgument; + return allocator.dupe(u8, args[i]); +} + +fn parseArg(comptime T: type, args: [][:0]const u8, i: usize) !T { + if (i >= args.len) return error.InvalidArgument; + return std.fmt.parseInt(T, args[i], 10); +} + +fn validateConfig(config: Config) !void { + if (config.host.len == 0 or config.collection.len == 0) return error.InvalidArgument; + if (config.rows == 0 or config.batch_size == 0 or config.get_rounds == 0 or config.get_batch_size == 0) return error.InvalidArgument; + if (config.pipeline_window == 0) return error.InvalidArgument; + if (config.batch_size > std.math.maxInt(u32) or config.get_batch_size > std.math.maxInt(u32)) return error.InvalidArgument; + if (config.collection.len > std.math.maxInt(u16) or config.auth_key.len > std.math.maxInt(u16)) return error.InvalidArgument; + if (config.get_batch_size > config.rows) return error.InvalidArgument; +} + +fn usage() void { + std.debug.print( + \\usage: wire2-perf-client [options] + \\ --host IP TurboDB wire host (default 127.0.0.1) + \\ --port PORT TurboDB wire port (default 27017) + \\ --collection NAME Collection to benchmark (default wire2_perf) + \\ --rows N Rows to bulk insert (default 1000000) + \\ --batch-size N Rows per bulk insert request (default 10000) + \\ --get-rounds N Batch-get repetitions (default 100) + \\ --get-batch-size N Keys per batch-get request (default batch-size) + \\ --pipeline-window N Batch-get requests in flight (default 4) + \\ --auth-key KEY Optional wire2 auth key + \\ --output PATH Optional JSON output path + \\ + , .{}); +} + +fn runBenchmark(allocator: std.mem.Allocator, config: Config) !BenchResult { + const request_capacity = try requiredRequestCapacity(config); + const request_buf = try allocator.alloc(u8, request_capacity); + defer allocator.free(request_buf); + const response_buf = try allocator.alloc(u8, MAX_SERVER_RESPONSE); + defer allocator.free(response_buf); + + const fd = try connectTcp(config.host, config.port); + defer _ = close(fd); + + var request_id: u64 = 1; + const hello = try doHello(fd, request_buf, response_buf, &request_id); + if (config.auth_key.len > 0) try doAuth(fd, request_buf, response_buf, &request_id, config.auth_key); + + const probe_start = compat.nanoTimestamp(); + try doProbe(fd, request_buf, response_buf, &request_id, config.collection); + const probe_seconds = nsToSeconds(compat.nanoTimestamp() - probe_start); + + const bulk = try doBulkInsert(fd, request_buf, response_buf, &request_id, config); + const batch_get = try doBatchGet(fd, request_buf, response_buf, &request_id, config); + + return .{ + .hello = hello, + .probe_seconds = probe_seconds, + .bulk = bulk, + .batch_get = batch_get, + }; +} + +fn requiredRequestCapacity(config: Config) !usize { + const row_width: usize = 6 + 32 + 96; + const key_width: usize = 2 + 32; + const bulk_body = 2 + config.collection.len + 4 + config.batch_size * row_width; + const get_body = 2 + config.collection.len + 4 + config.get_batch_size * key_width; + const need = FRAME_HDR + @max(bulk_body, get_body); + if (need > MAX_WIRE2_FRAME) return error.FrameTooLarge; + return @max(need, 64 * 1024); +} + +fn doHello(fd: std.c.fd_t, request_buf: []u8, response_buf: []u8, request_id: *u64) !HelloInfo { + const resp = try callWire2(fd, request_buf, response_buf, request_id, WIRE2_OP_HELLO, 0); + try expectStatus(resp, WIRE2_OP_HELLO, STATUS_OK); + if (resp.body.len < 12) return error.BadResponse; + return .{ + .max_frame = rdU32LE(resp.body[0..4]), + .max_response = rdU32LE(resp.body[4..8]), + .features = rdU32LE(resp.body[8..12]), + }; +} + +fn doAuth(fd: std.c.fd_t, request_buf: []u8, response_buf: []u8, request_id: *u64, auth_key: []const u8) !void { + if (auth_key.len > std.math.maxInt(u16)) return error.InvalidArgument; + const body = requestBody(request_buf); + if (body.len < 2 + auth_key.len) return error.FrameTooLarge; + wrU16LE(body[0..2], @intCast(auth_key.len)); + @memcpy(body[2..][0..auth_key.len], auth_key); + const resp = try callWire2(fd, request_buf, response_buf, request_id, WIRE2_OP_AUTH, 2 + auth_key.len); + try expectStatus(resp, WIRE2_OP_AUTH, STATUS_OK); +} + +fn doProbe(fd: std.c.fd_t, request_buf: []u8, response_buf: []u8, request_id: *u64, collection: []const u8) !void { + const key = "probe-native"; + const value = "{\"probe\":\"wire2-zig\"}"; + const body_len = encodeKeyValue(requestBody(request_buf), collection, key, value) catch return error.FrameTooLarge; + var resp = try callWire2(fd, request_buf, response_buf, request_id, WIRE2_OP_PUT, body_len); + try expectStatus(resp, WIRE2_OP_PUT, STATUS_OK); + + const get_len = encodeKey(requestBody(request_buf), collection, key) catch return error.FrameTooLarge; + resp = try callWire2(fd, request_buf, response_buf, request_id, WIRE2_OP_GET, get_len); + try expectStatus(resp, WIRE2_OP_GET, STATUS_OK); + if (resp.body.len < 13) return error.BadResponse; + const value_len: usize = rdU32LE(resp.body[9..13]); + if (resp.body.len != 13 + value_len) return error.BadResponse; + if (!std.mem.eql(u8, resp.body[13..], value)) return error.BadResponse; +} + +fn doBulkInsert(fd: std.c.fd_t, request_buf: []u8, response_buf: []u8, request_id: *u64, config: Config) !PhaseMetrics { + var metrics: PhaseMetrics = .{}; + const started = compat.nanoTimestamp(); + + var base: usize = 0; + while (base < config.rows) { + const count = @min(config.batch_size, config.rows - base); + const body_len = try encodeBulkInsertBody(requestBody(request_buf), config.collection, base, count); + const resp = try callWire2(fd, request_buf, response_buf, request_id, WIRE2_OP_BULK_INSERT, body_len); + try expectStatus(resp, WIRE2_OP_BULK_INSERT, STATUS_OK); + const stats = try parseStats(resp.body); + metrics.inserted += stats.inserted; + metrics.errors += stats.errors; + metrics.bytes += stats.bytes; + metrics.operations += count; + metrics.batches += 1; + if (stats.errors != 0) return error.ServerErrors; + base += count; + } + + metrics.seconds = nsToSeconds(compat.nanoTimestamp() - started); + metrics.per_second = rate(metrics.operations, metrics.seconds); + return metrics; +} + +fn doBatchGet(fd: std.c.fd_t, request_buf: []u8, response_buf: []u8, request_id: *u64, config: Config) !PhaseMetrics { + var metrics: PhaseMetrics = .{}; + const started = compat.nanoTimestamp(); + + const first_request_id = request_id.*; + var sent_rounds: usize = 0; + var received_rounds: usize = 0; + while (received_rounds < config.get_rounds) { + while (sent_rounds < config.get_rounds and sent_rounds - received_rounds < config.pipeline_window) : (sent_rounds += 1) { + const base = (sent_rounds * config.get_batch_size) % config.rows; + const body_len = try encodeBatchGetBody(requestBody(request_buf), config.collection, base, config.get_batch_size, config.rows); + const id = request_id.*; + request_id.* += 1; + try sendAll(fd, finishRequest(request_buf, id, WIRE2_OP_BATCH_GET, body_len)); + } + + const resp = try readWire2Response(fd, response_buf); + const expected_id = first_request_id + @as(u64, @intCast(received_rounds)); + if (resp.request_id != expected_id or resp.op != WIRE2_OP_BATCH_GET) return error.BadResponse; + try expectStatus(resp, WIRE2_OP_BATCH_GET, STATUS_OK); + const counts = try parseBatchGet(resp.body); + metrics.found += counts.found; + metrics.missing += counts.missing; + metrics.operations += config.get_batch_size; + metrics.batches += 1; + if (counts.missing != 0) return error.BadResponse; + received_rounds += 1; + } + + metrics.seconds = nsToSeconds(compat.nanoTimestamp() - started); + metrics.per_second = rate(metrics.operations, metrics.seconds); + return metrics; +} + +fn callWire2(fd: std.c.fd_t, request_buf: []u8, response_buf: []u8, request_id: *u64, op: u8, body_len: usize) !Wire2Response { + const id = request_id.*; + request_id.* += 1; + const frame = finishRequest(request_buf, id, op, body_len); + try sendAll(fd, frame); + const resp = try readWire2Response(fd, response_buf); + if (resp.request_id != id or resp.op != op) return error.BadResponse; + return resp; +} + +fn requestBody(request_buf: []u8) []u8 { + return request_buf[FRAME_HDR..]; +} + +fn finishRequest(request_buf: []u8, request_id: u64, op: u8, body_len: usize) []const u8 { + const total_len = FRAME_HDR + body_len; + wrU32BE(request_buf[0..4], @intCast(total_len)); + request_buf[4] = OP_WIRE2; + request_buf[5] = WIRE2_VERSION; + request_buf[6] = 0; + wrU64LE(request_buf[7..15], request_id); + request_buf[15] = op; + request_buf[16] = 0; + wrU32LE(request_buf[17..21], @intCast(body_len)); + return request_buf[0..total_len]; +} + +fn encodeBulkInsertBody(out: []u8, collection: []const u8, base: usize, count: usize) !usize { + var pos = try encodeCollectionHeader(out, collection, count); + for (0..count) |i| { + const n = base + i; + if (pos + 6 + 32 + 96 > out.len) return error.FrameTooLarge; + const key = try std.fmt.bufPrint(out[pos + 6 ..][0..32], "k-{d:0>10}", .{n}); + const value = try std.fmt.bufPrint(out[pos + 6 + key.len ..][0..96], "{{\"n\":{d},\"v\":\"wire2\"}}", .{n}); + wrU16LE(out[pos..][0..2], @intCast(key.len)); + wrU32LE(out[pos + 2 ..][0..4], @intCast(value.len)); + pos += 6 + key.len + value.len; + } + return pos; +} + +fn encodeBatchGetBody(out: []u8, collection: []const u8, base: usize, count: usize, rows: usize) !usize { + var pos = try encodeCollectionHeader(out, collection, count); + for (0..count) |i| { + const n = (base + i) % rows; + if (pos + 2 + 32 > out.len) return error.FrameTooLarge; + const key = try std.fmt.bufPrint(out[pos + 2 ..][0..32], "k-{d:0>10}", .{n}); + wrU16LE(out[pos..][0..2], @intCast(key.len)); + pos += 2 + key.len; + } + return pos; +} + +fn encodeCollectionHeader(out: []u8, collection: []const u8, count: usize) !usize { + if (collection.len > std.math.maxInt(u16) or count > std.math.maxInt(u32)) return error.InvalidArgument; + if (out.len < 2 + collection.len + 4) return error.FrameTooLarge; + wrU16LE(out[0..2], @intCast(collection.len)); + @memcpy(out[2..][0..collection.len], collection); + wrU32LE(out[2 + collection.len ..][0..4], @intCast(count)); + return 2 + collection.len + 4; +} + +fn encodeKey(out: []u8, collection: []const u8, key: []const u8) !usize { + if (collection.len > std.math.maxInt(u16) or key.len > std.math.maxInt(u16)) return error.InvalidArgument; + const need = 2 + collection.len + 2 + key.len; + if (out.len < need) return error.FrameTooLarge; + wrU16LE(out[0..2], @intCast(collection.len)); + @memcpy(out[2..][0..collection.len], collection); + const key_off = 2 + collection.len; + wrU16LE(out[key_off..][0..2], @intCast(key.len)); + @memcpy(out[key_off + 2 ..][0..key.len], key); + return need; +} + +fn encodeKeyValue(out: []u8, collection: []const u8, key: []const u8, value: []const u8) !usize { + if (collection.len > std.math.maxInt(u16) or key.len > std.math.maxInt(u16)) return error.InvalidArgument; + if (value.len > std.math.maxInt(u32)) return error.InvalidArgument; + const need = 2 + collection.len + 2 + key.len + 4 + value.len; + if (out.len < need) return error.FrameTooLarge; + wrU16LE(out[0..2], @intCast(collection.len)); + @memcpy(out[2..][0..collection.len], collection); + const key_off = 2 + collection.len; + wrU16LE(out[key_off..][0..2], @intCast(key.len)); + @memcpy(out[key_off + 2 ..][0..key.len], key); + const val_off = key_off + 2 + key.len; + wrU32LE(out[val_off..][0..4], @intCast(value.len)); + @memcpy(out[val_off + 4 ..][0..value.len], value); + return need; +} + +fn parseStats(body: []const u8) !Wire2Stats { + if (body.len != 28) return error.BadResponse; + return .{ + .inserted = rdU32LE(body[0..4]), + .updated = rdU32LE(body[4..8]), + .deleted = rdU32LE(body[8..12]), + .missing = rdU32LE(body[12..16]), + .errors = rdU32LE(body[16..20]), + .bytes = rdU64LE(body[20..28]), + }; +} + +fn parseBatchGet(body: []const u8) !struct { found: u64, missing: u64 } { + if (body.len < 4) return error.BadResponse; + const count = rdU32LE(body[0..4]); + var pos: usize = 4; + var found: u64 = 0; + var missing: u64 = 0; + for (0..count) |_| { + if (pos + 14 > body.len) return error.BadResponse; + const status = body[pos]; + const value_len: usize = rdU32LE(body[pos + 10 ..][0..4]); + pos += 14; + if (pos + value_len > body.len) return error.BadResponse; + pos += value_len; + if (status == STATUS_OK) { + found += 1; + } else if (status == STATUS_NOT_FOUND) { + missing += 1; + } else { + return error.BadResponse; + } + } + if (pos != body.len) return error.BadResponse; + return .{ .found = found, .missing = missing }; +} + +fn expectStatus(resp: Wire2Response, op: u8, status: u8) !void { + if (resp.op != op) return error.BadResponse; + if (resp.status != status) return error.BadStatus; +} + +fn readWire2Response(fd: std.c.fd_t, response_buf: []u8) !Wire2Response { + if (response_buf.len < FRAME_HDR) return error.BadResponse; + try recvExact(fd, response_buf[0..OUTER_HDR]); + const frame_len: usize = rdU32BE(response_buf[0..4]); + if (frame_len < FRAME_HDR or frame_len > response_buf.len) return error.BadResponse; + if (response_buf[4] != OP_WIRE2) return error.BadResponse; + try recvExact(fd, response_buf[OUTER_HDR..frame_len]); + if (response_buf[5] != WIRE2_VERSION) return error.BadResponse; + const body_len: usize = rdU32LE(response_buf[17..21]); + if (FRAME_HDR + body_len != frame_len) return error.BadResponse; + return .{ + .request_id = rdU64LE(response_buf[7..15]), + .op = response_buf[15], + .status = response_buf[16], + .body = response_buf[FRAME_HDR..frame_len], + }; +} + +fn connectTcp(host: []const u8, port: u16) !std.c.fd_t { + const fd = socket(std.c.AF.INET, std.c.SOCK.STREAM, 0); + switch (std.c.errno(fd)) { + .SUCCESS => {}, + else => |err| return errnoError(err), + } + errdefer _ = close(fd); + + var addr: std.c.sockaddr.in = .{ + .port = std.mem.nativeToBig(u16, port), + .addr = @bitCast(try parseIpv4(host)), + }; + const rc = std.c.connect(fd, @ptrCast(&addr), @sizeOf(std.c.sockaddr.in)); + switch (std.c.errno(rc)) { + .SUCCESS => {}, + else => |err| return errnoError(err), + } + return fd; +} + +fn parseIpv4(ip: []const u8) ![4]u8 { + var out: [4]u8 = undefined; + var it = std.mem.splitScalar(u8, ip, '.'); + var i: usize = 0; + while (it.next()) |part| { + if (i >= out.len or part.len == 0) return error.InvalidIpAddress; + out[i] = try std.fmt.parseInt(u8, part, 10); + i += 1; + } + if (i != out.len) return error.InvalidIpAddress; + return out; +} + +fn recvExact(fd: std.c.fd_t, buf: []u8) !void { + var got: usize = 0; + while (got < buf.len) { + const n = std.c.recv(fd, buf[got..].ptr, buf.len - got, 0); + switch (std.c.errno(n)) { + .SUCCESS => { + if (n == 0) return error.ConnectionClosed; + got += @intCast(n); + }, + .INTR => continue, + .AGAIN => continue, + .CONNRESET => return error.ConnectionClosed, + else => |err| return errnoError(err), + } + } +} + +fn sendAll(fd: std.c.fd_t, bytes: []const u8) !void { + var written: usize = 0; + while (written < bytes.len) { + const flags = if (@hasDecl(std.c.MSG, "NOSIGNAL")) std.c.MSG.NOSIGNAL else 0; + const n = std.c.send(fd, bytes[written..].ptr, bytes.len - written, flags); + switch (std.c.errno(n)) { + .SUCCESS => { + if (n == 0) return error.ConnectionClosed; + written += @intCast(n); + }, + .INTR => continue, + .AGAIN => continue, + else => |err| return errnoError(err), + } + } +} + +fn emitResult(config: Config, result: BenchResult) !void { + var buf: [8192]u8 = undefined; + const json = try std.fmt.bufPrint(&buf, + \\{{ + \\ "benchmark": "wire2_perf_client_zig", + \\ "timestamp_ms": {d}, + \\ "config": {{ + \\ "host": "{s}", + \\ "port": {d}, + \\ "collection": "{s}", + \\ "rows": {d}, + \\ "batch_size": {d}, + \\ "get_rounds": {d}, + \\ "get_batch_size": {d}, + \\ "pipeline_window": {d}, + \\ "auth": {s} + \\ }}, + \\ "hello": {{ + \\ "max_frame": {d}, + \\ "max_response": {d}, + \\ "features": {d} + \\ }}, + \\ "probe": {{ + \\ "seconds": {d:.9} + \\ }}, + \\ "bulk_insert": {{ + \\ "rows": {d}, + \\ "batches": {d}, + \\ "seconds": {d:.9}, + \\ "rows_per_second": {d:.3}, + \\ "inserted": {d}, + \\ "errors": {d}, + \\ "bytes": {d} + \\ }}, + \\ "batch_get": {{ + \\ "items": {d}, + \\ "batches": {d}, + \\ "seconds": {d:.9}, + \\ "items_per_second": {d:.3}, + \\ "found": {d}, + \\ "missing": {d} + \\ }} + \\}} + , .{ + compat.milliTimestamp(), + config.host, + config.port, + config.collection, + config.rows, + config.batch_size, + config.get_rounds, + config.get_batch_size, + config.pipeline_window, + if (config.auth_key.len > 0) "true" else "false", + result.hello.max_frame, + result.hello.max_response, + result.hello.features, + result.probe_seconds, + result.bulk.operations, + result.bulk.batches, + result.bulk.seconds, + result.bulk.per_second, + result.bulk.inserted, + result.bulk.errors, + result.bulk.bytes, + result.batch_get.operations, + result.batch_get.batches, + result.batch_get.seconds, + result.batch_get.per_second, + result.batch_get.found, + result.batch_get.missing, + }); + + std.debug.print("{s}\n", .{json}); + if (config.output.len > 0) { + const file = try compat.cwd().createFile(config.output, .{}); + defer file.close(); + try file.writeAll(json); + try file.writeAll("\n"); + } +} + +fn rate(ops: u64, seconds: f64) f64 { + if (seconds <= 0) return 0; + return @as(f64, @floatFromInt(ops)) / seconds; +} + +fn nsToSeconds(ns: i128) f64 { + return @as(f64, @floatFromInt(ns)) / 1_000_000_000.0; +} + +fn rdU32BE(b: []const u8) u32 { + return (@as(u32, b[0]) << 24) | (@as(u32, b[1]) << 16) | (@as(u32, b[2]) << 8) | b[3]; +} + +fn rdU16LE(b: []const u8) u16 { + return @as(u16, b[0]) | (@as(u16, b[1]) << 8); +} + +fn rdU32LE(b: []const u8) u32 { + return @as(u32, b[0]) | (@as(u32, b[1]) << 8) | (@as(u32, b[2]) << 16) | (@as(u32, b[3]) << 24); +} + +fn rdU64LE(b: []const u8) u64 { + return @as(u64, rdU32LE(b[0..4])) | (@as(u64, rdU32LE(b[4..8])) << 32); +} + +fn wrU32BE(w: []u8, v: u32) void { + w[0] = @intCast((v >> 24) & 0xff); + w[1] = @intCast((v >> 16) & 0xff); + w[2] = @intCast((v >> 8) & 0xff); + w[3] = @intCast(v & 0xff); +} + +fn wrU16LE(w: []u8, v: u16) void { + w[0] = @intCast(v & 0xff); + w[1] = @intCast((v >> 8) & 0xff); +} + +fn wrU32LE(w: []u8, v: u32) void { + w[0] = @intCast(v & 0xff); + w[1] = @intCast((v >> 8) & 0xff); + w[2] = @intCast((v >> 16) & 0xff); + w[3] = @intCast((v >> 24) & 0xff); +} + +fn wrU64LE(w: []u8, v: u64) void { + wrU32LE(w[0..4], @intCast(v & 0xffffffff)); + wrU32LE(w[4..8], @intCast((v >> 32) & 0xffffffff)); +} + +fn errnoError(err: std.c.E) error{ + BadResponse, + BadStatus, + ConnectionClosed, + ConnectFailed, + FrameTooLarge, + InvalidIpAddress, + ServerErrors, + Unexpected, +} { + switch (err) { + .CONNREFUSED, .HOSTUNREACH, .NETUNREACH, .TIMEDOUT => return error.ConnectFailed, + .CONNRESET, .PIPE, .NOTCONN => return error.ConnectionClosed, + else => return error.Unexpected, + } +} diff --git a/bench/wire2_smoke_client.py b/bench/wire2_smoke_client.py new file mode 100644 index 0000000..82b5e38 --- /dev/null +++ b/bench/wire2_smoke_client.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Smoke and throughput probe for TurboDB wire2.""" + +from __future__ import annotations + +import argparse +import json +import socket +import struct +import time +from dataclasses import dataclass +from pathlib import Path + + +OUTER_OP_WIRE2 = 0x20 +WIRE2_VERSION = 2 + +OP_HELLO = 0x00 +OP_PING = 0x01 +OP_GET = 0x02 +OP_PUT = 0x03 +OP_INSERT = 0x04 +OP_DELETE = 0x05 +OP_BATCH_GET = 0x06 +OP_BULK_INSERT = 0x07 +OP_BULK_UPSERT = 0x08 +OP_BULK_DELETE = 0x09 +OP_AUTH = 0x0A + +STATUS_OK = 0x00 +STATUS_NOT_FOUND = 0x01 + + +@dataclass +class Response: + request_id: int + op: int + status: int + body: bytes + + +class Wire2Client: + def __init__(self, host: str, port: int, timeout: float) -> None: + self.sock = socket.create_connection((host, port), timeout=timeout) + self.sock.settimeout(timeout) + self.request_id = 1 + + def close(self) -> None: + self.sock.close() + + def request(self, op: int, body: bytes = b"") -> Response: + rid = self.request_id + self.request_id += 1 + payload = struct.pack("IB", len(payload) + 5, OUTER_OP_WIRE2) + payload + self.sock.sendall(frame) + hdr = self._read_exact(5) + frame_len, outer_op = struct.unpack(">IB", hdr) + if outer_op != OUTER_OP_WIRE2 or frame_len < 21: + raise RuntimeError(f"bad outer response op={outer_op} len={frame_len}") + payload = self._read_exact(frame_len - 5) + version, _flags, resp_id, resp_op, status, body_len = struct.unpack(" bytes: + chunks: list[bytes] = [] + remaining = n + while remaining: + chunk = self.sock.recv(remaining) + if not chunk: + raise RuntimeError("connection closed") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def key_payload(collection: str, key: str) -> bytes: + col = collection.encode() + k = key.encode() + return struct.pack(" bytes: + col = collection.encode() + k = key.encode() + return struct.pack(" bytes: + col = collection.encode() + return struct.pack(" bytes: + k = key.encode() + return struct.pack(" bytes: + k = key.encode() + return struct.pack(" dict[str, int]: + if len(body) != 28: + raise RuntimeError(f"bad stats body length {len(body)}") + inserted, updated, deleted, missing, errors, byte_count = struct.unpack(" bytes: + if len(body) < 13: + raise RuntimeError("bad get body") + value_len = struct.unpack(" list[tuple[int, bytes]]: + if len(body) < 4: + raise RuntimeError("bad batch get body") + count = struct.unpack(" len(body): + raise RuntimeError("truncated batch item") + status = body[pos] + value_len = struct.unpack(" int: + ap = argparse.ArgumentParser(description="TurboDB wire2 smoke client") + ap.add_argument("--host", required=True) + ap.add_argument("--port", type=int, default=27017) + ap.add_argument("--collection", default="wire2_events") + ap.add_argument("--ops", type=int, default=1000) + ap.add_argument("--batch-size", type=int, default=100) + ap.add_argument("--get-rounds", type=int, default=1) + ap.add_argument("--output") + ap.add_argument("--timeout", type=float, default=10.0) + ap.add_argument("--auth-key") + args = ap.parse_args() + + client = Wire2Client(args.host, args.port, args.timeout) + try: + hello = client.request(OP_HELLO) + if hello.status != STATUS_OK: + raise RuntimeError(f"hello failed status={hello.status}") + max_frame, max_response, features = struct.unpack(" 0: + n = min(args.batch_size, remaining) + body = bytearray(collection_batch_prefix(args.collection, n)) + for _ in range(n): + value = json.dumps({"kind": "bulk", "n": key_index}, separators=(",", ":")).encode() + body += kv_record(f"k-{key_index:08d}", value) + key_index += 1 + start = time.perf_counter() + resp = client.request(OP_BULK_INSERT, bytes(body)) + bulk_seconds += time.perf_counter() - start + if resp.status != STATUS_OK: + raise RuntimeError(f"bulk insert failed status={resp.status}") + stats = decode_stats(resp.body) + if stats["errors"] != 0: + raise RuntimeError(f"bulk insert errors: {stats}") + inserted += stats["inserted"] + remaining -= n + + get_batch_size = min(args.batch_size, args.ops) + batch_seconds = 0.0 + batch_items = 0 + found = 0 + not_found = 0 + for round_idx in range(args.get_rounds): + body = bytearray(collection_batch_prefix(args.collection, get_batch_size + 1)) + offset = (round_idx * get_batch_size) % max(args.ops, 1) + for i in range(get_batch_size): + body += key_record(f"k-{(offset + i) % args.ops:08d}") + body += key_record(f"does-not-exist-{round_idx}") + start = time.perf_counter() + batch_resp = client.request(OP_BATCH_GET, bytes(body)) + batch_seconds += time.perf_counter() - start + if batch_resp.status != STATUS_OK: + raise RuntimeError(f"batch get failed status={batch_resp.status}") + items = decode_batch_get(batch_resp.body) + batch_items += len(items) + found += sum(1 for status, _ in items if status == STATUS_OK) + not_found += sum(1 for status, _ in items if status == STATUS_NOT_FOUND) + + summary = { + "status": "ok", + "config": { + "ops": args.ops, + "batch_size": args.batch_size, + "get_rounds": args.get_rounds, + "collection": args.collection, + "auth": bool(args.auth_key), + }, + "hello": { + "max_frame": max_frame, + "max_response": max_response, + "features": features, + }, + "single_put": decode_stats(put.body), + "bulk_insert": { + "inserted": inserted, + "seconds": bulk_seconds, + "ops_sec": inserted / bulk_seconds if bulk_seconds > 0 else None, + }, + "batch_get": { + "items": batch_items, + "found": found, + "not_found": not_found, + "seconds": batch_seconds, + "ops_sec": batch_items / batch_seconds if batch_seconds > 0 else None, + }, + } + if args.output: + out_path = Path(args.output) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + print(json.dumps(summary, indent=2, sort_keys=True)) + return 0 + finally: + client.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build.zig b/build.zig index 53619fc..e65b40f 100644 --- a/build.zig +++ b/build.zig @@ -4,35 +4,58 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); + const nanoapi_dep = b.dependency("nanoapi", .{ + .target = target, + .optimize = optimize, + }); + const nanoapi_mod = nanoapi_dep.module("nanoapi"); + + // ── Compat module (Zig 0.16 compatibility layer) ───────────────────────── + const compat_mod = b.createModule(.{ + .root_source_file = b.path("src/compat.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + // ── Storage modules (WAL, mmap, epoch, seqlock) ───────────────────────── const mmap_mod = b.createModule(.{ .root_source_file = b.path("src/storage/mmap.zig"), .target = target, .optimize = optimize, + .link_libc = true, }); + mmap_mod.addImport("compat", compat_mod); const seqlock_mod = b.createModule(.{ .root_source_file = b.path("src/storage/seqlock.zig"), .target = target, .optimize = optimize, + .link_libc = true, }); + seqlock_mod.addImport("compat", compat_mod); const epoch_mod = b.createModule(.{ .root_source_file = b.path("src/storage/epoch.zig"), .target = target, .optimize = optimize, + .link_libc = true, }); + epoch_mod.addImport("compat", compat_mod); const wal_mod = b.createModule(.{ .root_source_file = b.path("src/storage/wal.zig"), .target = target, .optimize = optimize, + .link_libc = true, }); + wal_mod.addImport("compat", compat_mod); // ── Helper: wire storage imports into a module ────────────────────────── const wireStorage = struct { - fn f(mod: *std.Build.Module, mmap: *std.Build.Module, wal: *std.Build.Module, epoch: *std.Build.Module, seqlock: *std.Build.Module) void { + fn f(mod: *std.Build.Module, mmap: *std.Build.Module, wal: *std.Build.Module, epoch: *std.Build.Module, seqlock: *std.Build.Module, compat: *std.Build.Module) void { mod.addImport("mmap", mmap); mod.addImport("wal", wal); mod.addImport("epoch", epoch); mod.addImport("seqlock", seqlock); + mod.addImport("compat", compat); } }.f; @@ -43,7 +66,8 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - wireStorage(turbodb_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(turbodb_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); + turbodb_mod.addImport("nanoapi", nanoapi_mod); const turbodb = b.addExecutable(.{ .name = "turbodb", @@ -58,7 +82,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - wireStorage(tdb_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(tdb_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const tdb = b.addExecutable(.{ .name = "tdb", @@ -78,7 +102,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - wireStorage(ffi_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(ffi_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const lib = b.addLibrary(.{ .linkage = .dynamic, @@ -97,6 +121,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); + zagdb_mod.addImport("compat", compat_mod); const zagdb = b.addExecutable(.{ .name = "zagdb", @@ -116,6 +141,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + reg_test_mod.addImport("compat", compat_mod); const reg_tests = b.addTest(.{ .name = "zagdb-tests", .root_module = reg_test_mod, @@ -131,6 +157,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); + zag_mod.addImport("compat", compat_mod); const zag_exe = b.addExecutable(.{ .name = "zag", @@ -156,7 +183,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - wireStorage(scale_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(scale_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const scale_exe = b.addExecutable(.{ .name = "scale-bench", @@ -174,10 +201,10 @@ pub fn build(b: *std.Build) void { const profile_mod = b.createModule(.{ .root_source_file = b.path("src/profile_index.zig"), .target = target, - .optimize = .ReleaseSafe, // ALWAYS safe — catches segfaults + .optimize = .ReleaseSafe, // ALWAYS safe — catches segfaults .link_libc = true, }); - wireStorage(profile_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(profile_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const profile_exe = b.addExecutable(.{ .name = "profile", @@ -198,7 +225,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - wireStorage(bench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(bench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const bench_exe = b.addExecutable(.{ .name = "bench-native", @@ -211,6 +238,55 @@ pub fn build(b: *std.Build) void { const bench_step = b.step("bench", "Run native Zig benchmark"); bench_step.dependOn(&bench_run.step); + // ── NanoAPI agent proxy benchmark service ────────────────────────────── + const nano_proxy_mod = b.createModule(.{ + .root_source_file = b.path("bench/nanoapi_agent_proxy.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + nano_proxy_mod.addImport("nanoapi", nanoapi_mod); + + const nano_proxy_exe = b.addExecutable(.{ + .name = "nanoapi-agent-proxy", + .root_module = nano_proxy_mod, + }); + b.installArtifact(nano_proxy_exe); + + // ── Wire2 native performance client ──────────────────────────────────── + const wire2_perf_mod = b.createModule(.{ + .root_source_file = b.path("bench/wire2_perf_client.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + wire2_perf_mod.addImport("compat", compat_mod); + + const wire2_perf_exe = b.addExecutable(.{ + .name = "wire2-perf-client", + .root_module = wire2_perf_mod, + }); + b.installArtifact(wire2_perf_exe); + + // ── WAL microbenchmark ────────────────────────────────────────────────── + const walbench_mod = b.createModule(.{ + .root_source_file = b.path("src/bench_wal.zig"), + .target = target, + .optimize = .ReleaseFast, + .link_libc = true, + }); + wireStorage(walbench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); + + const walbench_exe = b.addExecutable(.{ + .name = "bench-wal", + .root_module = walbench_mod, + }); + b.installArtifact(walbench_exe); + + const walbench_run = b.addRunArtifact(walbench_exe); + const walbench_step = b.step("bench-wal", "Run WAL microbenchmark"); + walbench_step.dependOn(&walbench_run.step); + // ── Regression benchmark ──────────────────────────────────────────────── const regbench_mod = b.createModule(.{ .root_source_file = b.path("src/bench_regression.zig"), @@ -218,7 +294,7 @@ pub fn build(b: *std.Build) void { .optimize = .ReleaseFast, .link_libc = true, }); - wireStorage(regbench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(regbench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const regbench_exe = b.addExecutable(.{ .name = "bench-regression", @@ -238,7 +314,7 @@ pub fn build(b: *std.Build) void { .optimize = .ReleaseFast, .link_libc = true, }); - wireStorage(partbench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(partbench_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const partbench_exe = b.addExecutable(.{ .name = "bench-partition", @@ -258,7 +334,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); - wireStorage(calvin_test_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + wireStorage(calvin_test_mod, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const calvin_test_exe = b.addExecutable(.{ .name = "test-calvin", @@ -271,21 +347,22 @@ pub fn build(b: *std.Build) void { const calvin_test_step = b.step("test-calvin", "Run Calvin replication E2E test"); calvin_test_step.dependOn(&calvin_test_run.step); - // ── Test steps ────────────────────────────────────────────────────────── // Helper: add a test module with storage imports const addTestMod = struct { - fn f(b2: *std.Build, src: []const u8, tgt: std.Build.ResolvedTarget, opt: std.builtin.OptimizeMode, mm: *std.Build.Module, wl: *std.Build.Module, ep: *std.Build.Module, sl: *std.Build.Module) *std.Build.Step.Compile { + fn f(b2: *std.Build, src: []const u8, tgt: std.Build.ResolvedTarget, opt: std.builtin.OptimizeMode, mm: *std.Build.Module, wl: *std.Build.Module, ep: *std.Build.Module, sl: *std.Build.Module, cm: *std.Build.Module) *std.Build.Step.Compile { const mod = b2.createModule(.{ .root_source_file = b2.path(src), .target = tgt, .optimize = opt, + .link_libc = true, }); mod.addImport("mmap", mm); mod.addImport("wal", wl); mod.addImport("epoch", ep); mod.addImport("seqlock", sl); + mod.addImport("compat", cm); // Extract just the filename without path for the test name. const basename = std.fs.path.stem(src); return b2.addTest(.{ .name = basename, .root_module = mod }); @@ -297,7 +374,9 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/doc.zig"), .target = target, .optimize = optimize, + .link_libc = true, }); + test_mod.addImport("compat", compat_mod); const unit_tests = b.addTest(.{ .name = "turbodb-tests", .root_module = test_mod, @@ -341,18 +420,25 @@ pub fn build(b: *std.Build) void { test_all_step.dependOn(&run_tests.step); for (new_test_files) |src| { - const t = addTestMod(b, src, target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + const t = addTestMod(b, src, target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const run_t = b.addRunArtifact(t); test_all_step.dependOn(&run_t.step); } // Also add collection test with storage imports - const col_test = addTestMod(b, "src/collection.zig", target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + const col_test = addTestMod(b, "src/collection.zig", target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const run_col_test = b.addRunArtifact(col_test); test_all_step.dependOn(&run_col_test.step); + // Wire protocol tests, including wire2 envelope and binary bulk routes. + const wire_test = addTestMod(b, "src/wire.zig", target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); + const run_wire_test = b.addRunArtifact(wire_test); + const wire_test_step = b.step("test-wire", "Run wire protocol tests"); + wire_test_step.dependOn(&run_wire_test.step); + test_all_step.dependOn(&run_wire_test.step); + // Parallel WAL test - const pwal_test = addTestMod(b, "src/storage/parallel_wal.zig", target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod); + const pwal_test = addTestMod(b, "src/storage/parallel_wal.zig", target, optimize, mmap_mod, wal_mod, epoch_mod, seqlock_mod, compat_mod); const run_pwal_test = b.addRunArtifact(pwal_test); test_all_step.dependOn(&run_pwal_test.step); } diff --git a/build.zig.zon b/build.zig.zon index c5e2840..8f30a30 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,8 +2,12 @@ .name = .turbodb, .version = "0.1.0", .fingerprint = 0x2d3f19d996a45887, - .minimum_zig_version = "0.15.0", - .dependencies = .{}, + .minimum_zig_version = "0.16.0", + .dependencies = .{ + .nanoapi = .{ + .path = "../nanoapi", + }, + }, .paths = .{ "build.zig", "build.zig.zon", diff --git a/optimizations/docs.md b/optimizations/docs.md index 3f1d428..ec8ecb2 100644 --- a/optimizations/docs.md +++ b/optimizations/docs.md @@ -50,6 +50,23 @@ compare_branches → side-by-side multi-branch review - 6 HTTP endpoints (`/branch/:col/...`) - CDC auto-triggers on merge via existing insert→emitChange pipeline +## TigerBeetle Contrast + +Reviewed against `tigerbeetle/tigerbeetle@210336d2dc3c68e56d285f0cf380b86cb54a7d28` +through `codedb_remote`. + +| # | Title | What | Key Technique | +|---|---|---|---| +| [7](optimization7_tigerbeetle_resource_bounds.md) | Tenant Resource Bounds | Predictable multi-tenant agent load | API-key tenant context + bounded admission before allocation/mutation | +| [8](optimization8_tigerbeetle_deterministic_recovery.md) | Deterministic Recovery Tests | Crash/fault confidence for WAL, branches, tenants | Seeded operation model + WAL truncation/corruption replay checks | +| [9](optimization9_tigerbeetle_batch_wire.md) | Batch-Shaped Wire Path | Higher swarm throughput | Bounded binary batch op that amortizes parse, dispatch, WAL, and response writes | + +**Contrast point:** TigerBeetle is a specialized replicated ledger with +deterministic physical repair, static resource bounds, and batch-first execution. +TurboDB is a general document/code/vector database for agent workloads. The +borrowed optimization is not the ledger data model; it is the discipline around +bounded resources, deterministic replay tests, and batching. + ## Architecture ``` diff --git a/src/activity.zig b/src/activity.zig index 2f26f99..8c46704 100644 --- a/src/activity.zig +++ b/src/activity.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); pub const ResourceState = enum(u8) { deep_sleep, @@ -13,7 +14,7 @@ pub const ActivityTracker = struct { queries_in_window: std.atomic.Value(u64), pub fn init() ActivityTracker { - const now = std.time.milliTimestamp(); + const now = compat.milliTimestamp(); return .{ .last_query_ms = std.atomic.Value(i64).init(now), .window_start_ms = std.atomic.Value(i64).init(now), @@ -22,7 +23,7 @@ pub const ActivityTracker = struct { } pub fn recordQuery(self: *ActivityTracker) void { - const now = std.time.milliTimestamp(); + const now = compat.milliTimestamp(); self.last_query_ms.store(now, .release); const start = self.window_start_ms.load(.acquire); @@ -43,7 +44,7 @@ pub const ActivityTracker = struct { } pub fn state(self: *const ActivityTracker) ResourceState { - const now = std.time.milliTimestamp(); + const now = compat.milliTimestamp(); const idle_ms = now - self.last_query_ms.load(.acquire); const qps = self.queries_in_window.load(.acquire); if (idle_ms >= 30 * 60 * 1000) return .deep_sleep; @@ -55,13 +56,13 @@ pub const ActivityTracker = struct { test "activity tracker state machine transitions" { var tracker = ActivityTracker.init(); - tracker.last_query_ms.store(std.time.milliTimestamp() - 31 * 60 * 1000, .release); + tracker.last_query_ms.store(compat.milliTimestamp() - 31 * 60 * 1000, .release); try std.testing.expectEqual(ResourceState.deep_sleep, tracker.state()); - tracker.last_query_ms.store(std.time.milliTimestamp() - 2 * 60 * 1000, .release); + tracker.last_query_ms.store(compat.milliTimestamp() - 2 * 60 * 1000, .release); try std.testing.expectEqual(ResourceState.light_sleep, tracker.state()); - tracker.last_query_ms.store(std.time.milliTimestamp(), .release); + tracker.last_query_ms.store(compat.milliTimestamp(), .release); tracker.queries_in_window.store(150, .release); try std.testing.expectEqual(ResourceState.hot, tracker.state()); diff --git a/src/art.zig b/src/art.zig index 7197d21..27c5a70 100644 --- a/src/art.zig +++ b/src/art.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const btree = @import("btree.zig"); pub const BTreeEntry = btree.BTreeEntry; @@ -1106,7 +1107,7 @@ test "ART: concurrent insert and search" { // Serialize all writes with a mutex. ART's optimistic lock coupling // provides safe concurrent reads alongside a single writer. - var write_mu = std.Thread.Mutex{}; + var write_mu = compat.Mutex{}; // Pre-insert keys under mutex (single-threaded here, but same pattern) for (0..keys_per_thread) |i| { @@ -1121,7 +1122,7 @@ test "ART: concurrent insert and search" { const Context = struct { tree: *ART, thread_id: usize, - mu: *std.Thread.Mutex, + mu: *compat.Mutex, fn insertWork(ctx: @This()) void { const base = (ctx.thread_id + 1) * 10000; diff --git a/src/auth.zig b/src/auth.zig index 10f0550..fd0a91e 100644 --- a/src/auth.zig +++ b/src/auth.zig @@ -8,6 +8,7 @@ /// /// No auth configured → open access (dev mode). const std = @import("std"); +const compat = @import("compat"); const crypto = @import("crypto.zig"); const Allocator = std.mem.Allocator; @@ -45,7 +46,7 @@ pub const AuthStore = struct { keys: [MAX_KEYS]KeyEntry = undefined, count: u32 = 0, enabled: bool = false, - lock: std.Thread.RwLock = .{}, + lock: compat.RwLock = .{}, /// Add an API key. Returns the BLAKE3 hash for storage. pub fn addKey(self: *AuthStore, raw_key: []const u8, name: []const u8, perm: Permission) [32]u8 { diff --git a/src/bench_native.zig b/src/bench_native.zig index 97b0113..66c15f1 100644 --- a/src/bench_native.zig +++ b/src/bench_native.zig @@ -1,10 +1,11 @@ const std = @import("std"); +const compat = @import("compat"); const client = @import("client.zig"); pub fn main() !void { const alloc = std.heap.c_allocator; const data_dir = "/tmp/turbodb_native_bench"; - std.fs.cwd().deleteTree(data_dir) catch {}; + compat.cwd().deleteTree(data_dir) catch {}; var db = try client.Db.open(alloc, data_dir); defer db.close(); @@ -17,37 +18,37 @@ pub fn main() !void { const value = "{}"; // ── INSERT benchmark ── - const t0 = std.time.nanoTimestamp(); + const t0 = compat.nanoTimestamp(); var i: usize = 0; while (i < N) : (i += 1) { const k = std.fmt.bufPrint(&key_buf, "k{d:0>9}", .{i}) catch continue; _ = db.insert("bench", k, value) catch continue; } - const t1 = std.time.nanoTimestamp(); + const t1 = compat.nanoTimestamp(); const insert_ns: f64 = @floatFromInt(@as(i128, t1 - t0)); const insert_ops: f64 = @as(f64, @floatFromInt(N)) / (insert_ns / 1e9); // ── GET benchmark (random lookups) ── var rng = std.Random.DefaultPrng.init(42); - const t2 = std.time.nanoTimestamp(); + const t2 = compat.nanoTimestamp(); var g: usize = 0; while (g < N) : (g += 1) { const idx = rng.random().intRangeAtMost(usize, 0, N - 1); const k = std.fmt.bufPrint(&key_buf, "k{d:0>9}", .{idx}) catch continue; _ = db.get("bench", k) catch null; } - const t3 = std.time.nanoTimestamp(); + const t3 = compat.nanoTimestamp(); const get_ns: f64 = @floatFromInt(@as(i128, t3 - t2)); const get_ops: f64 = @as(f64, @floatFromInt(N)) / (get_ns / 1e9); // ── SEARCH benchmark (brute-force path since trigram index is empty) ── - const t4 = std.time.nanoTimestamp(); + const t4 = compat.nanoTimestamp(); var s: usize = 0; while (s < 1000) : (s += 1) { var sr = db.search("bench", "k00000", 10, alloc) catch continue; sr.deinit(); } - const t5 = std.time.nanoTimestamp(); + const t5 = compat.nanoTimestamp(); const search_ns: f64 = @floatFromInt(@as(i128, t5 - t4)); const search_ops: f64 = 1000.0 / (search_ns / 1e9); @@ -59,5 +60,5 @@ pub fn main() !void { std.debug.print(" SEARCH : {d:.0} ops/s ({d:.1}us/op)\n", .{ search_ops, search_ns / 1000.0 / 1000.0 }); std.debug.print("\n", .{}); - std.fs.cwd().deleteTree(data_dir) catch {}; + compat.cwd().deleteTree(data_dir) catch {}; } diff --git a/src/bench_partition.zig b/src/bench_partition.zig index 6de7d38..174d800 100644 --- a/src/bench_partition.zig +++ b/src/bench_partition.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const collection_mod = @import("collection.zig"); const partition_mod = @import("partition.zig"); const doc_mod = @import("doc.zig"); @@ -74,7 +75,7 @@ fn record(name_src: []const u8, json_src: []const u8, ops: usize, elapsed_ns: i1 // ─── Timing helper ────────────────────────────────────────────────────────── inline fn now() i128 { - return std.time.nanoTimestamp(); + return compat.nanoTimestamp(); } // ─── Partition benchmark for a given partition count ───────────────────────── @@ -84,10 +85,10 @@ fn benchPartitionCount(n_partitions: u16) !void { const data_dir = std.fmt.bufPrint(&dir_buf, "/tmp/turbodb_partition_bench_{d}", .{n_partitions}) catch return; // Cleanup any previous run - std.fs.cwd().deleteTree(data_dir) catch {}; + compat.cwd().deleteTree(data_dir) catch {}; // Create data dir - std.fs.cwd().makeDir(data_dir) catch |e| switch (e) { + compat.cwd().makeDir(data_dir) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return e, }; @@ -181,14 +182,14 @@ fn benchPartitionCount(n_partitions: u16) !void { pc.close(); wal_log.close(); epochs.deinit(); - std.fs.cwd().deleteTree(data_dir) catch {}; + compat.cwd().deleteTree(data_dir) catch {}; } // ─── JSON emitter ─────────────────────────────────────────────────────────── fn emitJSON() void { // Timestamp - const ts = std.time.timestamp(); + const ts = compat.timestamp(); const epoch_secs: u64 = @intCast(ts); const secs_in_day: u64 = 86400; const days = epoch_secs / secs_in_day; @@ -224,7 +225,7 @@ fn emitJSON() void { // Also write to a JSON file for CI consumption const json_path = "/tmp/turbodb_shard_bench.json"; - const file = std.fs.cwd().createFile(json_path, .{}) catch return; + const file = compat.cwd().createFile(json_path, .{}) catch return; defer file.close(); var buf: [16384]u8 = undefined; @@ -282,6 +283,6 @@ pub fn main() !void { for (partition_counts) |n| { var dir_buf: [128]u8 = undefined; const data_dir = std.fmt.bufPrint(&dir_buf, "/tmp/turbodb_partition_bench_{d}", .{n}) catch continue; - std.fs.cwd().deleteTree(data_dir) catch {}; + compat.cwd().deleteTree(data_dir) catch {}; } } diff --git a/src/bench_regression.zig b/src/bench_regression.zig index 4b80358..0a7783f 100644 --- a/src/bench_regression.zig +++ b/src/bench_regression.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const client = @import("client.zig"); const compression = @import("compression.zig"); const art_mod = @import("art.zig"); @@ -57,13 +58,13 @@ fn record(name: []const u8, json_key: []const u8, ops: usize, elapsed_ns: i128) // ─── Timing helper ────────────────────────────────────────────────────────── inline fn now() i128 { - return std.time.nanoTimestamp(); + return compat.nanoTimestamp(); } // ─── Benchmark: Core Path (INSERT / GET / UPDATE / DELETE) ────────────────── fn benchCorePath() !void { - std.fs.cwd().deleteTree(DATA_DIR) catch {}; + compat.cwd().deleteTree(DATA_DIR) catch {}; var db = try client.Db.open(alloc, DATA_DIR); defer db.close(); @@ -114,7 +115,7 @@ fn benchCorePath() !void { record("Core DELETE", "core_delete_ops_sec", N, now() - t0); } - std.fs.cwd().deleteTree(DATA_DIR) catch {}; + compat.cwd().deleteTree(DATA_DIR) catch {}; } // ─── Benchmark: Compression ───────────────────────────────────────────────── @@ -251,11 +252,11 @@ fn benchQuery() void { fn benchLSM() !void { const N: usize = 100_000; - std.fs.cwd().deleteTree(LSM_DIR) catch {}; + compat.cwd().deleteTree(LSM_DIR) catch {}; var lsm = try lsm_mod.LSMTree.init(alloc, LSM_DIR); defer { lsm.deinit(); - std.fs.cwd().deleteTree(LSM_DIR) catch {}; + compat.cwd().deleteTree(LSM_DIR) catch {}; } // Put @@ -288,7 +289,7 @@ fn benchLSM() !void { { // Re-init with fresh data for flush timing lsm.deinit(); - std.fs.cwd().deleteTree(LSM_DIR) catch {}; + compat.cwd().deleteTree(LSM_DIR) catch {}; lsm = try lsm_mod.LSMTree.init(alloc, LSM_DIR); // Fill memtable to ~4MB @@ -408,7 +409,7 @@ fn benchMVCC() !void { fn emitJSON() void { // Timestamp - const ts = std.time.timestamp(); + const ts = compat.timestamp(); const epoch_secs: u64 = @intCast(ts); const secs_in_day: u64 = 86400; const days = epoch_secs / secs_in_day; @@ -436,7 +437,7 @@ fn emitJSON() void { // Also write to a JSON file for CI consumption using fmt.bufPrint + writeAll const json_path = "/tmp/turbodb_regression_bench.json"; - const file = std.fs.cwd().createFile(json_path, .{}) catch return; + const file = compat.cwd().createFile(json_path, .{}) catch return; defer file.close(); var buf: [8192]u8 = undefined; @@ -501,6 +502,6 @@ pub fn main() !void { emitJSON(); // Cleanup - std.fs.cwd().deleteTree(DATA_DIR) catch {}; - std.fs.cwd().deleteTree(LSM_DIR) catch {}; + compat.cwd().deleteTree(DATA_DIR) catch {}; + compat.cwd().deleteTree(LSM_DIR) catch {}; } diff --git a/src/bench_wal.zig b/src/bench_wal.zig new file mode 100644 index 0000000..656c7ec --- /dev/null +++ b/src/bench_wal.zig @@ -0,0 +1,152 @@ +const std = @import("std"); +const compat = @import("compat"); +const wal_mod = @import("wal"); +const parallel_wal_mod = @import("storage/parallel_wal.zig"); + +const BASE_DIR = "/tmp/turbodb_wal_bench"; +const PARALLEL_DIR = "/tmp/turbodb_wal_bench/parallel"; +const WRITE_ONLY_PATH: [:0]const u8 = "/tmp/turbodb_wal_bench/write_only.wal"; +const COMMIT_PATH: [:0]const u8 = "/tmp/turbodb_wal_bench/commit.wal"; + +const WRITE_ONLY_ITERS: usize = 50_000; +const COMMIT_ITERS: usize = 1_000; +const PARALLEL_SEGMENTS: u32 = 8; +const PARALLEL_ENTRIES_PER_SEGMENT: usize = 512; +const PAYLOAD_SIZE: usize = 64; + +const Result = struct { + name: []const u8, + iterations: usize, + bytes: usize, + elapsed_ns: u64, + uring_active: bool, +}; + +fn paddedEntrySize(payload_len: usize) usize { + const raw = wal_mod.HEADER_SIZE + payload_len; + return raw + ((8 - (raw % 8)) % 8); +} + +fn elapsedSince(start_ns: i128) u64 { + return @intCast(compat.nanoTimestamp() - start_ns); +} + +fn printResult(result: Result) void { + const ns_f: f64 = @floatFromInt(result.elapsed_ns); + const ops_f: f64 = @floatFromInt(result.iterations); + const ops_s = ops_f / (ns_f / 1e9); + const us_op = ns_f / ops_f / 1000.0; + const mb_s = (@as(f64, @floatFromInt(result.bytes)) / (1024.0 * 1024.0)) / (ns_f / 1e9); + + std.debug.print(" {s:<32} {d:>12.0} ops/s {d:>8.2} us/op {d:>8.1} MiB/s io={s}\n", .{ + result.name, + ops_s, + us_op, + mb_s, + if (result.uring_active) "uring" else "sync", + }); +} + +fn benchWriteOnly(allocator: std.mem.Allocator, payload: []const u8) !Result { + var wal = try wal_mod.WAL.open(WRITE_ONLY_PATH, allocator); + defer wal.close(); + + const start_ns = compat.nanoTimestamp(); + var i: usize = 0; + while (i < WRITE_ONLY_ITERS) : (i += 1) { + _ = try wal.write( + @intCast(i + 1), + .doc_insert, + wal_mod.DB_TAG_DOC, + wal_mod.FLAG_COMMIT, + payload, + ); + } + wal.flushPending(); + + return .{ + .name = "buffered append plus flush", + .iterations = WRITE_ONLY_ITERS, + .bytes = WRITE_ONLY_ITERS * paddedEntrySize(payload.len), + .elapsed_ns = elapsedSince(start_ns), + .uring_active = wal.usedLinuxUring(), + }; +} + +fn benchCommit(allocator: std.mem.Allocator, payload: []const u8) !Result { + var wal = try wal_mod.WAL.open(COMMIT_PATH, allocator); + defer wal.close(); + + const bytes_per_txn = paddedEntrySize(payload.len) + paddedEntrySize(16); + const start_ns = compat.nanoTimestamp(); + var i: usize = 0; + while (i < COMMIT_ITERS) : (i += 1) { + const txn_id: u64 = @intCast(i + 1); + _ = try wal.write(txn_id, .doc_insert, wal_mod.DB_TAG_DOC, 0, payload); + try wal.commit(txn_id, wal_mod.DB_TAG_DOC); + } + + return .{ + .name = "commit plus fsync", + .iterations = COMMIT_ITERS, + .bytes = COMMIT_ITERS * bytes_per_txn, + .elapsed_ns = elapsedSince(start_ns), + .uring_active = wal.usedLinuxUring(), + }; +} + +fn benchParallelGroupCommit(allocator: std.mem.Allocator, payload: []const u8) !Result { + try compat.cwd().makePath(PARALLEL_DIR); + var wal = try parallel_wal_mod.ParallelWAL.init(allocator, PARALLEL_DIR, PARALLEL_SEGMENTS); + defer wal.deinit(); + + var seg_i: u32 = 0; + while (seg_i < PARALLEL_SEGMENTS) : (seg_i += 1) { + var entry_i: usize = 0; + while (entry_i < PARALLEL_ENTRIES_PER_SEGMENT) : (entry_i += 1) { + _ = try wal.segments[seg_i].append( + .put, + (@as(u64, seg_i) << 32) | @as(u64, @intCast(entry_i)), + payload, + ); + } + } + + const bytes = @as(usize, PARALLEL_SEGMENTS) * + PARALLEL_ENTRIES_PER_SEGMENT * + (13 + payload.len); + const start_ns = compat.nanoTimestamp(); + try wal.groupCommit(); + + return .{ + .name = "parallel segment group commit", + .iterations = @as(usize, PARALLEL_SEGMENTS) * PARALLEL_ENTRIES_PER_SEGMENT, + .bytes = bytes, + .elapsed_ns = elapsedSince(start_ns), + .uring_active = wal.usingLinuxUring(), + }; +} + +pub fn main() !void { + const allocator = std.heap.smp_allocator; + + compat.cwd().deleteTree(BASE_DIR) catch {}; + try compat.cwd().makePath(BASE_DIR); + defer compat.cwd().deleteTree(BASE_DIR) catch {}; + + var payload: [PAYLOAD_SIZE]u8 = undefined; + @memset(payload[0..], 0xA5); + + const write_only = try benchWriteOnly(allocator, payload[0..]); + const commit = try benchCommit(allocator, payload[0..]); + const parallel = try benchParallelGroupCommit(allocator, payload[0..]); + + std.debug.print("\nTurboDB WAL Microbenchmark\n", .{}); + std.debug.print(" payload: {d} bytes\n", .{PAYLOAD_SIZE}); + std.debug.print(" write-only iterations: {d}\n", .{WRITE_ONLY_ITERS}); + std.debug.print(" commit iterations: {d}\n\n", .{COMMIT_ITERS}); + printResult(write_only); + printResult(commit); + printResult(parallel); + std.debug.print("\n", .{}); +} diff --git a/src/branch.zig b/src/branch.zig index 9fa7f88..1e1f748 100644 --- a/src/branch.zig +++ b/src/branch.zig @@ -5,6 +5,7 @@ //! Merging applies branch changes to main with conflict detection. const std = @import("std"); +const compat = @import("compat"); const Allocator = std.mem.Allocator; pub const BranchStatus = enum(u8) { @@ -54,14 +55,17 @@ pub const Branch = struct { /// Write a key-value pair on this branch (CoW — only stores the delta) pub fn write(self: *Branch, key: []const u8, value: []const u8, epoch: u64) !void { const key_hash = fnv1a(key); - // Free old write if exists + // Allocate new copies BEFORE freeing old ones — if alloc fails, + // the existing entry stays valid. + const owned_key = try self.allocator.dupe(u8, key); + errdefer self.allocator.free(owned_key); + const owned_val = try self.allocator.dupe(u8, value); + errdefer self.allocator.free(owned_val); + // Free old write if exists (safe — new copies already allocated) if (self.writes.getPtr(key_hash)) |old| { if (old.key.len > 0) self.allocator.free(old.key); if (old.value.len > 0) self.allocator.free(old.value); } - const owned_key = try self.allocator.dupe(u8, key); - errdefer self.allocator.free(owned_key); - const owned_val = try self.allocator.dupe(u8, value); try self.writes.put(key_hash, .{ .key = owned_key, .value = owned_val, @@ -428,7 +432,7 @@ pub const BranchManager = struct { .name = undefined, .name_len = name_len, .base_epoch = self.next_epoch.load(.acquire), - .created_at = std.time.milliTimestamp(), + .created_at = compat.milliTimestamp(), .status = .active, .agent_id = undefined, .agent_id_len = aid_len, diff --git a/src/branching.zig b/src/branching.zig index dd797ef..23273a7 100644 --- a/src/branching.zig +++ b/src/branching.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const collection = @import("collection.zig"); const cdc_mod = @import("cdc.zig"); const doc_mod = @import("doc.zig"); @@ -116,20 +117,11 @@ pub const BranchedDatabase = struct { const branches_dir = try std.fmt.allocPrint(alloc, "{s}/branches", .{base_data_dir}); defer alloc.free(branches_dir); - std.fs.cwd().makePath(branches_dir) catch |e| switch (e) { - error.PathAlreadyExists => {}, - else => return e, - }; - std.fs.cwd().makePath(branch_root) catch |e| switch (e) { - error.PathAlreadyExists => {}, - else => return e, - }; - std.fs.cwd().makePath(overlay_dir) catch |e| switch (e) { - error.PathAlreadyExists => {}, - else => return e, - }; + try compat.cwd().makePath(branches_dir); + try compat.cwd().makePath(branch_root); + try compat.cwd().makePath(overlay_dir); - var file = try std.fs.createFileAbsolute(manifest_path, .{ .truncate = true }); + var file = try compat.createFileAbsolute(manifest_path, .{ .truncate = true }); defer file.close(); try file.writeAll(base_data_dir); @@ -142,7 +134,7 @@ pub const BranchedDatabase = struct { pub fn open(alloc: std.mem.Allocator, branch_root: []const u8) !BranchedDatabase { const manifest_path = try std.fmt.allocPrint(alloc, "{s}/manifest.txt", .{branch_root}); defer alloc.free(manifest_path); - const parent_path = try std.fs.cwd().readFileAlloc(alloc, manifest_path, 4096); + const parent_path = try compat.cwd().readFileAlloc(alloc, manifest_path, 4096); errdefer alloc.free(parent_path); const overlay_dir = try std.fmt.allocPrint(alloc, "{s}/overlay", .{branch_root}); errdefer alloc.free(overlay_dir); @@ -188,7 +180,7 @@ pub const BranchedDatabase = struct { const snapshot_overlay = try std.fmt.allocPrint(self.alloc, "{s}/overlay", .{snapshot_root}); errdefer self.alloc.free(snapshot_overlay); - try std.fs.cwd().makePath(snapshot_overlay); + try compat.cwd().makePath(snapshot_overlay); try copyDirFilesAbsolute(self.overlay_dir, snapshot_overlay); return .{ @@ -222,7 +214,7 @@ pub const PublishedSnapshot = struct { const manifest_path = try std.fmt.allocPrint(alloc, "{s}/manifest.txt", .{replica_root}); defer alloc.free(manifest_path); - try std.fs.cwd().makePath(replica_overlay); + try compat.cwd().makePath(replica_overlay); try writeFileAbsolute(manifest_path, self.parent_path); try copyDirFilesAbsolute(self.overlay_dir, replica_overlay); @@ -236,7 +228,7 @@ fn branchNameFromRoot(branch_root: []const u8) []const u8 { } fn copyDirFilesAbsolute(src_dir: []const u8, dest_dir: []const u8) !void { - var src = try std.fs.openDirAbsolute(src_dir, .{ .iterate = true }); + var src = try compat.openDirAbsolute(src_dir, .{ .iterate = true }); defer src.close(); var it = src.iterate(); @@ -253,17 +245,28 @@ fn copyDirFilesAbsolute(src_dir: []const u8, dest_dir: []const u8) !void { } fn copyFileAbsolute(src_path: []const u8, dest_path: []const u8) !void { - var src_file = try std.fs.openFileAbsolute(src_path, .{}); + var src_file = try compat.openFileAbsolute(src_path, .{}); defer src_file.close(); - var dst_file = try std.fs.createFileAbsolute(dest_path, .{ .truncate = true }); + var dst_file = try compat.createFileAbsolute(dest_path, .{ .truncate = true }); defer dst_file.close(); const stat = try src_file.stat(); - _ = try src_file.copyRangeAll(0, dst_file, 0, stat.size); + const buf_len: usize = @min(@as(usize, 64 * 1024), @max(@as(usize, 1), @as(usize, @intCast(stat.size)))); + const buf = try std.heap.page_allocator.alloc(u8, buf_len); + defer std.heap.page_allocator.free(buf); + + var remaining = stat.size; + while (remaining > 0) { + const want: usize = @intCast(@min(remaining, buf.len)); + const n = try src_file.read(buf[0..want]); + if (n == 0) return error.UnexpectedEof; + try dst_file.writeAll(buf[0..n]); + remaining -= @intCast(n); + } } fn writeFileAbsolute(path: []const u8, content: []const u8) !void { - var file = try std.fs.createFileAbsolute(path, .{ .truncate = true }); + var file = try compat.createFileAbsolute(path, .{ .truncate = true }); defer file.close(); try file.writeAll(content); } @@ -271,9 +274,9 @@ fn writeFileAbsolute(path: []const u8, content: []const u8) !void { test "branched database reads from base and writes to overlay" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_branching_test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const base = try Database.open(alloc, tmp_dir); defer base.close(); @@ -296,9 +299,9 @@ test "branched database reads from base and writes to overlay" { test "published snapshot retains branch source metadata" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_branch_publish"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const base = try Database.open(alloc, tmp_dir); defer base.close(); @@ -317,11 +320,11 @@ test "published snapshot materializes embedded replica state" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_branch_replica"; const replica_root = "/tmp/turbodb_branch_replica_embedded"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - std.fs.cwd().deleteTree(replica_root) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(replica_root) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(replica_root) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(replica_root) catch {}; const base = try Database.open(alloc, tmp_dir); defer base.close(); diff --git a/src/btree.zig b/src/btree.zig index 14f4fca..1581045 100644 --- a/src/btree.zig +++ b/src/btree.zig @@ -11,6 +11,7 @@ /// BTreeEntry = {key_hash u64, page_no u32, page_off u16, doc_id u64} = 22 bytes /// max entries per leaf = (PAGE_USABLE - 2) / 22 = 184 const std = @import("std"); +const compat = @import("compat"); const page_mod = @import("page.zig"); const PageFile = page_mod.PageFile; const PAGE_USABLE = page_mod.PAGE_USABLE; @@ -19,12 +20,14 @@ const PAGE_USABLE = page_mod.PAGE_USABLE; pub const BTreeEntry = extern struct { key_hash: u64 align(8), // 0 - doc_id: u64, // 8 - page_no: u32, // 16 - page_off: u16, // 20 + doc_id: u64, // 8 + page_no: u32, // 16 + page_off: u16, // 20 // total: 24 (extern struct padded to align(8)) pub const size = @sizeOf(BTreeEntry); - comptime { std.debug.assert(size == 24); } + comptime { + std.debug.assert(size == 24); + } }; const MAX_ENTRIES_PER_LEAF: usize = (PAGE_USABLE - 2) / BTreeEntry.size; @@ -36,8 +39,8 @@ const MIN_KEYS: usize = MAX_KEYS_PER_INTERNAL / 2; pub const BTree = struct { pf: *PageFile, - root: u32, // root page number (0 = not yet created) - mu: std.Thread.RwLock, + root: u32, // root page number (0 = not yet created) + mu: compat.RwLock, pub fn init(pf: *PageFile, root_page: u32) BTree { return .{ .pf = pf, .root = root_page, .mu = .{} }; @@ -72,6 +75,19 @@ pub const BTree = struct { self.mu.lock(); defer self.mu.unlock(); + try self.insertLocked(entry); + } + + pub fn insertMany(self: *BTree, entries: []const BTreeEntry) !void { + if (entries.len == 0) return; + + self.mu.lock(); + defer self.mu.unlock(); + + for (entries) |entry| try self.insertLocked(entry); + } + + fn insertLocked(self: *BTree, entry: BTreeEntry) !void { if (self.root == 0) { // Create first root leaf (btree_leaf, NOT document leaf). self.root = try self.pf.allocPage(.btree_leaf); @@ -114,8 +130,7 @@ pub const BTree = struct { var hi: usize = n; while (lo < hi) { const mid = lo + (hi - lo) / 2; - if (entries[mid].key_hash < entry.key_hash) lo = mid + 1 - else hi = mid; + if (entries[mid].key_hash < entry.key_hash) lo = mid + 1 else hi = mid; } if (lo < n and entries[lo].key_hash == entry.key_hash) { // Overwrite. @@ -221,8 +236,7 @@ fn searchLeaf(data: []const u8, key_hash: u64) ?BTreeEntry { var hi: usize = n; while (lo < hi) { const mid = lo + (hi - lo) / 2; - if (entries[mid].key_hash < key_hash) lo = mid + 1 - else hi = mid; + if (entries[mid].key_hash < key_hash) lo = mid + 1 else hi = mid; } if (lo < n and entries[lo].key_hash == key_hash) return entries[lo]; return null; @@ -235,8 +249,7 @@ fn leafDelete(data: []u8, key_hash: u64) bool { var hi: usize = n; while (lo < hi) { const mid = lo + (hi - lo) / 2; - if (entries[mid].key_hash < key_hash) lo = mid + 1 - else hi = mid; + if (entries[mid].key_hash < key_hash) lo = mid + 1 else hi = mid; } if (lo >= n or entries[lo].key_hash != key_hash) return false; var i = lo; @@ -259,8 +272,12 @@ fn internalSetKeyCount(data: []u8, n: usize) void { } /// Offset of child[i] within data (after the 2-byte count + 2-byte pad). -fn childOff(i: usize) usize { return 4 + i * 12; } -fn keyOff(i: usize) usize { return 4 + i * 12 + 4; } +fn childOff(i: usize) usize { + return 4 + i * 12; +} +fn keyOff(i: usize) usize { + return 4 + i * 12 + 4; +} fn internalGetChild(data: []const u8, i: usize) u32 { const off = childOff(i); @@ -349,8 +366,12 @@ fn internalSplit( tc += 1; inserted = true; } - if (i < n) { tmp_keys[tk] = internalGetKey(ldata, i); tk += 1; } - tmp_children[tc] = internalGetChild(ldata, i); tc += 1; + if (i < n) { + tmp_keys[tk] = internalGetKey(ldata, i); + tk += 1; + } + tmp_children[tc] = internalGetChild(ldata, i); + tc += 1; } if (!inserted) { tmp_keys[tk] = median; @@ -365,13 +386,19 @@ fn internalSplit( // Left: keys 0..mid-1, children 0..mid internalSetKeyCount(ldata, mid); - for (0..mid) |j| { internalSetKey(ldata, j, tmp_keys[j]); internalSetChild(ldata, j, tmp_children[j]); } + for (0..mid) |j| { + internalSetKey(ldata, j, tmp_keys[j]); + internalSetChild(ldata, j, tmp_children[j]); + } internalSetChild(ldata, mid, tmp_children[mid]); // Right: keys mid+1..total_keys-1, children mid+1..total_keys const right_n = total_keys - mid - 1; internalSetKeyCount(rdata, right_n); - for (0..right_n) |j| { internalSetKey(rdata, j, tmp_keys[mid + 1 + j]); internalSetChild(rdata, j, tmp_children[mid + 1 + j]); } + for (0..right_n) |j| { + internalSetKey(rdata, j, tmp_keys[mid + 1 + j]); + internalSetChild(rdata, j, tmp_children[mid + 1 + j]); + } internalSetChild(rdata, right_n, tmp_children[total_keys]); return split_median; @@ -385,7 +412,7 @@ test "btree leaf insert/search" { leafSetCount(&data, 0); const e1 = BTreeEntry{ .key_hash = 100, .doc_id = 1, .page_no = 0, .page_off = 0 }; - const e2 = BTreeEntry{ .key_hash = 50, .doc_id = 2, .page_no = 0, .page_off = 0 }; + const e2 = BTreeEntry{ .key_hash = 50, .doc_id = 2, .page_no = 0, .page_off = 0 }; const e3 = BTreeEntry{ .key_hash = 200, .doc_id = 3, .page_no = 0, .page_off = 0 }; // Insert e1 @@ -406,7 +433,7 @@ test "btree leaf insert/search" { leafSetCount(&data, 3); try std.testing.expect(searchLeaf(&data, 100) != null); - try std.testing.expect(searchLeaf(&data, 50) != null); + try std.testing.expect(searchLeaf(&data, 50) != null); try std.testing.expect(searchLeaf(&data, 200) != null); try std.testing.expect(searchLeaf(&data, 999) == null); try std.testing.expectEqual(@as(u64, 1), searchLeaf(&data, 100).?.doc_id); diff --git a/src/bwtree.zig b/src/bwtree.zig index 2789e13..02b212a 100644 --- a/src/bwtree.zig +++ b/src/bwtree.zig @@ -7,6 +7,7 @@ /// Insert/delete prepend a delta record via CAS. /// Consolidation merges delta chain into a new base page when chain > 8. const std = @import("std"); +const compat = @import("compat"); // ─── Entry (22 bytes packed) ───────────────────────────────────────────── @@ -51,7 +52,7 @@ pub const BwTree = struct { // Deferred reclamation: old chains retired after consolidation are parked here // and freed on the next consolidation or deinit (simple two-phase approach). retired: std.ArrayList(*Page), - retired_mu: std.Thread.Mutex, + retired_mu: compat.Mutex, pub fn init(allocator: std.mem.Allocator) !BwTree { var tree: BwTree = undefined; diff --git a/src/cdc.zig b/src/cdc.zig index 65fe906..0faea3c 100644 --- a/src/cdc.zig +++ b/src/cdc.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const crypto = @import("crypto.zig"); pub const Op = enum(u8) { @@ -102,8 +103,9 @@ pub const CDCManager = struct { deliveries: std.ArrayList(Delivery), next_subscription_id: std.atomic.Value(u64), next_seq: std.atomic.Value(u64), - mu: std.Thread.Mutex, - cond: std.Thread.Condition, + subscription_count: std.atomic.Value(u32), + mu: compat.Mutex, + cond: compat.Condition, running: std.atomic.Value(bool), worker: ?std.Thread, @@ -115,6 +117,7 @@ pub const CDCManager = struct { .deliveries = .empty, .next_subscription_id = std.atomic.Value(u64).init(1), .next_seq = std.atomic.Value(u64).init(1), + .subscription_count = std.atomic.Value(u32).init(0), .mu = .{}, .cond = .{}, .running = std.atomic.Value(bool).init(false), @@ -154,10 +157,17 @@ pub const CDCManager = struct { self.mu.lock(); defer self.mu.unlock(); try self.subscriptions.append(self.allocator, sub); + _ = self.subscription_count.fetchAdd(1, .release); return sub.id; } + pub fn hasSubscriptions(self: *const CDCManager) bool { + return self.subscription_count.load(.acquire) != 0; + } + pub fn emit(self: *CDCManager, tenant_id: []const u8, collection: []const u8, key: []const u8, value: []const u8, doc_id: u64, op: Op) void { + if (!self.hasSubscriptions()) return; + var ev = std.mem.zeroes(Event); ev.seq = self.next_seq.fetchAdd(1, .monotonic); ev.doc_id = doc_id; @@ -198,10 +208,21 @@ pub const CDCManager = struct { return; } const ev = self.pending.orderedRemove(0); - const subs = self.subscriptions.items; + // Snapshot subscription count under lock — iterate by index so we + // re-read items pointer each iteration (safe if ArrayList grows). + const sub_count = self.subscriptions.items.len; self.mu.unlock(); - for (subs) |sub| { + var si: usize = 0; + while (si < sub_count) : (si += 1) { + self.mu.lock(); + if (si >= self.subscriptions.items.len) { + self.mu.unlock(); + break; + } + const sub = self.subscriptions.items[si]; + self.mu.unlock(); + if (!matches(sub, ev)) continue; const delivery = makeDelivery(sub, ev); self.mu.lock(); @@ -268,7 +289,7 @@ test "cdc filters by tenant and collection and signs payload" { cdc.emit("tenant-a", "users", "u1", "{\"name\":\"alice\"}", 1, .insert); cdc.emit("tenant-a", "orders", "o1", "{\"id\":1}", 2, .insert); cdc.emit("tenant-b", "users", "u2", "{\"name\":\"bob\"}", 3, .update); - std.Thread.sleep(20_000_000); + compat.sleep(20_000_000); const a = try cdc.listDeliveries(alloc, "tenant-a"); defer alloc.free(a); @@ -293,7 +314,7 @@ test "cdc preserves event order under queueing" { cdc.emit("tenant-a", "users", "u1", "{\"v\":1}", 1, .insert); cdc.emit("tenant-a", "users", "u1", "{\"v\":2}", 1, .update); cdc.emit("tenant-a", "users", "u1", "", 1, .delete); - std.Thread.sleep(20_000_000); + compat.sleep(20_000_000); const deliveries = try cdc.listDeliveries(alloc, "tenant-a"); defer alloc.free(deliveries); diff --git a/src/client.zig b/src/client.zig index 5d93259..b4a057b 100644 --- a/src/client.zig +++ b/src/client.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const collection_mod = @import("collection.zig"); const doc_mod = @import("doc.zig"); @@ -15,7 +16,7 @@ pub const Db = struct { /// Open a TurboDB database at the given directory. pub fn open(alloc: std.mem.Allocator, data_dir: []const u8) !Db { // Ensure directory exists - std.fs.cwd().makeDir(data_dir) catch |e| switch (e) { + compat.cwd().makeDir(data_dir) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return e, }; @@ -107,7 +108,7 @@ test "embedded client: open, insert, get, search" { // Use a temp dir const tmp_dir = "/tmp/turbodb_client_test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var db = try Db.open(alloc, tmp_dir); defer db.close(); @@ -133,17 +134,17 @@ test "embedded client: open, insert, get, search" { try std.testing.expect(gone == null); // Cleanup - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } test "embedded client isolates tenants" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_client_tenants"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var db = try Db.open(alloc, tmp_dir); defer db.close(); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; _ = try db.insertForTenant("tenant-a", "users", "u1", "{\"name\":\"alice\"}"); _ = try db.insertForTenant("tenant-b", "users", "u1", "{\"name\":\"bob\"}"); diff --git a/src/codeindex.zig b/src/codeindex.zig index 8d4e0fc..0f38f5d 100644 --- a/src/codeindex.zig +++ b/src/codeindex.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); // ── Inverted word index ───────────────────────────────────── // Maps word → list of (path, line) hits. O(1) word lookup. @@ -14,6 +15,13 @@ pub const WordIndex = struct { /// path → set of words contributed (for efficient re-index cleanup) file_words: std.StringHashMap(std.StringHashMap(void)), allocator: std.mem.Allocator, + /// Mutex for concurrent access (background indexer writes, search reads). + mu: compat.Mutex = .{}, + + /// Cap hits per word to bound memory. Common words ("the", "var", "if") + /// accumulate thousands of hits — beyond this they waste memory for + /// negligible search value. + const MAX_HITS_PER_WORD: usize = 5_000; pub fn init(allocator: std.mem.Allocator) WordIndex { return .{ @@ -74,9 +82,10 @@ pub const WordIndex = struct { } } - /// Index a file's content — tokenizes into words and records hits. pub fn indexFile(self: *WordIndex, path: []const u8, content: []const u8) !void { + self.mu.lock(); + defer self.mu.unlock(); // Clean up old entries first self.removeFile(path); @@ -99,9 +108,12 @@ pub const WordIndex = struct { if (!gop.found_existing) { const duped_word = try self.allocator.dupe(u8, word); gop.key_ptr.* = duped_word; - gop.value_ptr.* = .{}; + gop.value_ptr.* = .empty; } + // Skip overly common words to bound memory. + if (gop.value_ptr.items.len >= MAX_HITS_PER_WORD) continue; + if (gop.value_ptr.items.len > 0) { const last = gop.value_ptr.items[gop.value_ptr.items.len - 1]; if (std.mem.eql(u8, last.path, owned_path) and last.line_num == line_num) { @@ -126,11 +138,19 @@ pub const WordIndex = struct { } } + if (words_set.count() == 0) { + words_set.deinit(); + self.allocator.free(owned_path); + return; + } + try self.file_words.put(owned_path, words_set); } /// Look up all hits for a word. O(1) lookup + O(hits) iteration. pub fn search(self: *WordIndex, word: []const u8) []const WordHit { + self.mu.lock(); + defer self.mu.unlock(); if (self.index.get(word)) |hits| { return hits.items; } @@ -139,35 +159,53 @@ pub const WordIndex = struct { /// Look up hits, returning results allocated by the caller. /// Deduplicates by (path, line_num). -pub fn searchDeduped(self: *WordIndex, word: []const u8, allocator: std.mem.Allocator) ![]const WordHit { - const hits = self.search(word); - if (hits.len == 0) return try allocator.alloc(WordHit, 0); - if (hits.len == 1) { - var out = try allocator.alloc(WordHit, 1); - out[0] = hits[0]; - return out; - } + pub fn searchDeduped(self: *WordIndex, word: []const u8, allocator: std.mem.Allocator) ![]const WordHit { + const hits = self.search(word); + if (hits.len == 0) return try allocator.alloc(WordHit, 0); + if (hits.len == 1) { + var out = try allocator.alloc(WordHit, 1); + out[0] = hits[0]; + return out; + } - const DedupKey = struct { path_ptr: usize, line_num: u32 }; - var seen = std.AutoHashMap(DedupKey, void).init(allocator); - defer seen.deinit(); - try seen.ensureTotalCapacity(@intCast(hits.len)); + const DedupKey = struct { path_ptr: usize, line_num: u32 }; + var seen = std.AutoHashMap(DedupKey, void).init(allocator); + defer seen.deinit(); + try seen.ensureTotalCapacity(@intCast(hits.len)); - var result: std.ArrayList(WordHit) = .{}; - errdefer result.deinit(allocator); - try result.ensureTotalCapacity(allocator, hits.len); + var result: std.ArrayList(WordHit) = .{}; + errdefer result.deinit(allocator); + try result.ensureTotalCapacity(allocator, hits.len); - for (hits) |hit| { - const key = DedupKey{ .path_ptr = @intFromPtr(hit.path.ptr), .line_num = hit.line_num }; - const gop = try seen.getOrPut(key); - if (!gop.found_existing) { - result.appendAssumeCapacity(hit); + for (hits) |hit| { + const key = DedupKey{ .path_ptr = @intFromPtr(hit.path.ptr), .line_num = hit.line_num }; + const gop = try seen.getOrPut(key); + if (!gop.found_existing) { + result.appendAssumeCapacity(hit); + } } + return result.toOwnedSlice(allocator); } - return result.toOwnedSlice(allocator); -} }; +test "WordIndex skips empty per-file bookkeeping after hit cap" { + const alloc = std.testing.allocator; + var idx = WordIndex.init(alloc); + defer idx.deinit(); + + var saturated: std.ArrayList(WordHit) = .empty; + try saturated.ensureTotalCapacity(alloc, WordIndex.MAX_HITS_PER_WORD); + for (0..WordIndex.MAX_HITS_PER_WORD) |_| { + saturated.appendAssumeCapacity(.{ .path = "existing", .line_num = 1 }); + } + + const owned_word = try alloc.dupe(u8, "common"); + try idx.index.put(owned_word, saturated); + + try idx.indexFile("overflow", "common common common"); + try std.testing.expectEqual(@as(usize, 0), idx.file_words.count()); +} + // ── Trigram index ─────────────────────────────────────────── // Maps 3-byte sequences → set of file paths. // Enables fast substring search: extract trigrams from query, @@ -179,7 +217,6 @@ pub fn packTrigram(a: u8, b: u8, c: u8) Trigram { return @as(Trigram, a) << 16 | @as(Trigram, b) << 8 | @as(Trigram, c); } - pub const PostingMask = struct { next_mask: u8 = 0, // bloom filter of chars following this trigram loc_mask: u8 = 0, // bit mask of (position % 8) where trigram appears @@ -249,7 +286,6 @@ fn postingSortedInsert(list: *PostingList, alloc: std.mem.Allocator, entry: Post }; } - pub const TrigramIndex = struct { /// trigram → dense sorted posting list index: std.AutoHashMap(Trigram, PostingList), @@ -263,14 +299,14 @@ pub const TrigramIndex = struct { /// When true, deinit frees the path strings in id_to_path (set by readFromDisk). owns_paths: bool = false, /// Mutex for concurrent access (background indexer writes, search reads). - mu: std.Thread.Mutex = .{}, + mu: compat.Mutex = .{}, pub fn init(allocator: std.mem.Allocator) TrigramIndex { return .{ .index = std.AutoHashMap(Trigram, PostingList).init(allocator), .file_trigrams = std.AutoHashMap(u32, std.ArrayList(Trigram)).init(allocator), .path_to_id = std.StringHashMap(u32).init(allocator), - .id_to_path = .{}, + .id_to_path = .empty, .allocator = allocator, }; } @@ -368,7 +404,7 @@ pub const TrigramIndex = struct { self.removeFileById(file_id); } - var tri_list: std.ArrayList(Trigram) = .{}; + var tri_list: std.ArrayList(Trigram) = .empty; errdefer tri_list.deinit(self.allocator); try tri_list.ensureTotalCapacity(self.allocator, unique_tris.count()); @@ -379,7 +415,7 @@ pub const TrigramIndex = struct { const idx_gop = try self.index.getOrPut(tri); if (!idx_gop.found_existing) { - idx_gop.value_ptr.* = .{}; + idx_gop.value_ptr.* = .empty; } postingSortedInsert(idx_gop.value_ptr, self.allocator, .{ .file_id = file_id, .mask = PostingMask{ .loc_mask = 0xFF, .next_mask = 0xFF } }); } @@ -397,9 +433,15 @@ pub const TrigramIndex = struct { ) !void { if (paths.len == 0) return; - // Per-doc trigram sets collected outside the lock + // Per-doc trigram sets collected outside the lock. Keep the common + // small case on the stack, but accept larger caller batches too. const BatchEntry = struct { tris: []Trigram }; - var batch: [64]BatchEntry = undefined; + var stack_batch: [64]BatchEntry = undefined; + const batch = if (paths.len <= stack_batch.len) + stack_batch[0..paths.len] + else + try self.allocator.alloc(BatchEntry, paths.len); + defer if (paths.len > stack_batch.len) self.allocator.free(batch); for (paths, contents, 0..) |path, content, di| { _ = path; @@ -446,7 +488,7 @@ pub const TrigramIndex = struct { self.removeFileById(file_id); } - var tri_list: std.ArrayList(Trigram) = .{}; + var tri_list: std.ArrayList(Trigram) = .empty; tri_list.ensureTotalCapacity(self.allocator, tris.len) catch continue; for (tris) |tri| { @@ -454,7 +496,7 @@ pub const TrigramIndex = struct { const idx_gop = self.index.getOrPut(tri) catch continue; if (!idx_gop.found_existing) { - idx_gop.value_ptr.* = .{}; + idx_gop.value_ptr.* = .empty; } postingSortedInsert(idx_gop.value_ptr, self.allocator, .{ .file_id = file_id, .mask = PostingMask{ .loc_mask = 0xFF, .next_mask = 0xFF } }); } @@ -462,119 +504,118 @@ pub const TrigramIndex = struct { self.file_trigrams.put(file_id, tri_list) catch {}; } } -pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.Allocator) ?[]const []const u8 { - self.mu.lock(); - defer self.mu.unlock(); - - if (query.len < 3) return null; - const tri_count = query.len - 2; - - // Deduplicate query trigrams first so repeated trigrams don't do repeated work. - var unique = std.AutoHashMap(Trigram, void).init(allocator); - defer unique.deinit(); - unique.ensureTotalCapacity(@intCast(tri_count)) catch return null; - for (0..tri_count) |i| { - const tri = packTrigram( - normalizeChar(query[i]), - normalizeChar(query[i + 1]), - normalizeChar(query[i + 2]), - ); - _ = unique.getOrPut(tri) catch return null; - } + pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.Allocator) ?[]const []const u8 { + self.mu.lock(); + defer self.mu.unlock(); - var sets: std.ArrayList(*PostingList) = .{}; - defer sets.deinit(allocator); - sets.ensureTotalCapacity(allocator, unique.count()) catch return null; + if (query.len < 3) return null; + const tri_count = query.len - 2; - var tri_iter = unique.keyIterator(); - while (tri_iter.next()) |tri_ptr| { - const file_set = self.index.getPtr(tri_ptr.*) orelse { - return allocator.alloc([]const u8, 0) catch null; - }; - sets.appendAssumeCapacity(file_set); - } + // Deduplicate query trigrams first so repeated trigrams don't do repeated work. + var unique = std.AutoHashMap(Trigram, void).init(allocator); + defer unique.deinit(); + unique.ensureTotalCapacity(@intCast(tri_count)) catch return null; + for (0..tri_count) |i| { + const tri = packTrigram( + normalizeChar(query[i]), + normalizeChar(query[i + 1]), + normalizeChar(query[i + 2]), + ); + _ = unique.getOrPut(tri) catch return null; + } - if (sets.items.len == 0) { - return allocator.alloc([]const u8, 0) catch null; - } + var sets: std.ArrayList(*PostingList) = .empty; + defer sets.deinit(allocator); + sets.ensureTotalCapacity(allocator, unique.count()) catch return null; - // Iterate the smallest set and check membership in all others. - var min_idx: usize = 0; - var min_count = sets.items[0].items.len; - for (sets.items[1..], 1..) |set, i| { - const count = set.items.len; - if (count < min_count) { - min_count = count; - min_idx = i; + var tri_iter = unique.keyIterator(); + while (tri_iter.next()) |tri_ptr| { + const file_set = self.index.getPtr(tri_ptr.*) orelse { + return allocator.alloc([]const u8, 0) catch null; + }; + sets.appendAssumeCapacity(file_set); } - } - var result: std.ArrayList([]const u8) = .{}; - errdefer result.deinit(allocator); - result.ensureTotalCapacity(allocator, min_count) catch return null; - - for (sets.items[min_idx].items) |entry| { - const file_id = entry.file_id; - - // Intersection check: candidate must be in all sets - var in_all = true; - for (sets.items, 0..) |set, i| { - if (i == min_idx) continue; - if (!postingContains(set, file_id)) { - in_all = false; - break; + if (sets.items.len == 0) { + return allocator.alloc([]const u8, 0) catch null; + } + + // Iterate the smallest set and check membership in all others. + var min_idx: usize = 0; + var min_count = sets.items[0].items.len; + for (sets.items[1..], 1..) |set, i| { + const count = set.items.len; + if (count < min_count) { + min_count = count; + min_idx = i; } } - if (!in_all) continue; - // Bloom-filter check for consecutive trigram pairs - var bloom_pass = true; - if (tri_count >= 2) { - for (0..tri_count - 1) |j| { - const tri_a = packTrigram( - normalizeChar(query[j]), - normalizeChar(query[j + 1]), - normalizeChar(query[j + 2]), - ); - const tri_b = packTrigram( - normalizeChar(query[j + 1]), - normalizeChar(query[j + 2]), - normalizeChar(query[j + 3]), - ); - const set_a = self.index.getPtr(tri_a) orelse continue; - const set_b = self.index.getPtr(tri_b) orelse continue; - const mask_a = postingGet(set_a, file_id) orelse continue; - const mask_b = postingGet(set_b, file_id) orelse continue; - - // next_mask: bit for query[j+3] must be set in tri_a's next_mask - const next_bit: u8 = @as(u8, 1) << @intCast(normalizeChar(query[j + 3]) % 8); - if ((mask_a.next_mask & next_bit) == 0) { - bloom_pass = false; + var result: std.ArrayList([]const u8) = .empty; + errdefer result.deinit(allocator); + result.ensureTotalCapacity(allocator, min_count) catch return null; + + for (sets.items[min_idx].items) |entry| { + const file_id = entry.file_id; + + // Intersection check: candidate must be in all sets + var in_all = true; + for (sets.items, 0..) |set, i| { + if (i == min_idx) continue; + if (!postingContains(set, file_id)) { + in_all = false; break; } + } + if (!in_all) continue; + + // Bloom-filter check for consecutive trigram pairs + var bloom_pass = true; + if (tri_count >= 2) { + for (0..tri_count - 1) |j| { + const tri_a = packTrigram( + normalizeChar(query[j]), + normalizeChar(query[j + 1]), + normalizeChar(query[j + 2]), + ); + const tri_b = packTrigram( + normalizeChar(query[j + 1]), + normalizeChar(query[j + 2]), + normalizeChar(query[j + 3]), + ); + const set_a = self.index.getPtr(tri_a) orelse continue; + const set_b = self.index.getPtr(tri_b) orelse continue; + const mask_a = postingGet(set_a, file_id) orelse continue; + const mask_b = postingGet(set_b, file_id) orelse continue; + + // next_mask: bit for query[j+3] must be set in tri_a's next_mask + const next_bit: u8 = @as(u8, 1) << @intCast(normalizeChar(query[j + 3]) % 8); + if ((mask_a.next_mask & next_bit) == 0) { + bloom_pass = false; + break; + } - // loc_mask adjacency: use circular shift to handle position wrap-around - const rotated = (mask_a.loc_mask << 1) | (mask_a.loc_mask >> 7); - if ((rotated & mask_b.loc_mask) == 0) { - bloom_pass = false; - break; + // loc_mask adjacency: use circular shift to handle position wrap-around + const rotated = (mask_a.loc_mask << 1) | (mask_a.loc_mask >> 7); + if ((rotated & mask_b.loc_mask) == 0) { + bloom_pass = false; + break; + } } } - } - if (!bloom_pass) continue; + if (!bloom_pass) continue; - // Translate file_id -> path - if (file_id < self.id_to_path.items.len) { - result.appendAssumeCapacity(self.id_to_path.items[file_id]); + // Translate file_id -> path + if (file_id < self.id_to_path.items.len) { + result.appendAssumeCapacity(self.id_to_path.items[file_id]); + } } - } - - return result.toOwnedSlice(allocator) catch { - result.deinit(allocator); - return null; - }; -} + return result.toOwnedSlice(allocator) catch { + result.deinit(allocator); + return null; + }; + } /// Find candidate files matching a RegexQuery. /// Intersects AND trigrams, then for each OR group unions posting lists @@ -649,7 +690,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All if (result_set == null) return null; // Convert file_ids to paths - var result: std.ArrayList([]const u8) = .{}; + var result: std.ArrayList([]const u8) = .empty; errdefer result.deinit(allocator); result.ensureTotalCapacity(allocator, result_set.?.count()) catch return null; var it = result_set.?.keyIterator(); @@ -747,7 +788,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All defer self.allocator.free(postings_final); { - const file = try std.fs.cwd().createFile(postings_tmp, .{}); + const file = try compat.cwd().createFile(postings_tmp, .{}); defer file.close(); // Header v3: magic(4) + version(2) + file_count(4) + head_len(1) + head(40) = 51 bytes @@ -779,7 +820,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All const postings_bytes = std.mem.sliceAsBytes(postings_buf.items); try file.writeAll(postings_bytes); } - try std.fs.cwd().rename(postings_tmp, postings_final); + try compat.cwd().rename(postings_tmp, postings_final); // Step 5: Write lookup file atomically (random suffix prevents collisions) const lk_rand = std.crypto.random.int(u64); @@ -789,7 +830,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All defer self.allocator.free(lookup_final); { - const file = try std.fs.cwd().createFile(lookup_tmp, .{}); + const file = try compat.cwd().createFile(lookup_tmp, .{}); defer file.close(); // Header: magic(4) + version(2) + pad(2) + entry_count(4) = 12 bytes @@ -807,7 +848,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All const entry_bytes = std.mem.sliceAsBytes(lookup_entries.items); try file.writeAll(entry_bytes); } - try std.fs.cwd().rename(lookup_tmp, lookup_final); + try compat.cwd().rename(lookup_tmp, lookup_final); } /// Load index from disk files into a fresh TrigramIndex. @@ -823,9 +864,9 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All defer allocator.free(lookup_path); // Read both files - const postings_data = std.fs.cwd().readFileAlloc(allocator, postings_path, 64 * 1024 * 1024) catch return null; + const postings_data = compat.cwd().readFileAlloc(allocator, postings_path, 64 * 1024 * 1024) catch return null; defer allocator.free(postings_data); - const lookup_data = std.fs.cwd().readFileAlloc(allocator, lookup_path, 64 * 1024 * 1024) catch return null; + const lookup_data = compat.cwd().readFileAlloc(allocator, lookup_path, 64 * 1024 * 1024) catch return null; defer allocator.free(lookup_data); // Validate postings header (v1: 8 bytes, v2: 49 bytes, v3: 51 bytes) @@ -936,7 +977,10 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All if (result.file_trigrams.getPtr(file_id)) |tri_list| { var found = false; for (tri_list.items) |existing| { - if (existing == tri) { found = true; break; } + if (existing == tri) { + found = true; + break; + } } if (!found) try tri_list.append(allocator, tri); } @@ -965,7 +1009,7 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All const postings_path = try std.fmt.allocPrint(allocator, "{s}/trigram.postings", .{dir_path}); defer allocator.free(postings_path); - const file = std.fs.cwd().openFile(postings_path, .{}) catch return null; + const file = compat.cwd().openFile(postings_path, .{}) catch return null; defer file.close(); var buf: [51]u8 = undefined; @@ -1004,10 +1048,8 @@ pub fn candidates(self: *TrigramIndex, query: []const u8, allocator: std.mem.All const header = try readDiskHeader(dir_path, allocator) orelse return null; return header.git_head; } - }; - // ── Regex decomposition ───────────────────────────────────── pub const RegexQuery = struct { @@ -1044,11 +1086,30 @@ pub fn decomposeRegex(pattern: []const u8, allocator: std.mem.Allocator) !RegexQ i += 2; continue; } - if (c == '[') { in_bracket = true; i += 1; continue; } - if (c == ']') { in_bracket = false; i += 1; continue; } - if (in_bracket) { i += 1; continue; } - if (c == '(') { depth += 1; i += 1; continue; } - if (c == ')') { if (depth > 0) depth -= 1; i += 1; continue; } + if (c == '[') { + in_bracket = true; + i += 1; + continue; + } + if (c == ']') { + in_bracket = false; + i += 1; + continue; + } + if (in_bracket) { + i += 1; + continue; + } + if (c == '(') { + depth += 1; + i += 1; + continue; + } + if (c == ')') { + if (depth > 0) depth -= 1; + i += 1; + continue; + } if (c == '|' and depth == 0) { try top_pipes.append(allocator, i); } @@ -1241,7 +1302,6 @@ fn flushLiterals( literals.clearRetainingCapacity(); } - // ── Tokenizer ─────────────────────────────────────────────── pub const WordTokenizer = struct { @@ -1281,42 +1341,70 @@ pub const MAX_NGRAM_LEN: usize = 16; /// Rare pairs → HIGH weight (they become n-gram boundaries). /// All unspecified pairs default to 0xFE00 (rare = high weight). pub const default_pair_freq: [256][256]u16 = blk: { - var table: [256][256]u16 = .{.{0xFE00} ** 256} ** 256; // English bigrams (lowercase) — common in identifiers and prose - table['t']['h'] = 0x1000; table['h']['e'] = 0x1000; - table['i']['n'] = 0x1000; table['e']['r'] = 0x1000; - table['a']['n'] = 0x1000; table['r']['e'] = 0x1000; - table['o']['n'] = 0x1000; table['e']['n'] = 0x1000; - table['s']['t'] = 0x1000; table['e']['s'] = 0x1000; - table['a']['t'] = 0x1000; table['i']['o'] = 0x1000; - table['t']['e'] = 0x1000; table['o']['r'] = 0x1000; - table['t']['i'] = 0x1000; table['a']['r'] = 0x1000; - table['a']['l'] = 0x1000; table['l']['e'] = 0x1000; - table['n']['t'] = 0x1000; table['e']['d'] = 0x1000; - table['n']['d'] = 0x1000; table['o']['u'] = 0x1000; - table['e']['a'] = 0x1000; table['f']['o'] = 0x1000; + table['t']['h'] = 0x1000; + table['h']['e'] = 0x1000; + table['i']['n'] = 0x1000; + table['e']['r'] = 0x1000; + table['a']['n'] = 0x1000; + table['r']['e'] = 0x1000; + table['o']['n'] = 0x1000; + table['e']['n'] = 0x1000; + table['s']['t'] = 0x1000; + table['e']['s'] = 0x1000; + table['a']['t'] = 0x1000; + table['i']['o'] = 0x1000; + table['t']['e'] = 0x1000; + table['o']['r'] = 0x1000; + table['t']['i'] = 0x1000; + table['a']['r'] = 0x1000; + table['a']['l'] = 0x1000; + table['l']['e'] = 0x1000; + table['n']['t'] = 0x1000; + table['e']['d'] = 0x1000; + table['n']['d'] = 0x1000; + table['o']['u'] = 0x1000; + table['e']['a'] = 0x1000; + table['f']['o'] = 0x1000; // Common code keyword fragments - table['f']['n'] = 0x1000; table['i']['f'] = 0x1000; - table['r']['n'] = 0x1000; table['t']['u'] = 0x1000; - table['p']['u'] = 0x1000; table['b']['l'] = 0x1000; - table['c']['o'] = 0x1000; table['n']['s'] = 0x1000; - table['t']['r'] = 0x1000; table['u']['e'] = 0x1000; + table['f']['n'] = 0x1000; + table['i']['f'] = 0x1000; + table['r']['n'] = 0x1000; + table['t']['u'] = 0x1000; + table['p']['u'] = 0x1000; + table['b']['l'] = 0x1000; + table['c']['o'] = 0x1000; + table['n']['s'] = 0x1000; + table['t']['r'] = 0x1000; + table['u']['e'] = 0x1000; // Common operator / punctuation pairs - table['('][')'] = 0x0800; table['{']['}'] = 0x0800; - table['['][']'] = 0x0800; table['/']['/'] = 0x0800; - table['-']['>'] = 0x0800; table['=']['>'] = 0x0800; - table[':'][':'] = 0x0800; table['!']['='] = 0x0800; - table['=']['='] = 0x0800; table['<']['='] = 0x0800; - table['>']['='] = 0x0800; table['&']['&'] = 0x0800; + table['('][')'] = 0x0800; + table['{']['}'] = 0x0800; + table['['][']'] = 0x0800; + table['/']['/'] = 0x0800; + table['-']['>'] = 0x0800; + table['=']['>'] = 0x0800; + table[':'][':'] = 0x0800; + table['!']['='] = 0x0800; + table['=']['='] = 0x0800; + table['<']['='] = 0x0800; + table['>']['='] = 0x0800; + table['&']['&'] = 0x0800; table['|']['|'] = 0x0800; // Whitespace / structural pairs - table[' '][' '] = 0x0800; table['\t'][' '] = 0x0800; - table[' ']['('] = 0x0800; table[' ']['{'] = 0x0800; - table[';'][' '] = 0x0800; table[':'][' '] = 0x0800; - table['='][' '] = 0x0800; table[' ']['='] = 0x0800; - table[','][' '] = 0x0800; table['.']['.'] = 0x0800; - table['\n'][' '] = 0x0800; table['\n']['\t'] = 0x0800; + table[' '][' '] = 0x0800; + table['\t'][' '] = 0x0800; + table[' ']['('] = 0x0800; + table[' ']['{'] = 0x0800; + table[';'][' '] = 0x0800; + table[':'][' '] = 0x0800; + table['='][' '] = 0x0800; + table[' ']['='] = 0x0800; + table[','][' '] = 0x0800; + table['.']['.'] = 0x0800; + table['\n'][' '] = 0x0800; + table['\n']['\t'] = 0x0800; break :blk table; }; @@ -1325,7 +1413,6 @@ pub const default_pair_freq: [256][256]u16 = blk: { pub var active_pair_freq: *const [256][256]u16 = &default_pair_freq; var loaded_freq_table: [256][256]u16 = undefined; - /// Deterministic weight for a character pair, used to place content-defined /// boundaries between n-grams. Frequency-weighted: common source-code pairs /// get LOW weight (they stay interior to n-grams); rare pairs get HIGH weight @@ -1414,7 +1501,7 @@ fn finishFrequencyTable(counts: *const [256][256]u64) [256][256]u16 { /// Persist a frequency table as a raw binary blob to `/pair_freq.bin`. /// Uses tmp+rename for atomic writes. pub fn writeFrequencyTable(table: *const [256][256]u16, dir_path: []const u8) !void { - var dir = try std.fs.cwd().openDir(dir_path, .{}); + var dir = try compat.cwd().openDir(dir_path, .{}); defer dir.close(); { const tmp = try dir.createFile("pair_freq.bin.tmp", .{}); @@ -1436,7 +1523,7 @@ pub fn writeFrequencyTable(table: *const [256][256]u16, dir_path: []const u8) !v pub fn readFrequencyTable(dir_path: []const u8, allocator: std.mem.Allocator) !?*[256][256]u16 { const path = try std.fmt.allocPrint(allocator, "{s}/pair_freq.bin", .{dir_path}); defer allocator.free(path); - const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) { + const file = compat.cwd().openFile(path, .{}) catch |err| switch (err) { error.FileNotFound => return null, else => return err, }; @@ -1462,7 +1549,7 @@ pub fn readFrequencyTable(dir_path: []const u8, allocator: std.mem.Allocator) !? /// A single sparse n-gram extracted from a string. pub const SparseNgram = struct { - hash: u64, // Wyhash of the normalized (lowercased) n-gram bytes + hash: u64, // Wyhash of the normalized (lowercased) n-gram bytes pos: usize, // byte offset in the source string len: usize, // byte length of the n-gram }; @@ -1546,7 +1633,6 @@ pub fn extractSparseNgrams(content: []const u8, allocator: std.mem.Allocator) ![ // every byte in the span is covered. try result.append(allocator, makeNgram(content, ngram_end - MIN_LEN, MIN_LEN)); } - } } @@ -1681,7 +1767,7 @@ pub const SparseNgramIndex = struct { return allocator.alloc([]const u8, 0) catch null; } - var result: std.ArrayList([]const u8) = .{}; + var result: std.ArrayList([]const u8) = .empty; errdefer result.deinit(allocator); result.ensureTotalCapacity(allocator, seen_files.count()) catch return null; diff --git a/src/collection.zig b/src/collection.zig index ac78fbb..100d996 100644 --- a/src/collection.zig +++ b/src/collection.zig @@ -1,13 +1,14 @@ /// TurboDB — Collection (MVCC document store + query engine) const std = @import("std"); -const doc_mod = @import("doc.zig"); -const page_mod = @import("page.zig"); +const compat = @import("compat"); +const doc_mod = @import("doc.zig"); +const page_mod = @import("page.zig"); const btree_mod = @import("btree.zig"); const codeindex = @import("codeindex.zig"); -const wal_mod = @import("wal"); +const wal_mod = @import("wal"); const epoch_mod = @import("epoch"); const hot_cache = @import("hot_cache.zig"); -const mvcc_mod = @import("mvcc.zig"); +const mvcc_mod = @import("mvcc.zig"); const cdc_mod = @import("cdc.zig"); const vector = @import("vector.zig"); const branch_mod = @import("branch.zig"); @@ -25,16 +26,48 @@ const WordIndex = codeindex.WordIndex; pub const DEFAULT_TENANT = "default"; pub const MAX_TENANT_ID_LEN: usize = 64; pub const MAX_COLLECTION_NAME_LEN: usize = 64; +const DEFAULT_INDEX_QUEUE_MEMORY_LIMIT: usize = 64 * 1024 * 1024; +const INDEX_QUEUE_MEMORY_FRACTION: usize = 16; +const DEFAULT_TEXT_INDEX_MIN_VALUE_BYTES: usize = 128; + +fn writeCommittedDocumentWal(wal_log: *WAL, op: wal_mod.OpCode, payload: []const u8) !void { + const txn = wal_log.next_lsn.load(.monotonic); + if (comptime @hasDecl(WAL, "writeCommitted")) { + _ = try wal_log.writeCommitted(txn, op, wal_mod.DB_TAG_DOC, payload); + } else { + _ = try wal_log.write(txn, op, wal_mod.DB_TAG_DOC, wal_mod.FLAG_COMMIT, payload); + } +} + +fn indexQueueMemoryLimit() usize { + const explicit = compat.envUsize("TURBODB_INDEX_QUEUE_MEMORY_LIMIT_BYTES", 0); + if (explicit > 0) return explicit; + if (compat.availableMemoryBytes()) |available| { + return @min(available / INDEX_QUEUE_MEMORY_FRACTION, DEFAULT_INDEX_QUEUE_MEMORY_LIMIT); + } + return DEFAULT_INDEX_QUEUE_MEMORY_LIMIT; +} + +fn indexEntryBytes(key: []const u8, value: []const u8) usize { + return std.math.add(usize, key.len, value.len) catch std.math.maxInt(usize); +} + +fn textIndexMinValueBytes() usize { + return compat.envUsize("TURBODB_TEXT_INDEX_MIN_VALUE_BYTES", DEFAULT_TEXT_INDEX_MIN_VALUE_BYTES); +} // ─── IndexQueue ────────────────────────────────────────────────────────── // Lock-free MPSC queue for deferring trigram+word index builds to a background thread. // Insert path pushes owned (key, value) pairs; background thread pops and indexes. pub const IndexQueue = struct { - const CAPACITY = 32768; // 32K entries per queue + const CAPACITY = 131072; // 128K entries — sized for 100+ concurrent writers const Entry = struct { - key: []const u8, - value: []const u8, + key: []const u8 = "", + value: []const u8 = "", + loc: BTreeEntry = undefined, + stored: bool = false, + queued_bytes: usize = 0, }; buf: []Entry, @@ -56,7 +89,22 @@ pub const IndexQueue = struct { } /// Push a (key, value) pair. Lock-free MPSC via CAS + per-slot ready flag. - pub fn push(self: *IndexQueue, key: []const u8, value: []const u8) bool { + pub fn pushOwned(self: *IndexQueue, key: []const u8, value: []const u8, queued_bytes: usize) bool { + return self.push(.{ + .key = key, + .value = value, + .queued_bytes = queued_bytes, + }); + } + + pub fn pushStored(self: *IndexQueue, loc: BTreeEntry) bool { + return self.push(.{ + .loc = loc, + .stored = true, + }); + } + + fn push(self: *IndexQueue, entry: Entry) bool { while (true) { const h = self.head.load(.acquire); const next = (h + 1) % CAPACITY; @@ -66,7 +114,7 @@ pub const IndexQueue = struct { continue; // CAS failed — another producer won, retry } // We own slot h — write data then signal readiness. - self.buf[h] = .{ .key = key, .value = value }; + self.buf[h] = entry; self.ready[h].store(1, .release); return true; } @@ -97,7 +145,10 @@ fn fastParseFloat(s: []const u8) ?f32 { if (s.len == 0) return null; var i: usize = 0; var neg: bool = false; - if (s[0] == '-') { neg = true; i = 1; } + if (s[0] == '-') { + neg = true; + i = 1; + } var int_part: i32 = 0; while (i < s.len and s[i] >= '0' and s[i] <= '9') : (i += 1) { int_part = int_part * 10 + @as(i32, s[i] - '0'); @@ -162,9 +213,34 @@ fn extractJsonFloatArray(json: []const u8, field_name: []const u8, out: []f32) ? } // ─── Collection ────────────────────────────────────────────────────────── pub const Collection = struct { - pub const STRIPE_COUNT = 1024; + pub const STRIPE_COUNT = 4096; // MVCC GC: trigger version chain cleanup every GC_INTERVAL inserts. - const GC_INTERVAL: u64 = 10_000; + const GC_INTERVAL: u64 = 500; + + pub const BulkInsertRow = struct { + key: []const u8, + value: []const u8, + line_len: usize = 0, + }; + + pub const BulkInsertResult = struct { + inserted: u32 = 0, + errors: u32 = 0, + bytes: u64 = 0, + first_doc_id: u64 = 0, + }; + + const BulkPreparedDoc = struct { + key: []const u8, + value: []const u8, + doc_id: u64, + key_hash: u64, + enc_off: usize, + enc_len: usize, + line_len: usize, + page_no: u32 = 0, + page_off: u16 = 0, + }; name_buf: [128]u8, name_len: u8, @@ -178,12 +254,16 @@ pub const Collection = struct { next_doc_id: std.atomic.Value(u64), // Record-level latching: 1024 stripe locks replace single write_mu. // Two writers to different keys proceed in parallel; same-key writes serialize. - stripe_locks: [STRIPE_COUNT]std.Thread.Mutex, + stripe_locks: [STRIPE_COUNT]compat.Mutex, + // Protects shared page/index/MVCC metadata that is not internally thread-safe. + meta_mu: compat.Mutex, + cache_mu: compat.Mutex, hash_idx: std.AutoHashMap(u64, BTreeEntry), key_doc_ids: std.AutoHashMap(u64, u64), /// Per-key modification epoch — tracks when each key was last written on main. /// Used by mergeBranch to detect conflicts (key modified after branch was created). key_epochs: std.AutoHashMap(u64, u64), + append_leaf_hint: u32, alloc: std.mem.Allocator, cache: hot_cache.HotCache, // MVCC version chain — append-only, vacuumed periodically via gc_counter. @@ -197,6 +277,8 @@ pub const Collection = struct { index_stop: std.atomic.Value(bool), queue_toggle: std.atomic.Value(u32), indexing_count: std.atomic.Value(u32), + index_queue_bytes: std.atomic.Value(usize), + text_index_min_value_bytes: usize, // Vector embedding column (optional) vectors: ?*vector.VectorColumn = null, @@ -240,9 +322,12 @@ pub const Collection = struct { for (&col.stripe_locks) |*lock| { lock.* = .{}; } + col.meta_mu = .{}; + col.cache_mu = .{}; col.hash_idx = std.AutoHashMap(u64, BTreeEntry).init(alloc); col.key_doc_ids = std.AutoHashMap(u64, u64).init(alloc); col.key_epochs = std.AutoHashMap(u64, u64).init(alloc); + col.append_leaf_hint = 0; col.alloc = alloc; col.cache = hot_cache.HotCache.init(); col.versions = mvcc_mod.VersionChain.init(alloc); @@ -251,6 +336,8 @@ pub const Collection = struct { col.index_stop = std.atomic.Value(bool).init(false); col.queue_toggle = std.atomic.Value(u32).init(0); col.indexing_count = std.atomic.Value(u32).init(0); + col.index_queue_bytes = std.atomic.Value(usize).init(0); + col.text_index_min_value_bytes = textIndexMinValueBytes(); col.vectors = null; col.vector_field_len = 0; col.vec_entries = .empty; @@ -261,10 +348,10 @@ pub const Collection = struct { col.name_len = @intCast(n); try col.rebuildIndexes(); - // Start background indexer thread. If spawn fails, inserts fall back to sync + // Start background indexer threads. If spawn fails, inserts fall back to sync // indexing via the queue-full path (queue stays empty, retries exhaust immediately). col.index_thread = std.Thread.spawn(.{}, indexWorkerQ, .{ col, &col.index_queue }) catch null; - col.index_thread2 = null; // Second worker slot — available for future use + col.index_thread2 = std.Thread.spawn(.{}, indexWorkerQ, .{ col, &col.index_queue2 }) catch null; return col; } @@ -305,6 +392,9 @@ pub const Collection = struct { } pub fn configureVectors(self: *Collection, dims: u32, field_name: []const u8) !void { + self.meta_mu.lock(); + defer self.meta_mu.unlock(); + if (self.vectors) |old| { old.deinit(self.alloc); self.alloc.destroy(old); @@ -325,7 +415,7 @@ pub const Collection = struct { pub fn flushIndex(self: *Collection) void { var waited: u32 = 0; while ((self.index_queue.len() > 0 or self.index_queue2.len() > 0 or self.indexing_count.load(.acquire) > 0) and waited < 300_000) : (waited += 1) { - std.Thread.sleep(100_000); // 100µs + compat.sleep(100_000); // 100µs } } @@ -333,9 +423,11 @@ pub const Collection = struct { return self.name_buf[0..self.name_len]; } - /// Return the number of live indexed documents (excludes deleted docs). - pub fn docCount(self: *const Collection) u64 { - return @intCast(self.tri.fileCount()); + /// Return the number of live key entries (excludes deleted docs). + pub fn docCount(self: *Collection) u64 { + self.meta_mu.lock(); + defer self.meta_mu.unlock(); + return @intCast(self.hash_idx.count()); } pub fn storageBytes(self: *const Collection) usize { @@ -377,87 +469,216 @@ pub const Collection = struct { }; const enc = try d.encodeBuf(buf); - // Write to WAL buffer (background flusher will commit periodically). - const txn = self.wal_log.next_lsn.load(.monotonic); - _ = try self.wal_log.write(txn, .doc_insert, 0, 0, enc); - - // Find (or allocate) a leaf page with enough space. - const pno = try self.findOrAllocLeaf(enc.len); - const page_off = self.pf.leafAppend(pno, enc) orelse - return error.PageFull; - - // Index the document. - const entry = BTreeEntry{ - .key_hash = hdr.key_hash, - .doc_id = doc_id, - .page_no = pno, - .page_off = page_off, - }; - try self.idx.insert(entry); - self.hash_idx.put(hdr.key_hash, entry) catch {}; - - // MVCC: register version in the version chain. - const epoch = self.epochs.advance(); - self.versions.appendVersion(self.alloc, doc_id, pno, page_off, epoch) catch {}; - self.key_doc_ids.put(hdr.key_hash, doc_id) catch {}; - self.key_epochs.put(hdr.key_hash, doc_id) catch {}; // epoch = doc_id (monotonic) - - // Async trigram + word indexing — push to background queue, sync fallback if full or no worker. - if (value.len >= 3) { - if (self.index_thread == null) { - // No background worker — index synchronously to avoid losing data. - self.tri.indexFile(key, value) catch {}; - self.words.indexFile(key, value) catch {}; - } else { - const owned_key = self.alloc.dupe(u8, key) catch null; - const owned_val = self.alloc.dupe(u8, value) catch null; - if (owned_key != null and owned_val != null) { - const q = &self.index_queue; - var retries: u32 = 0; - while (!q.push(owned_key.?, owned_val.?)) { - retries += 1; - if (retries > 1000) { - // Queue full — fall back to synchronous indexing. - self.tri.indexFile(owned_key.?, owned_val.?) catch {}; - self.words.indexFile(owned_key.?, owned_val.?) catch {}; - self.alloc.free(owned_key.?); - self.alloc.free(owned_val.?); - break; + // Single-entry document writes are self-committed for recovery. + try writeCommittedDocumentWal(self.wal_log, .doc_insert, enc); + + var inserted_entry: BTreeEntry = undefined; + self.meta_mu.lock(); + { + defer self.meta_mu.unlock(); + + // Find (or allocate) a leaf page with enough space. + const pno = try self.findOrAllocLeaf(enc.len); + const page_off = self.pf.leafAppend(pno, enc) orelse + return error.PageFull; + + // Index the document. + const entry = BTreeEntry{ + .key_hash = hdr.key_hash, + .doc_id = doc_id, + .page_no = pno, + .page_off = page_off, + }; + try self.idx.insert(entry); + self.hash_idx.put(hdr.key_hash, entry) catch {}; + inserted_entry = entry; + + // MVCC: register version in the version chain. + const epoch = self.epochs.advance(); + self.versions.appendVersion(self.alloc, doc_id, pno, page_off, epoch) catch {}; + self.key_doc_ids.put(hdr.key_hash, doc_id) catch {}; + self.key_epochs.put(hdr.key_hash, doc_id) catch {}; // epoch = doc_id (monotonic) + + // Extract and store vector embedding if configured. + // Uses stack buffer to avoid heap allocation per insert. + if (self.vectors) |vc| { + const field = self.vector_field[0..self.vector_field_len]; + const dims: usize = vc.dims; + var embed_stack: [4096]f32 = undefined; + const emb = if (dims <= 4096) embed_stack[0..dims] else blk: { + break :blk self.alloc.alloc(f32, dims) catch null; + }; + if (emb) |e| { + defer if (dims > 4096) self.alloc.free(e); + if (extractJsonFloatArray(value, field, e)) |count| { + if (count == dims) { + vc.append(self.alloc, e) catch {}; + self.vec_entries.append(self.alloc, entry) catch {}; } - std.Thread.yield() catch {}; } } } + + // Periodic MVCC version chain GC to prevent unbounded memory growth. + if (self.gc_counter.fetchAdd(1, .monotonic) % GC_INTERVAL == GC_INTERVAL - 1) { + _ = self.versions.gc(self.alloc); + } } - // Extract and store vector embedding if configured. - // Uses stack buffer to avoid heap allocation per insert. - if (self.vectors) |vc| { - const field = self.vector_field[0..self.vector_field_len]; - const dims: usize = vc.dims; - var embed_stack: [4096]f32 = undefined; - const emb = if (dims <= 4096) embed_stack[0..dims] else blk: { - break :blk self.alloc.alloc(f32, dims) catch null; + self.enqueueStoredTextIndex(inserted_entry, key, value); + emitChange(self, .insert, key, value, doc_id); + + return doc_id; + } + + fn bulkEncodedLen(row: BulkInsertRow) ?usize { + if (row.key.len == 0 or row.key.len > 1024 or row.value.len > 64 * 1024 * 1024) return null; + const total_size = DocHeader.size + row.key.len + row.value.len; + if (total_size > page_mod.PAGE_USABLE) return null; + return total_size; + } + + /// Insert many documents as one durable ingestion batch. The WAL is flushed + /// before page/index updates are applied and before this call returns. + pub fn insertBulk(self: *Collection, rows: []const BulkInsertRow) !BulkInsertResult { + var result = BulkInsertResult{}; + if (rows.len == 0) return result; + + var encoded: std.ArrayList(u8) = .empty; + defer encoded.deinit(self.alloc); + var prepared: std.ArrayList(BulkPreparedDoc) = .empty; + defer prepared.deinit(self.alloc); + var btree_entries: std.ArrayList(BTreeEntry) = .empty; + defer btree_entries.deinit(self.alloc); + var payloads: std.ArrayList(wal_mod.BatchPayload) = .empty; + defer payloads.deinit(self.alloc); + + var valid_rows: usize = 0; + var estimated_bytes: usize = 0; + for (rows) |row| { + const total_size = bulkEncodedLen(row) orelse { + result.errors += 1; + continue; }; - if (emb) |e| { - defer if (dims > 4096) self.alloc.free(e); - if (extractJsonFloatArray(value, field, e)) |count| { - if (count == dims) { - vc.append(self.alloc, e) catch {}; - self.vec_entries.append(self.alloc, entry) catch {}; + estimated_bytes = std.math.add(usize, estimated_bytes, total_size) catch return error.OutOfMemory; + valid_rows += 1; + } + + if (valid_rows == 0) return result; + + try prepared.ensureTotalCapacity(self.alloc, valid_rows); + try btree_entries.ensureTotalCapacity(self.alloc, valid_rows); + try payloads.ensureTotalCapacity(self.alloc, valid_rows); + try encoded.ensureTotalCapacity(self.alloc, estimated_bytes); + + const first_doc_id = self.next_doc_id.fetchAdd(@intCast(valid_rows), .monotonic); + result.first_doc_id = first_doc_id; + var doc_id_offset: u64 = 0; + for (rows) |row| { + const total_size = bulkEncodedLen(row) orelse continue; + + const doc_id = first_doc_id + doc_id_offset; + doc_id_offset += 1; + const hdr = doc_mod.newHeader(doc_id, row.key, row.value); + const d = Doc{ .header = hdr, .key = row.key, .value = row.value }; + const enc_off = encoded.items.len; + const enc = encoded.addManyAsSliceAssumeCapacity(total_size); + _ = try d.encodeBuf(enc); + + prepared.appendAssumeCapacity(.{ + .key = row.key, + .value = row.value, + .doc_id = doc_id, + .key_hash = hdr.key_hash, + .enc_off = enc_off, + .enc_len = total_size, + .line_len = row.line_len, + }); + payloads.appendAssumeCapacity(enc); + } + + if (prepared.items.len == 0) return result; + + const last_lsn = try self.wal_log.writeCommittedBatch(.doc_insert, wal_mod.DB_TAG_DOC, payloads.items); + try self.wal_log.flushUpTo(last_lsn); + + self.meta_mu.lock(); + { + defer self.meta_mu.unlock(); + try self.hash_idx.ensureUnusedCapacity(@intCast(prepared.items.len)); + try self.key_doc_ids.ensureUnusedCapacity(@intCast(prepared.items.len)); + try self.key_epochs.ensureUnusedCapacity(@intCast(prepared.items.len)); + try self.versions.ensureUnusedCapacity(self.alloc, prepared.items.len); + + for (prepared.items) |*item| { + const enc = encoded.items[item.enc_off .. item.enc_off + item.enc_len]; + const pno = try self.findOrAllocLeaf(enc.len); + const page_off = self.pf.leafAppend(pno, enc) orelse return error.PageFull; + const entry = BTreeEntry{ + .key_hash = item.key_hash, + .doc_id = item.doc_id, + .page_no = pno, + .page_off = page_off, + }; + btree_entries.appendAssumeCapacity(entry); + item.page_no = pno; + item.page_off = page_off; + } + try self.idx.insertMany(btree_entries.items); + + const first_epoch = self.epochs.advanceMany(prepared.items.len); + for (prepared.items, btree_entries.items, 0..) |*item, entry, i| { + self.hash_idx.putAssumeCapacity(item.key_hash, entry); + const epoch = first_epoch + @as(u64, @intCast(i)); + self.versions.appendFreshVersionAssumeCapacity(item.doc_id, entry.page_no, entry.page_off, epoch); + self.key_doc_ids.putAssumeCapacity(item.key_hash, item.doc_id); + self.key_epochs.putAssumeCapacity(item.key_hash, item.doc_id); + + if (self.vectors) |vc| { + const field = self.vector_field[0..self.vector_field_len]; + const dims: usize = vc.dims; + var embed_stack: [4096]f32 = undefined; + const emb = if (dims <= 4096) embed_stack[0..dims] else blk: { + break :blk self.alloc.alloc(f32, dims) catch null; + }; + if (emb) |e| { + defer if (dims > 4096) self.alloc.free(e); + if (extractJsonFloatArray(item.value, field, e)) |count| { + if (count == dims) { + vc.append(self.alloc, e) catch {}; + self.vec_entries.append(self.alloc, entry) catch {}; + } + } } } + + result.inserted += 1; + result.bytes += item.line_len; } - } - emitChange(self, .insert, key, value, doc_id); + const inserted_count: u64 = @intCast(prepared.items.len); + const old_gc_counter = self.gc_counter.fetchAdd(inserted_count, .monotonic); + if (old_gc_counter / GC_INTERVAL != (old_gc_counter + inserted_count) / GC_INTERVAL) { + _ = self.versions.gc(self.alloc); + } + } - // Periodic MVCC version chain GC to prevent unbounded memory growth. - if (self.gc_counter.fetchAdd(1, .monotonic) % GC_INTERVAL == GC_INTERVAL - 1) { - _ = self.gcVersions(); + const emit_cdc = self.cdc.hasSubscriptions(); + for (prepared.items) |item| { + self.enqueueStoredTextIndex( + .{ + .key_hash = item.key_hash, + .doc_id = item.doc_id, + .page_no = item.page_no, + .page_off = item.page_off, + }, + item.key, + item.value, + ); + if (emit_cdc) emitChange(self, .insert, item.key, item.value, item.doc_id); } - return doc_id; + return result; } /// Insert a document with a pre-computed embedding (no JSON parsing needed). @@ -466,6 +687,8 @@ pub const Collection = struct { const doc_id = self.insert(key, value) catch |e| return e; // insert() already handles vector extraction from JSON, but if embedding // was passed directly AND the JSON extraction didn't find it, append now. + self.meta_mu.lock(); + defer self.meta_mu.unlock(); if (self.vectors) |vc| { // Check if insert() already appended (vec_entries grew) // vec_entries.len should equal vc.count after insert if JSON had embedding @@ -490,28 +713,39 @@ pub const Collection = struct { /// The Doc's key/value slices are valid until the next write to this collection. pub fn get(self: *Collection, key: []const u8) ?Doc { const key_hash = doc_mod.fnv1a(key); + self.meta_mu.lock(); + defer self.meta_mu.unlock(); // L1: Hot cache (O(1), no hash table overhead) - if (self.cache.lookup(key_hash)) |loc| { + self.cache_mu.lock(); + const cached = self.cache.lookup(key_hash); + self.cache_mu.unlock(); + if (cached) |loc| { if (self.readLoc(loc.page_no, loc.page_off)) |d| return d; } // L2: Hash index (O(1), but hash table lookup) if (self.hash_idx.get(key_hash)) |entry| { if (self.readEntry(entry)) |d| { + self.cache_mu.lock(); self.cache.insert(key_hash, entry.page_no, entry.page_off); + self.cache_mu.unlock(); return d; } } // L3: B-tree (O(log n)) const entry = self.idx.search(key_hash) orelse return null; const d = self.readEntry(entry) orelse return null; + self.cache_mu.lock(); self.cache.insert(key_hash, entry.page_no, entry.page_off); + self.cache_mu.unlock(); return d; } pub fn getAsOfEpoch(self: *Collection, key: []const u8, epoch: u64) ?Doc { const key_hash = doc_mod.fnv1a(key); + self.meta_mu.lock(); + defer self.meta_mu.unlock(); const doc_id = self.key_doc_ids.get(key_hash) orelse return null; const ver = self.versions.getAtEpoch(doc_id, epoch) orelse return null; return self.readLoc(ver.page_no, ver.page_off); @@ -524,6 +758,8 @@ pub const Collection = struct { /// Look up a document by doc_id using a linear scan of the index. pub fn getById(self: *Collection, doc_id: u64) ?Doc { + self.meta_mu.lock(); + defer self.meta_mu.unlock(); const total_pages = self.pf.next_alloc.load(.acquire); var pno: u32 = 0; while (pno < total_pages) : (pno += 1) { @@ -553,37 +789,44 @@ pub const Collection = struct { self.stripe_locks[stripe].lock(); defer self.stripe_locks[stripe].unlock(); - const old_entry = self.idx.search(key_hash) orelse return false; - const old_doc = self.readEntry(old_entry) orelse return false; + var doc_id: u64 = undefined; + { + self.meta_mu.lock(); + defer self.meta_mu.unlock(); - const doc_id = old_doc.header.doc_id; - var new_hdr = doc_mod.newHeader(doc_id, key, new_value); - new_hdr.version = old_doc.header.version +% 1; - // MVCC: encode old location into next_ver field. - new_hdr.next_ver = (@as(u64, old_entry.page_no) << 16) | old_entry.page_off; + const old_entry = self.idx.search(key_hash) orelse return false; + const old_doc = self.readEntry(old_entry) orelse return false; - const d = Doc{ .header = new_hdr, .key = key, .value = new_value }; - var enc_buf: [65536]u8 = undefined; - const enc = try d.encodeBuf(&enc_buf); + doc_id = old_doc.header.doc_id; + var new_hdr = doc_mod.newHeader(doc_id, key, new_value); + new_hdr.version = old_doc.header.version +% 1; + // MVCC: encode old location into next_ver field. + new_hdr.next_ver = (@as(u64, old_entry.page_no) << 16) | old_entry.page_off; - const txn = self.wal_log.next_lsn.load(.monotonic); - _ = try self.wal_log.write(txn, .doc_update, 0, 0, enc); + const d = Doc{ .header = new_hdr, .key = key, .value = new_value }; + var enc_buf: [65536]u8 = undefined; + const enc = try d.encodeBuf(&enc_buf); - const pno = try self.findOrAllocLeaf(enc.len); - const page_off = self.pf.leafAppend(pno, enc) orelse return error.PageFull; + try writeCommittedDocumentWal(self.wal_log, .doc_update, enc); - const new_entry = BTreeEntry{ - .key_hash = key_hash, - .doc_id = doc_id, - .page_no = pno, - .page_off = page_off, - }; - self.hash_idx.put(key_hash, new_entry) catch {}; - self.cache.invalidate(key_hash); + const pno = try self.findOrAllocLeaf(enc.len); + const page_off = self.pf.leafAppend(pno, enc) orelse return error.PageFull; - // MVCC: register new version in the version chain (links to old automatically). - const epoch = self.epochs.advance(); - self.versions.appendVersion(self.alloc, doc_id, pno, page_off, epoch) catch {}; + const new_entry = BTreeEntry{ + .key_hash = key_hash, + .doc_id = doc_id, + .page_no = pno, + .page_off = page_off, + }; + self.hash_idx.put(key_hash, new_entry) catch {}; + self.cache_mu.lock(); + self.cache.invalidate(key_hash); + self.cache_mu.unlock(); + + // MVCC: register new version in the version chain (links to old automatically). + const epoch = self.epochs.advance(); + self.versions.appendVersion(self.alloc, doc_id, pno, page_off, epoch) catch {}; + } emitChange(self, .update, key, new_value, doc_id); return true; @@ -597,26 +840,34 @@ pub const Collection = struct { self.stripe_locks[stripe].lock(); defer self.stripe_locks[stripe].unlock(); - const entry = self.idx.search(key_hash) orelse return false; - - const old_doc = self.readEntry(entry) orelse return false; - var tomb_hdr = doc_mod.newHeader(old_doc.header.doc_id, key, ""); - tomb_hdr.flags |= DocHeader.DELETED; - tomb_hdr.version = old_doc.header.version +% 1; - tomb_hdr.next_ver = (@as(u64, entry.page_no) << 16) | entry.page_off; - const txn = self.wal_log.next_lsn.load(.monotonic); - _ = try self.wal_log.write(txn, .doc_delete, 0, 0, std.mem.asBytes(&tomb_hdr)); - const tomb_doc = Doc{ .header = tomb_hdr, .key = key, .value = "" }; - var enc_buf: [65536]u8 = undefined; - const enc = try tomb_doc.encodeBuf(&enc_buf); - const pno = try self.findOrAllocLeaf(enc.len); - const page_off = self.pf.leafAppend(pno, enc) orelse return error.PageFull; - const epoch = self.epochs.advance(); - self.versions.appendVersion(self.alloc, old_doc.header.doc_id, pno, page_off, epoch) catch {}; - self.idx.delete(key_hash); - _ = self.hash_idx.remove(key_hash); - self.cache.invalidate(key_hash); - emitChange(self, .delete, key, "", old_doc.header.doc_id); + var doc_id: u64 = undefined; + { + self.meta_mu.lock(); + defer self.meta_mu.unlock(); + + const entry = self.idx.search(key_hash) orelse return false; + + const old_doc = self.readEntry(entry) orelse return false; + doc_id = old_doc.header.doc_id; + var tomb_hdr = doc_mod.newHeader(doc_id, key, ""); + tomb_hdr.flags |= DocHeader.DELETED; + tomb_hdr.version = old_doc.header.version +% 1; + tomb_hdr.next_ver = (@as(u64, entry.page_no) << 16) | entry.page_off; + const tomb_doc = Doc{ .header = tomb_hdr, .key = key, .value = "" }; + var enc_buf: [65536]u8 = undefined; + const enc = try tomb_doc.encodeBuf(&enc_buf); + try writeCommittedDocumentWal(self.wal_log, .doc_delete, enc); + const pno = try self.findOrAllocLeaf(enc.len); + const page_off = self.pf.leafAppend(pno, enc) orelse return error.PageFull; + const epoch = self.epochs.advance(); + self.versions.appendVersion(self.alloc, doc_id, pno, page_off, epoch) catch {}; + self.idx.delete(key_hash); + _ = self.hash_idx.remove(key_hash); + self.cache_mu.lock(); + self.cache.invalidate(key_hash); + self.cache_mu.unlock(); + } + emitChange(self, .delete, key, "", doc_id); return true; } @@ -659,7 +910,7 @@ pub const Collection = struct { const terms = terms_buf[0..term_count]; // For single terms or exact phrase, try trigram index on full query first. - if (term_count == 1) { + if (term_count == 1 and self.hasCompleteTextIndex()) { const cand_paths = self.tri.candidates(query, alloc) orelse { return self.bruteForceSearch(query, limit, alloc); }; @@ -688,6 +939,10 @@ pub const Collection = struct { if (t.len > terms[longest_idx].len) longest_idx = i; } + if (!self.hasCompleteTextIndex()) { + return self.multiTermBruteForce(terms, limit, alloc); + } + const cand_paths = self.tri.candidates(terms[longest_idx], alloc) orelse { return self.multiTermBruteForce(terms, limit, alloc); }; @@ -751,7 +1006,10 @@ pub const Collection = struct { const nc = needle[j]; const hl = if (hc >= 'A' and hc <= 'Z') hc + 32 else hc; const nl = if (nc >= 'A' and nc <= 'Z') nc + 32 else nc; - if (hl != nl) { match = false; break; } + if (hl != nl) { + match = false; + break; + } } if (match) return true; } @@ -794,7 +1052,9 @@ pub const Collection = struct { pub const ScanResult = struct { docs: []Doc, alloc: std.mem.Allocator, - pub fn deinit(self: ScanResult) void { self.alloc.free(self.docs); } + pub fn deinit(self: ScanResult) void { + self.alloc.free(self.docs); + } }; /// Linear scan of all live documents, with optional limit/offset. @@ -804,9 +1064,16 @@ pub const Collection = struct { offset: u32, alloc: std.mem.Allocator, ) !ScanResult { + if (limit == 0) { + return ScanResult{ .docs = try alloc.alloc(Doc, 0), .alloc = alloc }; + } + var results: std.ArrayList(Doc) = .empty; errdefer results.deinit(alloc); + self.meta_mu.lock(); + defer self.meta_mu.unlock(); + const total_pages = self.pf.next_alloc.load(.acquire); var skipped: u32 = 0; var pno: u32 = 0; @@ -821,7 +1088,10 @@ pub const Collection = struct { const d = decoded.doc; pos += decoded.consumed; if (d.header.flags & DocHeader.DELETED != 0) continue; - if (skipped < offset) { skipped += 1; continue; } + if (skipped < offset) { + skipped += 1; + continue; + } try results.append(alloc, d); if (results.items.len >= limit) break :outer; } @@ -830,9 +1100,16 @@ pub const Collection = struct { } pub fn scanAsOfEpoch(self: *Collection, epoch: u64, limit: u32, offset: u32, alloc: std.mem.Allocator) !ScanResult { + if (limit == 0) { + return ScanResult{ .docs = try alloc.alloc(Doc, 0), .alloc = alloc }; + } + var results: std.ArrayList(Doc) = .empty; errdefer results.deinit(alloc); + self.meta_mu.lock(); + defer self.meta_mu.unlock(); + var skipped: u32 = 0; var it = self.key_doc_ids.iterator(); while (it.next()) |entry| { @@ -872,8 +1149,7 @@ pub const Collection = struct { } fn readEntry(self: *Collection, entry: BTreeEntry) ?Doc { - const raw = self.pf.leafRead(entry.page_no, entry.page_off, - DocHeader.size + 1024 + 65536); + const raw = self.pf.leafRead(entry.page_no, entry.page_off, DocHeader.size + 1024 + 65536); const decoded = doc_mod.decode(raw) catch return null; if (decoded.doc.header.flags & DocHeader.DELETED != 0) return null; return decoded.doc; @@ -885,16 +1161,44 @@ pub const Collection = struct { return @ptrCast(@alignCast(data[entry.page_off..].ptr)); } + fn shouldIndexText(self: *const Collection, value: []const u8) bool { + return value.len >= self.text_index_min_value_bytes; + } + + fn hasCompleteTextIndex(self: *Collection) bool { + self.meta_mu.lock(); + const live_docs = self.hash_idx.count(); + self.meta_mu.unlock(); + return live_docs > 0 and self.tri.fileCount() >= live_docs; + } + fn findOrAllocLeaf(self: *Collection, needed: usize) !u32 { + if (self.append_leaf_hint != 0) { + const ph = self.pf.pageHeader(self.append_leaf_hint); + if (@as(page_mod.PageType, @enumFromInt(ph.page_type)) == .leaf and + page_mod.PAGE_USABLE - ph.used_bytes >= needed) + { + return self.append_leaf_hint; + } + } + const total = self.pf.next_alloc.load(.acquire); var pno = if (total > 0) total - 1 else 0; var checked: u32 = 0; - while (checked < 32 and pno > 0) : ({ pno -= 1; checked += 1; }) { + while (checked < 32 and pno > 0) : ({ + pno -= 1; + checked += 1; + }) { const ph = self.pf.pageHeader(pno); if (@as(page_mod.PageType, @enumFromInt(ph.page_type)) != .leaf) continue; - if (page_mod.PAGE_USABLE - ph.used_bytes >= needed) return pno; + if (page_mod.PAGE_USABLE - ph.used_bytes >= needed) { + self.append_leaf_hint = pno; + return pno; + } } - return self.pf.allocPage(.leaf); + const allocated = try self.pf.allocPage(.leaf); + self.append_leaf_hint = allocated; + return allocated; } fn rebuildIndexes(self: *Collection) !void { @@ -919,7 +1223,7 @@ pub const Collection = struct { if (d.header.flags & DocHeader.DELETED == 0) { self.hash_idx.put(d.header.key_hash, entry) catch {}; self.key_doc_ids.put(d.header.key_hash, d.header.doc_id) catch {}; - if (d.value.len >= 3) { + if (self.shouldIndexText(d.value)) { self.tri.indexFile(d.key, d.value) catch {}; self.words.indexFile(d.key, d.value) catch {}; } @@ -932,6 +1236,7 @@ pub const Collection = struct { } fn emitChange(self: *Collection, op: cdc_mod.Op, key: []const u8, value: []const u8, doc_id: u64) void { + if (!self.cdc.hasSubscriptions()) return; const full_name = self.name(); const ref: TenantCollectionRef = splitTenantCollectionKey(full_name) orelse TenantCollectionRef{ .tenant_id = DEFAULT_TENANT, @@ -940,6 +1245,98 @@ pub const Collection = struct { self.cdc.emit(ref.tenant_id, ref.collection_name, key, value, doc_id, op); } + fn enqueueTextIndex(self: *Collection, key: []const u8, value: []const u8) void { + if (!self.shouldIndexText(value)) return; + if (self.index_thread == null and self.index_thread2 == null) { + self.tri.indexFile(key, value) catch {}; + self.words.indexFile(key, value) catch {}; + return; + } + + const queued_bytes = indexEntryBytes(key, value); + if (!self.tryReserveIndexQueueBytes(queued_bytes)) { + self.tri.indexFile(key, value) catch {}; + self.words.indexFile(key, value) catch {}; + return; + } + + const owned_key = self.alloc.dupe(u8, key) catch { + self.releaseIndexQueueBytes(queued_bytes); + return; + }; + const owned_val = self.alloc.dupe(u8, value) catch { + self.alloc.free(owned_key); + self.releaseIndexQueueBytes(queued_bytes); + return; + }; + + const q = if (self.index_thread != null and self.index_thread2 != null) + if (self.queue_toggle.fetchAdd(1, .monotonic) % 2 == 0) &self.index_queue else &self.index_queue2 + else if (self.index_thread != null) + &self.index_queue + else + &self.index_queue2; + + var retries: u32 = 0; + while (!q.pushOwned(owned_key, owned_val, queued_bytes)) { + retries += 1; + if (retries > 1000) { + self.releaseIndexQueueBytes(queued_bytes); + self.tri.indexFile(owned_key, owned_val) catch {}; + self.words.indexFile(owned_key, owned_val) catch {}; + self.alloc.free(owned_val); + self.alloc.free(owned_key); + return; + } + std.Thread.yield() catch {}; + } + } + + fn enqueueStoredTextIndex(self: *Collection, entry: BTreeEntry, key: []const u8, value: []const u8) void { + if (!self.shouldIndexText(value)) return; + if (self.index_thread == null and self.index_thread2 == null) { + self.tri.indexFile(key, value) catch {}; + self.words.indexFile(key, value) catch {}; + return; + } + + const q = if (self.index_thread != null and self.index_thread2 != null) + if (self.queue_toggle.fetchAdd(1, .monotonic) % 2 == 0) &self.index_queue else &self.index_queue2 + else if (self.index_thread != null) + &self.index_queue + else + &self.index_queue2; + + var retries: u32 = 0; + while (!q.pushStored(entry)) { + retries += 1; + if (retries > 1000) { + self.tri.indexFile(key, value) catch {}; + self.words.indexFile(key, value) catch {}; + return; + } + std.Thread.yield() catch {}; + } + } + + fn tryReserveIndexQueueBytes(self: *Collection, bytes: usize) bool { + const limit = indexQueueMemoryLimit(); + while (true) { + const current = self.index_queue_bytes.load(.acquire); + const next = std.math.add(usize, current, bytes) catch return false; + if (next > limit) return false; + if (self.index_queue_bytes.cmpxchgWeak(current, next, .acq_rel, .monotonic)) |_| { + continue; + } + return true; + } + } + + fn releaseIndexQueueBytes(self: *Collection, bytes: usize) void { + if (bytes == 0) return; + _ = self.index_queue_bytes.fetchSub(bytes, .release); + } + // ─── Vector search ──────────────────────────────────────────────────── pub const VectorSearchResult = struct { @@ -1339,9 +1736,9 @@ pub const Collection = struct { var name_end = name_start; while (name_end < doc.value.len and ((doc.value[name_end] >= 'a' and doc.value[name_end] <= 'z') or - (doc.value[name_end] >= 'A' and doc.value[name_end] <= 'Z') or - (doc.value[name_end] >= '0' and doc.value[name_end] <= '9') or - doc.value[name_end] == '_')) + (doc.value[name_end] >= 'A' and doc.value[name_end] <= 'Z') or + (doc.value[name_end] >= '0' and doc.value[name_end] <= '9') or + doc.value[name_end] == '_')) { name_end += 1; } @@ -1424,6 +1821,7 @@ fn indexWorkerQ(col: *Collection, queue: *IndexQueue) void { const BATCH = 64; var batch_keys: [BATCH][]const u8 = undefined; var batch_vals: [BATCH][]const u8 = undefined; + var batch_entries: [BATCH]IndexQueue.Entry = undefined; var reusable_tris = std.AutoHashMap(codeindex.Trigram, void).init(col.alloc); defer reusable_tris.deinit(); @@ -1434,8 +1832,22 @@ fn indexWorkerQ(col: *Collection, queue: *IndexQueue) void { var n: usize = 0; while (n < BATCH) { const entry = queue.pop() orelse break; - batch_keys[n] = entry.key; - batch_vals[n] = entry.value; + if (entry.stored) { + const doc = col.readEntry(entry.loc) orelse continue; + if (!col.shouldIndexText(doc.value)) continue; + batch_keys[n] = doc.key; + batch_vals[n] = doc.value; + } else { + if (!col.shouldIndexText(entry.value)) { + col.releaseIndexQueueBytes(entry.queued_bytes); + col.alloc.free(entry.value); + col.alloc.free(entry.key); + continue; + } + batch_keys[n] = entry.key; + batch_vals[n] = entry.value; + } + batch_entries[n] = entry; n += 1; } if (n == 0) { @@ -1450,17 +1862,32 @@ fn indexWorkerQ(col: *Collection, queue: *IndexQueue) void { col.words.indexFile(batch_keys[i], batch_vals[i]) catch {}; } for (0..n) |i| { - col.alloc.free(batch_vals[i]); - col.alloc.free(batch_keys[i]); + const entry = batch_entries[i]; + if (!entry.stored) { + col.releaseIndexQueueBytes(entry.queued_bytes); + col.alloc.free(entry.value); + col.alloc.free(entry.key); + } } _ = col.indexing_count.fetchSub(1, .release); } // Final drain on shutdown. while (queue.pop()) |entry| { - col.tri.indexFile(entry.key, entry.value) catch {}; - col.words.indexFile(entry.key, entry.value) catch {}; - col.alloc.free(entry.value); - col.alloc.free(entry.key); + if (entry.stored) { + if (col.readEntry(entry.loc)) |doc| { + if (!col.shouldIndexText(doc.value)) continue; + col.tri.indexFile(doc.key, doc.value) catch {}; + col.words.indexFile(doc.key, doc.value) catch {}; + } + } else { + if (col.shouldIndexText(entry.value)) { + col.tri.indexFile(entry.key, entry.value) catch {}; + col.words.indexFile(entry.key, entry.value) catch {}; + } + col.releaseIndexQueueBytes(entry.queued_bytes); + col.alloc.free(entry.value); + col.alloc.free(entry.key); + } } } @@ -1476,7 +1903,7 @@ pub const Database = struct { data_dir_buf: [256]u8, data_dir_len: usize, alloc: std.mem.Allocator, - mu: std.Thread.RwLock, + mu: compat.RwLock, auth: @import("auth.zig").AuthStore, pub const TenantQuota = struct { @@ -1603,6 +2030,13 @@ pub const Database = struct { kv.value.close(); self.alloc.free(kv.key); } + + // Delete the backing page file so data doesn't reappear on re-access. + var storage_name_buf: [MAX_TENANT_ID_LEN + MAX_COLLECTION_NAME_LEN + 4]u8 = undefined; + const storage_name = makeStorageName(&storage_name_buf, tenant_id, name) catch return; + var path_buf: [512]u8 = undefined; + const page_path = std.fmt.bufPrint(&path_buf, "{s}/{s}.pages", .{ self.dataDir(), storage_name }) catch return; + compat.cwd().deleteFile(page_path) catch {}; } pub fn listCollectionsForTenant(self: *Database, tenant_id: []const u8, alloc: std.mem.Allocator) !std.ArrayList([]const u8) { @@ -1652,7 +2086,7 @@ pub const Database = struct { const quota = self.tenant_quotas.get(tenant_id) orelse return; if (quota.max_ops_per_second == std.math.maxInt(u32)) return; - const now_ms: i64 = std.time.milliTimestamp(); + const now_ms: i64 = compat.milliTimestamp(); const window_ms = now_ms - @mod(now_ms, 1000); const usage = try self.getOrCreateTenantUsageLocked(tenant_id); if (usage.ops_window_ms != window_ms) { @@ -1738,7 +2172,7 @@ fn validateCollectionComponent(name: []const u8) !void { } fn makeStorageName(buf: []u8, tenant_id: []const u8, collection_name: []const u8) ![]const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); try appendSanitizedComponent(w, tenant_id); try w.writeAll("__"); @@ -1758,32 +2192,92 @@ fn appendSanitizedComponent(writer: anytype, input: []const u8) !void { fn ensureDataDir(alloc: std.mem.Allocator, data_dir: []const u8) ![]u8 { try ensureDirPath(data_dir); - return try std.fs.realpathAlloc(alloc, data_dir); + return blk: { + var buf: [4096]u8 = undefined; + var z_buf: [4096]u8 = undefined; + @memcpy(z_buf[0..data_dir.len], data_dir); + z_buf[data_dir.len] = 0; + const rp = std.c.realpath(@ptrCast(&z_buf), @ptrCast(&buf)); + if (rp == null) break :blk error.FileNotFound; + const len = std.mem.indexOfScalar(u8, &buf, 0) orelse buf.len; + break :blk try alloc.dupe(u8, buf[0..len]); + }; } fn ensureDirPath(data_dir: []const u8) !void { - if (std.fs.path.isAbsolute(data_dir)) { - var root = try std.fs.openDirAbsolute("/", .{}); - defer root.close(); - const rel = std.mem.trimLeft(u8, data_dir, "/"); - if (rel.len > 0) root.makePath(rel) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; - } else { - std.fs.cwd().makePath(data_dir) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; - } + compat.cwd().makePath(data_dir) catch return error.MkdirError; } +const CollectionWalReplayProbe = struct { + const MAX_ENTRIES = 8; + + var count: usize = 0; + var ops: [MAX_ENTRIES]wal_mod.OpCode = undefined; + var flags: [MAX_ENTRIES]u16 = undefined; + var delete_payload: [256]u8 = undefined; + var delete_payload_len: usize = 0; + + fn reset() void { + count = 0; + delete_payload_len = 0; + } + + fn apply(entry: wal_mod.Entry) !void { + switch (entry.op_code) { + .doc_insert, .doc_update, .doc_delete => {}, + else => return, + } + + if (count >= MAX_ENTRIES) return error.TooManyCollectionWalEntries; + ops[count] = entry.op_code; + flags[count] = entry.flags; + count += 1; + + if (entry.op_code == .doc_delete) { + if (entry.payload.len > delete_payload.len) return error.DeletePayloadTooLarge; + delete_payload_len = entry.payload.len; + @memcpy(delete_payload[0..delete_payload_len], entry.payload); + } + } + + fn deletePayload() []const u8 { + return delete_payload[0..delete_payload_len]; + } +}; + +const ConcurrentInsertProbe = struct { + const inserts_per_worker = 128; + + col: *Collection, + worker_id: usize, + errors: *std.atomic.Value(u32), + + fn run(self: *ConcurrentInsertProbe) void { + var i: usize = 0; + while (i < inserts_per_worker) : (i += 1) { + var key_buf: [64]u8 = undefined; + var value_buf: [96]u8 = undefined; + const key = std.fmt.bufPrint(&key_buf, "worker-{d}-doc-{d}", .{ self.worker_id, i }) catch { + _ = self.errors.fetchAdd(1, .monotonic); + continue; + }; + const value = std.fmt.bufPrint(&value_buf, "{{\"worker\":{d},\"doc\":{d}}}", .{ self.worker_id, i }) catch { + _ = self.errors.fetchAdd(1, .monotonic); + continue; + }; + _ = self.col.insert(key, value) catch { + _ = self.errors.fetchAdd(1, .monotonic); + }; + } + } +}; + test "tenant collections are isolated" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_multi_tenant_test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1803,9 +2297,9 @@ test "tenant collections are isolated" { test "tenant collection quota limits new collections" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_tenant_quota_test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1818,9 +2312,9 @@ test "tenant collection quota limits new collections" { test "tenant ops quota is enforced per second" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_tenant_ops_test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1831,12 +2325,116 @@ test "tenant ops quota is enforced per second" { try std.testing.expectError(error.TenantOpsQuotaExceeded, db.recordTenantOperation("tenant-a")); } +test "collection WAL replays committed document writes and full tombstone" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_collection_wal_committed"; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + { + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + + const col = try db.collection("users"); + _ = try col.insert("u1", "{\"name\":\"alice\"}"); + try std.testing.expect(try col.update("u1", "{\"name\":\"alice-v2\"}")); + try std.testing.expect(try col.delete("u1")); + } + + var path_buf: [512]u8 = undefined; + const wal_path = try std.fmt.bufPrintZ(&path_buf, "{s}/doc.wal", .{tmp_dir}); + var wal = try WAL.open(wal_path, alloc); + defer wal.close(); + + CollectionWalReplayProbe.reset(); + try wal.recover(0, CollectionWalReplayProbe.apply, alloc); + + try std.testing.expectEqual(@as(usize, 3), CollectionWalReplayProbe.count); + try std.testing.expectEqual(wal_mod.OpCode.doc_insert, CollectionWalReplayProbe.ops[0]); + try std.testing.expectEqual(wal_mod.OpCode.doc_update, CollectionWalReplayProbe.ops[1]); + try std.testing.expectEqual(wal_mod.OpCode.doc_delete, CollectionWalReplayProbe.ops[2]); + for (CollectionWalReplayProbe.flags[0..CollectionWalReplayProbe.count]) |flags| { + try std.testing.expect(flags & wal_mod.FLAG_COMMIT != 0); + } + + const decoded = try doc_mod.decode(CollectionWalReplayProbe.deletePayload()); + try std.testing.expect(decoded.doc.isDeleted()); + try std.testing.expectEqualStrings("u1", decoded.doc.key); + try std.testing.expectEqualStrings("", decoded.doc.value); + try std.testing.expectEqual(DocHeader.size + "u1".len, decoded.consumed); +} + +test "collection bulk insert is durable and queryable" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_collection_bulk_insert"; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + + const col = try db.collection("users"); + const rows = [_]Collection.BulkInsertRow{ + .{ .key = "u1", .value = "{\"name\":\"alice\"}", .line_len = 25 }, + .{ .key = "u2", .value = "{\"name\":\"bob\"}", .line_len = 23 }, + .{ .key = "u3", .value = "{\"name\":\"carol\"}", .line_len = 25 }, + }; + + const result = try col.insertBulk(rows[0..]); + try std.testing.expectEqual(@as(u32, 3), result.inserted); + try std.testing.expectEqual(@as(u32, 0), result.errors); + try std.testing.expect(db.wal_log.synced_lsn >= 3); + + try std.testing.expectEqualStrings("{\"name\":\"alice\"}", col.get("u1").?.value); + try std.testing.expectEqualStrings("{\"name\":\"bob\"}", col.get("u2").?.value); + try std.testing.expectEqualStrings("{\"name\":\"carol\"}", col.get("u3").?.value); +} + +test "collection concurrent inserts keep shared indexes stable" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_collection_concurrent_insert"; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + + const col = try db.collection("load"); + const worker_count = 8; + var errors = std.atomic.Value(u32).init(0); + var probes: [worker_count]ConcurrentInsertProbe = undefined; + var threads: [worker_count]std.Thread = undefined; + + for (&probes, 0..) |*probe, i| { + probe.* = .{ + .col = col, + .worker_id = i, + .errors = &errors, + }; + threads[i] = try std.Thread.spawn(.{}, ConcurrentInsertProbe.run, .{probe}); + } + for (threads) |thread| thread.join(); + + try std.testing.expectEqual(@as(u32, 0), errors.load(.acquire)); + col.flushIndex(); + + const expected = worker_count * ConcurrentInsertProbe.inserts_per_worker; + const scan = try col.scan(expected, 0, alloc); + defer scan.deinit(); + try std.testing.expectEqual(@as(usize, expected), scan.docs.len); + try std.testing.expect(col.get("worker-0-doc-0") != null); + try std.testing.expect(col.get("worker-7-doc-127") != null); +} + test "time travel get returns historical version after update" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_time_travel_update"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1854,9 +2452,9 @@ test "time travel get returns historical version after update" { test "time travel get survives delete" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_time_travel_delete"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1873,9 +2471,9 @@ test "time travel get survives delete" { test "time travel scan uses historical snapshot" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_time_travel_scan"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1895,9 +2493,9 @@ test "time travel scan uses historical snapshot" { test "cdc emits signed ordered deliveries for tenant mutations" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_cdc_integration"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1907,7 +2505,7 @@ test "cdc emits signed ordered deliveries for tenant mutations" { _ = try col.insert("u1", "{\"name\":\"alice\"}"); _ = try col.update("u1", "{\"name\":\"alice-2\"}"); try std.testing.expect(try col.delete("u1")); - std.Thread.sleep(20_000_000); + compat.sleep(20_000_000); const deliveries = try db.listWebhookDeliveries(alloc, "tenant-a"); defer alloc.free(deliveries); @@ -1922,9 +2520,9 @@ test "cdc emits signed ordered deliveries for tenant mutations" { test "tenant collections isolate keys and listings" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_tenant_isolation"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); @@ -1949,12 +2547,53 @@ test "tenant collections isolate keys and listings" { try std.testing.expectEqualStrings("users", tenant_a_cols.items[0]); } +test "collection scan limit zero returns no documents" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_collection_scan_limit_zero"; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + + const col = try db.collection("users"); + _ = try col.insert("u1", "{\"name\":\"one\"}"); + + var result = try col.scan(0, 0, alloc); + defer result.deinit(); + try std.testing.expectEqual(@as(usize, 0), result.docs.len); +} + +test "small structured docs skip automatic text index and search falls back" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_small_doc_text_index_skip"; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + + const col = try db.collection("events"); + _ = try col.insert("small", "{\"kind\":\"tiny\"}"); + _ = try col.insert("large", "{\"body\":\"needle abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789\"}"); + col.flushIndex(); + + try std.testing.expectEqual(@as(u32, 1), col.tri.fileCount()); + + const result = try col.searchText("tiny", 10, alloc); + defer result.deinit(); + try std.testing.expectEqual(@as(usize, 1), result.docs.len); + try std.testing.expectEqualStrings("small", result.docs[0].key); +} + test "tenant quotas apply per tenant" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/turbodb_tenant_quotas"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - try std.fs.cwd().makePath(tmp_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + try compat.cwd().makePath(tmp_dir); + defer compat.cwd().deleteTree(tmp_dir) catch {}; const db = try Database.open(alloc, tmp_dir); defer db.close(); diff --git a/src/compat.zig b/src/compat.zig new file mode 100644 index 0000000..56f3d01 --- /dev/null +++ b/src/compat.zig @@ -0,0 +1,986 @@ +/// Zig 0.16 compatibility layer for TurboDB +/// +/// Provides backward-compatible APIs for code written against Zig 0.15's +/// std.fs, std.Thread, std.io, and std.time. Uses POSIX/C functions directly +/// to avoid threading std.Io through the entire codebase. +const std = @import("std"); +const builtin = @import("builtin"); +const c = std.c; +const posix = std.posix; +const linux = std.os.linux; + +// ═══════════════════════════════════════════════════════════════════════════════ +// File — drop-in replacement for std.fs.File (0.15) +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const File = struct { + handle: posix.fd_t, + + pub const Reader = struct { + file: File, + + pub fn readAll(self: Reader, buf: []u8) !usize { + return self.file.readAll(buf); + } + + pub fn read(self: Reader, buf: []u8) !usize { + return self.file.read(buf); + } + }; + + pub const Writer = struct { + file: File, + + pub fn writeAll(self: Writer, data: []const u8) !void { + return self.file.writeAll(data); + } + + pub fn write(self: Writer, data: []const u8) !usize { + return self.file.writePartial(data); + } + + pub fn print(self: Writer, comptime fmt_str: []const u8, args: anytype) !void { + var buf: [4096]u8 = undefined; + const result = std.fmt.bufPrint(&buf, fmt_str, args) catch return error.NoSpaceLeft; + try self.writeAll(result); + } + }; + + pub const Stat = struct { + size: u64, + }; + + pub fn close(self: File) void { + _ = c.close(self.handle); + } + + pub fn writeAll(self: File, data: []const u8) !void { + var written: usize = 0; + while (written < data.len) { + const n = c.write(self.handle, data[written..].ptr, data[written..].len); + if (n < 0) return error.WriteError; + if (n == 0) return error.WriteError; + written += @intCast(n); + } + } + + pub fn pwriteAll(self: File, data: []const u8, offset: u64) !void { + var written: usize = 0; + while (written < data.len) { + const n = c.pwrite(self.handle, data[written..].ptr, data[written..].len, @intCast(offset + written)); + if (n < 0) return error.WriteError; + if (n == 0) return error.WriteError; + written += @intCast(n); + } + } + + pub fn readAll(self: File, buf: []u8) !usize { + var total: usize = 0; + while (total < buf.len) { + const n = c.read(self.handle, buf[total..].ptr, buf[total..].len); + if (n < 0) return error.ReadError; + if (n == 0) break; + total += @intCast(n); + } + return total; + } + + pub fn read(self: File, buf: []u8) !usize { + const n = c.read(self.handle, buf.ptr, buf.len); + if (n < 0) return error.ReadError; + return @intCast(n); + } + + pub fn write(self: File, data: []const u8) !usize { + const n = c.write(self.handle, data.ptr, data.len); + if (n < 0) return error.WriteError; + return @intCast(n); + } + + pub fn stat(self: File) !Stat { + const current = c.lseek(self.handle, 0, c.SEEK.CUR); + if (current < 0) return error.StatError; + const end = c.lseek(self.handle, 0, c.SEEK.END); + if (end < 0) return error.StatError; + _ = c.lseek(self.handle, current, c.SEEK.SET); + return .{ .size = @intCast(end) }; + } + + pub fn seekTo(self: File, pos: u64) !void { + _ = c.lseek(self.handle, @intCast(pos), c.SEEK.SET); + } + + pub fn seekBy(self: File, delta: i64) !void { + _ = c.lseek(self.handle, @intCast(delta), c.SEEK.CUR); + } + + pub fn getPos(self: File) !u64 { + const pos = c.lseek(self.handle, 0, c.SEEK.CUR); + if (pos < 0) return error.SeekError; + return @intCast(pos); + } + + pub fn sync(self: File) !void { + if (c.fsync(self.handle) != 0) return error.SyncError; + } + + pub fn syncData(self: File) !void { + if (comptime builtin.os.tag == .linux) { + if (c.fdatasync(self.handle) != 0) return error.SyncError; + } else { + try self.sync(); + } + } + + pub fn pread(self: File, buf: []u8, offset: u64) !usize { + const n = c.pread(self.handle, buf.ptr, buf.len, @intCast(offset)); + if (n < 0) return error.ReadError; + return @intCast(n); + } + pub fn setEndPos(self: File, pos: u64) !void { + if (c.ftruncate(self.handle, @intCast(pos)) != 0) return error.TruncateError; + } + + pub fn reader(self: File) Reader { + return .{ .file = self }; + } + + pub fn writer(self: File) Writer { + return .{ .file = self }; + } +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Linux io_uring helpers — optional fast path for WAL flush/sync +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const LinuxUring = linux.IoUring; + +pub fn initLinuxUring(entries: u16) ?LinuxUring { + if (comptime builtin.os.tag != .linux) return null; + if (ioUringDisabledByEnv()) return null; + return LinuxUring.init(entries, 0) catch null; +} + +fn ioUringDisabledByEnv() bool { + const value = getenv("TURBODB_DISABLE_IO_URING") orelse return false; + return value[0] != 0 and value[0] != '0'; +} + +pub fn envUsize(comptime name: [:0]const u8, default_value: usize) usize { + const value_ptr = getenv(name.ptr) orelse return default_value; + const value = std.mem.sliceTo(value_ptr, 0); + if (value.len == 0) return default_value; + return std.fmt.parseInt(usize, value, 10) catch default_value; +} + +pub fn availableMemoryBytes() ?usize { + if (comptime builtin.os.tag != .linux) return null; + if (cgroupAvailableMemoryBytes()) |bytes| return bytes; + return procMemAvailableBytes(); +} + +fn cgroupAvailableMemoryBytes() ?usize { + var max_buf: [64]u8 = undefined; + const max_raw = readSmallAbsolute("/sys/fs/cgroup/memory.max", &max_buf) orelse return null; + const max_bytes = parseUsizePrefix(max_raw) orelse return null; + + var current_buf: [64]u8 = undefined; + const current_raw = readSmallAbsolute("/sys/fs/cgroup/memory.current", ¤t_buf) orelse return null; + const current_bytes = parseUsizePrefix(current_raw) orelse return null; + + if (current_bytes >= max_bytes) return 0; + return max_bytes - current_bytes; +} + +fn procMemAvailableBytes() ?usize { + var buf: [4096]u8 = undefined; + const data = readSmallAbsolute("/proc/meminfo", &buf) orelse return null; + const labels = [_][]const u8{ "MemAvailable:", "MemFree:" }; + for (labels) |label| { + const idx = std.mem.indexOf(u8, data, label) orelse continue; + const kb = parseUsizePrefix(data[idx + label.len ..]) orelse continue; + return std.math.mul(usize, kb, 1024) catch null; + } + return null; +} + +fn readSmallAbsolute(path: []const u8, buf: []u8) ?[]const u8 { + var file = openFileAbsolute(path, .{}) catch return null; + defer file.close(); + const n = file.read(buf) catch return null; + return buf[0..n]; +} + +fn parseUsizePrefix(raw: []const u8) ?usize { + const trimmed = std.mem.trim(u8, raw, " \t\r\n"); + if (trimmed.len == 0 or std.mem.startsWith(u8, trimmed, "max")) return null; + var end: usize = 0; + while (end < trimmed.len and trimmed[end] >= '0' and trimmed[end] <= '9') : (end += 1) {} + if (end == 0) return null; + return std.fmt.parseInt(usize, trimmed[0..end], 10) catch null; +} + +pub fn deinitLinuxUring(ring: *LinuxUring) void { + if (comptime builtin.os.tag != .linux) return; + ring.deinit(); +} + +pub fn uringWriteAllAt(ring: *LinuxUring, file: File, data: []const u8, offset: u64) !void { + if (comptime builtin.os.tag != .linux) return error.Unsupported; + + var written: usize = 0; + while (written < data.len) { + const chunk = data[written..]; + _ = ring.write(1, file.handle, chunk, offset + written) catch return error.WriteError; + _ = ring.submit_and_wait(1) catch return error.WriteError; + const cqe = ring.copy_cqe() catch return error.WriteError; + if (cqe.user_data != 1) return error.WriteError; + if (cqe.res <= 0) return error.WriteError; + const n: usize = @intCast(cqe.res); + if (n > chunk.len) return error.WriteError; + written += n; + } +} + +pub fn uringFsync(ring: *LinuxUring, file: File) !void { + if (comptime builtin.os.tag != .linux) return error.Unsupported; + + _ = ring.fsync(2, file.handle, linux.IORING_FSYNC_DATASYNC) catch return error.SyncError; + _ = ring.submit_and_wait(1) catch return error.SyncError; + const cqe = ring.copy_cqe() catch return error.SyncError; + if (cqe.user_data != 2) return error.SyncError; + if (cqe.res < 0) return error.SyncError; +} + +pub fn uringWriteAndSyncAt(ring: *LinuxUring, file: File, data: []const u8, offset: u64) !void { + if (comptime builtin.os.tag != .linux) return error.Unsupported; + if (data.len == 0) return uringFsync(ring, file); + + const write_sqe = ring.write(1, file.handle, data, offset) catch return error.WriteError; + write_sqe.flags |= linux.IOSQE_IO_LINK; + _ = ring.fsync(2, file.handle, linux.IORING_FSYNC_DATASYNC) catch return error.SyncError; + _ = ring.submit_and_wait(2) catch return error.WriteError; + + var saw_write = false; + var saw_sync = false; + var write_res: i32 = 0; + var sync_res: i32 = 0; + + while (!saw_write or !saw_sync) { + const cqe = ring.copy_cqe() catch return error.WriteError; + switch (cqe.user_data) { + 1 => { + saw_write = true; + write_res = cqe.res; + }, + 2 => { + saw_sync = true; + sync_res = cqe.res; + }, + else => return error.WriteError, + } + } + + if (write_res < 0) return error.WriteError; + if (@as(usize, @intCast(write_res)) != data.len) return error.ShortWrite; + if (sync_res < 0) return error.SyncError; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Dir / cwd() — drop-in for std.fs.cwd() +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn cwd() Dir { + return .{}; +} + +pub const Dir = struct { + pub fn makeDir(_: Dir, sub_path: []const u8) !void { + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + const rc = c.mkdir(z, @as(c.mode_t, 0o755)); + if (rc != 0) { + const e = std.posix.errno(rc); + return switch (e) { + .EXIST => error.PathAlreadyExists, + else => error.MkdirError, + }; + } + } + + pub fn makePath(_: Dir, sub_path: []const u8) !void { + var buf: [4096]u8 = undefined; + if (sub_path.len >= buf.len) return error.NameTooLong; + @memcpy(buf[0..sub_path.len], sub_path); + buf[sub_path.len] = 0; + + var i: usize = 1; + while (i <= sub_path.len) : (i += 1) { + if (i == sub_path.len or buf[i] == '/') { + const saved = buf[i]; + buf[i] = 0; + _ = c.mkdir(@ptrCast(&buf), @as(c.mode_t, 0o755)); + buf[i] = saved; + } + } + } + + pub fn deleteTree(_: Dir, sub_path: []const u8) !void { + var cmd_buf: [4200]u8 = undefined; + const cmd = std.fmt.bufPrint(&cmd_buf, "rm -rf '{s}'\x00", .{sub_path}) catch return error.NameTooLong; + _ = system(@ptrCast(cmd.ptr)); + } + + pub fn deleteFile(_: Dir, sub_path: []const u8) !void { + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + if (c.unlink(z) != 0) return error.DeleteError; + } + + pub fn createFile(_: Dir, sub_path: []const u8, opts: anytype) !File { + _ = opts; + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + const fd = c.open(z, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(c.mode_t, 0o644)); + if (fd < 0) return error.CreateFileError; + return .{ .handle = fd }; + } + + pub fn openFile(_: Dir, sub_path: []const u8, opts: anytype) !File { + _ = opts; + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + const fd = c.open(z, .{ .ACCMODE = .RDWR }, @as(c.mode_t, 0)); + if (fd < 0) { + const fd2 = c.open(z, .{ .ACCMODE = .RDONLY }, @as(c.mode_t, 0)); + if (fd2 < 0) return error.FileNotFound; + return .{ .handle = fd2 }; + } + return .{ .handle = fd }; + } + + pub fn openDir(_: Dir, sub_path: []const u8, opts: anytype) !IterableDir { + _ = opts; + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + const dp = c.opendir(z); + if (dp == null) return error.FileNotFound; + return .{ .dir_stream = dp.?, .path = sub_path }; + } + + pub fn rename(_: Dir, old: []const u8, new_path: []const u8) !void { + var buf1: [4096]u8 = undefined; + var buf2: [4096]u8 = undefined; + const z1 = sliceToZ(&buf1, old) orelse return error.NameTooLong; + const z2 = sliceToZ(&buf2, new_path) orelse return error.NameTooLong; + if (c.rename(z1, z2) != 0) return error.RenameError; + } + + pub fn readFileAlloc(_: Dir, allocator: std.mem.Allocator, sub_path: []const u8, max_size: usize) ![]u8 { + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + const fd = c.open(z, .{ .ACCMODE = .RDONLY }, @as(c.mode_t, 0)); + if (fd < 0) return error.FileNotFound; + defer _ = c.close(fd); + + const end = c.lseek(fd, 0, c.SEEK.END); + if (end < 0) return error.StatError; + _ = c.lseek(fd, 0, c.SEEK.SET); + const size: usize = @intCast(end); + if (size > max_size) return error.FileTooBig; + + const data = try allocator.alloc(u8, size); + errdefer allocator.free(data); + + var total: usize = 0; + while (total < size) { + const n = c.read(fd, data[total..].ptr, data[total..].len); + if (n < 0) return error.ReadError; + if (n == 0) break; + total += @intCast(n); + } + return data[0..total]; + } + + pub fn access(_: Dir, sub_path: []const u8, opts: anytype) !void { + _ = opts; + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, sub_path) orelse return error.NameTooLong; + if (c.access(z, c.F_OK) != 0) return error.FileNotFound; + } +}; + +pub fn openDirAbsolute(path: []const u8, opts: anytype) !IterableDir { + _ = opts; + var buf: [4096]u8 = undefined; + const z = sliceToZ(&buf, path) orelse return error.NameTooLong; + const dp = c.opendir(z); + if (dp == null) return error.FileNotFound; + return .{ .dir_stream = dp.?, .path = path }; +} + +pub fn createFileAbsolute(path: []const u8, opts: anytype) !File { + return cwd().createFile(path, opts); +} + +pub fn openFileAbsolute(path: []const u8, opts: anytype) !File { + return cwd().openFile(path, opts); +} + +pub const IterableDir = struct { + dir_stream: *c.DIR, + path: []const u8, + + pub fn close(self: *IterableDir) void { + _ = c.closedir(self.dir_stream); + } + + pub fn iterate(self: *IterableDir) Iterator { + return .{ .dir_stream = self.dir_stream }; + } + + pub const Iterator = struct { + dir_stream: *c.DIR, + + pub const Entry = struct { + name: []const u8, + kind: Kind, + }; + + pub const Kind = enum { file, directory, sym_link, unknown }; + + pub fn next(self: *Iterator) !?Entry { + while (true) { + const entry = c.readdir(self.dir_stream); + if (entry == null) return null; + const name_ptr: [*:0]const u8 = @ptrCast(&entry.?.name); + const name = std.mem.sliceTo(name_ptr, 0); + if (std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) continue; + const kind: Kind = switch (entry.?.type) { + c.DT.REG => .file, + c.DT.DIR => .directory, + c.DT.LNK => .sym_link, + else => .unknown, + }; + return .{ .name = name, .kind = kind }; + } + } + }; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Mutex — drop-in for std.Thread.Mutex +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const Mutex = struct { + inner: c.pthread_mutex_t = c.PTHREAD_MUTEX_INITIALIZER, + + pub fn lock(m: *Mutex) void { + _ = c.pthread_mutex_lock(&m.inner); + } + + pub fn unlock(m: *Mutex) void { + _ = c.pthread_mutex_unlock(&m.inner); + } + + pub fn tryLock(m: *Mutex) bool { + return c.pthread_mutex_trylock(&m.inner) == 0; + } +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// RwLock — drop-in for std.Thread.RwLock +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const RwLock = struct { + inner: c.pthread_rwlock_t = std.mem.zeroes(c.pthread_rwlock_t), + + pub fn lockShared(rw: *RwLock) void { + _ = c.pthread_rwlock_rdlock(&rw.inner); + } + + pub fn unlockShared(rw: *RwLock) void { + _ = c.pthread_rwlock_unlock(&rw.inner); + } + + pub fn lock(rw: *RwLock) void { + _ = c.pthread_rwlock_wrlock(&rw.inner); + } + + pub fn unlock(rw: *RwLock) void { + _ = c.pthread_rwlock_unlock(&rw.inner); + } +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Condition — drop-in for std.Thread.Condition +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const Condition = struct { + inner: c.pthread_cond_t = c.PTHREAD_COND_INITIALIZER, + + pub fn wait(cond_var: *Condition, mutex: *Mutex) void { + _ = c.pthread_cond_wait(&cond_var.inner, &mutex.inner); + } + + pub fn signal(cond_var: *Condition) void { + _ = c.pthread_cond_signal(&cond_var.inner); + } + + pub fn broadcast(cond_var: *Condition) void { + _ = c.pthread_cond_broadcast(&cond_var.inner); + } +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// sleep — drop-in for std.Thread.sleep +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn sleep(ns: u64) void { + const ts = c.timespec{ + .sec = @intCast(ns / std.time.ns_per_s), + .nsec = @intCast(ns % std.time.ns_per_s), + }; + _ = c.nanosleep(&ts, null); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Time — drop-in for std.time.milliTimestamp / nanoTimestamp / timestamp +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn milliTimestamp() i64 { + var ts: c.timespec = undefined; + _ = c.clock_gettime(.REALTIME, &ts); + return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000); +} + +pub fn nanoTimestamp() i128 { + var ts: c.timespec = undefined; + _ = c.clock_gettime(.REALTIME, &ts); + return @as(i128, ts.sec) * std.time.ns_per_s + ts.nsec; +} + +pub fn timestamp() i64 { + var ts: c.timespec = undefined; + _ = c.clock_gettime(.REALTIME, &ts); + return ts.sec; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// FixedBufferStream — drop-in for std.io.fixedBufferStream +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn fixedBufferStream(buf: []u8) FixedBufferStream { + return .{ .buffer = buf, .pos = 0 }; +} + +pub const FixedBufferStream = struct { + buffer: []u8, + pos: usize, + + pub fn writer(self: *FixedBufferStream) FbsWriter { + return .{ .context = self }; + } + + pub fn reader(self: *FixedBufferStream) FbsReader { + return .{ .context = self }; + } + + pub fn getWritten(self: *const FixedBufferStream) []const u8 { + return self.buffer[0..self.pos]; + } + + pub fn reset(self: *FixedBufferStream) void { + self.pos = 0; + } + + pub const FbsWriter = struct { + context: *FixedBufferStream, + + pub fn writeAll(self: FbsWriter, data: []const u8) !void { + const fbs = self.context; + if (fbs.pos + data.len > fbs.buffer.len) return error.NoSpaceLeft; + @memcpy(fbs.buffer[fbs.pos..][0..data.len], data); + fbs.pos += data.len; + } + + pub fn writeByte(self: FbsWriter, byte: u8) !void { + const fbs = self.context; + if (fbs.pos >= fbs.buffer.len) return error.NoSpaceLeft; + fbs.buffer[fbs.pos] = byte; + fbs.pos += 1; + } + + pub fn writeInt(self: FbsWriter, comptime T: type, value: T, comptime endian: std.builtin.Endian) !void { + const bytes = std.mem.toBytes(if (endian == .little) value else @byteSwap(value)); + try self.writeAll(&bytes); + } + + pub fn write(self: FbsWriter, data: []const u8) !usize { + const fbs = self.context; + const avail = fbs.buffer.len - fbs.pos; + const n = @min(data.len, avail); + @memcpy(fbs.buffer[fbs.pos..][0..n], data[0..n]); + fbs.pos += n; + return n; + } + + pub fn print(self: FbsWriter, comptime fmt_str: []const u8, args: anytype) !void { + var tmp: [8192]u8 = undefined; + const result = std.fmt.bufPrint(&tmp, fmt_str, args) catch return error.NoSpaceLeft; + try self.writeAll(result); + } + }; + + pub const FbsReader = struct { + context: *FixedBufferStream, + + pub fn readAll(self: FbsReader, buf: []u8) !usize { + const fbs = self.context; + const avail = fbs.buffer.len - fbs.pos; + const n = @min(buf.len, avail); + @memcpy(buf[0..n], fbs.buffer[fbs.pos..][0..n]); + fbs.pos += n; + return n; + } + }; +}; + +pub fn constFixedBufferStream(buf: []const u8) ConstFixedBufferStream { + return .{ .buffer = buf, .pos = 0 }; +} + +pub const ConstFixedBufferStream = struct { + buffer: []const u8, + pos: usize, + + pub fn reader(self: *ConstFixedBufferStream) ConstFbsReader { + return .{ .context = self }; + } + + pub const ConstFbsReader = struct { + context: *ConstFixedBufferStream, + + pub fn readAll(self: ConstFbsReader, buf: []u8) !usize { + const fbs = self.context; + const avail = fbs.buffer.len - fbs.pos; + const n = @min(buf.len, avail); + @memcpy(buf[0..n], fbs.buffer[fbs.pos..][0..n]); + fbs.pos += n; + return n; + } + + pub fn readByte(self: ConstFbsReader) !u8 { + const fbs = self.context; + if (fbs.pos >= fbs.buffer.len) return error.EndOfStream; + const b = fbs.buffer[fbs.pos]; + fbs.pos += 1; + return b; + } + + pub fn readInt(self: ConstFbsReader, comptime T: type, comptime endian: std.builtin.Endian) !T { + const bytes_needed = @sizeOf(T); + const fbs = self.context; + if (fbs.pos + bytes_needed > fbs.buffer.len) return error.EndOfStream; + var bytes: [@sizeOf(T)]u8 = undefined; + @memcpy(&bytes, fbs.buffer[fbs.pos..][0..bytes_needed]); + fbs.pos += bytes_needed; + const raw = std.mem.bytesToValue(T, &bytes); + return if (endian == .little) raw else @byteSwap(raw); + } + }; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// BufferedReader — drop-in for std.io.BufferedReader / std.io.bufferedReader +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn GenericBufferedReader(comptime buf_size: usize, comptime ReaderType: type) type { + return struct { + unbuffered: ReaderType, + buf: [buf_size]u8 = undefined, + start: usize = 0, + end: usize = 0, + + const Self = @This(); + + pub fn reader(self: *Self) InnerReader { + return .{ .context = self }; + } + + pub const InnerReader = struct { + context: *Self, + + pub fn readAll(self_inner: InnerReader, out: []u8) !usize { + var total: usize = 0; + while (total < out.len) { + const br = self_inner.context; + if (br.start < br.end) { + const buffered = br.end - br.start; + const n = @min(out.len - total, buffered); + @memcpy(out[total..][0..n], br.buf[br.start..][0..n]); + br.start += n; + total += n; + continue; + } + const n = try br.unbuffered.readAll(br.buf[0..]); + if (n == 0) break; + br.start = 0; + br.end = n; + } + return total; + } + }; + }; +} + +pub fn bufferedReader(underlying: anytype) GenericBufferedReader(4096, @TypeOf(underlying)) { + return .{ .unbuffered = underlying }; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Crypto helper — random bytes without Io +// ═══════════════════════════════════════════════════════════════════════════════ + +extern "c" fn arc4random_buf(buf: *anyopaque, nbytes: usize) void; +extern "c" fn getenv(name: [*:0]const u8) ?[*:0]const u8; +extern "c" fn system(command: [*:0]const u8) c_int; + +pub fn randomBytes(buf: []u8) void { + if (comptime builtin.os.tag == .linux) { + var filled: usize = 0; + while (filled < buf.len) { + const rc = linux.getrandom(buf[filled..].ptr, buf.len - filled, 0); + switch (linux.errno(rc)) { + .SUCCESS => { + const n: usize = @intCast(rc); + if (n == 0) @panic("getrandom returned zero bytes"); + filled += n; + }, + .INTR => continue, + else => @panic("getrandom failed"), + } + } + return; + } + arc4random_buf(buf.ptr, buf.len); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// fmt.format replacement — writes to any writer with .writeAll +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn format(w: anytype, comptime fmt_str: []const u8, args: anytype) !void { + var tmp: [8192]u8 = undefined; + const result = std.fmt.bufPrint(&tmp, fmt_str, args) catch return error.NoSpaceLeft; + try w.writeAll(result); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// TCP Networking — drop-in for std.net (removed in 0.16) +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const net = struct { + pub const Address = struct { + family: u8, + port: u16, + addr: u32, + + pub fn parseIp(ip: []const u8, port: u16) !Address { + return .{ .family = 2, .port = port, .addr = try parseIpv4(ip) }; + } + + pub fn listen(self: Address, opts: anytype) !Server { + const fd = c.socket(2, 1, 0); // AF_INET=2, SOCK_STREAM=1 + if (fd < 0) return error.SocketError; + + // SO_REUSEADDR + if (opts.reuse_address) { + var opt_val: c_int = 1; + _ = c.setsockopt(fd, 0xFFFF, 0x0004, @ptrCast(&opt_val), @sizeOf(c_int)); // SOL_SOCKET, SO_REUSEADDR + } + + // Bind + var addr: extern struct { family: u16, port: u16 align(1), addr: u32 align(1), zero: [8]u8 align(1) } = .{ + .family = 2, // AF_INET + .port = std.mem.nativeToBig(u16, self.port), + .addr = std.mem.nativeToBig(u32, self.addr), + .zero = std.mem.zeroes([8]u8), + }; + if (c.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) { + _ = c.close(fd); + return error.BindError; + } + + // Listen + const backlog: c_int = if (@hasField(@TypeOf(opts), "kernel_backlog")) @intCast(opts.kernel_backlog) else 128; + if (c.listen(fd, backlog) != 0) { + _ = c.close(fd); + return error.ListenError; + } + + return .{ .fd = fd }; + } + + pub fn initUnix(path: anytype) !Address { + _ = path; + return .{ .family = 1, .port = 0, .addr = 0 }; + } + + fn parseIpv4(ip: []const u8) !u32 { + if (std.mem.eql(u8, ip, "localhost")) return 0x7f000001; + var parts = std.mem.splitScalar(u8, ip, '.'); + var out: u32 = 0; + var count: u8 = 0; + while (parts.next()) |part| { + if (count == 4 or part.len == 0) return error.InvalidIpAddress; + const octet = try std.fmt.parseInt(u8, part, 10); + out = (out << 8) | octet; + count += 1; + } + if (count != 4) return error.InvalidIpAddress; + return out; + } + }; + + pub const Stream = struct { + handle: posix.fd_t, + + pub fn read(self: Stream, buf: []u8) !usize { + const n = c.read(self.handle, buf.ptr, buf.len); + if (n < 0) return error.ReadError; + return @intCast(n); + } + + pub fn readAll(self: Stream, buf: []u8) !usize { + var total: usize = 0; + while (total < buf.len) { + const n = try self.read(buf[total..]); + if (n == 0) break; + total += n; + } + return total; + } + + pub fn write(self: Stream, data: []const u8) !usize { + const n = c.write(self.handle, data.ptr, data.len); + if (n < 0) return error.WriteError; + return @intCast(n); + } + + pub fn writeAll(self: Stream, data: []const u8) !void { + var written: usize = 0; + while (written < data.len) { + const n = try self.write(data[written..]); + if (n == 0) return error.WriteError; + written += n; + } + } + + pub fn close(self: Stream) void { + _ = c.close(self.handle); + } + }; + + pub fn tcpConnectToAddress(address: Address) !Stream { + const fd = c.socket(2, 1, 0); + if (fd < 0) return error.SocketError; + errdefer _ = c.close(fd); + + var sockaddr: extern struct { family: u16, port: u16 align(1), addr: u32 align(1), zero: [8]u8 align(1) } = .{ + .family = 2, + .port = std.mem.nativeToBig(u16, address.port), + .addr = std.mem.nativeToBig(u32, address.addr), + .zero = std.mem.zeroes([8]u8), + }; + if (c.connect(fd, @ptrCast(&sockaddr), @sizeOf(@TypeOf(sockaddr))) != 0) { + return error.ConnectError; + } + return .{ .handle = fd }; + } + + pub const Server = struct { + fd: posix.fd_t, + + pub const Connection = struct { + stream: Stream, + address: Address, + }; + + pub fn accept(self: Server) !Connection { + const client_fd = c.accept(self.fd, null, null); + if (client_fd < 0) return error.AcceptError; + return .{ + .stream = .{ .handle = client_fd }, + .address = .{ .family = 2, .port = 0, .addr = 0 }, + }; + } + + pub fn deinit(self: *Server) void { + _ = c.close(self.fd); + } + }; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// ArrayListWriter — drop-in for ArrayList(u8).writer() (removed in 0.16) +// ═══════════════════════════════════════════════════════════════════════════════ + +pub const ArrayListWriter = struct { + list: *std.ArrayList(u8), + gpa: std.mem.Allocator, + + pub fn writeAll(self: ArrayListWriter, data: []const u8) !void { + try self.list.appendSlice(self.gpa, data); + } + + pub fn writeByte(self: ArrayListWriter, byte: u8) !void { + try self.list.append(self.gpa, byte); + } + + pub fn print(self: ArrayListWriter, comptime fmt_str: []const u8, args: anytype) !void { + var tmp: [8192]u8 = undefined; + const result = std.fmt.bufPrint(&tmp, fmt_str, args) catch return error.NoSpaceLeft; + try self.writeAll(result); + } +}; + +pub fn arrayListWriter(list: *std.ArrayList(u8), gpa: std.mem.Allocator) ArrayListWriter { + return .{ .list = list, .gpa = gpa }; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Args — drop-in for std.process.argsAlloc (0.15) +// ═══════════════════════════════════════════════════════════════════════════════ + +pub fn argsAlloc(allocator: std.mem.Allocator, init: std.process.Init) ![][:0]const u8 { + // Count args first + var count: usize = 0; + { + var it = std.process.Args.Iterator.init(init.minimal.args); + while (it.next()) |_| count += 1; + } + const args = try allocator.alloc([:0]const u8, count); + var it = std.process.Args.Iterator.init(init.minimal.args); + var i: usize = 0; + while (it.next()) |arg| : (i += 1) { + args[i] = arg; + } + return args; +} + +pub fn argsFree(allocator: std.mem.Allocator, args: [][:0]const u8) void { + allocator.free(args); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════════════════════ + +fn sliceToZ(buf: *[4096]u8, s: []const u8) ?[*:0]const u8 { + if (s.len >= buf.len) return null; + @memcpy(buf[0..s.len], s); + buf[s.len] = 0; + return @ptrCast(buf); +} diff --git a/src/crypto.zig b/src/crypto.zig index ff6e122..461f301 100644 --- a/src/crypto.zig +++ b/src/crypto.zig @@ -8,6 +8,7 @@ /// /// Uses Zig's std.crypto — no external dependencies, no OpenSSL. const std = @import("std"); +const compat = @import("compat"); // ── Hash algorithms ────────────────────────────────────────────────────────── @@ -82,7 +83,9 @@ pub const KeyPair = struct { secret_key: [64]u8, pub fn generate() KeyPair { - const kp = Ed25519.KeyPair.generate(); + var seed: [32]u8 = undefined; + compat.randomBytes(&seed); + const kp = Ed25519.KeyPair.generateDeterministic(seed) catch unreachable; return .{ .public_key = kp.public_key.toBytes(), .secret_key = kp.secret_key.toBytes(), diff --git a/src/disk_index.zig b/src/disk_index.zig index 58fcbc4..8439939 100644 --- a/src/disk_index.zig +++ b/src/disk_index.zig @@ -14,6 +14,7 @@ /// files.tdb — file ID → path mapping: [n_files: u32][(offset: u32, len: u16)...][paths...] /// const std = @import("std"); +const compat = @import("compat"); // ─── Types ────────────────────────────────────────────────────────────────── @@ -151,7 +152,7 @@ pub const DiskIndexBuilder = struct { var posts_path_buf: [512]u8 = undefined; const posts_path = try std.fmt.bufPrint(&posts_path_buf, "{s}/posts.tdb", .{dir_path}); - const posts_file = try std.fs.cwd().createFile(posts_path, .{}); + const posts_file = try compat.cwd().createFile(posts_path, .{}); defer posts_file.close(); var offset: u32 = 0; @@ -173,7 +174,7 @@ pub const DiskIndexBuilder = struct { var idx_path_buf: [512]u8 = undefined; const idx_path = try std.fmt.bufPrint(&idx_path_buf, "{s}/index.tdb", .{dir_path}); - const idx_file = try std.fs.cwd().createFile(idx_path, .{}); + const idx_file = try compat.cwd().createFile(idx_path, .{}); defer idx_file.close(); // Header: entry count @@ -190,7 +191,7 @@ pub const DiskIndexBuilder = struct { var files_path_buf: [512]u8 = undefined; const files_path = try std.fmt.bufPrint(&files_path_buf, "{s}/files.tdb", .{dir_path}); - const files_file = try std.fs.cwd().createFile(files_path, .{}); + const files_file = try compat.cwd().createFile(files_path, .{}); defer files_file.close(); const n_files: u32 = @intCast(self.file_paths.items.len); @@ -215,7 +216,7 @@ pub const DiskIndexBuilder = struct { // ── 5. Write frequency table ──────────────────────────────────── var freq_path_buf: [512]u8 = undefined; const freq_path = try std.fmt.bufPrint(&freq_path_buf, "{s}/freq.tdb", .{dir_path}); - const freq_file = try std.fs.cwd().createFile(freq_path, .{}); + const freq_file = try compat.cwd().createFile(freq_path, .{}); defer freq_file.close(); try freq_file.writeAll(std.mem.asBytes(&self.freq.counts)); stats.freq_bytes = 256 * 256 * 4; @@ -244,7 +245,7 @@ pub const DiskIndex = struct { lookup: []const LookupEntry, lookup_mmap: []align(std.heap.page_size_min) const u8, /// Postings file handle (pread for on-demand loading) - posts_fd: std.fs.File, + posts_fd: compat.File, /// File path table (mmap'd) files_mmap: []align(std.heap.page_size_min) const u8, n_files: u32, @@ -257,10 +258,10 @@ pub const DiskIndex = struct { // mmap lookup table const idx_path = try std.fmt.bufPrint(&buf, "{s}/index.tdb", .{dir_path}); - const idx_file = try std.fs.cwd().openFile(idx_path, .{}); + const idx_file = try compat.cwd().openFile(idx_path, .{}); defer idx_file.close(); const idx_stat = try idx_file.stat(); - const idx_mmap = try std.posix.mmap(null, idx_stat.size, std.posix.PROT.READ, .{ .TYPE = .PRIVATE }, idx_file.handle, 0); + const idx_mmap = try std.posix.mmap(null, idx_stat.size, std.posix.PROT{ .READ = true }, .{ .TYPE = .PRIVATE }, idx_file.handle, 0); const n_entries = std.mem.bytesToValue(u32, idx_mmap[0..4]); const entries_start = 4; @@ -269,22 +270,22 @@ pub const DiskIndex = struct { // Open postings file for pread const posts_path = try std.fmt.bufPrint(&buf, "{s}/posts.tdb", .{dir_path}); - const posts_fd = try std.fs.cwd().openFile(posts_path, .{}); + const posts_fd = try compat.cwd().openFile(posts_path, .{}); // mmap files table const files_path = try std.fmt.bufPrint(&buf, "{s}/files.tdb", .{dir_path}); - const files_file = try std.fs.cwd().openFile(files_path, .{}); + const files_file = try compat.cwd().openFile(files_path, .{}); defer files_file.close(); const files_stat = try files_file.stat(); - const files_mmap = try std.posix.mmap(null, files_stat.size, std.posix.PROT.READ, .{ .TYPE = .PRIVATE }, files_file.handle, 0); + const files_mmap = try std.posix.mmap(null, files_stat.size, std.posix.PROT{ .READ = true }, .{ .TYPE = .PRIVATE }, files_file.handle, 0); const n_files = std.mem.bytesToValue(u32, files_mmap[0..4]); // mmap frequency table const freq_path = try std.fmt.bufPrint(&buf, "{s}/freq.tdb", .{dir_path}); - const freq_file = try std.fs.cwd().openFile(freq_path, .{}); + const freq_file = try compat.cwd().openFile(freq_path, .{}); defer freq_file.close(); const freq_stat = try freq_file.stat(); - const freq_mmap = try std.posix.mmap(null, freq_stat.size, std.posix.PROT.READ, .{ .TYPE = .PRIVATE }, freq_file.handle, 0); + const freq_mmap = try std.posix.mmap(null, freq_stat.size, std.posix.PROT{ .READ = true }, .{ .TYPE = .PRIVATE }, freq_file.handle, 0); return .{ .lookup = lookup, @@ -519,8 +520,8 @@ test "DiskIndexBuilder roundtrip" { // Write to temp dir const tmp = "/tmp/tdb_disk_test"; - std.fs.cwd().makeDir(tmp) catch {}; - defer std.fs.cwd().deleteTree(tmp) catch {}; + compat.cwd().makeDir(tmp) catch {}; + defer compat.cwd().deleteTree(tmp) catch {}; const stats = try builder.writeToDisk(tmp); try std.testing.expect(stats.n_trigrams > 0); diff --git a/src/doc.zig b/src/doc.zig index 55a9947..a3fec37 100644 --- a/src/doc.zig +++ b/src/doc.zig @@ -76,9 +76,11 @@ pub const Doc = struct { pub fn decode(buf: []const u8) error{TooShort,Corrupt}!struct { doc: Doc, consumed: usize } { if (buf.len < DocHeader.size) return error.TooShort; const hdr: DocHeader = std.mem.bytesToValue(DocHeader, buf[0..DocHeader.size]); - const total = DocHeader.size + hdr.key_len + hdr.val_len; - if (buf.len < total) return error.TooShort; if (hdr.key_len > 1024) return error.Corrupt; + // Sanity check: reject impossibly large values (>64MB) as corruption. + if (hdr.val_len > 64 * 1024 * 1024) return error.Corrupt; + const total = DocHeader.size + @as(usize, hdr.key_len) + @as(usize, hdr.val_len); + if (buf.len < total) return error.TooShort; return .{ .doc = .{ .header = hdr, diff --git a/src/errors.zig b/src/errors.zig index 5689575..50a8e81 100644 --- a/src/errors.zig +++ b/src/errors.zig @@ -6,6 +6,7 @@ /// Wire protocol: STATUS byte + 2-byte error code + message. /// HTTP: {"error": {"code": N, "message": "..."}} const std = @import("std"); +const compat = @import("compat"); pub const ErrorCode = enum(u16) { // ── Success ── @@ -94,7 +95,7 @@ pub const ErrorCode = enum(u16) { /// Format a JSON error response into a buffer. pub fn jsonError(buf: []u8, code: ErrorCode) []const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); w.print("{{\"error\":{{\"code\":{d},\"message\":\"{s}\"}}}}", .{ @intFromEnum(code), code.message(), @@ -104,7 +105,7 @@ pub fn jsonError(buf: []u8, code: ErrorCode) []const u8 { /// Format a JSON error with custom detail message. pub fn jsonErrorDetail(buf: []u8, code: ErrorCode, detail: []const u8) []const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); w.print("{{\"error\":{{\"code\":{d},\"message\":\"{s}\",\"detail\":\"{s}\"}}}}", .{ @intFromEnum(code), code.message(), detail, diff --git a/src/ffi.zig b/src/ffi.zig index b3ef623..ee128fe 100644 --- a/src/ffi.zig +++ b/src/ffi.zig @@ -7,6 +7,7 @@ /// Memory: Uses std.heap.c_allocator (libc malloc/free) so that foreign /// callers can reason about memory ownership without Zig-specific abstractions. const std = @import("std"); +const compat = @import("compat"); const collection = @import("collection.zig"); const doc_mod = @import("doc.zig"); const crypto = @import("crypto.zig"); @@ -16,6 +17,8 @@ const Collection = collection.Collection; const Doc = doc_mod.Doc; const alloc = std.heap.c_allocator; +const BULK_INSERT_CHUNK_ROWS: usize = 16384; +const BULK_INSERT_CHUNK_BYTES: usize = 16 * 1024 * 1024; // ── Handle wrappers with magic sentinel for validation ────────────────────── @@ -70,7 +73,7 @@ pub const TurboScanHandle = extern struct { export fn turbodb_open(dir: [*]const u8, dir_len: usize) ?*anyopaque { // Ensure data directory exists. const dir_slice = dir[0..dir_len]; - std.fs.cwd().makeDir(dir_slice) catch |e| switch (e) { + compat.cwd().makeDir(dir_slice) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return null, }; @@ -159,12 +162,81 @@ export fn turbodb_insert( return 0; } +/// Bulk insert NDJSON lines shaped as {"key":"...","value":...}. +/// Returns 0 if the request was processed; per-row failures are counted. +export fn turbodb_insert_bulk_ndjson( + col_handle: *anyopaque, + body: [*]const u8, + body_len: usize, + out_inserted: *u32, + out_errors: *u32, +) c_int { + const col = validateColHandle(col_handle) orelse return -1; + var inserted: u32 = 0; + var errors: u32 = 0; + const input = body[0..body_len]; + var rows: std.ArrayList(Collection.BulkInsertRow) = .empty; + defer rows.deinit(alloc); + rows.ensureTotalCapacity(alloc, @min(BULK_INSERT_CHUNK_ROWS, @max(@as(usize, 1), input.len / 96))) catch return -1; + var chunk_bytes: usize = 0; + + const BulkFlush = struct { + fn run( + col_arg: *Collection, + rows_arg: *std.ArrayList(Collection.BulkInsertRow), + chunk_bytes_arg: *usize, + inserted_arg: *u32, + errors_arg: *u32, + ) !void { + if (rows_arg.items.len == 0) return; + const result = try col_arg.insertBulk(rows_arg.items); + inserted_arg.* += result.inserted; + errors_arg.* += result.errors; + rows_arg.clearRetainingCapacity(); + chunk_bytes_arg.* = 0; + } + }; + + var pos: usize = 0; + while (pos < input.len) { + const line_end = std.mem.indexOfScalarPos(u8, input, pos, '\n') orelse input.len; + const line = std.mem.trim(u8, input[pos..line_end], " \t\r"); + pos = if (line_end < input.len) line_end + 1 else line_end; + if (line.len == 0) continue; + + const parsed = parseBulkLine(line) orelse { + errors += 1; + continue; + }; + const key = parsed.key; + const value = parsed.value; + const row_bytes = key.len + value.len + 128; + if (rows.items.len > 0 and chunk_bytes + row_bytes > BULK_INSERT_CHUNK_BYTES) { + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors) catch return -1; + } + rows.append(alloc, .{ .key = key, .value = value, .line_len = line.len }) catch return -1; + chunk_bytes += row_bytes; + + if (rows.items.len >= BULK_INSERT_CHUNK_ROWS) { + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors) catch return -1; + } + } + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors) catch return -1; + + out_inserted.* = inserted; + out_errors.* = errors; + return 0; +} + /// Insert with a pre-computed embedding (no JSON parsing). Fast path for vector inserts. export fn turbodb_insert_with_embedding( col_handle: *anyopaque, - key: [*]const u8, key_len: usize, - val: [*]const u8, val_len: usize, - embedding: [*]const f32, dims: u32, + key: [*]const u8, + key_len: usize, + val: [*]const u8, + val_len: usize, + embedding: [*]const f32, + dims: u32, out_id: *u64, ) c_int { const col = validateColHandle(col_handle) orelse return -1; @@ -188,6 +260,38 @@ export fn turbodb_get( return 0; } +/// Read many newline-delimited or JSON-array keys in one FFI call. +/// The output reports found/missing counts and total key+value bytes read. +export fn turbodb_get_many_keys( + col_handle: *anyopaque, + keys_body: [*]const u8, + keys_len: usize, + out_found: *u32, + out_missing: *u32, + out_bytes: *usize, +) c_int { + const col = validateColHandle(col_handle) orelse return -1; + var iter = KeyIter.init(keys_body[0..keys_len]); + var found: u32 = 0; + var missing: u32 = 0; + var bytes_read: usize = 0; + + while (iter.next()) |key| { + if (key.len == 0) continue; + if (col.get(key)) |d| { + found += 1; + bytes_read += d.key.len + d.value.len; + } else { + missing += 1; + } + } + + out_found.* = found; + out_missing.* = missing; + out_bytes.* = bytes_read; + return 0; +} + /// Update a document by key. Returns 0 on success, -1 if not found. export fn turbodb_update( col_handle: *anyopaque, @@ -201,6 +305,54 @@ export fn turbodb_update( return if (updated) 0 else -1; } +/// Bulk upsert NDJSON lines shaped as {"key":"...","value":...}. +/// Returns 0 if the request was processed; per-row failures are counted. +export fn turbodb_update_bulk_ndjson( + col_handle: *anyopaque, + body: [*]const u8, + body_len: usize, + out_updated: *u32, + out_inserted: *u32, + out_errors: *u32, +) c_int { + const col = validateColHandle(col_handle) orelse return -1; + const input = body[0..body_len]; + var updated: u32 = 0; + var inserted: u32 = 0; + var errors: u32 = 0; + + var pos: usize = 0; + while (pos < input.len) { + const line_end = std.mem.indexOfScalarPos(u8, input, pos, '\n') orelse input.len; + const line = std.mem.trim(u8, input[pos..line_end], " \t\r"); + pos = if (line_end < input.len) line_end + 1 else line_end; + if (line.len == 0) continue; + + const parsed = parseStrictBulkLine(line) orelse { + errors += 1; + continue; + }; + const did_update = col.update(parsed.key, parsed.value) catch { + errors += 1; + continue; + }; + if (did_update) { + updated += 1; + } else { + _ = col.insert(parsed.key, parsed.value) catch { + errors += 1; + continue; + }; + inserted += 1; + } + } + + out_updated.* = updated; + out_inserted.* = inserted; + out_errors.* = errors; + return 0; +} + /// Delete a document by key. Returns 0 on success, -1 if not found. export fn turbodb_delete( col_handle: *anyopaque, @@ -212,6 +364,36 @@ export fn turbodb_delete( return if (deleted) 0 else -1; } +/// Delete many newline-delimited or JSON-array keys in one FFI call. +export fn turbodb_delete_many_keys( + col_handle: *anyopaque, + keys_body: [*]const u8, + keys_len: usize, + out_deleted: *u32, + out_missing: *u32, + out_errors: *u32, +) c_int { + const col = validateColHandle(col_handle) orelse return -1; + var iter = KeyIter.init(keys_body[0..keys_len]); + var deleted: u32 = 0; + var missing: u32 = 0; + var errors: u32 = 0; + + while (iter.next()) |key| { + if (key.len == 0) continue; + const did_delete = col.delete(key) catch { + errors += 1; + continue; + }; + if (did_delete) deleted += 1 else missing += 1; + } + + out_deleted.* = deleted; + out_missing.* = missing; + out_errors.* = errors; + return 0; +} + // ── Scan ──────────────────────────────────────────────────────────────────── /// Scan a collection. Returns 0 on success, -1 on error. @@ -269,6 +451,234 @@ fn docToResult(d: Doc) TurboDocResult { }; } +const KeyIter = struct { + body: []const u8, + pos: usize = 0, + array_mode: bool = false, + + fn init(body: []const u8) KeyIter { + const trimmed = std.mem.trim(u8, body, " \t\r\n"); + if (trimmed.len == 0) return .{ .body = trimmed }; + if (trimmed[0] == '{') { + if (jsonValue(trimmed, "keys")) |keys| { + if (keys.len > 0 and keys[0] == '[') { + return .{ .body = keys, .pos = 1, .array_mode = true }; + } + } + } + if (trimmed[0] == '[') return .{ .body = trimmed, .pos = 1, .array_mode = true }; + return .{ .body = trimmed }; + } + + fn next(self: *KeyIter) ?[]const u8 { + if (self.array_mode) return self.nextArray(); + return self.nextLine(); + } + + fn nextLine(self: *KeyIter) ?[]const u8 { + while (self.pos < self.body.len) { + const line_end = std.mem.indexOfScalarPos(u8, self.body, self.pos, '\n') orelse self.body.len; + const raw_line = std.mem.trim(u8, self.body[self.pos..line_end], " \t\r"); + self.pos = if (line_end < self.body.len) line_end + 1 else line_end; + if (raw_line.len == 0) continue; + if (raw_line[0] == '{') return jsonStr(raw_line, "key") orelse continue; + if (raw_line.len >= 2 and raw_line[0] == '"' and raw_line[raw_line.len - 1] == '"') + return raw_line[1 .. raw_line.len - 1]; + return raw_line; + } + return null; + } + + fn nextArray(self: *KeyIter) ?[]const u8 { + while (self.pos < self.body.len) { + while (self.pos < self.body.len and + (self.body[self.pos] == ' ' or self.body[self.pos] == '\t' or + self.body[self.pos] == '\r' or self.body[self.pos] == '\n' or + self.body[self.pos] == ',')) : (self.pos += 1) + {} + if (self.pos >= self.body.len or self.body[self.pos] == ']') return null; + + if (self.body[self.pos] == '"') { + const start = self.pos + 1; + var i = start; + while (i < self.body.len) : (i += 1) { + if (self.body[i] == '\\' and i + 1 < self.body.len) { + i += 1; + continue; + } + if (self.body[i] == '"') { + self.pos = i + 1; + return self.body[start..i]; + } + } + self.pos = self.body.len; + return null; + } + + const start = self.pos; + while (self.pos < self.body.len and self.body[self.pos] != ',' and self.body[self.pos] != ']') : (self.pos += 1) {} + const raw_key = std.mem.trim(u8, self.body[start..self.pos], " \t\r\n"); + if (raw_key.len == 0) continue; + return raw_key; + } + return null; + } +}; + +const BulkLine = struct { + key: []const u8, + value: []const u8, +}; + +fn parseBulkLine(line: []const u8) ?BulkLine { + if (parseBulkLineExact(line)) |parsed| return parsed; + if (parseBulkLineFast(line)) |parsed| return parsed; + const key = jsonStr(line, "key") orelse return null; + const value = jsonValue(line, "value") orelse line; + return .{ .key = key, .value = value }; +} + +fn parseStrictBulkLine(line: []const u8) ?BulkLine { + if (parseBulkLineExact(line)) |parsed| return parsed; + if (parseBulkLineFast(line)) |parsed| return parsed; + const key = jsonStr(line, "key") orelse return null; + const value = jsonValue(line, "value") orelse return null; + return .{ .key = key, .value = value }; +} + +fn parseBulkLineExact(line: []const u8) ?BulkLine { + const prefix = "{\"key\":\""; + const middle = "\",\"value\":"; + if (line.len < prefix.len + middle.len + 1 or !std.mem.startsWith(u8, line, prefix)) return null; + + var key_end = prefix.len; + while (key_end < line.len and line[key_end] != '"') : (key_end += 1) { + if (line[key_end] == '\\') return null; + } + if (key_end >= line.len) return null; + if (!std.mem.startsWith(u8, line[key_end..], middle)) return null; + + const value_start = key_end + middle.len; + if (value_start >= line.len or line[line.len - 1] != '}') return null; + return .{ .key = line[prefix.len..key_end], .value = line[value_start .. line.len - 1] }; +} + +fn parseBulkLineFast(line: []const u8) ?BulkLine { + if (line.len < "{\"key\":\"\",\"value\":}".len or line[0] != '{') return null; + var row_end = line.len; + while (row_end > 0 and (line[row_end - 1] == ' ' or line[row_end - 1] == '\t' or line[row_end - 1] == '\r')) row_end -= 1; + if (row_end == 0 or line[row_end - 1] != '}') return null; + row_end -= 1; + + var i: usize = 1; + skipJsonSpaces(line, &i, row_end); + if (!std.mem.startsWith(u8, line[i..row_end], "\"key\"")) return null; + i += "\"key\"".len; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end or line[i] != ':') return null; + i += 1; + skipJsonSpaces(line, &i, row_end); + const key = parseJsonStringToken(line, &i, row_end) orelse return null; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end or line[i] != ',') return null; + i += 1; + skipJsonSpaces(line, &i, row_end); + if (!std.mem.startsWith(u8, line[i..row_end], "\"value\"")) return null; + i += "\"value\"".len; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end or line[i] != ':') return null; + i += 1; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end) return null; + + var value_end = row_end; + while (value_end > i and (line[value_end - 1] == ' ' or line[value_end - 1] == '\t')) value_end -= 1; + if (value_end <= i) return null; + return .{ .key = key, .value = line[i..value_end] }; +} + +fn skipJsonSpaces(data: []const u8, pos: *usize, limit: usize) void { + while (pos.* < limit and (data[pos.*] == ' ' or data[pos.*] == '\t')) pos.* += 1; +} + +fn parseJsonStringToken(data: []const u8, pos: *usize, limit: usize) ?[]const u8 { + if (pos.* >= limit or data[pos.*] != '"') return null; + pos.* += 1; + const start = pos.*; + while (pos.* < limit) : (pos.* += 1) { + if (data[pos.*] == '\\' and pos.* + 1 < limit) { + pos.* += 1; + continue; + } + if (data[pos.*] == '"') { + const end = pos.*; + pos.* += 1; + return data[start..end]; + } + } + return null; +} + +fn jsonStr(json: []const u8, key: []const u8) ?[]const u8 { + var kbuf: [64]u8 = undefined; + const needle = std.fmt.bufPrint(&kbuf, "\"{s}\":", .{key}) catch return null; + const pos = std.mem.indexOf(u8, json, needle) orelse return null; + var start = pos + needle.len; + while (start < json.len and (json[start] == ' ' or json[start] == '\t')) start += 1; + if (start >= json.len or json[start] != '"') return null; + start += 1; + const end = std.mem.indexOfScalarPos(u8, json, start, '"') orelse return null; + return json[start..end]; +} + +fn jsonValue(json: []const u8, key: []const u8) ?[]const u8 { + var kbuf: [64]u8 = undefined; + const needle = std.fmt.bufPrint(&kbuf, "\"{s}\":", .{key}) catch return null; + const pos = std.mem.indexOf(u8, json, needle) orelse return null; + var start = pos + needle.len; + while (start < json.len and (json[start] == ' ' or json[start] == '\t')) start += 1; + if (start >= json.len) return null; + + const ch = json[start]; + if (ch == '"') { + var i = start + 1; + while (i < json.len) : (i += 1) { + if (json[i] == '\\' and i + 1 < json.len) { + i += 1; + continue; + } + if (json[i] == '"') return json[start .. i + 1]; + } + return null; + } else if (ch == '{' or ch == '[') { + const close: u8 = if (ch == '{') '}' else ']'; + var depth: u32 = 1; + var i = start + 1; + var in_str = false; + while (i < json.len and depth > 0) : (i += 1) { + if (json[i] == '\\' and in_str) { + i += 1; + continue; + } + if (json[i] == '"') { + in_str = !in_str; + continue; + } + if (in_str) continue; + if (json[i] == ch) depth += 1; + if (json[i] == close) { + depth -= 1; + if (depth == 0) return json[start .. i + 1]; + } + } + return null; + } else { + var end = start; + while (end < json.len and json[end] != ',' and json[end] != '}' and json[end] != '\n' and json[end] != '\r') : (end += 1) {} + return std.mem.trim(u8, json[start..end], " \t"); + } +} + // ── Search (trigram-indexed) ───────────────────────────────────────────────── /// Search documents by substring using trigram index. @@ -659,7 +1069,7 @@ export fn turbodb_branch_diff( const branch_mod = @import("branch.zig"); var buf: std.ArrayList(u8) = .empty; - const w = buf.writer(alloc); + const w = compat.arrayListWriter(&buf, alloc); w.writeAll("{\"files\":[") catch return -1; var first_file = true; @@ -690,7 +1100,7 @@ export fn turbodb_branch_diff( .added => "added", .removed => "removed", }; - std.fmt.format(w, "{{\"no\":{d},\"kind\":\"{s}\",\"text\":\"", .{ d.line_no, kind_str }) catch return -1; + compat.format(w, "{{\"no\":{d},\"kind\":\"{s}\",\"text\":\"", .{ d.line_no, kind_str }) catch return -1; // Escape text for JSON for (d.text) |c| { switch (c) { @@ -778,24 +1188,24 @@ export fn turbodb_discover_context( // Format as JSON var buf: std.ArrayList(u8) = .empty; - const w = buf.writer(alloc); + const w = compat.arrayListWriter(&buf, alloc); w.writeAll("{\"matching_files\":[") catch return -1; for (result.matching_files, 0..) |d, i| { if (i > 0) w.writeByte(',') catch return -1; - std.fmt.format(w, "{{\"key\":\"{s}\",\"size\":{d}}}", .{ d.key, d.value.len }) catch return -1; + compat.format(w, "{{\"key\":\"{s}\",\"size\":{d}}}", .{ d.key, d.value.len }) catch return -1; } w.writeAll("],\"related_files\":[") catch return -1; for (result.related_files, 0..) |d, i| { if (i > 0) w.writeByte(',') catch return -1; - std.fmt.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch return -1; + compat.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch return -1; } w.writeAll("],\"test_files\":[") catch return -1; for (result.test_files, 0..) |d, i| { if (i > 0) w.writeByte(',') catch return -1; - std.fmt.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch return -1; + compat.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch return -1; } - std.fmt.format(w, "],\"recent_versions\":{d},\"total_files\":{d}}}", .{ result.recent_versions, result.total_files }) catch return -1; + compat.format(w, "],\"recent_versions\":{d},\"total_files\":{d}}}", .{ result.recent_versions, result.total_files }) catch return -1; // Copy to output const json = buf.toOwnedSlice(alloc) catch return -1; diff --git a/src/io_engine.zig b/src/io_engine.zig index 1024ca7..4561485 100644 --- a/src/io_engine.zig +++ b/src/io_engine.zig @@ -144,7 +144,8 @@ const KqueueBackend = struct { const PendingClose = struct { fd: posix.fd_t, user_data: usize }; fn init(alloc: Allocator) !KqueueBackend { - const kq = try posix.kqueue(); + const kq = std.c.kqueue(); + if (kq < 0) return error.KqueueInitFailed; return .{ .kq = kq, .alloc = alloc, @@ -155,7 +156,7 @@ const KqueueBackend = struct { } fn deinit(self: *KqueueBackend) void { - posix.close(self.kq); + _ = std.c.close(self.kq); self.changelist.deinit(self.alloc); self.pending_accepts.deinit(self.alloc); self.pending_closes.deinit(self.alloc); @@ -216,7 +217,7 @@ const KqueueBackend = struct { var count: usize = 0; for (self.pending_closes.items) |pc| { - posix.close(pc.fd); + _ = std.c.close(pc.fd); if (count < completions.len) { completions[count] = .{ .fd = pc.fd, @@ -481,7 +482,7 @@ pub const EventLoop = struct { self.running.store(false, .release); } if (self.listen_fd >= 0) { - posix.close(self.listen_fd); + _ = std.c.close(self.listen_fd); } self.engine.deinit(); self.pool.deinit(); @@ -491,16 +492,22 @@ pub const EventLoop = struct { /// Bind and listen on the given TCP port. pub fn bind(self: *EventLoop, port: u16) !void { - const addr = try std.net.Address.resolveIp("0.0.0.0", port); - const flags: u32 = posix.SOCK.STREAM | posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC; - const fd = try posix.socket(addr.any.family, flags, posix.IPPROTO.TCP); - errdefer posix.close(fd); + const fd = std.c.socket(2, std.c.SOCK.STREAM, 0); + if (fd < 0) return error.SocketError; + errdefer _ = std.c.close(fd); // SO_REUSEADDR for fast restart. - posix.setsockopt(fd, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1))) catch {}; - - try posix.bind(fd, &addr.any, addr.getOsSockLen()); - try posix.listen(fd, 128); + var opt_val: c_int = 1; + _ = std.c.setsockopt(fd, std.c.SOL.SOCKET, std.c.SO.REUSEADDR, @ptrCast(&opt_val), @sizeOf(c_int)); + + var addr: extern struct { family: u16, port_be: u16 align(1), addr_be: u32 align(1), zero: [8]u8 align(1) } = .{ + .family = 2, + .port_be = std.mem.nativeToBig(u16, port), + .addr_be = 0, + .zero = std.mem.zeroes([8]u8), + }; + if (std.c.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return error.BindError; + if (std.c.listen(fd, 128) != 0) return error.ListenError; self.listen_fd = fd; } @@ -553,7 +560,7 @@ pub const EventLoop = struct { const idx = self.pool.acquire() orelse { // Pool exhausted — reject. - posix.close(comp.fd); + _ = std.c.close(comp.fd); return; }; @@ -563,7 +570,7 @@ pub const EventLoop = struct { // Submit a read on the new connection. self.engine.submitRead(comp.fd, &self.pool.conns[idx].read_buf, idx) catch { self.pool.release(idx); - posix.close(comp.fd); + _ = std.c.close(comp.fd); }; } @@ -575,7 +582,7 @@ pub const EventLoop = struct { // EOF or error — close. conn.state = .closing; self.engine.submitClose(conn.fd, comp.user_data) catch { - posix.close(conn.fd); + _ = std.c.close(conn.fd); self.pool.release(comp.user_data); }; return; diff --git a/src/lsm.zig b/src/lsm.zig index 89fbc86..3217f36 100644 --- a/src/lsm.zig +++ b/src/lsm.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const btree = @import("btree.zig"); const BTreeEntry = btree.BTreeEntry; @@ -82,83 +83,166 @@ pub const KVEntry = struct { // ─── MemTable ──────────────────────────────────────────────────────────────── pub const MemTable = struct { - entries: std.ArrayList(KVEntry), + /// Pre-allocated slab. Capacity is fixed at init; never grows. + entries: []KVEntry, + count: u32, size_bytes: usize, + + /// Sorted runs over `entries[0..count]`. Each run is a contiguous, ascending + /// range. New keys extend the last run when strictly greater than its tail; + /// out-of-order keys open a new run. On flush (or when MAX_RUNS is hit) we + /// stable-sort + dedup last-wins, collapsing back to a single run. + runs: [MAX_RUNS]Run, + run_count: u32, + alloc: std.mem.Allocator, - const ENTRY_OVERHEAD = @sizeOf(KVEntry); + pub const MAX_RUNS: u32 = 64; + pub const ENTRY_OVERHEAD = @sizeOf(KVEntry); + + pub const Run = struct { + start: u32, + end: u32, // exclusive + }; - pub fn init(alloc: std.mem.Allocator) MemTable { + pub fn init(alloc: std.mem.Allocator) !MemTable { + // Capacity = ceil(MEMTABLE_SIZE / entry_size) + 1 slot of slack so the + // last put that pushes size_bytes >= MEMTABLE_SIZE still fits. + const target: usize = (LSMTree.MEMTABLE_SIZE + ENTRY_OVERHEAD - 1) / ENTRY_OVERHEAD; + const cap: u32 = @intCast(target + 1); + const buf = try alloc.alloc(KVEntry, cap); return .{ - .entries = .empty, + .entries = buf, + .count = 0, .size_bytes = 0, + .runs = undefined, + .run_count = 0, .alloc = alloc, }; } pub fn deinit(self: *MemTable) void { - self.entries.deinit(self.alloc); + if (self.entries.len > 0) self.alloc.free(self.entries); + self.entries = &.{}; + self.count = 0; + self.run_count = 0; self.size_bytes = 0; } - /// Binary search for the index of key_hash. Returns the index if found, or - /// the insertion point if not. - fn findIndex(self: *const MemTable, key_hash: u64) struct { idx: usize, found: bool } { - const items = self.entries.items; - var lo: usize = 0; - var hi: usize = items.len; - while (lo < hi) { - const mid = lo + (hi - lo) / 2; - if (items[mid].key_hash < key_hash) { - lo = mid + 1; - } else { - hi = mid; + /// Append a raw KVEntry. Extends the active run when key strictly increases; + /// otherwise opens a new run (collapsing first if MAX_RUNS is hit). + fn appendEntry(self: *MemTable, e: KVEntry) !void { + if (self.count >= self.entries.len) return error.MemTableFull; + + if (self.run_count > 0) { + const last = &self.runs[self.run_count - 1]; + // last.end > last.start is invariant for any tracked run. + if (self.entries[last.end - 1].key_hash < e.key_hash) { + self.entries[self.count] = e; + last.end = self.count + 1; + self.count += 1; + self.size_bytes += ENTRY_OVERHEAD; + return; } } - const found = lo < items.len and items[lo].key_hash == key_hash; - return .{ .idx = lo, .found = found }; + + if (self.run_count >= MAX_RUNS) { + // Collapse all runs into a single sorted+deduped run, then continue. + self.sortAndDedup(); + } + + self.entries[self.count] = e; + self.runs[self.run_count] = .{ .start = self.count, .end = self.count + 1 }; + self.run_count += 1; + self.count += 1; + self.size_bytes += ENTRY_OVERHEAD; } pub fn put(self: *MemTable, key_hash: u64, entry: BTreeEntry) !void { - const result = self.findIndex(key_hash); - if (result.found) { - // Update in place — no size change. - self.entries.items[result.idx].value = .{ .live = entry }; - } else { - try self.entries.insert(self.alloc, result.idx, .{ - .key_hash = key_hash, - .value = .{ .live = entry }, - }); - self.size_bytes += ENTRY_OVERHEAD; + // Fast path: same key as the most recent entry — overwrite in place. + // Common in tight update loops; avoids opening a new run. + if (self.count > 0 and self.entries[self.count - 1].key_hash == key_hash) { + self.entries[self.count - 1].value = .{ .live = entry }; + return; + } + try self.appendEntry(.{ .key_hash = key_hash, .value = .{ .live = entry } }); + } + + pub fn delete(self: *MemTable, key_hash: u64) !void { + if (self.count > 0 and self.entries[self.count - 1].key_hash == key_hash) { + self.entries[self.count - 1].value = .tombstone; + return; } + try self.appendEntry(.{ .key_hash = key_hash, .value = .tombstone }); + } + + /// Search runs newest-first. Returns the EntryValue at the first hit + /// (tombstone or live) so callers can distinguish "deleted here" from + /// "absent". Each run is sorted, so each lookup is O(log n) per run. + pub fn lookup(self: *const MemTable, key_hash: u64) ?KVEntry.EntryValue { + var ri = self.run_count; + while (ri > 0) { + ri -= 1; + const r = self.runs[ri]; + var lo: u32 = r.start; + var hi: u32 = r.end; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (self.entries[mid].key_hash < key_hash) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo < r.end and self.entries[lo].key_hash == key_hash) { + return self.entries[lo].value; + } + } + return null; } pub fn get(self: *const MemTable, key_hash: u64) ?BTreeEntry { - const result = self.findIndex(key_hash); - if (!result.found) return null; - return switch (self.entries.items[result.idx].value) { + return switch (self.lookup(key_hash) orelse return null) { .live => |e| e, .tombstone => null, }; } - pub fn delete(self: *MemTable, key_hash: u64) !void { - const result = self.findIndex(key_hash); - if (result.found) { - self.entries.items[result.idx].value = .tombstone; - } else { - try self.entries.insert(self.alloc, result.idx, .{ - .key_hash = key_hash, - .value = .tombstone, - }); - self.size_bytes += ENTRY_OVERHEAD; - } - } - pub fn isFull(self: *const MemTable) bool { return self.size_bytes >= LSMTree.MEMTABLE_SIZE; } + /// Stable-sort `entries[0..count]` by key, then dedup last-wins. Stability + /// guarantees that equal-key duplicates retain insertion order, so the + /// last entry in each duplicate group is the newest write. Collapses to a + /// single run [0, count). + pub fn sortAndDedup(self: *MemTable) void { + if (self.count == 0) { + self.run_count = 0; + self.size_bytes = 0; + return; + } + std.mem.sort(KVEntry, self.entries[0..self.count], {}, KVEntry.orderByKey); + + var w: u32 = 0; + var i: u32 = 0; + while (i < self.count) { + var j: u32 = i + 1; + while (j < self.count and self.entries[j].key_hash == self.entries[i].key_hash) { + j += 1; + } + // j-1 is the newest occurrence of this key (stable sort preserves + // insertion order among duplicates). + self.entries[w] = self.entries[j - 1]; + w += 1; + i = j; + } + self.count = w; + self.size_bytes = @as(usize, w) * ENTRY_OVERHEAD; + self.runs[0] = .{ .start = 0, .end = w }; + self.run_count = if (w > 0) 1 else 0; + } + pub const Iterator = struct { items: []const KVEntry, pos: usize, @@ -171,8 +255,11 @@ pub const MemTable = struct { } }; - pub fn iterator(self: *const MemTable) Iterator { - return .{ .items = self.entries.items, .pos = 0 }; + /// Returns an iterator over a sorted, deduped view. Mutates the table to + /// collapse runs first (cheap if already sorted). + pub fn iterator(self: *MemTable) Iterator { + self.sortAndDedup(); + return .{ .items = self.entries[0..self.count], .pos = 0 }; } }; @@ -201,21 +288,21 @@ pub const SSTable = struct { const FLAG_TOMBSTONE: u8 = 0x00; /// Write a u64 in little-endian to a file. - fn writeU64(file: std.fs.File, val: u64) !void { + fn writeU64(file: compat.File, val: u64) !void { var buf: [8]u8 = undefined; std.mem.writeInt(u64, &buf, val, .little); try file.writeAll(&buf); } /// Write a u32 in little-endian to a file. - fn writeU32(file: std.fs.File, val: u32) !void { + fn writeU32(file: compat.File, val: u32) !void { var buf: [4]u8 = undefined; std.mem.writeInt(u32, &buf, val, .little); try file.writeAll(&buf); } /// Read a u64 in little-endian from a file. - fn readU64(file: std.fs.File) !u64 { + fn readU64(file: compat.File) !u64 { var buf: [8]u8 = undefined; const n = try file.readAll(&buf); if (n < 8) return error.UnexpectedEof; @@ -223,7 +310,7 @@ pub const SSTable = struct { } /// Read a u32 in little-endian from a file. - fn readU32(file: std.fs.File) !u32 { + fn readU32(file: compat.File) !u32 { var buf: [4]u8 = undefined; const n = try file.readAll(&buf); if (n < 4) return error.UnexpectedEof; @@ -231,7 +318,7 @@ pub const SSTable = struct { } /// Read a single byte from a file. - fn readByte(file: std.fs.File) !u8 { + fn readByte(file: compat.File) !u8 { var buf: [1]u8 = undefined; const n = try file.readAll(&buf); if (n < 1) return error.UnexpectedEof; @@ -269,11 +356,11 @@ pub const SSTable = struct { } // Write data file. - const data_file = try std.fs.cwd().createFile(dp, .{}); + const data_file = try compat.cwd().createFile(dp, .{}); defer data_file.close(); // Write index file. - const idx_file = try std.fs.cwd().createFile(ip, .{}); + const idx_file = try compat.cwd().createFile(ip, .{}); defer idx_file.close(); var data_offset: u64 = 0; @@ -322,14 +409,14 @@ pub const SSTable = struct { // Range check. if (key_hash < self.min_key or key_hash > self.max_key) return null; - const data_file = try std.fs.cwd().openFile( + const data_file = try compat.cwd().openFile( self.data_path[0..self.data_path_len], .{}, ); defer data_file.close(); // Load sparse index to find starting offset. - const idx_file = try std.fs.cwd().openFile( + const idx_file = try compat.cwd().openFile( self.index_path[0..self.index_path_len], .{}, ); @@ -386,7 +473,7 @@ pub const SSTable = struct { } pub const Iterator = struct { - file: std.fs.File, + file: compat.File, remaining: u32, pub fn next(self: *Iterator) ?KVEntry { @@ -417,7 +504,7 @@ pub const SSTable = struct { pub fn iterator(self: *const SSTable, alloc: std.mem.Allocator) !Iterator { _ = alloc; - const f = try std.fs.cwd().openFile( + const f = try compat.cwd().openFile( self.data_path[0..self.data_path_len], .{}, ); @@ -442,7 +529,7 @@ pub const LSMTree = struct { data_dir: [256]u8, data_dir_len: usize, alloc: std.mem.Allocator, - flush_mu: std.Thread.Mutex, + flush_mu: compat.Mutex, pub const MAX_LEVELS = 7; pub const MEMTABLE_SIZE = 4 * 1024 * 1024; // 4MB @@ -451,7 +538,7 @@ pub const LSMTree = struct { pub fn init(alloc: std.mem.Allocator, data_dir: []const u8) !LSMTree { var lsm: LSMTree = undefined; - lsm.active_mem = MemTable.init(alloc); + lsm.active_mem = try MemTable.init(alloc); lsm.immutable_mem = null; lsm.alloc = alloc; lsm.next_sst_id = 0; @@ -467,7 +554,7 @@ pub const LSMTree = struct { } // Ensure data directory exists. - std.fs.cwd().makeDir(data_dir) catch |err| switch (err) { + compat.cwd().makeDir(data_dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return err, }; @@ -493,12 +580,22 @@ pub const LSMTree = struct { } pub fn get(self: *const LSMTree, key_hash: u64) ?BTreeEntry { - // 1. Check active memtable. - if (memtableSearch(self.active_mem.entries.items, key_hash)) |result| return result.entry; + // 1. Check active memtable. A tombstone shadows older SSTable entries. + if (self.active_mem.lookup(key_hash)) |v| { + return switch (v) { + .live => |bte| bte, + .tombstone => null, + }; + } - // 2. Check immutable memtable. - if (self.immutable_mem) |imm| { - if (memtableSearch(imm.entries.items, key_hash)) |result| return result.entry; + // 2. Check immutable memtable (mid-flush). + if (self.immutable_mem) |*imm| { + if (imm.lookup(key_hash)) |v| { + return switch (v) { + .live => |bte| bte, + .tombstone => null, + }; + } } // 3. Check SSTables level by level, newest first. @@ -516,31 +613,6 @@ pub const LSMTree = struct { return null; } - /// Search helper for const memtable slices. Returns the BTreeEntry (or null - /// for tombstones). The outer ?FoundEntry is null when the key isn't present - /// at all — distinguishing "not here" from "deleted here". - const FoundEntry = struct { entry: ?BTreeEntry }; - - fn memtableSearch(items: []const KVEntry, key_hash: u64) ?FoundEntry { - var lo: usize = 0; - var hi: usize = items.len; - while (lo < hi) { - const mid = lo + (hi - lo) / 2; - if (items[mid].key_hash < key_hash) { - lo = mid + 1; - } else { - hi = mid; - } - } - if (lo < items.len and items[lo].key_hash == key_hash) { - return .{ .entry = switch (items[lo].value) { - .live => |e| e, - .tombstone => null, - } }; - } - return null; - } - pub fn delete(self: *LSMTree, key_hash: u64) !void { try self.active_mem.delete(key_hash); } @@ -550,11 +622,14 @@ pub const LSMTree = struct { self.flush_mu.lock(); defer self.flush_mu.unlock(); - if (self.active_mem.entries.items.len == 0) return; + if (self.active_mem.count == 0) return; + + // Collapse runs into a single sorted+deduped run for SSTable.create. + self.active_mem.sortAndDedup(); - // Rotate: move active → immutable. + // Rotate: move active → immutable, install a fresh active. var old = self.active_mem; - self.active_mem = MemTable.init(self.alloc); + self.active_mem = try MemTable.init(self.alloc); defer { old.deinit(); @@ -566,7 +641,7 @@ pub const LSMTree = struct { const id = self.next_sst_id; self.next_sst_id += 1; const sst = try SSTable.create( - old.entries.items, + old.entries[0..old.count], 0, id, self.data_dir[0..self.data_dir_len], @@ -639,8 +714,8 @@ pub const LSMTree = struct { // Close and remove old SSTables at this level. for (src.items) |*sst| { // Delete files. - std.fs.cwd().deleteFile(sst.data_path[0..sst.data_path_len]) catch {}; - std.fs.cwd().deleteFile(sst.index_path[0..sst.index_path_len]) catch {}; + compat.cwd().deleteFile(sst.data_path[0..sst.data_path_len]) catch {}; + compat.cwd().deleteFile(sst.index_path[0..sst.index_path_len]) catch {}; sst.close(); } src.clearRetainingCapacity(); @@ -659,7 +734,7 @@ fn testEntry(key: u64, doc: u64) BTreeEntry { } test "MemTable put/get/delete" { - var mt = MemTable.init(std.testing.allocator); + var mt = try MemTable.init(std.testing.allocator); defer mt.deinit(); try mt.put(100, testEntry(100, 1)); @@ -687,7 +762,7 @@ test "MemTable put/get/delete" { try std.testing.expect(mt.get(777) == null); } test "MemTable isFull threshold" { - var mt = MemTable.init(std.testing.allocator); + var mt = try MemTable.init(std.testing.allocator); defer mt.deinit(); try std.testing.expect(!mt.isFull()); @@ -702,7 +777,7 @@ test "MemTable isFull threshold" { try std.testing.expect(mt.isFull()); } test "MemTable iterator order" { - var mt = MemTable.init(std.testing.allocator); + var mt = try MemTable.init(std.testing.allocator); defer mt.deinit(); try mt.put(300, testEntry(300, 3)); @@ -745,11 +820,15 @@ test "BloomFilter add/mayContain" { try std.testing.expect(false_positives < 500); } +fn testingTmpPath(alloc: std.mem.Allocator, tmp: *const std.testing.TmpDir) ![]u8 { + return std.fmt.allocPrint(alloc, ".zig-cache/tmp/{s}", .{tmp.sub_path}); +} + test "SSTable create and read back" { // Use a tmp directory. var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const tmp_path = tmp_dir.dir.realpathAlloc(std.testing.allocator, ".") catch unreachable; + const tmp_path = testingTmpPath(std.testing.allocator, &tmp_dir) catch unreachable; defer std.testing.allocator.free(tmp_path); // Create sorted entries. @@ -778,7 +857,7 @@ test "SSTable create and read back" { test "LSMTree put/get across memtable and SSTable" { var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const tmp_path = tmp_dir.dir.realpathAlloc(std.testing.allocator, ".") catch unreachable; + const tmp_path = testingTmpPath(std.testing.allocator, &tmp_dir) catch unreachable; defer std.testing.allocator.free(tmp_path); var lsm = try LSMTree.init(std.testing.allocator, tmp_path); @@ -811,7 +890,7 @@ test "LSMTree put/get across memtable and SSTable" { test "LSMTree flush" { var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const tmp_path = tmp_dir.dir.realpathAlloc(std.testing.allocator, ".") catch unreachable; + const tmp_path = testingTmpPath(std.testing.allocator, &tmp_dir) catch unreachable; defer std.testing.allocator.free(tmp_path); var lsm = try LSMTree.init(std.testing.allocator, tmp_path); @@ -822,7 +901,7 @@ test "LSMTree flush" { try lsm.flush(); // Active memtable should be empty after flush. - try std.testing.expectEqual(@as(usize, 0), lsm.active_mem.entries.items.len); + try std.testing.expectEqual(@as(u32, 0), lsm.active_mem.count); // L0 should have one SSTable. try std.testing.expectEqual(@as(usize, 1), lsm.levels[0].items.len); // Data still readable. @@ -832,7 +911,7 @@ test "LSMTree flush" { test "Tombstone handling" { var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const tmp_path = tmp_dir.dir.realpathAlloc(std.testing.allocator, ".") catch unreachable; + const tmp_path = testingTmpPath(std.testing.allocator, &tmp_dir) catch unreachable; defer std.testing.allocator.free(tmp_path); var lsm = try LSMTree.init(std.testing.allocator, tmp_path); diff --git a/src/main.zig b/src/main.zig index f90732c..289a670 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,22 +1,24 @@ /// TurboDB — entry point const std = @import("std"); +const compat = @import("compat"); const collection = @import("collection.zig"); const server = @import("server.zig"); const wire = @import("wire.zig"); -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + // GPA replaced by smp_allocator for Zig 0.16 + + const alloc = std.heap.smp_allocator; // ── CLI args ────────────────────────────────────────────────────────── - const args = try std.process.argsAlloc(alloc); - defer std.process.argsFree(alloc, args); + const args = try compat.argsAlloc(alloc, init); + defer compat.argsFree(alloc, args); var data_dir: []const u8 = "./turbodb_data"; var port: u16 = 27017; var use_wire: bool = true; // wire protocol by default var use_http: bool = false; + var http_runtime: server.HttpRuntime = .threaded; var unix_path: ?[]const u8 = null; var auth_key: ?[]const u8 = null; @@ -30,9 +32,11 @@ pub fn main() !void { var i: usize = 1; while (i < args.len) : (i += 1) { if (std.mem.eql(u8, args[i], "--data") and i + 1 < args.len) { - i += 1; data_dir = args[i]; + i += 1; + data_dir = args[i]; } else if (std.mem.eql(u8, args[i], "--port") and i + 1 < args.len) { - i += 1; port = try std.fmt.parseInt(u16, args[i], 10); + i += 1; + port = try std.fmt.parseInt(u16, args[i], 10); } else if (std.mem.eql(u8, args[i], "--wire")) { use_wire = true; use_http = false; @@ -42,6 +46,18 @@ pub fn main() !void { } else if (std.mem.eql(u8, args[i], "--both")) { use_wire = true; use_http = true; + } else if (std.mem.eql(u8, args[i], "--http-runtime") and i + 1 < args.len) { + i += 1; + if (std.mem.eql(u8, args[i], "threaded")) { + http_runtime = .threaded; + } else if (std.mem.eql(u8, args[i], "nanoapi-raw")) { + http_runtime = .nanoapi_raw; + } else { + std.log.err("unknown HTTP runtime: {s}", .{args[i]}); + return error.InvalidArgument; + } + } else if (std.mem.eql(u8, args[i], "--http-nanoapi-raw")) { + http_runtime = .nanoapi_raw; } else if (std.mem.eql(u8, args[i], "--unix") and i + 1 < args.len) { i += 1; unix_path = args[i]; @@ -70,6 +86,8 @@ pub fn main() !void { \\ --wire binary wire protocol (default) \\ --http HTTP REST API \\ --both run wire + HTTP (wire on port, HTTP on port+1) + \\ --http-runtime HTTP runtime: threaded, nanoapi-raw + \\ --http-nanoapi-raw shortcut for --http-runtime nanoapi-raw \\ --unix also listen on a Unix domain socket \\ --auth-key require this API key for all requests \\ @@ -99,7 +117,7 @@ pub fn main() !void { } // ── ensure data directory ───────────────────────────────────────────── - std.fs.cwd().makeDir(data_dir) catch |e| switch (e) { + compat.cwd().makeDir(data_dir) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return e, }; @@ -132,7 +150,7 @@ pub fn main() !void { const S = struct { var g_http: ?*server.Server = null; var g_wire: ?*wire.WireServer = null; - fn handler(_: c_int) callconv(.c) void { + fn handler(_: std.c.SIG) callconv(.c) void { if (g_http) |s| s.stop(); if (g_wire) |w| w.stop(); } @@ -163,13 +181,13 @@ pub fn main() !void { if (use_http and use_wire) { // Run HTTP in a background thread, wire in foreground - const http_thread = try std.Thread.spawn(.{}, server.Server.run, .{&http_srv}); + const http_thread = try std.Thread.spawn(.{}, server.Server.runWithRuntime, .{ &http_srv, http_runtime }); _ = http_thread; try wire_srv.run(); } else if (use_wire) { try wire_srv.run(); } else { - try http_srv.run(); + try http_srv.runWithRuntime(http_runtime); } std.log.info("TurboDB stopped.", .{}); } diff --git a/src/marketplace.zig b/src/marketplace.zig index b8b25b5..f67a3c6 100644 --- a/src/marketplace.zig +++ b/src/marketplace.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const branching = @import("branching.zig"); const collection = @import("collection.zig"); const doc_mod = @import("doc.zig"); @@ -53,7 +54,7 @@ pub const Marketplace = struct { try copyDirRecursive(self.alloc, snapshot.parent_path, base_dir); try copyDirRecursive(self.alloc, snapshot.overlay_dir, overlay_dir); - const created_at_ms = std.time.milliTimestamp(); + const created_at_ms = compat.milliTimestamp(); try self.writeManifest(release_root, snapshot, description, base_dir, overlay_dir, version, created_at_ms); return .{ .dataset_name = try self.alloc.dupe(u8, snapshot.dataset_name), @@ -78,7 +79,7 @@ pub const Marketplace = struct { const dataset_dir = try std.fmt.allocPrint(self.alloc, "{s}/{s}", .{ self.root_dir, dataset_name }); defer self.alloc.free(dataset_dir); - var dir = try std.fs.openDirAbsolute(dataset_dir, .{ .iterate = true }); + var dir = try compat.openDirAbsolute(dataset_dir, .{ .iterate = true }); defer dir.close(); var versions: std.ArrayList(u32) = .empty; @@ -111,7 +112,7 @@ pub const Marketplace = struct { try copyDirRecursive(self.alloc, release.base_dir, base_dir); try copyDirRecursive(self.alloc, release.overlay_dir, overlay_dir); - var manifest = try std.fs.createFileAbsolute(manifest_path, .{ .truncate = true }); + var manifest = try compat.createFileAbsolute(manifest_path, .{ .truncate = true }); defer manifest.close(); try manifest.writeAll(base_dir); @@ -155,7 +156,7 @@ pub const Marketplace = struct { ); defer self.alloc.free(content); - var file = try std.fs.createFileAbsolute(manifest_path, .{ .truncate = true }); + var file = try compat.createFileAbsolute(manifest_path, .{ .truncate = true }); defer file.close(); try file.writeAll(content); } @@ -197,20 +198,13 @@ fn parseManifest(alloc: std.mem.Allocator, raw: []const u8) !Release { } fn ensureDir(path: []const u8) !void { - if (std.fs.path.isAbsolute(path)) { - var root = try std.fs.openDirAbsolute("/", .{}); - defer root.close(); - const rel = std.mem.trimLeft(u8, path, "/"); - if (rel.len > 0) try root.makePath(rel); - } else { - try std.fs.cwd().makePath(path); - } + try compat.cwd().makePath(path); } fn copyDirRecursive(alloc: std.mem.Allocator, src_dir: []const u8, dst_dir: []const u8) !void { try ensureDir(dst_dir); - var dir = try std.fs.openDirAbsolute(src_dir, .{ .iterate = true }); + var dir = try compat.openDirAbsolute(src_dir, .{ .iterate = true }); defer dir.close(); var it = dir.iterate(); @@ -224,27 +218,36 @@ fn copyDirRecursive(alloc: std.mem.Allocator, src_dir: []const u8, dst_dir: []co .directory => try copyDirRecursive(alloc, src_path, dst_path), .file => { if (!std.mem.endsWith(u8, entry.name, ".pages")) continue; - try copyFileAbsolute(src_path, dst_path); + try copyFileAbsolute(alloc, src_path, dst_path); }, else => {}, } } } -fn copyFileAbsolute(src_path: []const u8, dst_path: []const u8) !void { - var src = try std.fs.openFileAbsolute(src_path, .{}); +fn copyFileAbsolute(alloc: std.mem.Allocator, src_path: []const u8, dst_path: []const u8) !void { + var src = try compat.openFileAbsolute(src_path, .{}); defer src.close(); - var dst = try std.fs.createFileAbsolute(dst_path, .{ .truncate = true }); + var dst = try compat.createFileAbsolute(dst_path, .{ .truncate = true }); defer dst.close(); const stat = try src.stat(); - _ = try src.copyRangeAll(0, dst, 0, stat.size); + const buf_len: usize = @min(@as(usize, 64 * 1024), @max(@as(usize, 1), @as(usize, @intCast(stat.size)))); + const buf = try alloc.alloc(u8, buf_len); + defer alloc.free(buf); + + var remaining = stat.size; + while (remaining > 0) { + const want: usize = @intCast(@min(remaining, buf.len)); + const n = try src.read(buf[0..want]); + if (n == 0) return error.UnexpectedEof; + try dst.writeAll(buf[0..n]); + remaining -= @intCast(n); + } } fn readFileAbsoluteAlloc(alloc: std.mem.Allocator, path: []const u8, max_bytes: usize) ![]u8 { - const file = try std.fs.openFileAbsolute(path, .{}); - defer file.close(); - return try file.readToEndAlloc(alloc, max_bytes); + return try compat.cwd().readFileAlloc(alloc, path, max_bytes); } test "marketplace publish and install keeps snapshot immutable" { @@ -252,14 +255,14 @@ test "marketplace publish and install keeps snapshot immutable" { const tmp_dir = "/tmp/turbodb_marketplace_publish"; const market_dir = "/tmp/turbodb_marketplace_catalog"; const install_dir = "/tmp/turbodb_marketplace_install"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - std.fs.cwd().deleteTree(market_dir) catch {}; - std.fs.cwd().deleteTree(install_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(market_dir) catch {}; + compat.cwd().deleteTree(install_dir) catch {}; try ensureDir(tmp_dir); try ensureDir(market_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(market_dir) catch {}; - defer std.fs.cwd().deleteTree(install_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(market_dir) catch {}; + defer compat.cwd().deleteTree(install_dir) catch {}; const base = try collection.Database.open(alloc, tmp_dir); defer base.close(); @@ -300,16 +303,16 @@ test "marketplace keeps multiple release versions installable" { const market_dir = "/tmp/turbodb_marketplace_versions_catalog"; const install_v1_dir = "/tmp/turbodb_marketplace_versions_install_v1"; const install_v2_dir = "/tmp/turbodb_marketplace_versions_install_v2"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - std.fs.cwd().deleteTree(market_dir) catch {}; - std.fs.cwd().deleteTree(install_v1_dir) catch {}; - std.fs.cwd().deleteTree(install_v2_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(market_dir) catch {}; + compat.cwd().deleteTree(install_v1_dir) catch {}; + compat.cwd().deleteTree(install_v2_dir) catch {}; try ensureDir(tmp_dir); try ensureDir(market_dir); - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(market_dir) catch {}; - defer std.fs.cwd().deleteTree(install_v1_dir) catch {}; - defer std.fs.cwd().deleteTree(install_v2_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(market_dir) catch {}; + defer compat.cwd().deleteTree(install_v1_dir) catch {}; + defer compat.cwd().deleteTree(install_v2_dir) catch {}; const base = try collection.Database.open(alloc, tmp_dir); defer base.close(); @@ -354,11 +357,11 @@ test "marketplace rejects missing releases" { const alloc = std.testing.allocator; const market_dir = "/tmp/turbodb_marketplace_missing_catalog"; const install_dir = "/tmp/turbodb_marketplace_missing_install"; - std.fs.cwd().deleteTree(market_dir) catch {}; - std.fs.cwd().deleteTree(install_dir) catch {}; + compat.cwd().deleteTree(market_dir) catch {}; + compat.cwd().deleteTree(install_dir) catch {}; try ensureDir(market_dir); - defer std.fs.cwd().deleteTree(market_dir) catch {}; - defer std.fs.cwd().deleteTree(install_dir) catch {}; + defer compat.cwd().deleteTree(market_dir) catch {}; + defer compat.cwd().deleteTree(install_dir) catch {}; var marketplace = try Marketplace.init(alloc, market_dir); defer marketplace.deinit(); diff --git a/src/mvcc.zig b/src/mvcc.zig index 8f359da..88225c6 100644 --- a/src/mvcc.zig +++ b/src/mvcc.zig @@ -113,6 +113,26 @@ pub const VersionChain = struct { try self.all_versions.append(alloc, .{ .doc_id = doc_id, .ver = ver }); } + pub fn ensureUnusedCapacity(self: *VersionChain, alloc: Allocator, additional: usize) !void { + try self.latest.ensureUnusedCapacity(@intCast(additional)); + try self.history.ensureUnusedCapacity(@intCast(additional)); + try self.all_versions.ensureUnusedCapacity(alloc, additional); + } + + pub fn appendFreshVersionAssumeCapacity(self: *VersionChain, doc_id: u64, page_no: u32, page_off: u16, epoch: u64) void { + const ver = VersionPtr{ + .page_no = page_no, + .page_off = page_off, + .epoch = epoch, + .prev_page_no = NO_PREV_PAGE_NO, + .prev_page_off = NO_PREV_PAGE_OFF, + }; + + self.latest.putAssumeCapacityNoClobber(doc_id, ver); + self.history.putAssumeCapacityNoClobber(chainKey(page_no, page_off), ver); + self.all_versions.appendAssumeCapacity(.{ .doc_id = doc_id, .ver = ver }); + } + /// Get the latest version location for a document. pub fn getLatest(self: *VersionChain, doc_id: u64) ?VersionPtr { return self.latest.get(doc_id); @@ -221,6 +241,27 @@ test "version chain traversal" { try testing.expectEqual(@as(u16, 0), latest.prev_page_off); } +test "bulk fresh append uses reserved capacity" { + const alloc = testing.allocator; + var chain = VersionChain.init(alloc); + defer chain.deinit(alloc); + + try chain.ensureUnusedCapacity(alloc, 3); + chain.appendFreshVersionAssumeCapacity(1, 10, 20, 2); + chain.appendFreshVersionAssumeCapacity(2, 10, 40, 3); + chain.appendFreshVersionAssumeCapacity(3, 11, 0, 4); + + const first = chain.getLatest(1).?; + try testing.expectEqual(@as(u32, 10), first.page_no); + try testing.expectEqual(@as(u16, 20), first.page_off); + try testing.expectEqual(@as(u64, 2), first.epoch); + try testing.expectEqual(NO_PREV_PAGE_NO, first.prev_page_no); + try testing.expectEqual(NO_PREV_PAGE_OFF, first.prev_page_off); + try testing.expect(chain.getAtEpoch(3, 3) == null); + try testing.expectEqual(@as(usize, 3), chain.latest.count()); + try testing.expectEqual(@as(usize, 3), chain.all_versions.items.len); +} + test "read transaction sees correct epoch" { const alloc = testing.allocator; var chain = VersionChain.init(alloc); diff --git a/src/page.zig b/src/page.zig index 114fbc7..503bb4c 100644 --- a/src/page.zig +++ b/src/page.zig @@ -1,5 +1,6 @@ /// TurboDB — 4KB page allocator const std = @import("std"); +const compat = @import("compat"); const mmap = @import("mmap"); pub const PAGE_SIZE: usize = 65536; // 64KB — supports code files up to ~65KB per doc pub const PAGE_HEADER_SIZE: usize = 32; @@ -34,7 +35,7 @@ pub const PageFile = struct { mm: mmap.MmapFile, free_head: std.atomic.Value(u32), // head of free-list (0 = empty) next_alloc: std.atomic.Value(u32), // next never-allocated page - mu: std.Thread.Mutex, + mu: compat.Mutex, pub fn open(path: []const u8) !PageFile { var path_buf: [std.fs.max_path_bytes + 1]u8 = undefined; diff --git a/src/partition.zig b/src/partition.zig index f7ae886..c865757 100644 --- a/src/partition.zig +++ b/src/partition.zig @@ -174,7 +174,7 @@ pub const PartitionedCollection = struct { offset: u32, alloc_: std.mem.Allocator, ) !ScanResult { - var all_docs: std.ArrayList(Doc) = .{}; + var all_docs: std.ArrayList(Doc) = .empty; defer all_docs.deinit(alloc_); // Gather from each partition — request enough to cover offset + limit @@ -234,7 +234,7 @@ pub const PartitionedCollection = struct { } // Merge - var merged: std.ArrayList(Doc) = .{}; + var merged: std.ArrayList(Doc) = .empty; defer merged.deinit(alloc_); for (results) |maybe_res| { diff --git a/src/profile_index.zig b/src/profile_index.zig index ffc0bdc..73a3547 100644 --- a/src/profile_index.zig +++ b/src/profile_index.zig @@ -6,6 +6,7 @@ /// Usage: zig build profile -- /path/to/codebase /// const std = @import("std"); +const compat = @import("compat"); const collection_mod = @import("collection.zig"); const codeindex = @import("codeindex.zig"); const fast_index = @import("fast_index.zig"); @@ -34,10 +35,10 @@ const Timer = struct { start: i128 = 0, fn begin(self: *Timer) void { - self.start = std.time.nanoTimestamp(); + self.start = compat.nanoTimestamp(); } fn end(self: *Timer) void { - self.total_ns += std.time.nanoTimestamp() - self.start; + self.total_ns += compat.nanoTimestamp() - self.start; self.count += 1; } fn avgUs(self: *const Timer) f64 { @@ -53,25 +54,14 @@ const Timer = struct { } }; -pub fn main() !void { +pub fn main(init: std.process.Init) !void { // Use page_allocator for speed (no safety overhead in profiling mode) // Switch to GPA for leak/safety checks - var gpa = std.heap.GeneralPurposeAllocator(.{ - .safety = true, - .never_unmap = true, // keeps freed pages mapped for use-after-free detection - }){}; - defer { - const check = gpa.deinit(); - if (check == .leak) { - std.debug.print("\n ⚠ MEMORY LEAK DETECTED\n", .{}); - } else { - std.debug.print("\n ✓ No memory leaks\n", .{}); - } - } - const alloc = gpa.allocator(); + // smp_allocator for Zig 0.16 (no leak checking available) + const alloc = std.heap.smp_allocator; - const args = try std.process.argsAlloc(alloc); - defer std.process.argsFree(alloc, args); + const args = try compat.argsAlloc(alloc, init); + defer compat.argsFree(alloc, args); if (args.len < 2) { std.debug.print("Usage: profile \n", .{}); @@ -95,24 +85,26 @@ pub fn main() !void { files.deinit(alloc); } - var dir = try std.fs.cwd().openDir(args[1], .{ .iterate = true }); + var dir = try compat.cwd().openDir(args[1], .{ .iterate = true }); defer dir.close(); - var walker = try dir.walk(alloc); - defer walker.deinit(); + var walker = dir.iterate(); + // iterate() needs no deinit var total_bytes: u64 = 0; while (try walker.next()) |entry| { if (entry.kind != .file) continue; - if (!hasValidExt(entry.basename)) continue; + if (!hasValidExt(entry.name)) continue; - var file = dir.openFile(entry.path, .{}) catch continue; + var path_buf2: [4096]u8 = undefined; + const full_path = std.fmt.bufPrint(&path_buf2, "{s}/{s}", .{ args[1], entry.name }) catch continue; + var file = compat.cwd().openFile(full_path, .{}) catch continue; defer file.close(); var buf: [8192]u8 = undefined; const n = file.read(&buf) catch continue; if (n < 3) continue; try files.append(alloc, .{ - .path = try alloc.dupe(u8, entry.path), + .path = try alloc.dupe(u8, entry.name), .content = try alloc.dupe(u8, buf[0..n]), }); total_bytes += n; @@ -220,14 +212,14 @@ pub fn main() !void { t_build.end(); const tmp = "/tmp/tdb_profile_disk"; - std.fs.cwd().makeDir(tmp) catch |e| switch (e) { + compat.cwd().makeDir(tmp) catch |e| switch (e) { error.PathAlreadyExists => { - std.fs.cwd().deleteTree(tmp) catch {}; - std.fs.cwd().makeDir(tmp) catch {}; + compat.cwd().deleteTree(tmp) catch {}; + compat.cwd().makeDir(tmp) catch {}; }, else => return e, }; - defer std.fs.cwd().deleteTree(tmp) catch {}; + defer compat.cwd().deleteTree(tmp) catch {}; t_write.begin(); const stats = try builder.writeToDisk(tmp); diff --git a/src/registry/api.zig b/src/registry/api.zig index adf2bcc..5dacae5 100644 --- a/src/registry/api.zig +++ b/src/registry/api.zig @@ -18,6 +18,7 @@ /// - Simple dispatch routing /// - JSON responses const std = @import("std"); +const compat = @import("compat"); const registry_mod = @import("registry.zig"); const sign_mod = @import("sign.zig"); const hash_mod = @import("hash.zig"); @@ -61,7 +62,7 @@ pub const RegistryServer = struct { } pub fn run(self: *RegistryServer) !void { - const addr = try std.net.Address.parseIp("0.0.0.0", self.port); + const addr = try compat.net.Address.parseIp("0.0.0.0", self.port); var listener = try addr.listen(.{ .reuse_address = true, .kernel_backlog = 256, @@ -87,7 +88,7 @@ pub const RegistryServer = struct { } }; -fn handleConn(srv: *RegistryServer, conn: std.net.Server.Connection) void { +fn handleConn(srv: *RegistryServer, conn: compat.net.Server.Connection) void { defer conn.stream.close(); const bufs = std.heap.page_allocator.create(ConnBufs) catch return; @@ -107,7 +108,7 @@ fn handleConn(srv: *RegistryServer, conn: std.net.Server.Connection) void { fn dispatch(srv: *RegistryServer, raw: []const u8) usize { const nl = std.mem.indexOfScalar(u8, raw, '\n') orelse return err(400, "bad request"); - const req_line = std.mem.trimRight(u8, raw[0..nl], "\r"); + const req_line = std.mem.trimEnd(u8, raw[0..nl], "\r"); var parts = std.mem.splitScalar(u8, req_line, ' '); const method = parts.next() orelse return err(400, "bad request"); const full_path = parts.next() orelse return err(400, "bad request"); @@ -131,8 +132,8 @@ fn dispatch(srv: *RegistryServer, raw: []const u8) usize { // ─── /metrics ─────────────────────────────────────────────────────── if (std.mem.eql(u8, path, "/metrics")) { - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), "{{\"requests\":{d},\"errors\":{d}}}", .{ + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"requests\":{d},\"errors\":{d}}}", .{ srv.req_count.load(.acquire), srv.err_count.load(.acquire), }) catch {}; @@ -215,18 +216,18 @@ fn handleSearch(srv: *RegistryServer, query_str: []const u8, auth: AuthContext) var results: [50]registry_mod.PackageInfo = undefined; const count = srv.registry.searchAuth(q, capped, &results, auth) catch return err(500, "search failed"); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); w.writeAll("{\"results\":[") catch {}; for (0..count) |i| { if (i > 0) w.writeAll(",") catch {}; - std.fmt.format(w, "{{\"name\":\"{s}\",\"description\":\"{s}\",\"version\":\"{s}\"}}", .{ + compat.format(w, "{{\"name\":\"{s}\",\"description\":\"{s}\",\"version\":\"{s}\"}}", .{ results[i].name, results[i].description, results[i].version, }) catch {}; } - std.fmt.format(w, "],\"count\":{d}}}", .{count}) catch {}; + compat.format(w, "],\"count\":{d}}}", .{count}) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } @@ -255,8 +256,8 @@ fn handlePublish(srv: *RegistryServer, body: []const u8) usize { }; }; - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), "{{\"ok\":true,\"name\":\"{s}\",\"version\":\"{s}\",\"hash\":\"{s}\"}}", .{ + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"ok\":true,\"name\":\"{s}\",\"version\":\"{s}\",\"hash\":\"{s}\"}}", .{ result.package_name, result.version, result.source_hash_hex, @@ -318,8 +319,8 @@ fn handleDownload(srv: *RegistryServer, hash_hex: []const u8) usize { // For blobs, return raw data (not JSON) if (data.len > MAX_RESP - 256) return err(413, "blob too large for response buffer"); - var fbs = std.io.fixedBufferStream(getRespBuf()); - std.fmt.format(fbs.writer(), "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", .{data.len}) catch {}; + var fbs = compat.fixedBufferStream(getRespBuf()); + compat.format(fbs.writer(), "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", .{data.len}) catch {}; const header_len = fbs.pos; if (header_len + data.len <= MAX_RESP) { @memcpy(getRespBuf()[header_len..][0..data.len], data); @@ -361,8 +362,8 @@ fn err(code: u16, msg: []const u8) usize { } fn respond(code: u16, status: []const u8, body: []const u8) usize { - var fbs = std.io.fixedBufferStream(getRespBuf()); - std.fmt.format(fbs.writer(), "HTTP/1.1 {d} {s}\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n{s}", .{ code, status, body.len, body }) catch {}; + var fbs = compat.fixedBufferStream(getRespBuf()); + compat.format(fbs.writer(), "HTTP/1.1 {d} {s}\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n{s}", .{ code, status, body.len, body }) catch {}; return fbs.pos; } diff --git a/src/registry/auth.zig b/src/registry/auth.zig index 5176274..e39be5d 100644 --- a/src/registry/auth.zig +++ b/src/registry/auth.zig @@ -6,6 +6,7 @@ /// - Access grants (per-package, per-pubkey permissions) /// - Request signing/verification (Ed25519 over method+path+timestamp) const std = @import("std"); +const compat = @import("compat"); const sign_mod = @import("sign.zig"); // ─── Visibility ──────────────────────────────────────────────────────────────── @@ -54,7 +55,7 @@ pub const Permissions = struct { /// Serialize to JSON-friendly string: "read,publish,yank,admin" pub fn toStr(self: Permissions, buf: []u8) ![]const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); var first = true; if (self.read) { if (!first) try w.writeAll(","); try w.writeAll("read"); first = false; } @@ -204,7 +205,7 @@ pub fn signRequest(method: []const u8, path: []const u8, timestamp: i64, secret_ /// Verify an HTTP request signature. pub fn verifyRequest(method: []const u8, path: []const u8, timestamp: i64, signature: [64]u8, pubkey: [32]u8) bool { // Check timestamp is within 5 minutes - const now = std.time.timestamp(); + const now = compat.timestamp(); const diff = if (now > timestamp) now - timestamp else timestamp - now; if (diff > 300) return false; // 5 minute window @@ -362,7 +363,7 @@ test "format package key" { test "request signing and verification" { const kp = sign_mod.KeyPair.generate(); - const now = std.time.timestamp(); + const now = compat.timestamp(); const sig = signRequest("GET", "/api/v1/packages/test", now, kp.secret_key); try std.testing.expect(verifyRequest("GET", "/api/v1/packages/test", now, sig, kp.public_key)); diff --git a/src/registry/cli.zig b/src/registry/cli.zig index 5543514..d12e056 100644 --- a/src/registry/cli.zig +++ b/src/registry/cli.zig @@ -13,6 +13,7 @@ /// audit Verify all dep signatures and hashes /// keygen Generate Ed25519 keypair const std = @import("std"); +const compat = @import("compat"); const manifest_mod = @import("manifest.zig"); const sign_mod = @import("sign.zig"); const hash_mod = @import("hash.zig"); @@ -25,13 +26,13 @@ const DEFAULT_REGISTRY = "http://localhost:8080"; const ZAG_DIR = ".zag"; const GLOBAL_DIR = ".zag"; // ~/.zag/ -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + // GPA replaced by smp_allocator for Zig 0.16 - const args = try std.process.argsAlloc(alloc); - defer std.process.argsFree(alloc, args); + const alloc = std.heap.smp_allocator; + + const args = try compat.argsAlloc(alloc, init); + defer compat.argsFree(alloc, args); if (args.len < 2) { printUsage(); @@ -105,28 +106,28 @@ fn cmdInit(alloc: std.mem.Allocator, args: []const []const u8) !void { } // Check if zag.json already exists - std.fs.cwd().access("zag.json", .{}) catch |e| { + compat.cwd().access("zag.json", .{}) catch |e| { if (e == error.FileNotFound) { var buf: [2048]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); + var fbs = compat.fixedBufferStream(&buf); const w = fbs.writer(); try w.writeAll("{\n"); - try std.fmt.format(w, " \"name\": \"{s}\",\n", .{name}); + try compat.format(w, " \"name\": \"{s}\",\n", .{name}); try w.writeAll(" \"version\": \"0.1.0\",\n"); try w.writeAll(" \"description\": \"\",\n"); - try std.fmt.format(w, " \"visibility\": \"{s}\",\n", .{if (private) "private" else "public"}); + try compat.format(w, " \"visibility\": \"{s}\",\n", .{if (private) "private" else "public"}); if (org) |o| { - try std.fmt.format(w, " \"org\": \"{s}\",\n", .{o}); + try compat.format(w, " \"org\": \"{s}\",\n", .{o}); } try w.writeAll(" \"license\": \"MIT\",\n"); - try w.writeAll(" \"zig_version\": \"0.15.0\",\n"); + try w.writeAll(" \"zig_version\": \"0.16.0\",\n"); try w.writeAll(" \"dependencies\": {},\n"); try w.writeAll(" \"dev_dependencies\": {}\n"); try w.writeAll("}"); const content = fbs.getWritten(); - const file = try std.fs.cwd().createFile("zag.json", .{}); + const file = try compat.cwd().createFile("zag.json", .{}); defer file.close(); try file.writeAll(content); @@ -143,7 +144,7 @@ fn cmdInit(alloc: std.mem.Allocator, args: []const []const u8) !void { } fn cmdKeygen() !void { - const home = std.posix.getenv("HOME") orelse "/tmp"; + const home = blk: { const e = std.c.getenv("HOME"); break :blk if (e) |p| std.mem.sliceTo(p, 0) else "/tmp"; }; var path_buf: [512]u8 = undefined; const keys_dir = try std.fmt.bufPrint(&path_buf, "{s}/.zag/keys", .{home}); @@ -190,7 +191,7 @@ fn cmdSearch(alloc: std.mem.Allocator, args: []const []const u8) !void { fn cmdPublish(alloc: std.mem.Allocator) !void { // Read zag.json - const manifest_content = std.fs.cwd().readFileAlloc(alloc, "zag.json", 64 * 1024) catch { + const manifest_content = compat.cwd().readFileAlloc(alloc, "zag.json", 64 * 1024) catch { std.debug.print("No zag.json found. Run 'zag init' first.\n", .{}); return; }; @@ -217,7 +218,7 @@ fn cmdPublish(alloc: std.mem.Allocator) !void { fn cmdAudit(alloc: std.mem.Allocator) !void { // Read lockfile - const lockfile = std.fs.cwd().readFileAlloc(alloc, ".zag/lock.json", 1024 * 1024) catch { + const lockfile = compat.cwd().readFileAlloc(alloc, ".zag/lock.json", 1024 * 1024) catch { std.debug.print("No .zag/lock.json found. Run 'zag install' first.\n", .{}); return; }; @@ -236,7 +237,7 @@ fn cmdLogin(args: []const []const u8) !void { const url = args[0]; // Generate or load keypair - const home = std.posix.getenv("HOME") orelse "/tmp"; + const home = blk: { const e = std.c.getenv("HOME"); break :blk if (e) |p| std.mem.sliceTo(p, 0) else "/tmp"; }; var keys_buf: [512]u8 = undefined; const keys_dir = try std.fmt.bufPrint(&keys_buf, "{s}/.zag/keys", .{home}); diff --git a/src/registry/config.zig b/src/registry/config.zig index 7272f65..2cb983e 100644 --- a/src/registry/config.zig +++ b/src/registry/config.zig @@ -12,6 +12,7 @@ /// "default_org": null /// } const std = @import("std"); +const compat = @import("compat"); pub const RegistryEntry = struct { name: []const u8, @@ -39,21 +40,21 @@ pub const Config = struct { /// Serialize config to JSON. pub fn toJson(self: Config, buf: []u8) ![]const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); try w.writeAll("{\"registries\":{"); for (self.registries, 0..) |reg, i| { if (i > 0) try w.writeAll(","); - try std.fmt.format(w, "\"{s}\":{{\"url\":\"{s}\"", .{ reg.name, reg.url }); + try compat.format(w, "\"{s}\":{{\"url\":\"{s}\"", .{ reg.name, reg.url }); if (reg.pubkey_hex) |pk| { - try std.fmt.format(w, ",\"pubkey\":\"{s}\"", .{pk}); + try compat.format(w, ",\"pubkey\":\"{s}\"", .{pk}); } try w.writeAll("}"); } - try std.fmt.format(w, "}},\"default_registry\":\"{s}\"", .{self.default_registry}); + try compat.format(w, "}},\"default_registry\":\"{s}\"", .{self.default_registry}); if (self.default_org) |org| { - try std.fmt.format(w, ",\"default_org\":\"{s}\"", .{org}); + try compat.format(w, ",\"default_org\":\"{s}\"", .{org}); } else { try w.writeAll(",\"default_org\":null"); } @@ -89,7 +90,7 @@ pub fn globalConfigPath(buf: []u8) ![]const u8 { pub fn saveGlobalConfig(config: Config) !void { var dir_buf: [512]u8 = undefined; const dir = try globalConfigDir(&dir_buf); - std.fs.cwd().makePath(dir) catch {}; + compat.cwd().makePath(dir) catch {}; var path_buf: [512]u8 = undefined; const path = try globalConfigPath(&path_buf); @@ -97,7 +98,7 @@ pub fn saveGlobalConfig(config: Config) !void { var json_buf: [4096]u8 = undefined; const json = try config.toJson(&json_buf); - const file = try std.fs.cwd().createFile(path, .{}); + const file = try compat.cwd().createFile(path, .{}); defer file.close(); try file.writeAll(json); try file.writeAll("\n"); diff --git a/src/registry/hash.zig b/src/registry/hash.zig index 30a4825..e535842 100644 --- a/src/registry/hash.zig +++ b/src/registry/hash.zig @@ -9,6 +9,7 @@ /// /// Same source tree ALWAYS produces same hash regardless of filesystem ordering. const std = @import("std"); +const compat = @import("compat"); const Blake3 = std.crypto.hash.Blake3; /// Hash raw bytes with BLAKE3. Returns 32-byte digest. @@ -45,7 +46,7 @@ pub fn hashSourceTree(alloc: std.mem.Allocator, dir_path: []const u8) !struct { var full_path_buf: [4096]u8 = undefined; const full_path = std.fmt.bufPrint(&full_path_buf, "{s}/{s}", .{ dir_path, rel_path }) catch continue; - const content = std.fs.cwd().readFileAlloc(alloc, full_path, 64 * 1024 * 1024) catch continue; + const content = compat.cwd().readFileAlloc(alloc, full_path, 64 * 1024 * 1024) catch continue; defer alloc.free(content); // BLAKE3(path ++ \0 ++ content) @@ -88,7 +89,7 @@ fn collectFiles( else base_dir; - var dir = std.fs.cwd().openDir(dir_to_open, .{ .iterate = true }) catch return; + var dir = compat.cwd().openDir(dir_to_open, .{ .iterate = true }) catch return; defer dir.close(); var iter = dir.iterate(); diff --git a/src/registry/main.zig b/src/registry/main.zig index 07bcfdd..e125950 100644 --- a/src/registry/main.zig +++ b/src/registry/main.zig @@ -4,20 +4,21 @@ /// /// Starts the ZagDB package registry HTTP server backed by TurboDB. const std = @import("std"); +const compat = @import("compat"); const api = @import("api.zig"); const registry_mod = @import("registry.zig"); -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + // GPA replaced by smp_allocator for Zig 0.16 + + const alloc = std.heap.smp_allocator; var port: u16 = 8080; var data_dir: []const u8 = "./zagdb-data"; // Parse CLI args - const args = try std.process.argsAlloc(alloc); - defer std.process.argsFree(alloc, args); + const args = try compat.argsAlloc(alloc, init); + defer compat.argsFree(alloc, args); var i: usize = 1; while (i < args.len) : (i += 1) { @@ -56,7 +57,7 @@ pub fn main() !void { } // Ensure data directory exists - std.fs.cwd().makeDir(data_dir) catch |e| switch (e) { + compat.cwd().makeDir(data_dir) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return e, }; diff --git a/src/registry/manifest.zig b/src/registry/manifest.zig index eacd088..8ae1649 100644 --- a/src/registry/manifest.zig +++ b/src/registry/manifest.zig @@ -12,13 +12,14 @@ /// "author": "Alice", /// "license": "MIT", /// "tags": ["web", "http", "framework"], -/// "zig_version": "0.15.0", +/// "zig_version": "0.16.0", /// "dependencies": { /// "router": "^1.0.0", /// "json": {"version": "^0.5.0", "hash": "a3f9c8..."} /// } /// } const std = @import("std"); +const compat = @import("compat"); pub const Dependency = struct { name: []const u8, @@ -39,12 +40,12 @@ pub const Manifest = struct { tags: []const []const u8 = &.{}, dependencies: []const Dependency = &.{}, dev_dependencies: []const Dependency = &.{}, - zig_version: []const u8 = "0.15.0", + zig_version: []const u8 = "0.16.0", /// Serialize manifest to JSON for TurboDB storage. /// Returns a slice into buf. pub fn toJson(self: Manifest, buf: []u8) ![]const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); try w.writeAll("{"); @@ -111,7 +112,7 @@ pub fn template(name: []const u8, buf: []u8) ![]const u8 { \\ "description": "", \\ "author": "", \\ "license": "MIT", - \\ "zig_version": "0.15.0", + \\ "zig_version": "0.16.0", \\ "tags": [], \\ "dependencies": {{}}, \\ "dev_dependencies": {{}} @@ -158,7 +159,7 @@ pub fn parse(alloc: std.mem.Allocator, source: []const u8) !Manifest { .license = jsonGetStr(source, "license") orelse "", .visibility = jsonGetStr(source, "visibility") orelse "public", .org = jsonGetStr(source, "org"), - .zig_version = jsonGetStr(source, "zig_version") orelse "0.15.0", + .zig_version = jsonGetStr(source, "zig_version") orelse "0.16.0", }; } diff --git a/src/registry/provenance.zig b/src/registry/provenance.zig index f6c93df..e4346bf 100644 --- a/src/registry/provenance.zig +++ b/src/registry/provenance.zig @@ -4,6 +4,7 @@ /// by a specific builder, at a specific time. Users can verify the chain: /// source → build → artifact const std = @import("std"); +const compat = @import("compat"); const sign_mod = @import("sign.zig"); const hash_mod = @import("hash.zig"); @@ -28,7 +29,7 @@ pub fn createAttestation( buf: *AttestationBuf, ) Provenance { const pubkey_hex = sign_mod.pubkeyHex(keypair.public_key); - const now = std.time.timestamp(); + const now = compat.timestamp(); // Build message to sign const msg = std.fmt.bufPrint(&buf.msg_buf, "provenance:{s}:{s}:{s}", .{ @@ -114,7 +115,7 @@ test "create and verify attestation" { "abc123source", "aarch64-macos", "def456artifact", - "0.15.0", + "0.16.0", kp, &att_buf, ); @@ -134,7 +135,7 @@ test "attestation fails with wrong key" { "src_hash", "x86_64-linux", "art_hash", - "0.15.0", + "0.16.0", kp1, &att_buf, ); @@ -151,7 +152,7 @@ test "attestation toJson" { "source123", "aarch64-macos", "artifact456", - "0.15.0", + "0.16.0", kp, &att_buf, ); @@ -162,5 +163,5 @@ test "attestation toJson" { try std.testing.expect(std.mem.indexOf(u8, json, "source123") != null); try std.testing.expect(std.mem.indexOf(u8, json, "aarch64-macos") != null); try std.testing.expect(std.mem.indexOf(u8, json, "artifact456") != null); - try std.testing.expect(std.mem.indexOf(u8, json, "0.15.0") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "0.16.0") != null); } diff --git a/src/registry/registry.zig b/src/registry/registry.zig index 444d3f6..2ad1c06 100644 --- a/src/registry/registry.zig +++ b/src/registry/registry.zig @@ -13,6 +13,7 @@ /// blobs — key: blake3 hex → blob metadata JSON /// identities — key: ed25519 pubkey hex → profile JSON const std = @import("std"); +const compat = @import("compat"); const hash_mod = @import("hash.zig"); const sign_mod = @import("sign.zig"); const manifest_mod = @import("manifest.zig"); @@ -59,7 +60,7 @@ pub const Registry = struct { pub fn init(alloc: std.mem.Allocator, data_dir: []const u8) !Registry { // Ensure data directory exists - std.fs.cwd().makePath(data_dir) catch {}; + compat.cwd().makePath(data_dir) catch {}; return .{ .store = try store_mod.BlobStore.init(alloc, data_dir), @@ -140,7 +141,7 @@ pub const Registry = struct { // 7. Build package metadata JSON var pubkey_hex = sign_mod.pubkeyHex(pubkey); var sig_hex = sign_mod.signatureHex(signature); - const now = std.time.timestamp(); + const now = compat.timestamp(); // Package metadata (upsert — latest version wins) var pkg_buf: [2048]u8 = undefined; @@ -313,7 +314,7 @@ pub const Registry = struct { /// Register an author identity. pub fn registerIdentity(self: *Registry, pubkey: [32]u8, display_name: []const u8, email: []const u8) !void { var pubkey_hex = sign_mod.pubkeyHex(pubkey); - const now = std.time.timestamp(); + const now = compat.timestamp(); var buf: [1024]u8 = undefined; const json = try std.fmt.bufPrint(&buf, @@ -413,8 +414,8 @@ fn jsonGetField(json: []const u8, key: []const u8) ?[]const u8 { test "publish and search" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -451,8 +452,8 @@ test "publish and search" { test "publish duplicate version fails" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-dup"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -475,8 +476,8 @@ test "publish duplicate version fails" { test "invalid signature rejected" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-badsig"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -498,8 +499,8 @@ test "invalid signature rejected" { test "yank version" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-yank"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -528,8 +529,8 @@ test "yank version" { test "download after publish" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-dl"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -554,8 +555,8 @@ test "download after publish" { test "register and lookup identity" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-id"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -572,8 +573,8 @@ test "register and lookup identity" { test "private package hidden from anonymous search" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-priv-search"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -609,8 +610,8 @@ test "private package hidden from anonymous search" { test "getPackageAuth visibility" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-pkg-auth"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -654,8 +655,8 @@ test "getPackageAuth visibility" { test "grantAccess and visibility" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-grant"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -692,8 +693,8 @@ test "grantAccess and visibility" { test "org-scoped package" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-registry-org"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try Registry.init(alloc, tmp_dir); defer reg.deinit(); diff --git a/src/registry/resolver.zig b/src/registry/resolver.zig index ed59751..74dd584 100644 --- a/src/registry/resolver.zig +++ b/src/registry/resolver.zig @@ -12,6 +12,7 @@ /// 5. Cycle detection via visited set /// 6. Output: topologically sorted flat list const std = @import("std"); +const compat = @import("compat"); const semver = @import("semver.zig"); const registry_mod = @import("registry.zig"); const manifest_mod = @import("manifest.zig"); @@ -135,7 +136,7 @@ pub const Resolver = struct { /// Generate a deterministic lockfile JSON. pub fn toLockfile(resolved: []const ResolvedDep, buf: []u8) ![]const u8 { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); try w.writeAll("{\"locked\":["); @@ -143,7 +144,7 @@ pub const Resolver = struct { if (i > 0) try w.writeAll(","); var ver_buf: [64]u8 = undefined; const ver_str = dep.version.format(&ver_buf) catch "0.0.0"; - try std.fmt.format(w, "{{\"name\":\"{s}\",\"version\":\"{s}\",\"hash\":\"{s}\"}}", .{ + try compat.format(w, "{{\"name\":\"{s}\",\"version\":\"{s}\",\"hash\":\"{s}\"}}", .{ dep.name, ver_str, dep.source_hash, @@ -195,8 +196,8 @@ fn jsonGetField(json: []const u8, key: []const u8) ?[]const u8 { test "resolve pinned deps" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-resolver-test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try registry_mod.Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -218,8 +219,8 @@ test "resolve pinned deps" { test "conflicting versions error" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-resolver-conflict"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try registry_mod.Registry.init(alloc, tmp_dir); defer reg.deinit(); @@ -258,8 +259,8 @@ test "lockfile generation" { test "resolve from registry" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-resolver-reg"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; - defer std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; var reg = try registry_mod.Registry.init(alloc, tmp_dir); defer reg.deinit(); diff --git a/src/registry/sign.zig b/src/registry/sign.zig index a1b8a02..ccf31c4 100644 --- a/src/registry/sign.zig +++ b/src/registry/sign.zig @@ -4,6 +4,7 @@ /// The signing target is the BLAKE3 hash of the source tarball. /// Keypairs are stored at ~/.zag/keys/ const std = @import("std"); +const compat = @import("compat"); const Ed25519 = std.crypto.sign.Ed25519; pub const KeyPair = struct { @@ -12,7 +13,9 @@ pub const KeyPair = struct { /// Generate a new random Ed25519 keypair. pub fn generate() KeyPair { - const kp = Ed25519.KeyPair.generate(); + var seed: [32]u8 = undefined; + compat.randomBytes(&seed); + const kp = Ed25519.KeyPair.generateDeterministic(seed) catch unreachable; return .{ .public_key = kp.public_key.toBytes(), .secret_key = kp.secret_key.toBytes(), @@ -98,16 +101,15 @@ fn hexDecode64(hex: []const u8) ![64]u8 { /// Creates: /default.pub (64 hex chars) and /default.sec (128 hex chars) pub fn saveKeyPair(kp: KeyPair, dir_path: []const u8) !void { // Ensure directory exists - std.fs.cwd().makePath(dir_path) catch {}; - - var dir = try std.fs.cwd().openDir(dir_path, .{}); - defer dir.close(); + compat.cwd().makePath(dir_path) catch {}; // Write public key { var hex: [64]u8 = undefined; hexEncode32(kp.public_key, &hex); - const file = try dir.createFile("default.pub", .{}); + var pub_path: [600]u8 = undefined; + const pub_p = std.fmt.bufPrint(&pub_path, "{s}/default.pub", .{dir_path}) catch return error.NameTooLong; + const file = try compat.cwd().createFile(pub_p, .{}); defer file.close(); try file.writeAll(&hex); try file.writeAll("\n"); @@ -117,7 +119,9 @@ pub fn saveKeyPair(kp: KeyPair, dir_path: []const u8) !void { { var hex: [128]u8 = undefined; hexEncode64(kp.secret_key, &hex); - const file = try dir.createFile("default.sec", .{}); + var sec_path: [600]u8 = undefined; + const sec_p = std.fmt.bufPrint(&sec_path, "{s}/default.sec", .{dir_path}) catch return error.NameTooLong; + const file = try compat.cwd().createFile(sec_p, .{}); defer file.close(); try file.writeAll(&hex); try file.writeAll("\n"); @@ -126,7 +130,7 @@ pub fn saveKeyPair(kp: KeyPair, dir_path: []const u8) !void { /// Load keypair from directory. pub fn loadKeyPair(dir_path: []const u8) !KeyPair { - var dir = try std.fs.cwd().openDir(dir_path, .{}); + var dir = try compat.cwd().openDir(dir_path, .{}); defer dir.close(); // Read public key @@ -207,7 +211,7 @@ test "save and load keypair" { const tmp_dir = "/tmp/zag-test-keys"; // Clean up from previous runs - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; try saveKeyPair(kp, tmp_dir); const loaded = try loadKeyPair(tmp_dir); @@ -220,7 +224,7 @@ test "save and load keypair" { try std.testing.expect(verify("test", sig, loaded.public_key)); // Clean up - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } test "pubkey and signature hex" { diff --git a/src/registry/store.zig b/src/registry/store.zig index e8d7bb3..3dea938 100644 --- a/src/registry/store.zig +++ b/src/registry/store.zig @@ -6,6 +6,7 @@ /// TurboDB's `blobs` collection tracks metadata only (hash, size, disk path). /// Deduplication is automatic: same content → same hash → same file. const std = @import("std"); +const compat = @import("compat"); const hash_mod = @import("hash.zig"); /// BlobStore manages content-addressed blob storage. @@ -18,7 +19,7 @@ pub const BlobStore = struct { // Ensure blobs directory exists var path_buf: [512]u8 = undefined; const blobs_dir = try std.fmt.bufPrint(&path_buf, "{s}/blobs", .{data_dir}); - try std.fs.cwd().makePath(blobs_dir); + try compat.cwd().makePath(blobs_dir); return .{ .data_dir = data_dir, @@ -44,22 +45,22 @@ pub const BlobStore = struct { if (self.existsPath(file_path)) return content_hash; // Create prefix directory - std.fs.cwd().makePath(prefix_dir) catch {}; + compat.cwd().makePath(prefix_dir) catch {}; // Write blob atomically: write to .tmp, then rename var tmp_buf: [512]u8 = undefined; const tmp_path = try std.fmt.bufPrint(&tmp_buf, "{s}.tmp", .{file_path}); { - const file = try std.fs.cwd().createFile(tmp_path, .{}); + const file = try compat.cwd().createFile(tmp_path, .{}); defer file.close(); try file.writeAll(data); } // Atomic rename - std.fs.cwd().rename(tmp_path, file_path) catch |err| { + compat.cwd().rename(tmp_path, file_path) catch |err| { // If rename fails, try to clean up tmp - std.fs.cwd().deleteFile(tmp_path) catch {}; + compat.cwd().deleteFile(tmp_path) catch {}; return err; }; @@ -88,7 +89,7 @@ pub const BlobStore = struct { hash_hex[0..], }) catch return null; - const file = std.fs.cwd().openFile(path, .{}) catch return null; + const file = compat.cwd().openFile(path, .{}) catch return null; defer file.close(); const stat = file.stat() catch return null; return stat.size; @@ -103,11 +104,11 @@ pub const BlobStore = struct { hash_hex[0..2], hash_hex[0..], }); - return std.fs.cwd().readFileAlloc(self.alloc, path, 256 * 1024 * 1024); + return compat.cwd().readFileAlloc(self.alloc, path, 256 * 1024 * 1024); } /// Open a blob file for streaming reads. Caller owns the file handle. - pub fn openBlob(self: *BlobStore, hash_hex: []const u8) !std.fs.File { + pub fn openBlob(self: *BlobStore, hash_hex: []const u8) !compat.File { if (hash_hex.len != 64) return error.InvalidHash; var path_buf: [512]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "{s}/blobs/{s}/{s}.tar.zst", .{ @@ -115,7 +116,7 @@ pub const BlobStore = struct { hash_hex[0..2], hash_hex[0..], }); - return std.fs.cwd().openFile(path, .{}); + return compat.cwd().openFile(path, .{}); } /// Get the filesystem path for a blob (for serving over HTTP). @@ -135,14 +136,14 @@ pub const BlobStore = struct { hash_hex[0..2], hash_hex[0..], }); - const now = std.time.timestamp(); + const now = compat.timestamp(); return std.fmt.bufPrint(buf, \\{{"hash":"{s}","size":{d},"content_type":"application/zstd","disk_path":"{s}","uploaded_at":{d},"ref_count":1}} , .{ hash_hex, data_len, disk_path, now }); } fn existsPath(_: *BlobStore, path: []const u8) bool { - std.fs.cwd().access(path, .{}) catch return false; + compat.cwd().access(path, .{}) catch return false; return true; } }; @@ -152,7 +153,7 @@ pub const BlobStore = struct { test "put and read blob" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-store-test"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var store = try BlobStore.init(alloc, tmp_dir); @@ -172,13 +173,13 @@ test "put and read blob" { // Size should match try std.testing.expectEqual(@as(u64, data.len), store.blobSize(&hex).?); - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } test "put deduplicates" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-store-dedup"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var store = try BlobStore.init(alloc, tmp_dir); @@ -189,13 +190,13 @@ test "put deduplicates" { // Same hash try std.testing.expectEqual(h1, h2); - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } test "different content different hash" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-store-diff"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var store = try BlobStore.init(alloc, tmp_dir); @@ -212,13 +213,13 @@ test "different content different hash" { try std.testing.expect(store.exists(&hex1)); try std.testing.expect(store.exists(&hex2)); - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } test "nonexistent blob" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-store-none"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var store = try BlobStore.init(alloc, tmp_dir); @@ -226,13 +227,13 @@ test "nonexistent blob" { try std.testing.expect(!store.exists(fake_hex)); try std.testing.expectEqual(@as(?u64, null), store.blobSize(fake_hex)); - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } test "metadata json" { const alloc = std.testing.allocator; const tmp_dir = "/tmp/zagdb-store-meta"; - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; var store = try BlobStore.init(alloc, tmp_dir); @@ -248,5 +249,5 @@ test "metadata json" { try std.testing.expect(std.mem.indexOf(u8, json, &hex) != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"size\":9") != null); - std.fs.cwd().deleteTree(tmp_dir) catch {}; + compat.cwd().deleteTree(tmp_dir) catch {}; } diff --git a/src/replication/calvin.zig b/src/replication/calvin.zig index ad5324b..f534da1 100644 --- a/src/replication/calvin.zig +++ b/src/replication/calvin.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); const sequencer = @import("sequencer.zig"); pub const NodeId = u16; @@ -48,7 +49,7 @@ pub const CalvinExecutor = struct { // Execution state last_executed_epoch: u64, - execute_mu: std.Thread.Mutex, + execute_mu: compat.Mutex, pub fn init(alloc: std.mem.Allocator, node_id: NodeId) CalvinExecutor { return .{ @@ -112,7 +113,7 @@ pub const CalvinExecutor = struct { /// Each txn: [txn_id:u64][type:u8][partition:u16][key_hash:u64] /// [data_len:u32][data...][rs_len:u32][rs...][ws_len:u32][ws...] pub fn serializeBatch(batch: *const sequencer.Batch, buf: []u8) !usize { - var fbs = std.io.fixedBufferStream(buf); + var fbs = compat.fixedBufferStream(buf); const w = fbs.writer(); try w.writeInt(u64, batch.epoch, .little); @@ -143,7 +144,7 @@ pub const CalvinExecutor = struct { /// Deserialize a batch from network bytes. pub fn deserializeBatch(data: []const u8, alloc: std.mem.Allocator) !sequencer.Batch { - var fbs = std.io.fixedBufferStream(data); + var fbs = compat.constFixedBufferStream(data); const r = fbs.reader(); const epoch = try r.readInt(u64, .little); diff --git a/src/replication/peer.zig b/src/replication/peer.zig index 9edc542..68d4e6d 100644 --- a/src/replication/peer.zig +++ b/src/replication/peer.zig @@ -6,6 +6,7 @@ /// /// Protocol: simple framed TCP — [4-byte big-endian length][batch payload] const std = @import("std"); +const compat = @import("compat"); const sequencer = @import("sequencer.zig"); const calvin = @import("calvin.zig"); @@ -51,15 +52,15 @@ pub const PeerSender = struct { fn sendToPeer(peer: *const PeerAddr, payload: []const u8) !void { // Parse IP address - const addr = try std.net.Address.parseIp(peer.hostSlice(), peer.port); - const stream = try std.net.tcpConnectToAddress(addr); + const addr = try compat.net.Address.parseIp(peer.hostSlice(), peer.port); + const stream = try compat.net.tcpConnectToAddress(addr); defer stream.close(); // Write length-prefixed frame var len_buf: [4]u8 = undefined; std.mem.writeInt(u32, &len_buf, @intCast(payload.len), .big); - _ = try stream.write(&len_buf); - _ = try stream.write(payload); + try stream.writeAll(&len_buf); + try stream.writeAll(payload); } }; @@ -86,7 +87,7 @@ pub const PeerReceiver = struct { } pub fn run(self: *PeerReceiver) !void { - const addr = try std.net.Address.parseIp("0.0.0.0", self.port); + const addr = try compat.net.Address.parseIp("0.0.0.0", self.port); var listener = try addr.listen(.{ .reuse_address = true }); defer listener.deinit(); @@ -107,7 +108,7 @@ pub const PeerReceiver = struct { self.running.store(false, .release); } - fn handlePeerConn(self: *PeerReceiver, conn: std.net.Server.Connection) void { + fn handlePeerConn(self: *PeerReceiver, conn: compat.net.Server.Connection) void { defer conn.stream.close(); // Read length-prefixed frame diff --git a/src/replication/sequencer.zig b/src/replication/sequencer.zig index 99a4925..ab71299 100644 --- a/src/replication/sequencer.zig +++ b/src/replication/sequencer.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); pub const TxnType = enum(u8) { put, delete, read }; @@ -45,7 +46,7 @@ pub const Sequencer = struct { next_seq: u64, current_epoch: u64, batch_window_ns: u64, // batching window in nanoseconds (default 5ms) - mu: std.Thread.Mutex, + mu: compat.Mutex, const default_batch_window_ns: u64 = 5_000_000; // 5ms diff --git a/src/replication/shard.zig b/src/replication/shard.zig index 5c8ec02..67f6df0 100644 --- a/src/replication/shard.zig +++ b/src/replication/shard.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const compat = @import("compat"); pub const NodeId = u16; pub const PartitionId = u16; @@ -137,7 +138,7 @@ pub const ShardManager = struct { ring: HashRing, local_node_id: NodeId, alloc: std.mem.Allocator, - mu: std.Thread.RwLock, + mu: compat.RwLock, migrations: std.ArrayListUnmanaged(Migration), pub fn init(alloc: std.mem.Allocator, local_node_id: NodeId) ShardManager { diff --git a/src/scale_bench.zig b/src/scale_bench.zig index 2af48f4..7db0749 100644 --- a/src/scale_bench.zig +++ b/src/scale_bench.zig @@ -7,6 +7,7 @@ /// Usage: zig build scale-bench -- /path/to/openclaw/src /// const std = @import("std"); +const compat = @import("compat"); const collection_mod = @import("collection.zig"); const codeindex = @import("codeindex.zig"); const Database = collection_mod.Database; @@ -27,13 +28,13 @@ fn hasValidExt(name_: []const u8) bool { return false; } -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + // GPA replaced by smp_allocator for Zig 0.16 - const args = try std.process.argsAlloc(alloc); - defer std.process.argsFree(alloc, args); + const alloc = std.heap.smp_allocator; + + const args = try compat.argsAlloc(alloc, init); + defer compat.argsFree(alloc, args); if (args.len < 2) { std.debug.print("Usage: scale-bench \n", .{}); @@ -57,23 +58,25 @@ pub fn main() !void { files.deinit(alloc); } - var dir = try std.fs.cwd().openDir(src_dir, .{ .iterate = true }); + var dir = try compat.cwd().openDir(src_dir, .{ .iterate = true }); defer dir.close(); - var walker = try dir.walk(alloc); - defer walker.deinit(); + var walker = dir.iterate(); + // iterate() needs no deinit while (try walker.next()) |entry| { if (entry.kind != .file) continue; - if (!hasValidExt(entry.basename)) continue; + if (!hasValidExt(entry.name)) continue; - var file = dir.openFile(entry.path, .{}) catch continue; + var path_buf2: [4096]u8 = undefined; + const full_path = std.fmt.bufPrint(&path_buf2, "{s}/{s}", .{ src_dir, entry.name }) catch continue; + var file = compat.cwd().openFile(full_path, .{}) catch continue; defer file.close(); var buf: [8192]u8 = undefined; const n = file.read(&buf) catch continue; if (n < 3) continue; try files.append(alloc, .{ - .path = try alloc.dupe(u8, entry.path), + .path = try alloc.dupe(u8, entry.name), .content = try alloc.dupe(u8, buf[0..n]), }); } @@ -86,11 +89,11 @@ pub fn main() !void { std.debug.print("Phase 1: Indexing {d} files...\n", .{base_files * COPIES}); const data_dir = "/tmp/turbodb_scale_bench"; - std.fs.cwd().makeDir(data_dir) catch |e| switch (e) { + compat.cwd().makeDir(data_dir) catch |e| switch (e) { error.PathAlreadyExists => { // Clean it - std.fs.cwd().deleteTree(data_dir) catch {}; - std.fs.cwd().makeDir(data_dir) catch {}; + compat.cwd().deleteTree(data_dir) catch {}; + compat.cwd().makeDir(data_dir) catch {}; }, else => return e, }; @@ -101,7 +104,7 @@ pub fn main() !void { var indexed: u64 = 0; var total_bytes: u64 = 0; - const t_index_start = std.time.nanoTimestamp(); + const t_index_start = compat.nanoTimestamp(); var copy: u32 = 0; while (copy < COPIES) : (copy += 1) { @@ -115,7 +118,7 @@ pub fn main() !void { indexed += 1; total_bytes += f.content.len; } - const elapsed_ns = std.time.nanoTimestamp() - t_index_start; + const elapsed_ns = compat.nanoTimestamp() - t_index_start; const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / 1e9; const rate = @as(f64, @floatFromInt(indexed)) / elapsed_s; std.debug.print("\r Copy {d}/{d}: {d} files indexed ({d:.0} files/s) ", .{ @@ -123,7 +126,7 @@ pub fn main() !void { }); } - const t_index_end = std.time.nanoTimestamp(); + const t_index_end = compat.nanoTimestamp(); const index_s = @as(f64, @floatFromInt(t_index_end - t_index_start)) / 1e9; const index_rate = @as(f64, @floatFromInt(indexed)) / index_s; const total_mb = @as(f64, @floatFromInt(total_bytes)) / (1024.0 * 1024.0); @@ -171,9 +174,9 @@ pub fn main() !void { var run: u32 = 0; while (run < 3) : (run += 1) { if (last_result) |r| r.deinit(); - const t0 = std.time.nanoTimestamp(); + const t0 = compat.nanoTimestamp(); last_result = try col.searchText(q, 50, alloc); - const elapsed_ns = std.time.nanoTimestamp() - t0; + const elapsed_ns = compat.nanoTimestamp() - t0; times[run] = @as(f64, @floatFromInt(elapsed_ns)) / 1e3; } @@ -230,9 +233,9 @@ pub fn main() !void { "Promise", "setTimeout", "addEventListener", }; - const t_burst_start = std.time.nanoTimestamp(); + const t_burst_start = compat.nanoTimestamp(); var burst_count: u32 = 0; - var rng = std.Random.DefaultPrng.init(@intCast(std.time.nanoTimestamp())); + var rng = std.Random.DefaultPrng.init(@intCast(compat.nanoTimestamp())); const random = rng.random(); while (burst_count < 1000) : (burst_count += 1) { @@ -241,7 +244,7 @@ pub fn main() !void { result.deinit(); } - const t_burst_end = std.time.nanoTimestamp(); + const t_burst_end = compat.nanoTimestamp(); const burst_s = @as(f64, @floatFromInt(t_burst_end - t_burst_start)) / 1e9; const burst_qps = 1000.0 / burst_s; diff --git a/src/server.zig b/src/server.zig index 7a61a20..bb1f6d9 100644 --- a/src/server.zig +++ b/src/server.zig @@ -1,8 +1,15 @@ /// TurboDB HTTP server — MongoDB-compatible-ish JSON REST API /// Routes: /// POST /db/:col insert document +/// POST /db/:col/batch_get get multiple documents by key +/// POST /db/:col/batch_update upsert multiple documents by key +/// POST /db/:col/batch_delete delete multiple documents by key +/// POST /grpc/:col/BulkInsert gRPC-framed binary bulk insert bridge +/// POST /grpc/:col/BulkUpdate gRPC-framed binary bulk update/upsert bridge +/// POST /grpc/:col/BulkDelete gRPC-framed binary bulk delete bridge +/// POST /db/:edge_col/join read edge keys and batch-get target docs /// GET /db/:col/:key get document by key -/// PUT /db/:col/:key update document +/// PUT /db/:col/:key upsert document /// DELETE /db/:col/:key delete document /// GET /db/:col scan collection (limit, offset query params) /// DELETE /db/:col drop collection @@ -11,27 +18,52 @@ /// GET /metrics server metrics /// GET /context/:col smart context discovery (q, limit query params) const std = @import("std"); +const builtin = @import("builtin"); +const compat = @import("compat"); +const nanoapi = @import("nanoapi"); const activity = @import("activity.zig"); const auth = @import("auth.zig"); const collection = @import("collection.zig"); +const doc_mod = @import("doc.zig"); +const page_mod = @import("page.zig"); const Database = collection.Database; -const MAX_REQ = 65536; // 64 KiB (initial read) +pub const HttpRuntime = enum { + threaded, + nanoapi_raw, +}; + +const MAX_REQ = 65536; // 64 KiB (initial read) const MAX_RESP = 131072; // 128 KiB -const MAX_BODY = 65536; // 64 KiB -const MAX_BULK = 16 * 1024 * 1024; // 16 MiB for bulk inserts +const MAX_BODY = 65536; // 64 KiB +const MAX_WRITE_BATCH = 65536; // 64 KiB coalesced keep-alive responses +const MAX_BULK = 64 * 1024 * 1024; // 64 MiB for large bulk inserts +const BULK_INSERT_CHUNK_ROWS: usize = 16384; +const BULK_INSERT_CHUNK_BYTES: usize = 16 * 1024 * 1024; +const DEFAULT_BULK_MEMORY_LIMIT: usize = 256 * 1024 * 1024; +const MAX_AUTO_BULK_MEMORY_LIMIT: usize = 512 * 1024 * 1024; +const BULK_MEMORY_AVAILABLE_FRACTION: usize = 4; +const BULK_TENANT_MEMORY_FRACTION: usize = 2; +const BULK_MEMORY_BASE_OVERHEAD: usize = 1024 * 1024; // Heap-allocated per-connection buffers (threadlocal pointers set in handleConn). // This avoids large threadlocal TLS segments that break in Release mode on macOS. const ConnBufs = struct { - req: [MAX_REQ]u8, + req: [MAX_REQ]u8, resp: [MAX_RESP]u8, body: [MAX_BODY]u8, + write: [MAX_WRITE_BATCH]u8, }; + +const CONN_THREAD_STACK_SIZE = 1024 * 1024; threadlocal var tl_bufs: ?*ConnBufs = null; -fn getRespBuf() *[MAX_RESP]u8 { return &tl_bufs.?.resp; } -fn getBodyBuf() *[MAX_BODY]u8 { return &tl_bufs.?.body; } +fn getRespBuf() *[MAX_RESP]u8 { + return &tl_bufs.?.resp; +} +fn getBodyBuf() *[MAX_BODY]u8 { + return &tl_bufs.?.body; +} pub const Server = struct { pub const QueryCost = struct { @@ -45,6 +77,25 @@ pub const Server = struct { cost_nanos_usd: u64, }; + const BulkTenantUsage = struct { + bytes: usize = 0, + requests: u32 = 0, + }; + + pub const BulkMemoryReservation = struct { + srv: *Server, + tenant_id: [collection.MAX_TENANT_ID_LEN]u8, + tenant_id_len: u8, + bytes: usize, + active: bool = true, + + pub fn release(self: *BulkMemoryReservation) void { + if (!self.active) return; + self.srv.releaseBulkMemory(self.tenant_id[0..self.tenant_id_len], self.bytes); + self.active = false; + } + }; + const MAX_CONNECTIONS: u32 = 512; const BILLING_LOG_CAP: usize = 1024; @@ -63,19 +114,24 @@ pub const Server = struct { query_cost_nanos_usd: std.atomic.Value(u64), // Ring buffer for billing log — avoids O(n) orderedRemove(0). billing_ring: [BILLING_LOG_CAP]QueryCost, - billing_ring_head: usize, // next write position + billing_ring_head: usize, // next write position billing_ring_count: usize, // entries currently stored - billing_mu: std.Thread.Mutex, + billing_mu: compat.Mutex, activity: activity.ActivityTracker, // Connection limiter — prevents unbounded thread spawning under flood. active_conns: std.atomic.Value(u32), + bulk_mu: compat.Mutex, + bulk_tenant_usage: std.StringHashMap(BulkTenantUsage), + bulk_inflight_bytes: usize, + bulk_inflight_requests: u32, + bulk_rejected: std.atomic.Value(u64), pub fn init(alloc: std.mem.Allocator, db: *Database, port: u16) Server { return .{ - .db = db, - .port = port, + .db = db, + .port = port, .running = std.atomic.Value(bool).init(false), - .alloc = alloc, + .alloc = alloc, .req_count = std.atomic.Value(u64).init(0), .err_count = std.atomic.Value(u64).init(0), .query_count = std.atomic.Value(u64).init(0), @@ -89,11 +145,107 @@ pub const Server = struct { .billing_mu = .{}, .activity = activity.ActivityTracker.init(), .active_conns = std.atomic.Value(u32).init(0), + .bulk_mu = .{}, + .bulk_tenant_usage = std.StringHashMap(BulkTenantUsage).init(alloc), + .bulk_inflight_bytes = 0, + .bulk_inflight_requests = 0, + .bulk_rejected = std.atomic.Value(u64).init(0), }; } + pub fn deinit(self: *Server) void { + self.bulk_mu.lock(); + defer self.bulk_mu.unlock(); + var it = self.bulk_tenant_usage.keyIterator(); + while (it.next()) |key| self.alloc.free(key.*); + self.bulk_tenant_usage.deinit(); + } + + fn acquireBulkMemory(self: *Server, tenant_id: []const u8, estimated_bytes: usize) !BulkMemoryReservation { + const global_limit = bulkGlobalMemoryLimit(); + const tenant_limit = bulkTenantMemoryLimit(global_limit); + return self.acquireBulkMemoryWithLimits(tenant_id, estimated_bytes, global_limit, tenant_limit); + } + + fn acquireBulkMemoryWithLimits( + self: *Server, + tenant_id: []const u8, + estimated_bytes: usize, + global_limit: usize, + tenant_limit: usize, + ) !BulkMemoryReservation { + if (tenant_id.len == 0 or tenant_id.len > collection.MAX_TENANT_ID_LEN) return error.BulkMemoryLimitExceeded; + if (estimated_bytes == 0 or estimated_bytes > global_limit or estimated_bytes > tenant_limit) { + _ = self.bulk_rejected.fetchAdd(1, .monotonic); + return error.BulkMemoryLimitExceeded; + } + + self.bulk_mu.lock(); + defer self.bulk_mu.unlock(); + + const global_next = std.math.add(usize, self.bulk_inflight_bytes, estimated_bytes) catch { + _ = self.bulk_rejected.fetchAdd(1, .monotonic); + return error.BulkMemoryLimitExceeded; + }; + if (global_next > global_limit) { + _ = self.bulk_rejected.fetchAdd(1, .monotonic); + return error.BulkMemoryLimitExceeded; + } + + const current_tenant_usage = self.bulk_tenant_usage.get(tenant_id) orelse BulkTenantUsage{}; + const tenant_next = std.math.add(usize, current_tenant_usage.bytes, estimated_bytes) catch { + _ = self.bulk_rejected.fetchAdd(1, .monotonic); + return error.BulkMemoryLimitExceeded; + }; + if (tenant_next > tenant_limit) { + _ = self.bulk_rejected.fetchAdd(1, .monotonic); + return error.BulkMemoryLimitExceeded; + } + + if (self.bulk_tenant_usage.getPtr(tenant_id)) |usage| { + usage.bytes = tenant_next; + usage.requests += 1; + } else { + const owned_key = try self.alloc.dupe(u8, tenant_id); + errdefer self.alloc.free(owned_key); + try self.bulk_tenant_usage.put(owned_key, .{ + .bytes = tenant_next, + .requests = 1, + }); + } + self.bulk_inflight_bytes = global_next; + self.bulk_inflight_requests += 1; + + var tenant_copy = [_]u8{0} ** collection.MAX_TENANT_ID_LEN; + @memcpy(tenant_copy[0..tenant_id.len], tenant_id); + return .{ + .srv = self, + .tenant_id = tenant_copy, + .tenant_id_len = @intCast(tenant_id.len), + .bytes = estimated_bytes, + }; + } + + fn releaseBulkMemory(self: *Server, tenant_id: []const u8, bytes: usize) void { + self.bulk_mu.lock(); + defer self.bulk_mu.unlock(); + + self.bulk_inflight_bytes = if (self.bulk_inflight_bytes >= bytes) self.bulk_inflight_bytes - bytes else 0; + if (self.bulk_inflight_requests > 0) self.bulk_inflight_requests -= 1; + + if (self.bulk_tenant_usage.getPtr(tenant_id)) |usage| { + usage.bytes = if (usage.bytes >= bytes) usage.bytes - bytes else 0; + if (usage.requests > 0) usage.requests -= 1; + if (usage.bytes == 0 and usage.requests == 0) { + if (self.bulk_tenant_usage.fetchRemove(tenant_id)) |kv| { + self.alloc.free(kv.key); + } + } + } + } + pub fn run(self: *Server) !void { - const addr = try std.net.Address.parseIp("0.0.0.0", self.port); + const addr = try compat.net.Address.parseIp("0.0.0.0", self.port); var listener = try addr.listen(.{ .reuse_address = true, .kernel_backlog = 256, @@ -111,11 +263,14 @@ pub const Server = struct { }; // Reject if at connection limit to prevent OOM from thread exhaustion. if (self.active_conns.load(.monotonic) >= MAX_CONNECTIONS) { + // Return HTTP 503 instead of silent RST so clients can retry. + const reject = "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 31\r\nConnection: close\r\n\r\n{\"error\":\"too many connections\"}"; + conn.stream.writeAll(reject) catch {}; conn.stream.close(); _ = self.err_count.fetchAdd(1, .monotonic); continue; } - const t = std.Thread.spawn(.{}, handleConnWrapped, .{self, conn}) catch { + const t = std.Thread.spawn(.{ .stack_size = CONN_THREAD_STACK_SIZE }, handleConnWrapped, .{ self, conn }) catch { conn.stream.close(); _ = self.err_count.fetchAdd(1, .monotonic); continue; @@ -124,36 +279,75 @@ pub const Server = struct { } } + pub fn runWithRuntime(self: *Server, runtime: HttpRuntime) !void { + switch (runtime) { + .threaded => return self.run(), + .nanoapi_raw => return self.runNanoapiRaw(), + } + } + + pub fn runNanoapiRaw(self: *Server) !void { + var ctx = RawDispatchContext{ .srv = self }; + const worker_threads = compat.envUsize("TURBODB_HTTP_WORKERS", 0); + const io_entries = @as(u16, @intCast(@min(compat.envUsize("TURBODB_HTTP_IO_URING_ENTRIES", 1024), std.math.maxInt(u16)))); + var raw_srv = nanoapi.raw.Server.init(&ctx, rawDispatch, self.alloc, .{ + .host = .{ 0, 0, 0, 0 }, + .port = self.port, + .backlog = 2048, + .read_buffer_size = MAX_REQ, + .response_buffer_size = MAX_RESP, + .write_buffer_size = MAX_WRITE_BATCH, + .max_request_size = MAX_BULK + MAX_REQ, + .runtime = if (builtin.os.tag == .linux) .io_uring else .thread_per_connection, + .worker_threads = worker_threads, + .io_uring_entries = io_entries, + .max_connections = MAX_CONNECTIONS, + .running = &self.running, + }); + defer raw_srv.deinit(); + + std.log.info("TurboDB HTTP nanoapi.raw runtime listening on :{d}", .{self.port}); + try raw_srv.listenAndServe(); + } + pub fn runUnix(self: *Server, path: []const u8) !void { // Remove any existing socket file - std.posix.unlink(path) catch {}; - const fd = try std.posix.socket(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0); - defer std.posix.close(fd); + // Remove existing socket + { + var zbuf: [256]u8 = undefined; + @memcpy(zbuf[0..path.len], path); + zbuf[path.len] = 0; + _ = std.c.unlink(@ptrCast(&zbuf)); + } + const fd = std.c.socket(1, 1, 0); // AF_UNIX=1, SOCK_STREAM=1 + if (fd < 0) return error.SocketError; + defer _ = std.c.close(fd); - // Construct sockaddr_un - var addr: std.posix.sockaddr.un = .{ .family = std.posix.AF.UNIX, .path = undefined }; + // Construct sockaddr_un (family u16 + path) + var addr: extern struct { family: u16, path: [104]u8 } = .{ .family = 1, .path = undefined }; @memset(&addr.path, 0); if (path.len >= addr.path.len) return error.PathTooLong; @memcpy(addr.path[0..path.len], path); - try std.posix.bind(fd, @ptrCast(&addr), @sizeOf(std.posix.sockaddr.un)); - try std.posix.listen(fd, 256); + if (std.c.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return error.BindError; + if (std.c.listen(fd, 256) != 0) return error.ListenError; self.running.store(true, .release); std.log.info("TurboDB HTTP on unix:{s}", .{path}); while (self.running.load(.acquire)) { - var client_addr: std.posix.sockaddr.un = undefined; - var addr_len: std.posix.socklen_t = @sizeOf(std.posix.sockaddr.un); - const client_fd = std.posix.accept(fd, @ptrCast(&client_addr), &addr_len, 0) catch continue; - const stream = std.net.Stream{ .handle = client_fd }; - const conn = std.net.Server.Connection{ .stream = stream, .address = std.net.Address.initUnix(path) catch continue }; + const client_fd = std.c.accept(fd, null, null); + if (client_fd < 0) continue; + const stream = compat.net.Stream{ .handle = client_fd }; + const conn = compat.net.Server.Connection{ .stream = stream, .address = compat.net.Address.initUnix(path) catch continue }; if (self.active_conns.load(.monotonic) >= MAX_CONNECTIONS) { + const reject = "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 31\r\nConnection: close\r\n\r\n{\"error\":\"too many connections\"}"; + conn.stream.writeAll(reject) catch {}; conn.stream.close(); _ = self.err_count.fetchAdd(1, .monotonic); continue; } - const t = std.Thread.spawn(.{}, handleConnWrapped, .{ self, conn }) catch { + const t = std.Thread.spawn(.{ .stack_size = CONN_THREAD_STACK_SIZE }, handleConnWrapped, .{ self, conn }) catch { conn.stream.close(); continue; }; @@ -166,7 +360,7 @@ pub const Server = struct { } fn recordQueryCost(self: *Server, tenant_id: []const u8, op: []const u8, rows_scanned: usize, bytes_read: usize, start_ns: i128) void { - const elapsed_ns = std.time.nanoTimestamp() - start_ns; + const elapsed_ns = compat.nanoTimestamp() - start_ns; const cpu_us: u64 = @intCast(@max(@divTrunc(elapsed_ns, 1000), 0)); const cost_nanos_usd: u64 = cpu_us * 5 + @as(u64, @intCast(rows_scanned)) + @as(u64, @intCast(bytes_read / 1024)); @@ -201,14 +395,268 @@ pub const Server = struct { } }; +threadlocal var tl_bulk_reservation: ?Server.BulkMemoryReservation = null; +threadlocal var tl_raw_dispatch_bufs: ?*ConnBufs = null; + +const RawDispatchContext = struct { + srv: *Server, +}; + +const ParsedRequestTarget = struct { + method: []const u8, + path: []const u8, + query: []const u8, +}; + +const GrpcTarget = struct { + col_name: []const u8, + rpc: []const u8, +}; + +const BulkPreflight = union(enum) { + not_bulk, + reserved: Server.BulkMemoryReservation, + reject: struct { + code: u16, + message: []const u8, + }, +}; + +const RequestInput = struct { + buf: []u8, + head: usize = 0, + len: usize = 0, + + fn bytes(self: *const RequestInput) []const u8 { + return self.buf[self.head .. self.head + self.len]; + } + + fn readAppend(self: *RequestInput, stream: compat.net.Stream) !bool { + if (self.head + self.len == self.buf.len) { + if (self.head == 0) return error.RequestTooLarge; + if (self.len > 0) { + std.mem.copyForwards(u8, self.buf[0..self.len], self.buf[self.head .. self.head + self.len]); + } + self.head = 0; + } + + const tail_start = self.head + self.len; + const n = try stream.read(self.buf[tail_start..]); + if (n == 0) return false; + self.len += n; + return true; + } + + fn consume(self: *RequestInput, amount: usize) void { + if (amount >= self.len) { + self.head = 0; + self.len = 0; + return; + } + self.head += amount; + self.len -= amount; + } +}; + +const HttpFrame = struct { + header_end: usize, + total_len: usize, + content_length: usize, +}; + +fn inspectHttpFrame(raw: []const u8) !HttpFrame { + const header_end = if (std.mem.indexOf(u8, raw, "\r\n\r\n")) |p| + p + 4 + else if (std.mem.indexOf(u8, raw, "\n\n")) |p| + p + 2 + else + return error.IncompleteRequestHead; + + const content_length = extractContentLength(raw[0..header_end]); + const total_len = std.math.add(usize, header_end, content_length) catch return error.RequestTooLarge; + return .{ + .header_end = header_end, + .total_len = total_len, + .content_length = content_length, + }; +} + +fn bulkGlobalMemoryLimit() usize { + const explicit = compat.envUsize("TURBODB_BULK_MEMORY_LIMIT_BYTES", 0); + if (explicit > 0) return explicit; + if (compat.availableMemoryBytes()) |available| { + return @min(available / BULK_MEMORY_AVAILABLE_FRACTION, MAX_AUTO_BULK_MEMORY_LIMIT); + } + return DEFAULT_BULK_MEMORY_LIMIT; +} + +fn bulkTenantMemoryLimit(global_limit: usize) usize { + const explicit = compat.envUsize("TURBODB_BULK_TENANT_MEMORY_LIMIT_BYTES", 0); + if (explicit > 0) return @min(explicit, global_limit); + return global_limit / BULK_TENANT_MEMORY_FRACTION; +} + +fn estimateBulkMemoryBytes(body_len: usize) usize { + const chunk_bytes = @min(body_len, BULK_INSERT_CHUNK_BYTES); + const row_meta = @min(body_len / 4 + 64 * 1024, 2 * 1024 * 1024); + var total = std.math.add(usize, body_len, BULK_MEMORY_BASE_OVERHEAD) catch return std.math.maxInt(usize); + total = std.math.add(usize, total, row_meta) catch return std.math.maxInt(usize); + total = std.math.add(usize, total, chunk_bytes) catch return std.math.maxInt(usize); + total = std.math.add(usize, total, chunk_bytes) catch return std.math.maxInt(usize); + return total; +} + +fn parseRequestTarget(raw: []const u8) ?ParsedRequestTarget { + const nl = std.mem.indexOfScalar(u8, raw, '\n') orelse return null; + const req_line = std.mem.trimEnd(u8, raw[0..nl], "\r"); + var parts = std.mem.splitScalar(u8, req_line, ' '); + const method = parts.next() orelse return null; + const full_path = parts.next() orelse return null; + + var path = full_path; + var query: []const u8 = ""; + if (std.mem.indexOfScalar(u8, full_path, '?')) |qi| { + path = full_path[0..qi]; + query = full_path[qi + 1 ..]; + } + + return .{ .method = method, .path = path, .query = query }; +} + +fn isBulkInsertTarget(method: []const u8, path: []const u8) bool { + if (!std.mem.eql(u8, method, "POST")) return false; + if (!std.mem.startsWith(u8, path, "/db/")) return false; + const rest = path[4..]; + const sep = std.mem.indexOfScalar(u8, rest, '/') orelse return false; + const key = rest[sep + 1 ..]; + return std.mem.eql(u8, key, "bulk") or std.mem.eql(u8, key, "bulk_binary"); +} + +fn parseGrpcTarget(path: []const u8) ?GrpcTarget { + if (!std.mem.startsWith(u8, path, "/grpc/")) return null; + const rest = path["/grpc/".len..]; + const sep = std.mem.indexOfScalar(u8, rest, '/') orelse return null; + if (sep == 0 or sep + 1 >= rest.len) return null; + return .{ + .col_name = rest[0..sep], + .rpc = rest[sep + 1 ..], + }; +} + +fn isGrpcBulkMutation(method: []const u8, path: []const u8) bool { + if (!std.mem.eql(u8, method, "POST")) return false; + const target = parseGrpcTarget(path) orelse return false; + return std.mem.eql(u8, target.rpc, "BulkInsert") or + std.mem.eql(u8, target.rpc, "BulkUpdate") or + std.mem.eql(u8, target.rpc, "BulkUpsert") or + std.mem.eql(u8, target.rpc, "BulkDelete"); +} + +fn isBulkMemoryTarget(method: []const u8, path: []const u8) bool { + return isBulkInsertTarget(method, path) or isGrpcBulkMutation(method, path); +} + +fn preflightBulkAdmission(srv: *Server, initial: []const u8, body_len: usize) BulkPreflight { + const target = parseRequestTarget(initial) orelse return .not_bulk; + if (!isBulkMemoryTarget(target.method, target.path)) return .not_bulk; + + var auth_ctx = auth.AuthContext{ + .perm = .admin, + .tenant_id = [_]u8{0} ** 64, + .tenant_id_len = 0, + }; + var auth_ctx_bound = false; + if (srv.db.auth.isEnabled()) { + const api_key = auth.AuthStore.extractHttpKey(initial) orelse + return .{ .reject = .{ .code = 401, .message = "unauthorized — missing X-Api-Key header" } }; + auth_ctx = srv.db.auth.resolve(api_key) orelse + return .{ .reject = .{ .code = 401, .message = "unauthorized — invalid API key" } }; + auth_ctx_bound = true; + } + + const auth_ctx_ptr: ?*const auth.AuthContext = if (auth_ctx_bound) &auth_ctx else null; + if (!canWriteDb(auth_ctx_ptr)) { + return .{ .reject = .{ .code = 403, .message = "forbidden: API key is read-only" } }; + } + + const tenant_id = requestTenant(initial, target.query, auth_ctx_ptr); + const estimated_bytes = estimateBulkMemoryBytes(body_len); + const reservation = srv.acquireBulkMemory(tenant_id, estimated_bytes) catch { + return .{ .reject = .{ .code = 429, .message = "bulk memory limit exceeded" } }; + }; + return .{ .reserved = reservation }; +} + +fn releaseThreadBulkReservation() void { + if (tl_bulk_reservation) |*reservation| reservation.release(); + tl_bulk_reservation = null; +} + /// Wrapper that tracks active connection count around handleConn. -fn handleConnWrapped(srv: *Server, conn: std.net.Server.Connection) void { +fn handleConnWrapped(srv: *Server, conn: compat.net.Server.Connection) void { _ = srv.active_conns.fetchAdd(1, .monotonic); defer _ = srv.active_conns.fetchSub(1, .monotonic); handleConn(srv, conn); } -fn handleConn(srv: *Server, conn: std.net.Server.Connection) void { +fn getRawDispatchBufs() ?*ConnBufs { + if (tl_raw_dispatch_bufs) |bufs| return bufs; + const bufs = std.heap.page_allocator.create(ConnBufs) catch return null; + tl_raw_dispatch_bufs = bufs; + return bufs; +} + +fn rawDispatch(ctx_opaque: *anyopaque, raw: []const u8, response: []u8) usize { + const ctx: *RawDispatchContext = @ptrCast(@alignCast(ctx_opaque)); + const srv = ctx.srv; + const bufs = getRawDispatchBufs() orelse + return rawResponseError(response, 500, "Internal Server Error", "request buffers unavailable"); + + tl_bufs = bufs; + defer tl_bufs = null; + defer releaseThreadBulkReservation(); + + const frame = inspectHttpFrame(raw) catch + return rawResponseError(response, 400, "Bad Request", "bad request"); + const header_bytes = raw[0..frame.header_end]; + + if (headerContains(header_bytes, "upgrade", "websocket")) { + return rawResponseError(response, 426, "Upgrade Required", "websocket requires threaded runtime"); + } + + switch (preflightBulkAdmission(srv, header_bytes, frame.content_length)) { + .not_bulk => {}, + .reserved => |reservation| tl_bulk_reservation = reservation, + .reject => |rejection| { + const resp_len = err(rejection.code, rejection.message); + return copyRawResponse(response, bufs.resp[0..resp_len]); + }, + } + + _ = srv.req_count.fetchAdd(1, .monotonic); + const resp_len = dispatch(srv, raw, std.heap.page_allocator); + return copyRawResponse(response, bufs.resp[0..resp_len]); +} + +fn copyRawResponse(dst: []u8, src: []const u8) usize { + if (src.len > dst.len) { + return rawResponseError(dst, 500, "Internal Server Error", "response too large"); + } + @memcpy(dst[0..src.len], src); + return src.len; +} + +fn rawResponseError(out: []u8, code: u16, status: []const u8, msg: []const u8) usize { + const body_len = msg.len + "{\"error\":\"\"}".len; + const bytes = std.fmt.bufPrint( + out, + "HTTP/1.1 {d} {s}\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n{{\"error\":\"{s}\"}}", + .{ code, status, body_len, msg }, + ) catch return 0; + return bytes.len; +} + +fn handleConn(srv: *Server, conn: compat.net.Server.Connection) void { defer conn.stream.close(); const bufs = std.heap.page_allocator.create(ConnBufs) catch { @@ -218,53 +666,137 @@ fn handleConn(srv: *Server, conn: std.net.Server.Connection) void { tl_bufs = bufs; defer tl_bufs = null; + var input = RequestInput{ .buf = bufs.req[0..] }; + var write_len: usize = 0; + while (true) { - var n = conn.stream.read(&bufs.req) catch return; - if (n == 0) return; - _ = srv.req_count.fetchAdd(1, .monotonic); - - // For bulk inserts: read the full body based on Content-Length. - // The initial read may only contain part of a large body. - const initial = bufs.req[0..n]; - const content_length = extractContentLength(initial); - if (content_length > MAX_REQ and content_length <= MAX_BULK) { - // Check this is actually a bulk request before allocating - const is_bulk = std.mem.indexOf(u8, initial[0..@min(n, 256)], "/bulk") != null; - if (is_bulk) { - // Find where headers end - const header_end = if (std.mem.indexOf(u8, initial, "\r\n\r\n")) |p| p + 4 - else if (std.mem.indexOf(u8, initial, "\n\n")) |p| p + 2 - else n; - const total_size = header_end + content_length; - if (total_size <= MAX_BULK) { - const big_buf = std.heap.page_allocator.alloc(u8, total_size) catch { - const resp_len = dispatch(srv, initial, std.heap.page_allocator); + if (input.len == 0) { + flushResponseBatch(conn, bufs, &write_len) catch return; + if ((input.readAppend(conn.stream) catch return) == false) return; + } + + while (input.len > 0) { + const current = input.bytes(); + const frame = inspectHttpFrame(current) catch |e| switch (e) { + error.IncompleteRequestHead => { + flushResponseBatch(conn, bufs, &write_len) catch return; + if ((input.readAppend(conn.stream) catch return) == false) return; + continue; + }, + error.RequestTooLarge => { + flushResponseBatch(conn, bufs, &write_len) catch return; + const resp_len = err(413, "request too large"); + conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + return; + }, + }; + + if (frame.content_length > MAX_BULK) { + flushResponseBatch(conn, bufs, &write_len) catch return; + const resp_len = err(413, "request too large"); + conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + return; + } + + const header_bytes = current[0..frame.header_end]; + // WebSocket upgrade: switch to persistent framed mode. + if (headerContains(header_bytes, "upgrade", "websocket")) { + flushResponseBatch(conn, bufs, &write_len) catch return; + handleWebSocket(srv, conn, current[0..@min(current.len, frame.total_len)]) catch {}; + return; // WS handler owns the connection until close + } + + if (frame.total_len > input.buf.len) { + const local_preflight = preflightBulkAdmission(srv, header_bytes, frame.content_length); + switch (local_preflight) { + .not_bulk => {}, + .reserved => |reservation| tl_bulk_reservation = reservation, + .reject => |rejection| { + flushResponseBatch(conn, bufs, &write_len) catch return; + const resp_len = err(rejection.code, rejection.message); conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; - continue; + return; + }, + } + + var n = current.len; + const big_buf = std.heap.page_allocator.alloc(u8, frame.total_len) catch { + releaseThreadBulkReservation(); + flushResponseBatch(conn, bufs, &write_len) catch return; + const resp_len = err(500, "request allocation failed"); + conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + return; + }; + defer std.heap.page_allocator.free(big_buf); + @memcpy(big_buf[0..n], current); + while (n < frame.total_len) { + const r = conn.stream.read(big_buf[n..frame.total_len]) catch { + releaseThreadBulkReservation(); + return; }; - defer std.heap.page_allocator.free(big_buf); - @memcpy(big_buf[0..n], initial); - // Read remaining bytes - while (n < total_size) { - const r = conn.stream.read(big_buf[n..total_size]) catch break; - if (r == 0) break; - n += r; + if (r == 0) { + releaseThreadBulkReservation(); + return; } - const resp_len = dispatch(srv, big_buf[0..n], std.heap.page_allocator); - conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; - continue; + n += r; } + _ = srv.req_count.fetchAdd(1, .monotonic); + const resp_len = dispatch(srv, big_buf[0..frame.total_len], std.heap.page_allocator); + releaseThreadBulkReservation(); + queueResponse(conn, bufs, &write_len, bufs.resp[0..resp_len]) catch return; + input.consume(input.len); + continue; + } + + if (current.len < frame.total_len) { + flushResponseBatch(conn, bufs, &write_len) catch return; + if ((input.readAppend(conn.stream) catch return) == false) return; + continue; } + + const raw = current[0..frame.total_len]; + switch (preflightBulkAdmission(srv, header_bytes, frame.content_length)) { + .not_bulk => {}, + .reserved => |reservation| tl_bulk_reservation = reservation, + .reject => |rejection| { + flushResponseBatch(conn, bufs, &write_len) catch return; + const resp_len = err(rejection.code, rejection.message); + conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; + return; + }, + } + + _ = srv.req_count.fetchAdd(1, .monotonic); + const resp_len = dispatch(srv, raw, std.heap.page_allocator); + releaseThreadBulkReservation(); + queueResponse(conn, bufs, &write_len, bufs.resp[0..resp_len]) catch return; + input.consume(frame.total_len); } + } +} - const resp_len = dispatch(srv, initial, std.heap.page_allocator); - conn.stream.writeAll(bufs.resp[0..resp_len]) catch return; +fn flushResponseBatch(conn: compat.net.Server.Connection, bufs: *ConnBufs, write_len: *usize) !void { + if (write_len.* == 0) return; + const bytes = bufs.write[0..write_len.*]; + write_len.* = 0; + try conn.stream.writeAll(bytes); +} + +fn queueResponse(conn: compat.net.Server.Connection, bufs: *ConnBufs, write_len: *usize, response: []const u8) !void { + if (response.len > bufs.write.len) { + try flushResponseBatch(conn, bufs, write_len); + return conn.stream.writeAll(response); + } + if (write_len.* + response.len > bufs.write.len) { + try flushResponseBatch(conn, bufs, write_len); } + @memcpy(bufs.write[write_len.*..][0..response.len], response); + write_len.* += response.len; } fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { // Parse request line. const nl = std.mem.indexOfScalar(u8, raw, '\n') orelse return err(400, "bad request"); - const req_line = std.mem.trimRight(u8, raw[0..nl], "\r"); + const req_line = std.mem.trimEnd(u8, raw[0..nl], "\r"); var parts = std.mem.splitScalar(u8, req_line, ' '); const method = parts.next() orelse return err(400, "bad request"); const full_path = parts.next() orelse return err(400, "bad request"); @@ -278,9 +810,7 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { } // Body. - const body = if (std.mem.indexOf(u8, raw, "\r\n\r\n")) |p| raw[p + 4 ..] - else if (std.mem.indexOf(u8, raw, "\n\n")) |p| raw[p + 2 ..] - else @as([]const u8, ""); + const body = if (std.mem.indexOf(u8, raw, "\r\n\r\n")) |p| raw[p + 4 ..] else if (std.mem.indexOf(u8, raw, "\n\n")) |p| raw[p + 2 ..] else @as([]const u8, ""); // Route: /health if (std.mem.eql(u8, path, "/health")) { @@ -291,28 +821,45 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { // Route: /metrics if (std.mem.eql(u8, path, "/metrics")) { - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), - "{{\"requests\":{d},\"errors\":{d},\"queries\":{d},\"rows_scanned\":{d},\"bytes_read\":{d},\"cpu_us\":{d},\"cost_nanos_usd\":{d}}}", - .{ - srv.req_count.load(.acquire), - srv.err_count.load(.acquire), - srv.query_count.load(.acquire), - srv.query_rows_scanned.load(.acquire), - srv.query_bytes_read.load(.acquire), - srv.query_cpu_us.load(.acquire), - srv.query_cost_nanos_usd.load(.acquire), - }) catch {}; + var fbs = compat.fixedBufferStream(getBodyBuf()); + srv.bulk_mu.lock(); + const bulk_inflight_bytes = srv.bulk_inflight_bytes; + const bulk_inflight_requests = srv.bulk_inflight_requests; + srv.bulk_mu.unlock(); + const bulk_global_limit = bulkGlobalMemoryLimit(); + compat.format(fbs.writer(), "{{\"requests\":{d},\"errors\":{d},\"queries\":{d},\"rows_scanned\":{d},\"bytes_read\":{d},\"cpu_us\":{d},\"cost_nanos_usd\":{d},\"bulk_inflight_bytes\":{d},\"bulk_inflight_requests\":{d},\"bulk_rejected\":{d},\"bulk_memory_limit_bytes\":{d},\"bulk_tenant_memory_limit_bytes\":{d}}}", .{ + srv.req_count.load(.acquire), + srv.err_count.load(.acquire), + srv.query_count.load(.acquire), + srv.query_rows_scanned.load(.acquire), + srv.query_bytes_read.load(.acquire), + srv.query_cpu_us.load(.acquire), + srv.query_cost_nanos_usd.load(.acquire), + bulk_inflight_bytes, + bulk_inflight_requests, + srv.bulk_rejected.load(.acquire), + bulk_global_limit, + bulkTenantMemoryLimit(bulk_global_limit), + }) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } + var auth_ctx = auth.AuthContext{ + .perm = .admin, + .tenant_id = [_]u8{0} ** 64, + .tenant_id_len = 0, + }; + var auth_ctx_bound = false; + // ── Auth gate — public endpoints above, protected endpoints below ──── if (srv.db.auth.isEnabled()) { const api_key = auth.AuthStore.extractHttpKey(raw) orelse return err(401, "unauthorized — missing X-Api-Key header"); - if (srv.db.auth.verify(api_key) == null) + auth_ctx = srv.db.auth.resolve(api_key) orelse return err(401, "unauthorized — invalid API key"); + auth_ctx_bound = true; } + const auth_ctx_ptr: ?*const auth.AuthContext = if (auth_ctx_bound) &auth_ctx else null; if (std.mem.eql(u8, path, "/billing") and std.mem.eql(u8, method, "GET")) return handleBillingLog(srv); @@ -321,14 +868,14 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { return handleResourceState(srv); if (std.mem.eql(u8, path, "/cdc/webhooks") and std.mem.eql(u8, method, "POST")) - return handleWebhookRegistration(srv, body); + return handleWebhookRegistration(srv, body, auth_ctx_ptr); if (std.mem.eql(u8, path, "/cdc/events") and std.mem.eql(u8, method, "GET")) - return handleCdcEvents(srv, qparam(query, "tenant"), alloc); + return handleCdcEvents(srv, requestTenantFilter(query, auth_ctx_ptr), alloc); // Route: /collections if (std.mem.eql(u8, path, "/collections") and std.mem.eql(u8, method, "GET")) - return handleListCollections(srv, requestTenant(raw, query), alloc); + return handleListCollections(srv, requestTenant(raw, query, auth_ctx_ptr), alloc); // Route: /search/:col?q=... if (std.mem.startsWith(u8, path, "/search/") and std.mem.eql(u8, method, "GET")) { @@ -336,8 +883,8 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { const q_raw = qparam(query, "q") orelse return err(400, "missing q parameter"); var decode_buf: [4096]u8 = undefined; const q = urlDecode(q_raw, &decode_buf) orelse q_raw; - const limit_val: u32 = qparamInt(query, "limit") orelse 50; - return handleSearch(srv, requestTenant(raw, query), col_name, q, limit_val, alloc); + const limit_val: u32 = @min(qparamInt(query, "limit") orelse 50, 500); + return handleSearch(srv, requestTenant(raw, query, auth_ctx_ptr), col_name, q, limit_val, alloc); } // Route: /context/:col?q=...&limit=20 @@ -346,14 +893,14 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { const q_raw = qparam(query, "q") orelse return err(400, "missing q parameter"); var decode_buf: [4096]u8 = undefined; const q = urlDecode(q_raw, &decode_buf) orelse q_raw; - const limit_val: u32 = qparamInt(query, "limit") orelse 20; - return handleDiscoverContext(srv, requestTenant(raw, query), col_name, q, limit_val, alloc); + const limit_val: u32 = @min(qparamInt(query, "limit") orelse 20, 100); + return handleDiscoverContext(srv, requestTenant(raw, query, auth_ctx_ptr), col_name, q, limit_val, alloc); } // Routes under /branch/:col[/:branch[/:key]] if (std.mem.startsWith(u8, path, "/branch/")) { const rest = path[8..]; // after "/branch/" - const tenant_id = requestTenant(raw, query); + const tenant_id = requestTenant(raw, query, auth_ctx_ptr); // Parse: rest = col_name[/branch_name[/key...]] if (std.mem.indexOfScalar(u8, rest, '/')) |sep1| { @@ -402,6 +949,24 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { } } + // Routes under /grpc/:col/:rpc. + // This is a bridge for gRPC-style clients on the current HTTP/1 transport: + // if Content-Type is application/grpc, the body must be a single uncompressed + // gRPC frame; otherwise the same raw binary payload is accepted directly. + if (parseGrpcTarget(path)) |target| { + if (!std.mem.eql(u8, method, "POST")) return err(404, "not found"); + if (!canWriteDb(auth_ctx_ptr)) return err(403, "forbidden: API key is read-only"); + const payload = grpcPayload(raw, body) orelse return err(400, "bad grpc frame"); + const tenant_id = requestTenant(raw, query, auth_ctx_ptr); + if (std.mem.eql(u8, target.rpc, "BulkInsert")) + return handleBulkInsertBinaryWithMode(srv, tenant_id, target.col_name, payload, alloc, "grpc_bulk_insert", "grpc_bridge_binary"); + if (std.mem.eql(u8, target.rpc, "BulkUpdate") or std.mem.eql(u8, target.rpc, "BulkUpsert")) + return handleBulkUpdateBinary(srv, tenant_id, target.col_name, payload, "grpc_bulk_update", "grpc_bridge_binary"); + if (std.mem.eql(u8, target.rpc, "BulkDelete")) + return handleBulkDeleteBinary(srv, tenant_id, target.col_name, payload, "grpc_bulk_delete", "grpc_bridge_binary"); + return err(404, "not found"); + } + // Routes under /db/:col if (std.mem.startsWith(u8, path, "/db/")) { const rest = path[4..]; @@ -409,18 +974,37 @@ fn dispatch(srv: *Server, raw: []const u8, alloc: std.mem.Allocator) usize { if (std.mem.indexOfScalar(u8, rest, '/')) |sep| { const col_name = rest[0..sep]; const key = rest[sep + 1 ..]; - const tenant_id = requestTenant(raw, query); + const tenant_id = requestTenant(raw, query, auth_ctx_ptr); + // POST /db/:col/batch_get — read-only bulk point lookup. + if (std.mem.eql(u8, key, "batch_get") and std.mem.eql(u8, method, "POST")) + return handleBatchGet(srv, tenant_id, col_name, body, requestAsOf(raw, query), batchDocsCompact(query)); + // POST /db/:edge_col/join — read-only edge doc lookup + target batch lookup. + if (std.mem.eql(u8, key, "join") and std.mem.eql(u8, method, "POST")) + return handleJoin(srv, tenant_id, col_name, body, batchDocsCompact(query)); + if (isBasicDbWriteMethod(method) and !canWriteDb(auth_ctx_ptr)) + return err(403, "forbidden: API key is read-only"); + // POST /db/:col/batch_update — write-gated bulk upsert. + if (std.mem.eql(u8, key, "batch_update") and std.mem.eql(u8, method, "POST")) + return handleBatchUpdate(srv, tenant_id, col_name, body, alloc); + // POST /db/:col/batch_delete — write-gated bulk delete. + if (std.mem.eql(u8, key, "batch_delete") and std.mem.eql(u8, method, "POST")) + return handleBatchDelete(srv, tenant_id, col_name, body); // POST /db/:col/bulk — bulk insert if (std.mem.eql(u8, key, "bulk") and std.mem.eql(u8, method, "POST")) return handleBulkInsert(srv, tenant_id, col_name, body, alloc); - if (std.mem.eql(u8, method, "GET")) return handleGet(srv, tenant_id, col_name, key, requestAsOf(raw, query)); - if (std.mem.eql(u8, method, "PUT")) return handleUpdate(srv, tenant_id, col_name, key, body, alloc); + // POST /db/:col/bulk_binary — length-prefixed bulk insert. + if (std.mem.eql(u8, key, "bulk_binary") and std.mem.eql(u8, method, "POST")) + return handleBulkInsertBinaryWithMode(srv, tenant_id, col_name, body, alloc, "bulk_insert_bin", "binary"); + if (std.mem.eql(u8, method, "GET")) return handleGet(srv, tenant_id, col_name, key, requestAsOf(raw, query)); + if (std.mem.eql(u8, method, "PUT")) return handleUpdate(srv, tenant_id, col_name, key, body, alloc); if (std.mem.eql(u8, method, "DELETE")) return handleDelete(srv, tenant_id, col_name, key); } else { const col_name = rest; - const tenant_id = requestTenant(raw, query); - if (std.mem.eql(u8, method, "POST")) return handleInsert(srv, tenant_id, col_name, body, alloc); - if (std.mem.eql(u8, method, "GET")) return handleScan(srv, tenant_id, col_name, query, requestAsOf(raw, query), alloc); + const tenant_id = requestTenant(raw, query, auth_ctx_ptr); + if (isBasicDbWriteMethod(method) and !canWriteDb(auth_ctx_ptr)) + return err(403, "forbidden: API key is read-only"); + if (std.mem.eql(u8, method, "POST")) return handleInsert(srv, tenant_id, col_name, body, alloc); + if (std.mem.eql(u8, method, "GET")) return handleScan(srv, tenant_id, col_name, query, requestAsOf(raw, query), alloc); if (std.mem.eql(u8, method, "DELETE")) return handleDrop(srv, tenant_id, col_name); } } @@ -439,7 +1023,7 @@ fn handleInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: const key_raw = jsonStr(body, "key") orelse { // Auto-generate key from timestamp + counter. var kb: [32]u8 = undefined; - const k = std.fmt.bufPrint(&kb, "doc_{d}", .{std.time.milliTimestamp()}) catch + const k = std.fmt.bufPrint(&kb, "doc_{d}", .{compat.milliTimestamp()}) catch return err(400, "bad key"); return doInsert(srv, tenant_id, col_name, k, value); }; @@ -447,16 +1031,15 @@ fn handleInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: } fn doInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []const u8, value: []const u8) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); + if (!documentFitsLeaf(key, value)) return err(413, "document too large"); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); srv.db.ensureTenantStorageAvailable(tenant_id, value.len) catch return err(429, "tenant storage quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); const doc_id = col.insert(key, value) catch return err(500, "insert failed"); srv.recordQueryCost(tenant_id, "insert", 1, value.len, start_ns); - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), - "{{\"doc_id\":{d},\"key\":\"{s}\",\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", - .{ doc_id, key, col_name, tenant_id }) catch {}; + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"doc_id\":{d},\"key\":\"{s}\",\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", .{ doc_id, key, col_name, tenant_id }) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } @@ -464,14 +1047,45 @@ fn doInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []co /// Body: NDJSON — one {"key":"...","value":"..."} per line. /// Response: {"inserted":N,"errors":M,"collection":"...","tenant":"..."} fn handleBulkInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, alloc: std.mem.Allocator) usize { - _ = alloc; - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); + var local_bulk_reservation: ?Server.BulkMemoryReservation = null; + if (tl_bulk_reservation == null) { + local_bulk_reservation = srv.acquireBulkMemory(tenant_id, estimateBulkMemoryBytes(body.len)) catch + return err(429, "bulk memory limit exceeded"); + } + defer if (local_bulk_reservation) |*reservation| reservation.release(); + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + srv.db.ensureTenantStorageAvailable(tenant_id, body.len) catch return err(429, "tenant storage quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); var inserted: u32 = 0; var errors: u32 = 0; var total_bytes: u64 = 0; + var chunk_bytes: usize = 0; + var rows: std.ArrayList(collection.Collection.BulkInsertRow) = .empty; + defer rows.deinit(alloc); + rows.ensureTotalCapacity(alloc, @min(BULK_INSERT_CHUNK_ROWS, @max(@as(usize, 1), body.len / 96))) catch + return err(500, "bulk request too large"); + + const BulkFlush = struct { + fn run( + col_arg: *collection.Collection, + rows_arg: *std.ArrayList(collection.Collection.BulkInsertRow), + chunk_bytes_arg: *usize, + inserted_arg: *u32, + errors_arg: *u32, + bytes_arg: *u64, + ) !void { + if (rows_arg.items.len == 0) return; + const bulk = try col_arg.insertBulk(rows_arg.items); + inserted_arg.* += bulk.inserted; + errors_arg.* += bulk.errors; + bytes_arg.* += bulk.bytes; + rows_arg.clearRetainingCapacity(); + chunk_bytes_arg.* = 0; + } + }; // Parse NDJSON: iterate lines, each is a {"key":"...","value":...} object var pos: usize = 0; @@ -483,85 +1097,467 @@ fn handleBulkInsert(srv: *Server, tenant_id: []const u8, col_name: []const u8, b if (line.len < 2) continue; // skip empty lines - // Extract key from this JSON line - const key = jsonStr(line, "key") orelse continue; - // Extract value field; fall back to full line for backwards compat. - const value = jsonValue(line, "value") orelse line; + const parsed = parseBulkLine(line) orelse { + errors += 1; + continue; + }; + const key = parsed.key; + const value = parsed.value; + const row_bytes = key.len + value.len + 128; + if (rows.items.len > 0 and chunk_bytes + row_bytes > BULK_INSERT_CHUNK_BYTES) { + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors, &total_bytes) catch + return err(500, "bulk insert failed"); + } + rows.append(alloc, .{ .key = key, .value = value, .line_len = line.len }) catch + return err(500, "bulk request too large"); + chunk_bytes += row_bytes; + + if (rows.items.len >= BULK_INSERT_CHUNK_ROWS) { + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors, &total_bytes) catch + return err(500, "bulk insert failed"); + } + } + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors, &total_bytes) catch + return err(500, "bulk insert failed"); - _ = col.insert(key, value) catch { + srv.recordQueryCost(tenant_id, "bulk_insert", inserted, total_bytes, start_ns); + + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"inserted\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", .{ inserted, errors, col_name, tenant_id }) catch {}; + return ok(getBodyBuf()[0..fbs.pos]); +} + +/// POST /db/:col/bulk_binary — insert multiple documents in one request. +/// Body: repeated little-endian records: +/// [key_len:u16][value_len:u32][key bytes][raw value bytes] +fn handleBulkInsertBinaryWithMode( + srv: *Server, + tenant_id: []const u8, + col_name: []const u8, + body: []const u8, + alloc: std.mem.Allocator, + op_name: []const u8, + mode: []const u8, +) usize { + const start_ns = compat.nanoTimestamp(); + var local_bulk_reservation: ?Server.BulkMemoryReservation = null; + if (tl_bulk_reservation == null) { + local_bulk_reservation = srv.acquireBulkMemory(tenant_id, estimateBulkMemoryBytes(body.len)) catch + return err(429, "bulk memory limit exceeded"); + } + defer if (local_bulk_reservation) |*reservation| reservation.release(); + + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + srv.db.ensureTenantStorageAvailable(tenant_id, body.len) catch return err(429, "tenant storage quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + + var inserted: u32 = 0; + var errors: u32 = 0; + var total_bytes: u64 = 0; + var chunk_bytes: usize = 0; + var rows: std.ArrayList(collection.Collection.BulkInsertRow) = .empty; + defer rows.deinit(alloc); + rows.ensureTotalCapacity(alloc, @min(BULK_INSERT_CHUNK_ROWS, @max(@as(usize, 1), body.len / 80))) catch + return err(500, "bulk request too large"); + + const BulkFlush = struct { + fn run( + col_arg: *collection.Collection, + rows_arg: *std.ArrayList(collection.Collection.BulkInsertRow), + chunk_bytes_arg: *usize, + inserted_arg: *u32, + errors_arg: *u32, + bytes_arg: *u64, + ) !void { + if (rows_arg.items.len == 0) return; + const bulk = try col_arg.insertBulk(rows_arg.items); + inserted_arg.* += bulk.inserted; + errors_arg.* += bulk.errors; + bytes_arg.* += bulk.bytes; + rows_arg.clearRetainingCapacity(); + chunk_bytes_arg.* = 0; + } + }; + + var pos: usize = 0; + while (pos < body.len) { + if (pos + 6 > body.len) { + errors += 1; + break; + } + const key_len: usize = std.mem.readInt(u16, body[pos..][0..2], .little); + const value_len: usize = std.mem.readInt(u32, body[pos + 2 ..][0..4], .little); + pos += 6; + const end = std.math.add(usize, pos, key_len) catch { + errors += 1; + break; + }; + const value_end = std.math.add(usize, end, value_len) catch { + errors += 1; + break; + }; + if (key_len == 0 or end > body.len or value_end > body.len) { + errors += 1; + break; + } + + const key = body[pos..end]; + const value = body[end..value_end]; + pos = value_end; + + const row_bytes = key.len + value.len + 128; + if (rows.items.len > 0 and chunk_bytes + row_bytes > BULK_INSERT_CHUNK_BYTES) { + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors, &total_bytes) catch + return err(500, "bulk insert failed"); + } + rows.append(alloc, .{ .key = key, .value = value, .line_len = 6 + key.len + value.len }) catch + return err(500, "bulk request too large"); + chunk_bytes += row_bytes; + + if (rows.items.len >= BULK_INSERT_CHUNK_ROWS) { + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors, &total_bytes) catch + return err(500, "bulk insert failed"); + } + } + BulkFlush.run(col, &rows, &chunk_bytes, &inserted, &errors, &total_bytes) catch + return err(500, "bulk insert failed"); + + srv.recordQueryCost(tenant_id, op_name, inserted, total_bytes, start_ns); + + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"inserted\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\",\"mode\":\"{s}\"}}", .{ inserted, errors, col_name, tenant_id, mode }) catch {}; + return ok(getBodyBuf()[0..fbs.pos]); +} + +/// Binary bulk update/upsert used by the gRPC bridge. +/// Body: repeated little-endian records: +/// [key_len:u16][value_len:u32][key bytes][raw value bytes] +fn handleBulkUpdateBinary(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, op_name: []const u8, mode: []const u8) usize { + const start_ns = compat.nanoTimestamp(); + var local_bulk_reservation: ?Server.BulkMemoryReservation = null; + if (tl_bulk_reservation == null) { + local_bulk_reservation = srv.acquireBulkMemory(tenant_id, estimateBulkMemoryBytes(body.len)) catch + return err(429, "bulk memory limit exceeded"); + } + defer if (local_bulk_reservation) |*reservation| reservation.release(); + + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + srv.db.ensureTenantStorageAvailable(tenant_id, body.len) catch return err(429, "tenant storage quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + + var updated: u32 = 0; + var inserted: u32 = 0; + var errors: u32 = 0; + var total_bytes: u64 = 0; + var pos: usize = 0; + while (pos < body.len) { + const record = nextBinaryKeyValue(body, &pos) orelse { + errors += 1; + break; + }; + if (!documentFitsLeaf(record.key, record.value)) { + errors += 1; + continue; + } + const did_update = col.update(record.key, record.value) catch { errors += 1; continue; }; - inserted += 1; - total_bytes += line.len; + if (did_update) { + updated += 1; + } else { + _ = col.insert(record.key, record.value) catch { + errors += 1; + continue; + }; + inserted += 1; + } + total_bytes += record.key.len + record.value.len; + } + + srv.recordQueryCost(tenant_id, op_name, updated + inserted + errors, total_bytes, start_ns); + + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"updated\":{d},\"inserted\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\",\"mode\":\"{s}\"}}", .{ updated, inserted, errors, col_name, tenant_id, mode }) catch {}; + return ok(getBodyBuf()[0..fbs.pos]); +} + +/// Binary bulk delete used by the gRPC bridge. +/// Body: repeated little-endian records: +/// [key_len:u16][key bytes] +fn handleBulkDeleteBinary(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, op_name: []const u8, mode: []const u8) usize { + const start_ns = compat.nanoTimestamp(); + var local_bulk_reservation: ?Server.BulkMemoryReservation = null; + if (tl_bulk_reservation == null) { + local_bulk_reservation = srv.acquireBulkMemory(tenant_id, estimateBulkMemoryBytes(body.len)) catch + return err(429, "bulk memory limit exceeded"); + } + defer if (local_bulk_reservation) |*reservation| reservation.release(); + + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + + var deleted: u32 = 0; + var missing: u32 = 0; + var errors: u32 = 0; + var pos: usize = 0; + while (pos < body.len) { + const record = nextBinaryKey(body, &pos) orelse { + errors += 1; + break; + }; + const did_delete = col.delete(record.key) catch { + errors += 1; + continue; + }; + if (did_delete) deleted += 1 else missing += 1; + } + + srv.recordQueryCost(tenant_id, op_name, deleted + missing + errors, body.len, start_ns); + + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"deleted\":{d},\"missing\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\",\"mode\":\"{s}\"}}", .{ deleted, missing, errors, col_name, tenant_id, mode }) catch {}; + return ok(getBodyBuf()[0..fbs.pos]); +} + +/// POST /db/:col/batch_get — read multiple documents in one request. +/// Body can be a JSON string array, {"keys":[...]}, or one key per line. +fn handleBatchGet(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, as_of: ?i64, compact: bool) usize { + const start_ns = compat.nanoTimestamp(); + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + var iter = KeyIter.init(body); + return writeBatchDocsResponse(srv, tenant_id, col_name, col, &iter, as_of, "batch_get", start_ns, 0, compact); +} + +/// POST /db/:col/batch_update — upsert multiple documents in one request. +/// Body: NDJSON — one {"key":"...","value":...} per line. +fn handleBatchUpdate(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8, alloc: std.mem.Allocator) usize { + _ = alloc; + const start_ns = compat.nanoTimestamp(); + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + srv.db.ensureTenantStorageAvailable(tenant_id, body.len) catch return err(429, "tenant storage quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + + var iter = BatchUpdateIter.init(body); + var updated: u32 = 0; + var inserted: u32 = 0; + var errors: u32 = 0; + var total_bytes: u64 = 0; + + while (iter.next()) |entry| { + if (!entry.valid) { + errors += 1; + continue; + } + if (!documentFitsLeaf(entry.key, entry.value)) { + errors += 1; + continue; + } + + const did_update = col.update(entry.key, entry.value) catch { + errors += 1; + continue; + }; + if (did_update) { + updated += 1; + } else { + _ = col.insert(entry.key, entry.value) catch { + errors += 1; + continue; + }; + inserted += 1; + } + total_bytes += entry.line_len; + } + + srv.recordQueryCost(tenant_id, "batch_update", updated + inserted + errors, total_bytes, start_ns); + + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"updated\":{d},\"inserted\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", .{ updated, inserted, errors, col_name, tenant_id }) catch {}; + return ok(getBodyBuf()[0..fbs.pos]); +} + +/// POST /db/:col/batch_delete — delete multiple documents in one request. +/// Body can be a JSON string array, {"keys":[...]}, or one key per line. +fn handleBatchDelete(srv: *Server, tenant_id: []const u8, col_name: []const u8, body: []const u8) usize { + const start_ns = compat.nanoTimestamp(); + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + + var iter = KeyIter.init(body); + var deleted: u32 = 0; + var missing: u32 = 0; + var errors: u32 = 0; + while (iter.next()) |key| { + if (key.len == 0) continue; + const did_delete = col.delete(key) catch { + errors += 1; + continue; + }; + if (did_delete) deleted += 1 else missing += 1; + } + + srv.recordQueryCost(tenant_id, "batch_delete", deleted + missing + errors, 0, start_ns); + + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"deleted\":{d},\"missing\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", .{ deleted, missing, errors, col_name, tenant_id }) catch {}; + return ok(getBodyBuf()[0..fbs.pos]); +} + +/// POST /db/:edge_col/join — read edge.value[field] as target keys and batch-get targets. +fn handleJoin(srv: *Server, tenant_id: []const u8, edge_col_name: []const u8, body: []const u8, compact: bool) usize { + const req = JoinRequest.parse(body) orelse return err(400, "missing key, target_collection, or field"); + const start_ns = compat.nanoTimestamp(); + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + const edge_col = srv.db.collectionForTenant(tenant_id, edge_col_name) catch return err(500, "open collection failed"); + const target_col = srv.db.collectionForTenant(tenant_id, req.target_collection) catch return err(500, "open collection failed"); + + const edge = edge_col.get(req.key) orelse return err(404, "not found"); + const raw_keys = jsonValue(edge.value, req.field) orelse return err(400, "missing join field"); + const keys = std.mem.trim(u8, raw_keys, " \t\r\n"); + if (keys.len == 0 or keys[0] != '[') return err(400, "join field must be an array"); + + var iter = KeyIter.init(keys); + return writeBatchDocsResponse( + srv, + tenant_id, + req.target_collection, + target_col, + &iter, + null, + "join", + start_ns, + edge.key.len + edge.value.len, + compact, + ); +} + +fn writeBatchDocsResponse( + srv: *Server, + tenant_id: []const u8, + col_name: []const u8, + col: *collection.Collection, + iter: *KeyIter, + as_of: ?i64, + op_name: []const u8, + start_ns: i128, + initial_bytes_read: usize, + compact: bool, +) usize { + const HEADER_RESERVE = 256; + var resp = getRespBuf(); + var fbs = compat.fixedBufferStream(resp[HEADER_RESERVE..]); + const w = fbs.writer(); + if (!compact) { + compat.format(w, "{{\"tenant\":\"{s}\",\"collection\":\"{s}\",\"docs\":[", .{ tenant_id, col_name }) catch return err(500, "response too large"); + } + + var found: usize = 0; + var missing: usize = 0; + var bytes_read: usize = initial_bytes_read; + var truncated = false; + while (iter.next()) |key| { + if (key.len == 0) continue; + const d = if (as_of) |ts_ms| + (col.getAsOfTimestamp(key, ts_ms) orelse { + missing += 1; + continue; + }) + else + (col.get(key) orelse { + missing += 1; + continue; + }); + + if (!compact) { + const val = if (d.value.len > 0) d.value else "{}"; + const is_json = isJsonValue(val); + const next_len = (if (found > 0) @as(usize, 1) else 0) + docJsonLen(d, val, is_json); + if (fbs.pos + next_len + 80 >= resp[HEADER_RESERVE..].len) { + truncated = true; + break; + } + if (found > 0) w.writeByte(',') catch return err(500, "response too large"); + writeDocJson(w, d, val, is_json) catch return err(500, "response too large"); + } + found += 1; + bytes_read += d.key.len + d.value.len; + } + + if (compact) { + compat.format(w, "{{\"tenant\":\"{s}\",\"collection\":\"{s}\",\"count\":{d},\"missing\":{d},\"bytes_read\":{d},\"truncated\":{},\"mode\":\"count\"}}", .{ tenant_id, col_name, found, missing, bytes_read, truncated }) catch return err(500, "response too large"); + } else { + compat.format(w, "],\"count\":{d},\"missing\":{d},\"truncated\":{}}}", .{ found, missing, truncated }) catch return err(500, "response too large"); } + const body_len = fbs.pos; - srv.recordQueryCost(tenant_id, "bulk_insert", inserted, total_bytes, start_ns); + srv.recordQueryCost(tenant_id, op_name, found + missing, bytes_read, start_ns); - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), - "{{\"inserted\":{d},\"errors\":{d},\"collection\":\"{s}\",\"tenant\":\"{s}\"}}", - .{ inserted, errors, col_name, tenant_id }) catch {}; - return ok(getBodyBuf()[0..fbs.pos]); + var hdr_fbs = compat.fixedBufferStream(resp[0..HEADER_RESERVE]); + compat.format(hdr_fbs.writer(), "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", .{body_len}) catch {}; + const hdr_len = hdr_fbs.pos; + if (hdr_len < HEADER_RESERVE) { + std.mem.copyForwards(u8, resp[hdr_len .. hdr_len + body_len], resp[HEADER_RESERVE .. HEADER_RESERVE + body_len]); + } + return hdr_len + body_len; } + fn handleGet(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []const u8, as_of: ?i64) usize { - const start_ns = std.time.nanoTimestamp(); - srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); - const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); - const d = if (as_of) |ts_ms| - (col.getAsOfTimestamp(key, ts_ms) orelse return err(404, "not found")) - else - (col.get(key) orelse return err(404, "not found")); - srv.recordQueryCost(tenant_id, "get", 1, d.key.len + d.value.len, start_ns); + const start_ns = compat.nanoTimestamp(); + srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); + const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); + const d = if (as_of) |ts_ms| + (col.getAsOfTimestamp(key, ts_ms) orelse return err(404, "not found")) + else + (col.get(key) orelse return err(404, "not found")); + srv.recordQueryCost(tenant_id, "get", 1, d.key.len + d.value.len, start_ns); + + // Write JSON body directly into resp_buf at offset 256 (reserve space for headers) + const HEADER_RESERVE = 256; + var resp = getRespBuf(); + var fbs = compat.fixedBufferStream(resp[HEADER_RESERVE..]); + const val = if (d.value.len > 0) d.value else "{}"; + const is_json = isJsonValue(val); + const w = fbs.writer(); + writeDocJson(w, d, val, is_json) catch return err(500, "response too large"); + const body_len = fbs.pos; - // Write JSON body directly into resp_buf at offset 256 (reserve space for headers) - const HEADER_RESERVE = 256; - var resp = getRespBuf(); - var fbs = std.io.fixedBufferStream(resp[HEADER_RESERVE..]); - const val = if (d.value.len > 0) d.value else "{}"; - const is_json = val.len > 0 and (val[0] == '{' or val[0] == '[' or val[0] == '"'); - const w = fbs.writer(); - if (is_json) { - std.fmt.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"version\":{d},\"value\":{s}}}", .{ d.header.doc_id, d.key, d.header.version, val }) catch {}; - } else { - std.fmt.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"version\":{d},\"value\":\"", .{ d.header.doc_id, d.key, d.header.version }) catch {}; - for (val) |ch| { - if (ch == '"' or ch == '\\') w.writeByte('\\') catch {}; - if (ch == '\n') { w.writeAll("\\n") catch {}; continue; } - w.writeByte(ch) catch {}; - } - w.writeAll("\"}") catch {}; - } - const body_len = fbs.pos; - - // Now write headers into the reserved space at the front - var hdr_fbs = std.io.fixedBufferStream(resp[0..HEADER_RESERVE]); - std.fmt.format(hdr_fbs.writer(), - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", - .{body_len}) catch {}; - const hdr_len = hdr_fbs.pos; - - // Move body right after headers (memmove if needed) - if (hdr_len < HEADER_RESERVE) { - std.mem.copyForwards(u8, resp[hdr_len .. hdr_len + body_len], resp[HEADER_RESERVE .. HEADER_RESERVE + body_len]); - } - return hdr_len + body_len; + // Now write headers into the reserved space at the front + var hdr_fbs = compat.fixedBufferStream(resp[0..HEADER_RESERVE]); + compat.format(hdr_fbs.writer(), "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", .{body_len}) catch {}; + const hdr_len = hdr_fbs.pos; + + // Move body right after headers (memmove if needed) + if (hdr_len < HEADER_RESERVE) { + std.mem.copyForwards(u8, resp[hdr_len .. hdr_len + body_len], resp[HEADER_RESERVE .. HEADER_RESERVE + body_len]); } + return hdr_len + body_len; +} fn handleUpdate(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []const u8, body: []const u8, alloc: std.mem.Allocator) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); _ = alloc; + if (!documentFitsLeaf(key, body)) return err(413, "document too large"); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); srv.db.ensureTenantStorageAvailable(tenant_id, body.len) catch return err(429, "tenant storage quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); const updated = col.update(key, body) catch return err(500, "update failed"); - if (!updated) return err(404, "not found"); - srv.recordQueryCost(tenant_id, "update", 1, body.len, start_ns); - return ok("{\"updated\":true}"); + if (updated) { + srv.recordQueryCost(tenant_id, "update", 1, body.len, start_ns); + return ok("{\"updated\":true,\"inserted\":false}"); + } + + const doc_id = col.insert(key, body) catch return err(500, "insert failed"); + srv.recordQueryCost(tenant_id, "upsert", 1, body.len, start_ns); + + var buf: [128]u8 = undefined; + const response = std.fmt.bufPrint(&buf, "{{\"updated\":false,\"inserted\":true,\"doc_id\":{d}}}", .{doc_id}) catch return err(500, "format failed"); + return ok(response); } fn handleDelete(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: []const u8) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); const deleted = col.delete(key) catch return err(500, "delete failed"); @@ -570,9 +1566,13 @@ fn handleDelete(srv: *Server, tenant_id: []const u8, col_name: []const u8, key: return ok("{\"deleted\":true}"); } +fn documentFitsLeaf(key: []const u8, value: []const u8) bool { + return doc_mod.DocHeader.size + key.len + value.len <= page_mod.PAGE_USABLE; +} + fn handleScan(srv: *Server, tenant_id: []const u8, col_name: []const u8, query_str: []const u8, as_of: ?i64, alloc: std.mem.Allocator) usize { - const start_ns = std.time.nanoTimestamp(); - const limit: u32 = qparamInt(query_str, "limit") orelse 20; + const start_ns = compat.nanoTimestamp(); + const limit: u32 = @min(qparamInt(query_str, "limit") orelse 20, 1000); const offset: u32 = qparamInt(query_str, "offset") orelse 0; srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); @@ -585,32 +1585,112 @@ fn handleScan(srv: *Server, tenant_id: []const u8, col_name: []const u8, query_s for (result.docs) |d| bytes_read += d.key.len + d.value.len; srv.recordQueryCost(tenant_id, "scan", result.docs.len, bytes_read, start_ns); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + const emit_count = scanEmitCount(tenant_id, col_name, result.docs); + + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); - std.fmt.format(w, "{{\"tenant\":\"{s}\",\"collection\":\"{s}\",\"count\":{d},\"docs\":[", - .{ tenant_id, col_name, result.docs.len }) catch {}; - for (result.docs, 0..) |d, i| { + compat.format(w, "{{\"tenant\":\"{s}\",\"collection\":\"{s}\",\"count\":{d},\"docs\":[", .{ tenant_id, col_name, emit_count }) catch {}; + for (result.docs[0..emit_count], 0..) |d, i| { if (i > 0) w.writeByte(',') catch {}; const val = if (d.value.len > 0) d.value else "{}"; - const is_json = val.len > 0 and (val[0] == '{' or val[0] == '[' or val[0] == '"'); - if (is_json) { - std.fmt.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"version\":{d},\"value\":{s}}}", .{ d.header.doc_id, d.key, d.header.version, val }) catch {}; - } else { - std.fmt.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"version\":{d},\"value\":\"", .{ d.header.doc_id, d.key, d.header.version }) catch {}; - for (val) |ch| { - if (ch == '"' or ch == '\\') w.writeByte('\\') catch {}; - if (ch == '\n') { w.writeAll("\\n") catch {}; continue; } - w.writeByte(ch) catch {}; - } - w.writeAll("\"}") catch {}; - } + const is_json = isJsonValue(val); + const start_pos = fbs.pos; + writeDocJson(w, d, val, is_json) catch { + fbs.pos = start_pos; + break; + }; } w.writeAll("]}") catch {}; return ok(getBodyBuf()[0..fbs.pos]); } +fn scanEmitCount(tenant_id: []const u8, col_name: []const u8, docs: []const doc_mod.Doc) usize { + const max_count_digits = decimalLen(docs.len); + var used: usize = "{\"tenant\":\"".len + tenant_id.len + + "\",\"collection\":\"".len + col_name.len + + "\",\"count\":".len + max_count_digits + + ",\"docs\":[".len + "]}".len; + + var emit_count: usize = 0; + for (docs) |d| { + const val = if (d.value.len > 0) d.value else "{}"; + const is_json = isJsonValue(val); + const next_len = (if (emit_count > 0) @as(usize, 1) else 0) + docJsonLen(d, val, is_json); + if (used + next_len > MAX_BODY) break; + used += next_len; + emit_count += 1; + } + return emit_count; +} + +fn writeDocJson(w: anytype, d: doc_mod.Doc, val: []const u8, is_json: bool) !void { + try w.writeAll("{\"doc_id\":"); + try w.print("{d}", .{d.header.doc_id}); + try w.writeAll(",\"key\":"); + try writeJsonString(w, d.key); + try w.writeAll(",\"version\":"); + try w.print("{d}", .{d.header.version}); + try w.writeAll(",\"value\":"); + if (is_json) { + try w.writeAll(val); + } else { + try writeJsonString(w, val); + } + try w.writeByte('}'); +} + +fn isJsonValue(value: []const u8) bool { + if (value.len == 0) return false; + return std.json.validate(std.heap.page_allocator, value) catch false; +} + +fn docJsonLen(d: doc_mod.Doc, val: []const u8, is_json: bool) usize { + return "{\"doc_id\":".len + decimalLen(d.header.doc_id) + + ",\"key\":".len + jsonStringLen(d.key) + + ",\"version\":".len + decimalLen(d.header.version) + + ",\"value\":".len + if (is_json) val.len else jsonStringLen(val) + + "}".len; +} + +fn decimalLen(value: anytype) usize { + var buf: [32]u8 = undefined; + return (std.fmt.bufPrint(&buf, "{d}", .{value}) catch return 32).len; +} + +fn jsonStringLen(value: []const u8) usize { + var len: usize = 2; + for (value) |ch| { + len += switch (ch) { + '"', '\\', '\n', '\r', '\t' => 2, + 0...8, 11...12, 14...0x1f => 6, + else => 1, + }; + } + return len; +} + +fn writeJsonString(w: anytype, value: []const u8) !void { + const hex = "0123456789abcdef"; + try w.writeByte('"'); + for (value) |ch| { + switch (ch) { + '"' => try w.writeAll("\\\""), + '\\' => try w.writeAll("\\\\"), + '\n' => try w.writeAll("\\n"), + '\r' => try w.writeAll("\\r"), + '\t' => try w.writeAll("\\t"), + 0...8, 11...12, 14...0x1f => { + try w.writeAll("\\u00"); + try w.writeByte(hex[ch >> 4]); + try w.writeByte(hex[ch & 0x0f]); + }, + else => try w.writeByte(ch), + } + } + try w.writeByte('"'); +} fn handleDrop(srv: *Server, tenant_id: []const u8, col_name: []const u8) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); srv.db.dropCollectionForTenant(tenant_id, col_name); srv.recordQueryCost(tenant_id, "drop", 0, 0, start_ns); @@ -618,7 +1698,7 @@ fn handleDrop(srv: *Server, tenant_id: []const u8, col_name: []const u8) usize { } fn handleSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, query: []const u8, limit: u32, alloc: std.mem.Allocator) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); const result = col.searchText(query, limit, alloc) catch return err(500, "search failed"); @@ -627,30 +1707,34 @@ fn handleSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, query for (result.docs) |d| bytes_read += d.key.len + d.value.len; srv.recordQueryCost(tenant_id, "search", result.docs.len, bytes_read, start_ns); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); // Write JSON header with escaped query string w.writeAll("{\"query\":\"") catch {}; for (query) |ch| { - if (ch == '"' or ch == '\\') { w.writeByte('\\') catch {}; } + if (ch == '"' or ch == '\\') { + w.writeByte('\\') catch {}; + } w.writeByte(ch) catch {}; } - std.fmt.format(w, - "\",\"hits\":{d},\"candidates\":{d},\"total_docs\":{d},\"total_files\":{d},\"results\":[", - .{ result.docs.len, result.candidate_paths.len, col.docCount(), result.total_files }) catch {}; + compat.format(w, "\",\"hits\":{d},\"candidates\":{d},\"total_docs\":{d},\"total_files\":{d},\"results\":[", .{ result.docs.len, result.candidate_paths.len, col.docCount(), result.total_files }) catch {}; for (result.docs, 0..) |d, i| { + if (fbs.pos + 256 >= MAX_BODY) break; if (i > 0) w.writeByte(',') catch {}; // Output value as valid JSON — objects/arrays as-is, strings quoted const val = if (d.value.len > 0) d.value else "{}"; - const is_json = val.len > 0 and (val[0] == '{' or val[0] == '[' or val[0] == '"'); + const is_json = isJsonValue(val); if (is_json) { - std.fmt.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"value\":{s}}}", .{ d.header.doc_id, d.key, val }) catch {}; + compat.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"value\":{s}}}", .{ d.header.doc_id, d.key, val }) catch {}; } else { w.writeAll("{\"doc_id\":") catch {}; - std.fmt.format(w, "{d},\"key\":\"{s}\",\"value\":\"", .{ d.header.doc_id, d.key }) catch {}; + compat.format(w, "{d},\"key\":\"{s}\",\"value\":\"", .{ d.header.doc_id, d.key }) catch {}; for (val) |ch| { if (ch == '"' or ch == '\\') w.writeByte('\\') catch {}; - if (ch == '\n') { w.writeAll("\\n") catch {}; continue; } + if (ch == '\n') { + w.writeAll("\\n") catch {}; + continue; + } w.writeByte(ch) catch {}; } w.writeAll("\"}") catch {}; @@ -661,7 +1745,7 @@ fn handleSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, query } fn handleDiscoverContext(srv: *Server, tenant_id: []const u8, col_name: []const u8, query: []const u8, limit: u32, alloc: std.mem.Allocator) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); const col = srv.db.collectionForTenant(tenant_id, col_name) catch return err(500, "open collection failed"); var result = col.discoverContext(query, limit, alloc) catch return err(500, "context discovery failed"); @@ -670,48 +1754,53 @@ fn handleDiscoverContext(srv: *Server, tenant_id: []const u8, col_name: []const for (result.matching_files) |d| bytes_read += d.key.len + d.value.len; srv.recordQueryCost(tenant_id, "context", result.matching_files.len, bytes_read, start_ns); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); // Write JSON: matching_files w.writeAll("{\"query\":\"") catch {}; for (query) |ch| { - if (ch == '"' or ch == '\\') { w.writeByte('\\') catch {}; } + if (ch == '"' or ch == '\\') { + w.writeByte('\\') catch {}; + } w.writeByte(ch) catch {}; } w.writeAll("\",\"matching_files\":[") catch {}; for (result.matching_files, 0..) |d, i| { + if (fbs.pos + 128 >= MAX_BODY) break; if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, "{{\"key\":\"{s}\",\"size\":{d}}}", .{ d.key, d.value.len }) catch {}; + compat.format(w, "{{\"key\":\"{s}\",\"size\":{d}}}", .{ d.key, d.value.len }) catch {}; } w.writeAll("],\"related_files\":[") catch {}; for (result.related_files, 0..) |d, i| { + if (fbs.pos + 128 >= MAX_BODY) break; if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch {}; + compat.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch {}; } w.writeAll("],\"test_files\":[") catch {}; for (result.test_files, 0..) |d, i| { + if (fbs.pos + 128 >= MAX_BODY) break; if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch {}; + compat.format(w, "{{\"key\":\"{s}\"}}", .{d.key}) catch {}; } - std.fmt.format(w, "],\"recent_versions\":{d},\"total_files\":{d}}}", .{ result.recent_versions, result.total_files }) catch {}; + compat.format(w, "],\"recent_versions\":{d},\"total_files\":{d}}}", .{ result.recent_versions, result.total_files }) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } fn handleListCollections(srv: *Server, tenant_id: []const u8, alloc: std.mem.Allocator) usize { - const start_ns = std.time.nanoTimestamp(); + const start_ns = compat.nanoTimestamp(); srv.db.recordTenantOperation(tenant_id) catch return err(429, "tenant ops quota exceeded"); var cols = srv.db.listCollectionsForTenant(tenant_id, alloc) catch return err(500, "list collections failed"); defer { for (cols.items) |name| alloc.free(name); cols.deinit(alloc); } - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); - std.fmt.format(w, "{{\"tenant\":\"{s}\",\"collections\":[", .{tenant_id}) catch {}; + compat.format(w, "{{\"tenant\":\"{s}\",\"collections\":[", .{tenant_id}) catch {}; for (cols.items, 0..) |name, i| { if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, "\"{s}\"", .{name}) catch {}; + compat.format(w, "\"{s}\"", .{name}) catch {}; } srv.recordQueryCost(tenant_id, "list_collections", cols.items.len, 0, start_ns); w.writeAll("]}") catch {}; @@ -719,7 +1808,7 @@ fn handleListCollections(srv: *Server, tenant_id: []const u8, alloc: std.mem.All } fn handleBillingLog(srv: *Server) usize { - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); w.writeAll("{\"queries\":[") catch {}; srv.billing_mu.lock(); @@ -734,7 +1823,8 @@ fn handleBillingLog(srv: *Server) usize { const idx = (start_idx + i) % cap; const entry = srv.billing_ring[idx]; if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, + compat.format( + w, "{{\"tenant\":\"{s}\",\"op\":\"{s}\",\"rows_scanned\":{d},\"bytes_read\":{d},\"cpu_us\":{d},\"cost_nanos_usd\":{d}}}", .{ entry.tenant_id[0..entry.tenant_id_len], @@ -751,8 +1841,8 @@ fn handleBillingLog(srv: *Server) usize { } fn handleResourceState(srv: *Server) usize { - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format( + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format( fbs.writer(), "{{\"state\":\"{s}\",\"queries_per_second\":{d},\"last_query_ms\":{d}}}", .{ resourceStateName(srv.activity.state()), srv.activity.queriesPerSecond(), srv.activity.lastQueryMs() }, @@ -760,26 +1850,27 @@ fn handleResourceState(srv: *Server) usize { return ok(getBodyBuf()[0..fbs.pos]); } -fn handleWebhookRegistration(srv: *Server, body: []const u8) usize { - const tenant = jsonStr(body, "tenant") orelse return err(400, "missing tenant"); +fn handleWebhookRegistration(srv: *Server, body: []const u8, auth_ctx: ?*const auth.AuthContext) usize { + const tenant = requestBodyTenant(body, auth_ctx) orelse return err(400, "missing tenant"); const webhook = jsonStr(body, "webhook_url") orelse return err(400, "missing webhook_url"); const secret = jsonStr(body, "secret") orelse return err(400, "missing secret"); const collection_name = jsonStr(body, "collection") orelse ""; const id = srv.db.registerWebhook(tenant, collection_name, webhook, secret) catch return err(500, "register webhook failed"); - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), "{{\"subscription_id\":{d}}}", .{id}) catch {}; + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"subscription_id\":{d}}}", .{id}) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } fn handleCdcEvents(srv: *Server, tenant_filter: ?[]const u8, alloc: std.mem.Allocator) usize { const deliveries = srv.db.listWebhookDeliveries(alloc, tenant_filter) catch return err(500, "cdc read failed"); defer alloc.free(deliveries); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); w.writeAll("{\"events\":[") catch {}; for (deliveries, 0..) |entry, i| { if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, + compat.format( + w, "{{\"seq\":{d},\"tenant\":\"{s}\",\"collection\":\"{s}\",\"webhook_url\":\"{s}\",\"signature\":\"{s}\",\"payload\":{s}}}", .{ entry.seq, @@ -820,8 +1911,8 @@ fn handleBranchRead(srv: *Server, tenant_id: []const u8, col_name: []const u8, b const br = col.getBranch(branch_name) orelse return err(404, "branch not found"); const val = col.getOnBranch(br, key) orelse return err(404, "not found"); // Write response with raw value - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), "{{\"key\":\"{s}\",\"value\":{s}}}", .{ + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"key\":\"{s}\",\"value\":{s}}}", .{ key, if (val.len > 0) val else "{}", }) catch {}; return ok(getBodyBuf()[0..fbs.pos]); @@ -835,12 +1926,12 @@ fn handleBranchMerge(srv: *Server, tenant_id: []const u8, col_name: []const u8, defer result.deinit(); if (result.conflicts.len > 0) { // Return conflict count - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), "{{\"merged\":false,\"conflicts\":{d}}}", .{result.conflicts.len}) catch {}; + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"merged\":false,\"conflicts\":{d}}}", .{result.conflicts.len}) catch {}; return respond(409, "Conflict", getBodyBuf()[0..fbs.pos]); } - var fbs = std.io.fixedBufferStream(getBodyBuf()); - std.fmt.format(fbs.writer(), "{{\"merged\":true,\"applied\":{d}}}", .{result.applied}) catch {}; + var fbs = compat.fixedBufferStream(getBodyBuf()); + compat.format(fbs.writer(), "{{\"merged\":true,\"applied\":{d}}}", .{result.applied}) catch {}; return ok(getBodyBuf()[0..fbs.pos]); } @@ -851,15 +1942,12 @@ fn handleBranchSearch(srv: *Server, tenant_id: []const u8, col_name: []const u8, const result = col.searchOnBranch(br, query_text, limit, alloc) catch return err(500, "branch search failed"); defer result.deinit(); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); - std.fmt.format(w, "{{\"branch\":\"{s}\",\"hits\":{d},\"results\":[", .{ branch_name, result.docs.len }) catch {}; + compat.format(w, "{{\"branch\":\"{s}\",\"hits\":{d},\"results\":[", .{ branch_name, result.docs.len }) catch {}; for (result.docs, 0..) |d, i| { if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, - "{{\"doc_id\":{d},\"key\":\"{s}\",\"value\":{s}}}", - .{ d.header.doc_id, d.key, - if (d.value.len > 0) d.value else "{}" }) catch {}; + compat.format(w, "{{\"doc_id\":{d},\"key\":\"{s}\",\"value\":{s}}}", .{ d.header.doc_id, d.key, if (d.value.len > 0) d.value else "{}" }) catch {}; } w.writeAll("]}") catch {}; return ok(getBodyBuf()[0..fbs.pos]); @@ -871,26 +1959,67 @@ fn handleListBranches(srv: *Server, tenant_id: []const u8, col_name: []const u8, const names = col.listBranches(alloc) catch return err(500, "list branches failed"); defer alloc.free(names); - var fbs = std.io.fixedBufferStream(getBodyBuf()); + var fbs = compat.fixedBufferStream(getBodyBuf()); const w = fbs.writer(); w.writeAll("{\"branches\":[") catch {}; for (names, 0..) |name, i| { if (i > 0) w.writeByte(',') catch {}; - std.fmt.format(w, "\"{s}\"", .{name}) catch {}; + compat.format(w, "\"{s}\"", .{name}) catch {}; } w.writeAll("]}") catch {}; return ok(getBodyBuf()[0..fbs.pos]); } -fn requestTenant(raw: []const u8, query: []const u8) []const u8 { +fn requestTenant(raw: []const u8, query: []const u8, auth_ctx: ?*const auth.AuthContext) []const u8 { + if (auth_ctx) |ctx| { + if (ctx.perm != .admin) return boundTenant(ctx); + } return header(raw, "X-Tenant-Id: ") orelse qparam(query, "tenant") orelse collection.DEFAULT_TENANT; } +fn requestTenantFilter(query: []const u8, auth_ctx: ?*const auth.AuthContext) ?[]const u8 { + if (auth_ctx) |ctx| { + if (ctx.perm != .admin) return boundTenant(ctx); + } + return qparam(query, "tenant"); +} + +fn requestBodyTenant(body: []const u8, auth_ctx: ?*const auth.AuthContext) ?[]const u8 { + if (auth_ctx) |ctx| { + if (ctx.perm != .admin) return boundTenant(ctx); + } + return jsonStr(body, "tenant"); +} + +fn boundTenant(ctx: *const auth.AuthContext) []const u8 { + const tenant_id = ctx.tenant(); + return if (tenant_id.len > 0) tenant_id else collection.DEFAULT_TENANT; +} + +fn isBasicDbWriteMethod(method: []const u8) bool { + return std.mem.eql(u8, method, "POST") or + std.mem.eql(u8, method, "PUT") or + std.mem.eql(u8, method, "DELETE"); +} + +fn canWriteDb(auth_ctx: ?*const auth.AuthContext) bool { + const ctx = auth_ctx orelse return true; + return ctx.perm != .read_only; +} + fn requestAsOf(raw: []const u8, query: []const u8) ?i64 { const value = header(raw, "X-As-Of: ") orelse qparam(query, "as_of") orelse return null; return parseAsOfTimestamp(value); } +fn batchDocsCompact(query: []const u8) bool { + if (qparam(query, "mode")) |mode| + return std.mem.eql(u8, mode, "count") or std.mem.eql(u8, mode, "compact"); + if (qparam(query, "docs")) |docs| + return std.mem.eql(u8, docs, "0") or std.mem.eql(u8, docs, "false"); + return false; +} + fn header(raw: []const u8, needle: []const u8) ?[]const u8 { const pos = std.mem.indexOf(u8, raw, needle) orelse return null; const start = pos + needle.len; @@ -928,7 +2057,9 @@ fn err(code: u16, msg: []const u8) usize { const status = switch (code) { 400 => "Bad Request", 401 => "Unauthorized", + 403 => "Forbidden", 429 => "Too Many Requests", + 413 => "Payload Too Large", 404 => "Not Found", else => "Internal Server Error", }; @@ -936,23 +2067,56 @@ fn err(code: u16, msg: []const u8) usize { } fn respond(code: u16, status: []const u8, body: []const u8) usize { - var fbs = std.io.fixedBufferStream(getRespBuf()); - std.fmt.format(fbs.writer(), - "HTTP/1.1 {d} {s}\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n{s}", - .{ code, status, body.len, body }) catch {}; - return fbs.pos; + const resp = getRespBuf(); + var header_buf: [256]u8 = undefined; + const header_bytes = std.fmt.bufPrint( + &header_buf, + "HTTP/1.1 {d} {s}\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: keep-alive\r\n\r\n", + .{ code, status, body.len }, + ) catch return 0; + + if (header_bytes.len + body.len > resp.len) { + const fallback_body = "{\"error\":\"response too large\"}"; + const fallback_header = std.fmt.bufPrint( + &header_buf, + "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", + .{fallback_body.len}, + ) catch return 0; + @memcpy(resp[0..fallback_header.len], fallback_header); + @memcpy(resp[fallback_header.len..][0..fallback_body.len], fallback_body); + return fallback_header.len + fallback_body.len; + } + + @memcpy(resp[0..header_bytes.len], header_bytes); + @memcpy(resp[header_bytes.len..][0..body.len], body); + return header_bytes.len + body.len; } // ─── mini parsers ──────────────────────────────────────────────────────── fn extractContentLength(raw: []const u8) usize { - // Case-insensitive search for Content-Length header + // Fully case-insensitive search for Content-Length header. const headers = raw[0..@min(raw.len, 2048)]; // only scan headers - const needle = "ontent-length: "; // skip first char for case insensitivity var i: usize = 0; - while (i + needle.len < headers.len) : (i += 1) { - if ((headers[i] == 'C' or headers[i] == 'c') and std.mem.eql(u8, headers[i + 1 .. i + 1 + needle.len], needle)) { - const start = i + 1 + needle.len; + while (i + 16 < headers.len) : (i += 1) { + if ((headers[i] == 'C' or headers[i] == 'c') and + (headers[i + 1] == 'o' or headers[i + 1] == 'O') and + (headers[i + 2] == 'n' or headers[i + 2] == 'N') and + (headers[i + 3] == 't' or headers[i + 3] == 'T') and + (headers[i + 4] == 'e' or headers[i + 4] == 'E') and + (headers[i + 5] == 'n' or headers[i + 5] == 'N') and + (headers[i + 6] == 't' or headers[i + 6] == 'T') and + headers[i + 7] == '-' and + (headers[i + 8] == 'L' or headers[i + 8] == 'l') and + (headers[i + 9] == 'e' or headers[i + 9] == 'E') and + (headers[i + 10] == 'n' or headers[i + 10] == 'N') and + (headers[i + 11] == 'g' or headers[i + 11] == 'G') and + (headers[i + 12] == 't' or headers[i + 12] == 'T') and + (headers[i + 13] == 'h' or headers[i + 13] == 'H') and + headers[i + 14] == ':') + { + var start = i + 15; + while (start < headers.len and (headers[start] == ' ' or headers[start] == '\t')) start += 1; var end = start; while (end < headers.len and headers[end] >= '0' and headers[end] <= '9') : (end += 1) {} if (end > start) { @@ -963,6 +2127,272 @@ fn extractContentLength(raw: []const u8) usize { return 0; } +fn grpcPayload(raw: []const u8, body: []const u8) ?[]const u8 { + const requires_frame = headerContains(raw, "content-type", "application/grpc"); + if (body.len >= 5 and body[0] == 0) { + const frame_len: usize = @intCast(std.mem.readInt(u32, body[1..][0..4], .big)); + if (frame_len == body.len - 5) return body[5..]; + if (requires_frame) return null; + } else if (requires_frame) { + return null; + } + return body; +} + +const BinaryKeyValueRecord = struct { + key: []const u8, + value: []const u8, +}; + +fn nextBinaryKeyValue(body: []const u8, pos: *usize) ?BinaryKeyValueRecord { + if (pos.* + 6 > body.len) return null; + const key_len: usize = std.mem.readInt(u16, body[pos.*..][0..2], .little); + const value_len: usize = std.mem.readInt(u32, body[pos.* + 2 ..][0..4], .little); + pos.* += 6; + const key_end = std.math.add(usize, pos.*, key_len) catch return null; + const value_end = std.math.add(usize, key_end, value_len) catch return null; + if (key_len == 0 or key_end > body.len or value_end > body.len) return null; + const key = body[pos.*..key_end]; + const value = body[key_end..value_end]; + pos.* = value_end; + return .{ .key = key, .value = value }; +} + +const BinaryKeyRecord = struct { + key: []const u8, +}; + +fn nextBinaryKey(body: []const u8, pos: *usize) ?BinaryKeyRecord { + if (pos.* + 2 > body.len) return null; + const key_len: usize = std.mem.readInt(u16, body[pos.*..][0..2], .little); + pos.* += 2; + const key_end = std.math.add(usize, pos.*, key_len) catch return null; + if (key_len == 0 or key_end > body.len) return null; + const key = body[pos.*..key_end]; + pos.* = key_end; + return .{ .key = key }; +} + +const KeyIter = struct { + body: []const u8, + pos: usize = 0, + array_mode: bool = false, + + fn init(body: []const u8) KeyIter { + const trimmed = std.mem.trim(u8, body, " \t\r\n"); + if (trimmed.len == 0) return .{ .body = trimmed }; + if (trimmed[0] == '{') { + if (jsonValue(trimmed, "keys")) |keys| { + if (keys.len > 0 and keys[0] == '[') { + return .{ .body = keys, .pos = 1, .array_mode = true }; + } + } + } + if (trimmed[0] == '[') return .{ .body = trimmed, .pos = 1, .array_mode = true }; + return .{ .body = trimmed }; + } + + fn next(self: *KeyIter) ?[]const u8 { + if (self.array_mode) return self.nextArray(); + return self.nextLine(); + } + + fn nextLine(self: *KeyIter) ?[]const u8 { + while (self.pos < self.body.len) { + const line_end = std.mem.indexOfScalarPos(u8, self.body, self.pos, '\n') orelse self.body.len; + const raw_line = std.mem.trim(u8, self.body[self.pos..line_end], " \t\r"); + self.pos = if (line_end < self.body.len) line_end + 1 else line_end; + if (raw_line.len == 0) continue; + if (raw_line[0] == '{') return jsonStr(raw_line, "key") orelse continue; + if (raw_line.len >= 2 and raw_line[0] == '"' and raw_line[raw_line.len - 1] == '"') + return raw_line[1 .. raw_line.len - 1]; + return raw_line; + } + return null; + } + + fn nextArray(self: *KeyIter) ?[]const u8 { + while (self.pos < self.body.len) { + while (self.pos < self.body.len and + (self.body[self.pos] == ' ' or self.body[self.pos] == '\t' or + self.body[self.pos] == '\r' or self.body[self.pos] == '\n' or + self.body[self.pos] == ',')) : (self.pos += 1) + {} + if (self.pos >= self.body.len or self.body[self.pos] == ']') return null; + + if (self.body[self.pos] == '"') { + const start = self.pos + 1; + var i = start; + while (i < self.body.len) : (i += 1) { + if (self.body[i] == '\\' and i + 1 < self.body.len) { + i += 1; + continue; + } + if (self.body[i] == '"') { + self.pos = i + 1; + return self.body[start..i]; + } + } + self.pos = self.body.len; + return null; + } + + const start = self.pos; + while (self.pos < self.body.len and self.body[self.pos] != ',' and self.body[self.pos] != ']') : (self.pos += 1) {} + const raw_key = std.mem.trim(u8, self.body[start..self.pos], " \t\r\n"); + if (raw_key.len == 0) continue; + return raw_key; + } + return null; + } +}; + +const BulkLine = struct { + key: []const u8, + value: []const u8, +}; + +fn parseBulkLine(line: []const u8) ?BulkLine { + if (parseBulkLineExact(line)) |parsed| return parsed; + if (parseBulkLineFast(line)) |parsed| return parsed; + const key = jsonStr(line, "key") orelse return null; + const value = jsonValue(line, "value") orelse line; + return .{ .key = key, .value = value }; +} + +fn parseStrictBulkLine(line: []const u8) ?BulkLine { + if (parseBulkLineExact(line)) |parsed| return parsed; + if (parseBulkLineFast(line)) |parsed| return parsed; + const key = jsonStr(line, "key") orelse return null; + const value = jsonValue(line, "value") orelse return null; + return .{ .key = key, .value = value }; +} + +fn parseBulkLineExact(line: []const u8) ?BulkLine { + const prefix = "{\"key\":\""; + const middle = "\",\"value\":"; + if (line.len < prefix.len + middle.len + 1 or !std.mem.startsWith(u8, line, prefix)) return null; + + var key_end = prefix.len; + while (key_end < line.len and line[key_end] != '"') : (key_end += 1) { + if (line[key_end] == '\\') return null; + } + if (key_end >= line.len) return null; + if (!std.mem.startsWith(u8, line[key_end..], middle)) return null; + + const value_start = key_end + middle.len; + if (value_start >= line.len or line[line.len - 1] != '}') return null; + return .{ .key = line[prefix.len..key_end], .value = line[value_start .. line.len - 1] }; +} + +fn parseBulkLineFast(line: []const u8) ?BulkLine { + if (line.len < "{\"key\":\"\",\"value\":}".len or line[0] != '{') return null; + var row_end = line.len; + while (row_end > 0 and (line[row_end - 1] == ' ' or line[row_end - 1] == '\t' or line[row_end - 1] == '\r')) row_end -= 1; + if (row_end == 0 or line[row_end - 1] != '}') return null; + row_end -= 1; + + var i: usize = 1; + skipJsonSpaces(line, &i, row_end); + if (!std.mem.startsWith(u8, line[i..row_end], "\"key\"")) return null; + i += "\"key\"".len; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end or line[i] != ':') return null; + i += 1; + skipJsonSpaces(line, &i, row_end); + const key = parseJsonStringToken(line, &i, row_end) orelse return null; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end or line[i] != ',') return null; + i += 1; + skipJsonSpaces(line, &i, row_end); + if (!std.mem.startsWith(u8, line[i..row_end], "\"value\"")) return null; + i += "\"value\"".len; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end or line[i] != ':') return null; + i += 1; + skipJsonSpaces(line, &i, row_end); + if (i >= row_end) return null; + + var value_end = row_end; + while (value_end > i and (line[value_end - 1] == ' ' or line[value_end - 1] == '\t')) value_end -= 1; + if (value_end <= i) return null; + return .{ .key = key, .value = line[i..value_end] }; +} + +fn skipJsonSpaces(data: []const u8, pos: *usize, limit: usize) void { + while (pos.* < limit and (data[pos.*] == ' ' or data[pos.*] == '\t')) pos.* += 1; +} + +fn parseJsonStringToken(data: []const u8, pos: *usize, limit: usize) ?[]const u8 { + if (pos.* >= limit or data[pos.*] != '"') return null; + pos.* += 1; + const start = pos.*; + while (pos.* < limit) : (pos.* += 1) { + if (data[pos.*] == '\\' and pos.* + 1 < limit) { + pos.* += 1; + continue; + } + if (data[pos.*] == '"') { + const end = pos.*; + pos.* += 1; + return data[start..end]; + } + } + return null; +} + +const BatchUpdateLine = struct { + key: []const u8 = "", + value: []const u8 = "", + line_len: usize = 0, + valid: bool = false, +}; + +const BatchUpdateIter = struct { + body: []const u8, + pos: usize = 0, + + fn init(body: []const u8) BatchUpdateIter { + return .{ .body = body }; + } + + fn next(self: *BatchUpdateIter) ?BatchUpdateLine { + while (self.pos < self.body.len) { + const line_end = std.mem.indexOfScalarPos(u8, self.body, self.pos, '\n') orelse self.body.len; + const line = std.mem.trim(u8, self.body[self.pos..line_end], " \t\r"); + self.pos = if (line_end < self.body.len) line_end + 1 else line_end; + if (line.len == 0) continue; + + const parsed = parseStrictBulkLine(line) orelse return .{ .line_len = line.len }; + return .{ + .key = parsed.key, + .value = parsed.value, + .line_len = line.len, + .valid = true, + }; + } + return null; + } +}; + +const JoinRequest = struct { + key: []const u8, + target_collection: []const u8, + field: []const u8, + + fn parse(body: []const u8) ?JoinRequest { + const key = jsonStr(body, "key") orelse return null; + const target_collection = jsonStr(body, "target_collection") orelse return null; + const field = jsonStr(body, "field") orelse return null; + if (key.len == 0 or target_collection.len == 0 or field.len == 0) return null; + return .{ + .key = key, + .target_collection = target_collection, + .field = field, + }; + } +}; + fn jsonStr(json: []const u8, key: []const u8) ?[]const u8 { var kbuf: [64]u8 = undefined; const needle = std.fmt.bufPrint(&kbuf, "\"{s}\":", .{key}) catch return null; @@ -995,10 +2425,13 @@ fn jsonValue(json: []const u8, key: []const u8) ?[]const u8 { // and re-emitted verbatim by handleGet. var i = start + 1; while (i < json.len) : (i += 1) { - if (json[i] == '\\' and i + 1 < json.len) { i += 1; continue; } - if (json[i] == '"') break; + if (json[i] == '\\' and i + 1 < json.len) { + i += 1; + continue; + } + if (json[i] == '"') return json[start .. i + 1]; // include both quotes } - return json[start .. i + 1]; // include both quotes + return null; // unterminated string — don't read past buffer } else if (ch == '{' or ch == '[') { // Object or array — find matching close bracket const close: u8 = if (ch == '{') '}' else ']'; @@ -1006,8 +2439,14 @@ fn jsonValue(json: []const u8, key: []const u8) ?[]const u8 { var i = start + 1; var in_str = false; while (i < json.len and depth > 0) : (i += 1) { - if (json[i] == '\\' and in_str) { i += 1; continue; } - if (json[i] == '"') { in_str = !in_str; continue; } + if (json[i] == '\\' and in_str) { + i += 1; + continue; + } + if (json[i] == '"') { + in_str = !in_str; + continue; + } if (in_str) continue; if (json[i] == ch) depth += 1; if (json[i] == close) depth -= 1; @@ -1084,6 +2523,229 @@ fn hexVal(c: u8) ?u4 { return null; } +// ─── WebSocket ─────────────────────────────────────────────────────────── + +/// Read exactly `buf.len` bytes from the stream, looping as needed. +fn wsReadExact(conn: compat.net.Server.Connection, buf: []u8) !void { + var filled: usize = 0; + while (filled < buf.len) { + const n = conn.stream.read(buf[filled..]) catch return error.ReadFailed; + if (n == 0) return error.ConnectionClosed; + filled += n; + } +} +/// Case-insensitive header value check. +fn headerContains(raw: []const u8, name: []const u8, value: []const u8) bool { + const headers = raw[0..@min(raw.len, 4096)]; + var i: usize = 0; + while (i + name.len + 2 < headers.len) : (i += 1) { + if (headers[i] == '\n') { + const line_start = i + 1; + if (line_start + name.len + 2 >= headers.len) continue; + var match = true; + for (0..name.len) |j| { + const a = headers[line_start + j]; + const b = name[j]; + const al = if (a >= 'A' and a <= 'Z') a + 32 else a; + const bl = if (b >= 'A' and b <= 'Z') b + 32 else b; + if (al != bl) { + match = false; + break; + } + } + if (!match) continue; + if (headers[line_start + name.len] != ':') continue; + // Found header — check value (case-insensitive) + var vs = line_start + name.len + 1; + while (vs < headers.len and (headers[vs] == ' ' or headers[vs] == '\t')) vs += 1; + const ve = std.mem.indexOfScalarPos(u8, headers, vs, '\r') orelse + std.mem.indexOfScalarPos(u8, headers, vs, '\n') orelse headers.len; + const hval = headers[vs..ve]; + if (hval.len < value.len) continue; + var vmatch = true; + for (0..value.len) |j| { + const a = hval[j]; + const b = value[j]; + const al = if (a >= 'A' and a <= 'Z') a + 32 else a; + const bl = if (b >= 'A' and b <= 'Z') b + 32 else b; + if (al != bl) { + vmatch = false; + break; + } + } + if (vmatch) return true; + } + } + return false; +} + +/// Extract a specific header value from raw HTTP request. +fn headerValue(raw: []const u8, name: []const u8) ?[]const u8 { + const headers = raw[0..@min(raw.len, 4096)]; + var i: usize = 0; + while (i + name.len + 2 < headers.len) : (i += 1) { + if (headers[i] == '\n') { + const ls = i + 1; + if (ls + name.len + 2 >= headers.len) continue; + var match = true; + for (0..name.len) |j| { + const a = headers[ls + j]; + const b = name[j]; + const al = if (a >= 'A' and a <= 'Z') a + 32 else a; + const bl = if (b >= 'A' and b <= 'Z') b + 32 else b; + if (al != bl) { + match = false; + break; + } + } + if (!match) continue; + if (headers[ls + name.len] != ':') continue; + var vs = ls + name.len + 1; + while (vs < headers.len and (headers[vs] == ' ' or headers[vs] == '\t')) vs += 1; + const ve = std.mem.indexOfScalarPos(u8, headers, vs, '\r') orelse + std.mem.indexOfScalarPos(u8, headers, vs, '\n') orelse headers.len; + return headers[vs..ve]; + } + } + return null; +} + +const WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +/// Perform WebSocket upgrade handshake, then loop reading WS frames. +/// Each text message is a JSON request dispatched through the normal +/// HTTP handler; the response body is sent back as a WS text frame. +fn handleWebSocket(srv: *Server, conn: compat.net.Server.Connection, initial: []const u8) !void { + // Extract Sec-WebSocket-Key + const ws_key = headerValue(initial, "Sec-WebSocket-Key") orelse return error.MissingKey; + + // Compute accept hash: SHA1(key ++ magic), base64-encoded + var sha = std.crypto.hash.Sha1.init(.{}); + sha.update(ws_key); + sha.update(WS_MAGIC); + const digest = sha.finalResult(); + var accept_buf: [28]u8 = undefined; + const accept = std.base64.standard.Encoder.encode(&accept_buf, &digest); + + // Send upgrade response + var resp_buf: [256]u8 = undefined; + const resp_len = std.fmt.bufPrint(&resp_buf, "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {s}\r\n\r\n", .{accept}) catch return error.FormatFailed; + conn.stream.writeAll(resp_len) catch return error.WriteFailed; + + // WebSocket frame loop — heap-allocate frame buffer (too large for stack). + const frame_buf = std.heap.page_allocator.alloc(u8, MAX_BULK) catch return error.OutOfMemory; + defer std.heap.page_allocator.free(frame_buf); + while (true) { + // Read frame header (2 bytes minimum) + var hdr: [14]u8 = undefined; + wsReadExact(conn, hdr[0..2]) catch return; + const fin = hdr[0] & 0x80 != 0; + const opcode = hdr[0] & 0x0F; + const masked = hdr[1] & 0x80 != 0; + var payload_len: u64 = hdr[1] & 0x7F; + + if (opcode == 0x8) return; // close frame + if (opcode == 0x9) { // ping → pong + hdr[0] = 0x8A; // FIN + pong + conn.stream.writeAll(hdr[0..2]) catch return; + continue; + } + + // Extended payload length + if (payload_len == 126) { + wsReadExact(conn, hdr[2..4]) catch return; + payload_len = std.mem.readInt(u16, hdr[2..4], .big); + } else if (payload_len == 127) { + wsReadExact(conn, hdr[2..10]) catch return; + payload_len = std.mem.readInt(u64, hdr[2..10], .big); + } + + if (payload_len > MAX_BULK) { + wsWriteClose(conn, 1009); // message too big + return; + } + + // Read mask key (4 bytes if masked) + var mask: [4]u8 = .{ 0, 0, 0, 0 }; + if (masked) wsReadExact(conn, &mask) catch return; + + // Read payload + const plen: usize = @intCast(payload_len); + const payload = frame_buf[0..plen]; + wsReadExact(conn, payload) catch return; + + // Unmask + if (masked) { + for (payload, 0..) |*b, j| b.* ^= mask[j % 4]; + } + + _ = fin; // TODO: handle fragmented messages + + if (opcode == 0x1) { // text frame — dispatch as request + _ = srv.req_count.fetchAdd(1, .monotonic); + + // Build a synthetic HTTP request from the WS message. + // Expected format: {"op":"insert","col":"name","key":"k","value":{...}} + // Or direct HTTP path: "POST /db/mycol\n{...body...}" + const resp_body = wsDispatch(srv, payload); + wsWriteText(conn, resp_body) catch return; + } + } +} + +/// Dispatch a WebSocket text message. Supports two formats: +/// 1. Raw HTTP: "METHOD /path\n{body}" — reuses dispatch() +/// 2. JSON ops: {"op":"insert","col":"c","key":"k","value":{}} (future) +fn wsDispatch(srv: *Server, msg: []const u8) []const u8 { + // Wrap as a minimal HTTP request so we can reuse dispatch(). + // Find first \n to split method+path from body. + const nl = std.mem.indexOfScalar(u8, msg, '\n') orelse msg.len; + const req_line = msg[0..nl]; + const body = if (nl < msg.len) msg[nl + 1 ..] else ""; + + // Build HTTP/1.1 request with Content-Length. + // Heap-allocate since WS messages can be up to 16MB. + const needed = req_line.len + 64 + body.len; + const http_buf = std.heap.page_allocator.alloc(u8, needed) catch + return "{\"error\":\"request too large\"}"; + defer std.heap.page_allocator.free(http_buf); + const http_len = std.fmt.bufPrint(http_buf, "{s} HTTP/1.1\r\nContent-Length: {d}\r\n\r\n{s}", .{ req_line, body.len, body }) catch return "{\"error\":\"request too large\"}"; + + const resp_len = dispatch(srv, http_len, std.heap.page_allocator); + const resp = getRespBuf()[0..resp_len]; + + // Strip HTTP headers from response, return just the body + if (std.mem.indexOf(u8, resp, "\r\n\r\n")) |p| return resp[p + 4 ..]; + return resp; +} + +fn wsWriteText(conn: compat.net.Server.Connection, payload: []const u8) !void { + var hdr: [10]u8 = undefined; + hdr[0] = 0x81; // FIN + text + var hdr_len: usize = 2; + if (payload.len < 126) { + hdr[1] = @intCast(payload.len); + } else if (payload.len < 65536) { + hdr[1] = 126; + std.mem.writeInt(u16, hdr[2..4], @intCast(payload.len), .big); + hdr_len = 4; + } else { + hdr[1] = 127; + std.mem.writeInt(u64, hdr[2..10], @intCast(payload.len), .big); + hdr_len = 10; + } + try conn.stream.writeAll(hdr[0..hdr_len]); + try conn.stream.writeAll(payload); +} + +fn wsWriteClose(conn: compat.net.Server.Connection, code: u16) void { + var frame: [4]u8 = undefined; + frame[0] = 0x88; // FIN + close + frame[1] = 2; // payload = 2 bytes (status code) + std.mem.writeInt(u16, frame[2..4], code, .big); + conn.stream.writeAll(&frame) catch {}; +} + test "parse as_of accepts seconds and milliseconds" { try std.testing.expectEqual(@as(?i64, 1_700_000_000_000), parseAsOfTimestamp("1700000000")); try std.testing.expectEqual(@as(?i64, 1_700_000_000_123), parseAsOfTimestamp("1700000000123")); @@ -1092,5 +2754,237 @@ test "parse as_of accepts seconds and milliseconds" { test "request tenant prefers header over query" { const raw = "GET /db/users?tenant=query-tenant HTTP/1.1\r\nX-Tenant-Id: header-tenant\r\n\r\n"; - try std.testing.expectEqualStrings("header-tenant", requestTenant(raw, "tenant=query-tenant")); + try std.testing.expectEqualStrings("header-tenant", requestTenant(raw, "tenant=query-tenant", null)); +} + +test "request tenant uses bound tenant for non-admin auth" { + const raw = "GET /db/users?tenant=query-tenant HTTP/1.1\r\nX-Tenant-Id: header-tenant\r\n\r\n"; + const tenant = "bound-tenant"; + var ctx = auth.AuthContext{ + .perm = .read_write, + .tenant_id = [_]u8{0} ** 64, + .tenant_id_len = @intCast(tenant.len), + }; + @memcpy(ctx.tenant_id[0..tenant.len], tenant); + + try std.testing.expectEqualStrings(tenant, requestTenant(raw, "tenant=query-tenant", &ctx)); + try std.testing.expectEqualStrings(tenant, requestTenantFilter("tenant=query-tenant", &ctx).?); + try std.testing.expectEqualStrings(tenant, requestBodyTenant("{\"tenant\":\"body-tenant\"}", &ctx).?); +} + +test "admin auth can select request tenant" { + const raw = "GET /db/users?tenant=query-tenant HTTP/1.1\r\nX-Tenant-Id: header-tenant\r\n\r\n"; + var ctx = auth.AuthContext{ + .perm = .admin, + .tenant_id = [_]u8{0} ** 64, + .tenant_id_len = 0, + }; + + try std.testing.expectEqualStrings("header-tenant", requestTenant(raw, "tenant=query-tenant", &ctx)); +} + +test "respond writes bodies larger than format scratch buffer" { + var bufs: ConnBufs = undefined; + tl_bufs = &bufs; + defer tl_bufs = null; + + var body: [9000]u8 = undefined; + @memset(&body, 'a'); + + const n = respond(200, "OK", body[0..]); + try std.testing.expect(n > body.len); + try std.testing.expect(std.mem.indexOf(u8, bufs.resp[0..n], "\r\n\r\n") != null); + try std.testing.expect(std.mem.endsWith(u8, bufs.resp[0..n], body[0..])); +} + +test "HTTP frame parser preserves pipelined request boundary" { + const raw = + "POST /db/users HTTP/1.1\r\nContent-Length: 3\r\n\r\nabc" ++ + "GET /health HTTP/1.1\r\n\r\n"; + const frame = try inspectHttpFrame(raw); + try std.testing.expectEqual(@as(usize, "POST /db/users HTTP/1.1\r\nContent-Length: 3\r\n\r\nabc".len), frame.total_len); + try std.testing.expectEqual(@as(usize, 3), frame.content_length); + + var backing: [128]u8 = undefined; + @memcpy(backing[0..raw.len], raw); + var input = RequestInput{ .buf = backing[0..], .len = raw.len }; + input.consume(frame.total_len); + try std.testing.expectEqualStrings("GET /health HTTP/1.1\r\n\r\n", input.bytes()); +} + +test "read-only auth cannot write basic db methods" { + var ctx = auth.AuthContext{ + .perm = .read_only, + .tenant_id = [_]u8{0} ** 64, + .tenant_id_len = 0, + }; + + try std.testing.expect(canWriteDb(null)); + try std.testing.expect(!canWriteDb(&ctx)); + try std.testing.expect(isBasicDbWriteMethod("POST")); + try std.testing.expect(isBasicDbWriteMethod("PUT")); + try std.testing.expect(isBasicDbWriteMethod("DELETE")); + try std.testing.expect(!isBasicDbWriteMethod("GET")); + + ctx.perm = .read_write; + try std.testing.expect(canWriteDb(&ctx)); +} + +test "bulk line parser fast path and generic fallback" { + const fast = parseBulkLine("{\"key\":\"u1\",\"value\":{\"name\":\"alice\",\"tags\":[1,2]}}").?; + try std.testing.expectEqualStrings("u1", fast.key); + try std.testing.expectEqualStrings("{\"name\":\"alice\",\"tags\":[1,2]}", fast.value); + + const fallback = parseBulkLine("{\"value\":{\"name\":\"bob\"},\"key\":\"u2\"}").?; + try std.testing.expectEqualStrings("u2", fallback.key); + try std.testing.expectEqualStrings("{\"name\":\"bob\"}", fallback.value); +} + +test "bulk insert target recognizes binary bulk endpoint" { + try std.testing.expect(isBulkInsertTarget("POST", "/db/users/bulk")); + try std.testing.expect(isBulkInsertTarget("POST", "/db/users/bulk_binary")); + try std.testing.expect(!isBulkInsertTarget("GET", "/db/users/bulk_binary")); + try std.testing.expect(!isBulkInsertTarget("POST", "/db/users/batch_update")); +} + +test "grpc bridge target parser and admission detection" { + const target = parseGrpcTarget("/grpc/users/BulkInsert").?; + try std.testing.expectEqualStrings("users", target.col_name); + try std.testing.expectEqualStrings("BulkInsert", target.rpc); + + try std.testing.expect(isGrpcBulkMutation("POST", "/grpc/users/BulkInsert")); + try std.testing.expect(isGrpcBulkMutation("POST", "/grpc/users/BulkUpdate")); + try std.testing.expect(isGrpcBulkMutation("POST", "/grpc/users/BulkUpsert")); + try std.testing.expect(isGrpcBulkMutation("POST", "/grpc/users/BulkDelete")); + try std.testing.expect(!isGrpcBulkMutation("GET", "/grpc/users/BulkInsert")); + try std.testing.expect(!isGrpcBulkMutation("POST", "/grpc/users/PointGet")); + try std.testing.expect(!isGrpcBulkMutation("POST", "/grpc/users")); +} + +test "grpc payload accepts raw binary or one uncompressed grpc frame" { + const raw_plain = "POST /grpc/users/BulkInsert HTTP/1.1\r\nContent-Length: 3\r\n\r\nabc"; + try std.testing.expectEqualStrings("abc", grpcPayload(raw_plain, "abc").?); + + const raw_grpc = "POST /grpc/users/BulkInsert HTTP/1.1\r\nContent-Type: application/grpc\r\nContent-Length: 8\r\n\r\n"; + const framed = [_]u8{ 0, 0, 0, 0, 3, 'a', 'b', 'c' }; + try std.testing.expectEqualStrings("abc", grpcPayload(raw_grpc, framed[0..]).?); + + const bad_framed = [_]u8{ 0, 0, 0, 0, 4, 'a', 'b', 'c' }; + try std.testing.expect(grpcPayload(raw_grpc, bad_framed[0..]) == null); + try std.testing.expect(grpcPayload(raw_grpc, "abc") == null); +} + +test "binary bulk record parsers walk key value and key-only bodies" { + const kv = [_]u8{ + 2, 0, 7, 0, 0, 0, 'k', '1', '{', '"', 'n', '"', ':', '1', '}', + 2, 0, 7, 0, 0, 0, 'k', '2', '{', '"', 'n', '"', ':', '2', '}', + }; + var kv_pos: usize = 0; + const first = nextBinaryKeyValue(kv[0..], &kv_pos).?; + try std.testing.expectEqualStrings("k1", first.key); + try std.testing.expectEqualStrings("{\"n\":1}", first.value); + const second = nextBinaryKeyValue(kv[0..], &kv_pos).?; + try std.testing.expectEqualStrings("k2", second.key); + try std.testing.expectEqualStrings("{\"n\":2}", second.value); + try std.testing.expect(kv_pos == kv.len); + try std.testing.expect(nextBinaryKeyValue(kv[0 .. kv.len - 1], &kv_pos) == null); + + const keys = [_]u8{ 2, 0, 'k', '1', 2, 0, 'k', '2' }; + var key_pos: usize = 0; + try std.testing.expectEqualStrings("k1", nextBinaryKey(keys[0..], &key_pos).?.key); + try std.testing.expectEqualStrings("k2", nextBinaryKey(keys[0..], &key_pos).?.key); + try std.testing.expect(key_pos == keys.len); +} + +test "bulk memory estimate includes body and bounded chunk workspace" { + const small = estimateBulkMemoryBytes(1024); + const large = estimateBulkMemoryBytes(MAX_BULK); + try std.testing.expect(small > 1024); + try std.testing.expect(large > MAX_BULK); + try std.testing.expect(large < MAX_BULK + 40 * 1024 * 1024); +} + +test "bulk memory admission enforces tenant and global limits" { + const alloc = std.testing.allocator; + var fake_db: Database = undefined; + var srv = Server.init(alloc, &fake_db, 0); + defer srv.deinit(); + + const request_bytes = estimateBulkMemoryBytes(1024 * 1024); + const global_limit = request_bytes * 2 + 1024; + const tenant_limit = request_bytes + 512; + + var r1 = try srv.acquireBulkMemoryWithLimits("tenant-a", request_bytes, global_limit, tenant_limit); + try std.testing.expectError( + error.BulkMemoryLimitExceeded, + srv.acquireBulkMemoryWithLimits("tenant-a", request_bytes, global_limit, tenant_limit), + ); + + var r2 = try srv.acquireBulkMemoryWithLimits("tenant-b", request_bytes, global_limit, tenant_limit); + try std.testing.expectError( + error.BulkMemoryLimitExceeded, + srv.acquireBulkMemoryWithLimits("tenant-c", request_bytes, global_limit, tenant_limit), + ); + + r1.release(); + var r3 = try srv.acquireBulkMemoryWithLimits("tenant-c", request_bytes, global_limit, tenant_limit); + r2.release(); + r3.release(); + + try std.testing.expectEqual(@as(usize, 0), srv.bulk_inflight_bytes); + try std.testing.expectEqual(@as(u32, 0), srv.bulk_inflight_requests); + try std.testing.expectEqual(@as(u32, 0), srv.bulk_tenant_usage.count()); +} + +test "KeyIter reads JSON arrays and keyed newline bodies" { + var array_iter = KeyIter.init("{\"keys\":[\"1\",\"2\",\"3\"]}"); + try std.testing.expectEqualStrings("1", array_iter.next().?); + try std.testing.expectEqualStrings("2", array_iter.next().?); + try std.testing.expectEqualStrings("3", array_iter.next().?); + try std.testing.expect(array_iter.next() == null); + + var line_iter = KeyIter.init("{\"key\":\"a\"}\nplain\n\"quoted\"\n"); + try std.testing.expectEqualStrings("a", line_iter.next().?); + try std.testing.expectEqualStrings("plain", line_iter.next().?); + try std.testing.expectEqualStrings("quoted", line_iter.next().?); + try std.testing.expect(line_iter.next() == null); +} + +test "BatchUpdateIter reads NDJSON key value lines and flags invalid lines" { + var iter = BatchUpdateIter.init( + "{\"key\":\"a\",\"value\":{\"n\":1}}\n" ++ + "{\"key\":\"b\",\"value\":\"text\"}\n" ++ + "{\"key\":\"missing-value\"}\n", + ); + + const first = iter.next().?; + try std.testing.expect(first.valid); + try std.testing.expectEqualStrings("a", first.key); + try std.testing.expectEqualStrings("{\"n\":1}", first.value); + + const second = iter.next().?; + try std.testing.expect(second.valid); + try std.testing.expectEqualStrings("b", second.key); + try std.testing.expectEqualStrings("\"text\"", second.value); + + const third = iter.next().?; + try std.testing.expect(!third.valid); + try std.testing.expect(iter.next() == null); +} + +test "JoinRequest parses required join fields" { + const req = JoinRequest.parse("{\"key\":\"customer-1\",\"target_collection\":\"orders\",\"field\":\"order_ids\"}").?; + try std.testing.expectEqualStrings("customer-1", req.key); + try std.testing.expectEqualStrings("orders", req.target_collection); + try std.testing.expectEqualStrings("order_ids", req.field); + + try std.testing.expect(JoinRequest.parse("{\"key\":\"customer-1\",\"field\":\"order_ids\"}") == null); +} + +test "batchDocsCompact detects compact response query options" { + try std.testing.expect(batchDocsCompact("mode=count")); + try std.testing.expect(batchDocsCompact("mode=compact&tenant=t1")); + try std.testing.expect(batchDocsCompact("tenant=t1&docs=0")); + try std.testing.expect(!batchDocsCompact("mode=full")); + try std.testing.expect(!batchDocsCompact("")); } diff --git a/src/storage/epoch.zig b/src/storage/epoch.zig index d265f8d..2ffbf94 100644 --- a/src/storage/epoch.zig +++ b/src/storage/epoch.zig @@ -14,15 +14,18 @@ //! Capacity: MAX_READERS concurrent reader threads. Each occupies one 64-byte //! slot to avoid false sharing. const std = @import("std"); +const compat = @import("compat"); -pub const MAX_READERS: usize = 1024; -pub const EPOCH_INACTIVE: u64 = std.math.maxInt(u64); +pub const MAX_READERS: usize = 1024; +pub const EPOCH_INACTIVE: u64 = std.math.maxInt(u64); /// One slot per reader thread. Padded to 64 bytes to avoid false sharing. const Slot = struct { epoch: std.atomic.Value(u64) = std.atomic.Value(u64).init(EPOCH_INACTIVE), - _pad: [56]u8 = [_]u8{0} ** 56, - comptime { std.debug.assert(@sizeOf(@This()) == 64); } + _pad: [56]u8 = [_]u8{0} ** 56, + comptime { + std.debug.assert(@sizeOf(@This()) == 64); + } }; pub const EpochManager = struct { @@ -37,14 +40,14 @@ pub const EpochManager = struct { slots: []Slot, allocator: std.mem.Allocator, timeline: std.ArrayList(EpochPoint), - timeline_mu: std.Thread.Mutex, + timeline_mu: compat.Mutex, pub fn init(allocator: std.mem.Allocator) !EpochManager { const slots = try allocator.alloc(Slot, MAX_READERS); for (slots) |*s| s.epoch.store(EPOCH_INACTIVE, .release); return .{ .global_ts = std.atomic.Value(u64).init(1), - .slots = slots, + .slots = slots, .allocator = allocator, .timeline = .empty, .timeline_mu = .{}, @@ -60,7 +63,7 @@ pub const EpochManager = struct { /// Called by a writer at commit. Returns the new timestamp. pub fn advance(self: *EpochManager) u64 { - return self.advanceAt(std.time.milliTimestamp()); + return self.advanceAt(compat.milliTimestamp()); } pub fn advanceAt(self: *EpochManager, ts_ms: i64) u64 { @@ -71,6 +74,24 @@ pub const EpochManager = struct { return epoch; } + /// Reserve a contiguous epoch range for a single batched commit. + /// Returns the first epoch in the range. Timestamp lookups map to the + /// batch's final epoch so the whole batch becomes visible together. + pub fn advanceMany(self: *EpochManager, count: usize) u64 { + return self.advanceManyAt(count, compat.milliTimestamp()); + } + + pub fn advanceManyAt(self: *EpochManager, count: usize, ts_ms: i64) u64 { + if (count == 0) return self.now(); + const count_u64: u64 = @intCast(count); + const first_epoch = self.global_ts.fetchAdd(count_u64, .acq_rel) + 1; + const last_epoch = first_epoch + count_u64 - 1; + self.timeline_mu.lock(); + defer self.timeline_mu.unlock(); + self.timeline.append(self.allocator, .{ .epoch = last_epoch, .ts_ms = ts_ms }) catch {}; + return first_epoch; + } + /// Current global timestamp (snapshot for reads). pub fn now(self: *const EpochManager) u64 { return self.global_ts.load(.acquire); @@ -139,3 +160,15 @@ test "epoch manager maps timestamps to epochs" { try std.testing.expectEqual(@as(?u64, @as(u64, 2)), mgr.epochForTimestamp(1_500)); try std.testing.expectEqual(@as(?u64, @as(u64, 3)), mgr.epochForTimestamp(2_000)); } + +test "epoch manager reserves batched epoch ranges" { + const alloc = std.testing.allocator; + var mgr = try EpochManager.init(alloc); + defer mgr.deinit(); + + try std.testing.expectEqual(@as(u64, 2), mgr.advanceManyAt(4, 1_000)); + try std.testing.expectEqual(@as(u64, 5), mgr.now()); + try std.testing.expectEqual(@as(?u64, @as(u64, 5)), mgr.epochForTimestamp(1_000)); + try std.testing.expectEqual(@as(u64, 6), mgr.advanceAt(2_000)); + try std.testing.expectEqual(@as(?u64, @as(u64, 6)), mgr.epochForTimestamp(2_000)); +} diff --git a/src/storage/mmap.zig b/src/storage/mmap.zig index d10e842..1981155 100644 --- a/src/storage/mmap.zig +++ b/src/storage/mmap.zig @@ -7,6 +7,7 @@ //! Growth strategy: grow in GROW_CHUNK increments (256 MiB) to amortise //! ftruncate + remap syscalls. On macOS (no mremap) we munmap and remap. const std = @import("std"); +const compat = @import("compat"); const posix = std.posix; pub const GROW_CHUNK: usize = 256 * 1024 * 1024; // 256 MiB @@ -19,28 +20,31 @@ pub const MmapFile = struct { capacity: usize, // mapped (file) length; >= len, multiple of PAGE_SIZE /// Protects ptr/capacity against concurrent grow+read. /// Writers (grow) take exclusive; readers (at/slice) take shared. - rw_lock: std.Thread.RwLock, + rw_lock: compat.RwLock, // ── Open / Close ────────────────────────────────────────────────────────── pub fn open(path: [:0]const u8, initial_size: usize) !MmapFile { - const flags = posix.O{ .ACCMODE = .RDWR, .CREAT = true }; - const fd = try posix.open(path, flags, 0o644); - errdefer posix.close(fd); - - const stat = try posix.fstat(fd); - const existing: usize = @intCast(stat.size); + const fd = std.c.open(path, .{ .ACCMODE = .RDWR, .CREAT = true }, @as(std.c.mode_t, 0o644)); + if (fd < 0) return error.OpenError; + errdefer _ = std.c.close(fd); + + const existing_raw = std.c.lseek(fd, 0, std.c.SEEK.END); + if (existing_raw < 0) return error.StatError; + _ = std.c.lseek(fd, 0, std.c.SEEK.SET); + const existing: usize = @intCast(existing_raw); const capacity = alignUp( @max(existing, @max(initial_size, GROW_CHUNK)), PAGE_SIZE, ); - if (@as(usize, @intCast(stat.size)) < capacity) - try posix.ftruncate(fd, @intCast(capacity)); + if (existing < capacity) { + if (std.c.ftruncate(fd, @intCast(capacity)) != 0) return error.TruncateError; + } const ptr = try posix.mmap( null, capacity, - posix.PROT.READ | posix.PROT.WRITE, + posix.PROT{ .READ = true, .WRITE = true }, .{ .TYPE = .SHARED }, fd, 0, ); @@ -56,7 +60,7 @@ pub const MmapFile = struct { pub fn close(self: *MmapFile) void { posix.munmap(@alignCast(self.ptr[0..self.capacity])); - posix.close(self.fd); + _ = std.c.close(self.fd); } // ── Sync / Checkpoint ───────────────────────────────────────────────────── @@ -88,12 +92,12 @@ pub const MmapFile = struct { } const new_cap = alignUp(needed_len + GROW_CHUNK, PAGE_SIZE); // Extend file - try posix.ftruncate(self.fd, @intCast(new_cap)); + if (std.c.ftruncate(self.fd, @intCast(new_cap)) != 0) return error.TruncateError; // Remap (macOS has no mremap; unmap then remap) posix.munmap(@alignCast(self.ptr[0..self.capacity])); const ptr = try posix.mmap( null, new_cap, - posix.PROT.READ | posix.PROT.WRITE, + posix.PROT{ .READ = true, .WRITE = true }, .{ .TYPE = .SHARED }, self.fd, 0, ); diff --git a/src/storage/parallel_wal.zig b/src/storage/parallel_wal.zig index 1ef8b3e..3469c11 100644 --- a/src/storage/parallel_wal.zig +++ b/src/storage/parallel_wal.zig @@ -1,4 +1,7 @@ const std = @import("std"); +const builtin = @import("builtin"); +const compat = @import("compat"); +const linux = std.os.linux; const Allocator = std.mem.Allocator; /// Operation types for WAL entries. @@ -13,13 +16,16 @@ pub const OpType = enum(u8) { const ENTRY_HEADER_SIZE = 13; /// Per-core WAL segment. Each segment owns a file and a page-aligned write -/// buffer. Appends are lock-free: writers atomically reserve space via -/// `fetchAdd` on `pos`. +/// buffer. Appends reserve space with `pos`, then publish completed entries +/// into `published_pos` in reservation order so flush never reads a partial +/// entry. pub const WALSegment = struct { segment_id: u32, - fd: std.fs.File, + fd: compat.File, + uring: ?compat.LinuxUring, buf: []align(4096) u8, pos: std.atomic.Value(u32), + published_pos: std.atomic.Value(u32), flushed_pos: u32, pub const BUF_SIZE: u32 = 65536; @@ -29,7 +35,7 @@ pub const WALSegment = struct { var path_buf: [512]u8 = undefined; const path = std.fmt.bufPrint(&path_buf, "{s}/wal_seg_{d:0>4}", .{ data_dir, id }) catch return error.PathTooLong; - const fd = try std.fs.cwd().createFile(path, .{ .truncate = true }); + const fd = try compat.cwd().createFile(path, .{ .truncate = true }); const buf = try alloc.alignedAlloc(u8, .fromByteUnits(4096), BUF_SIZE); @memset(buf, 0); @@ -37,13 +43,19 @@ pub const WALSegment = struct { return WALSegment{ .segment_id = id, .fd = fd, + .uring = compat.initLinuxUring(8), .buf = buf, .pos = std.atomic.Value(u32).init(0), + .published_pos = std.atomic.Value(u32).init(0), .flushed_pos = 0, }; } pub fn deinit(self: *WALSegment, alloc: Allocator) void { + if (self.uring) |*ring| { + compat.deinitLinuxUring(ring); + self.uring = null; + } self.fd.close(); alloc.free(self.buf); } @@ -51,44 +63,78 @@ pub const WALSegment = struct { /// Append an entry to the segment buffer. Returns the offset where data /// was written, or `error.SegmentFull` if the buffer cannot hold the entry. pub fn append(self: *WALSegment, op: OpType, key_hash: u64, data: []const u8) !u32 { - const total: u32 = ENTRY_HEADER_SIZE + @as(u32, @intCast(data.len)); - - // Reserve space atomically — lock-free. - const offset = self.pos.fetchAdd(total, .monotonic); - if (offset + total > BUF_SIZE) { - // Roll back so other threads see accurate remaining space. - _ = self.pos.fetchSub(total, .monotonic); - return error.SegmentFull; - } + if (data.len > @as(usize, BUF_SIZE - ENTRY_HEADER_SIZE)) return error.SegmentFull; + + const data_len: u32 = @intCast(data.len); + const total: u32 = ENTRY_HEADER_SIZE + data_len; + const offset = try self.reserve(total); // Write header: [op:1][key_hash:8][len:4] self.buf[offset] = @intFromEnum(op); @memcpy(self.buf[offset + 1 .. offset + 9], std.mem.asBytes(&key_hash)); - const data_len: u32 = @intCast(data.len); @memcpy(self.buf[offset + 9 .. offset + 13], std.mem.asBytes(&data_len)); // Write payload. @memcpy(self.buf[offset + ENTRY_HEADER_SIZE .. offset + total], data); + self.publish(offset, offset + total); return offset; } + fn reserve(self: *WALSegment, total: u32) !u32 { + while (true) { + const current = self.pos.load(.monotonic); + if (current > BUF_SIZE or total > BUF_SIZE - current) return error.SegmentFull; + + if (self.pos.cmpxchgWeak(current, current + total, .monotonic, .monotonic) == null) { + return current; + } + + std.atomic.spinLoopHint(); + } + } + + fn tryPublish(self: *WALSegment, offset: u32, end: u32) bool { + return self.published_pos.cmpxchgStrong(offset, end, .release, .acquire) == null; + } + + fn publish(self: *WALSegment, offset: u32, end: u32) void { + while (!self.tryPublish(offset, end)) { + std.atomic.spinLoopHint(); + } + } + /// Flush dirty bytes to the underlying file. Called by the group-commit /// flusher — NOT per-transaction. pub fn flush(self: *WALSegment) !void { - const current = self.pos.load(.acquire); + const current = self.published_pos.load(.acquire); if (current <= self.flushed_pos) return; const dirty = self.buf[self.flushed_pos..current]; - const written = try self.fd.write(dirty); - if (written != dirty.len) return error.ShortWrite; + if (self.uring) |*ring| { + compat.uringWriteAllAt(ring, self.fd, dirty, self.flushed_pos) catch { + compat.deinitLinuxUring(ring); + self.uring = null; + }; + if (self.uring == null) try self.fd.pwriteAll(dirty, self.flushed_pos); + } else { + try self.fd.pwriteAll(dirty, self.flushed_pos); + } self.flushed_pos = current; } /// Fsync the segment file to durable storage. pub fn sync(self: *WALSegment) !void { - try self.fd.sync(); + if (self.uring) |*ring| { + compat.uringFsync(ring, self.fd) catch { + compat.deinitLinuxUring(ring); + self.uring = null; + }; + if (self.uring == null) try self.fd.syncData(); + } else { + try self.fd.syncData(); + } } /// Read back an entry at the given buffer offset. Returns the fields and @@ -99,7 +145,7 @@ pub const WALSegment = struct { data: []const u8, next_offset: u32, } { - const end = self.pos.load(.acquire); + const end = self.published_pos.load(.acquire); if (offset + ENTRY_HEADER_SIZE > end) return error.InvalidOffset; const op: OpType = @enumFromInt(self.buf[offset]); @@ -133,9 +179,22 @@ pub const ParallelWAL = struct { allocator: Allocator, flusher_thread: ?std.Thread, running: std.atomic.Value(u8), + uring: ?compat.LinuxUring, + dirty_ends: []u32, + dirty_lens: []usize, + + const MAX_URING_SEGMENTS: u32 = 256; + const URING_QUEUE_ENTRIES: u16 = 512; + const SYNC_USER_DATA_BASE: u64 = MAX_URING_SEGMENTS + 1; pub fn init(alloc: Allocator, data_dir: []const u8, n_segments: u32) !*ParallelWAL { const segs = try alloc.alloc(*WALSegment, n_segments); + const dirty_ends = try alloc.alloc(u32, n_segments); + errdefer alloc.free(dirty_ends); + const dirty_lens = try alloc.alloc(usize, n_segments); + errdefer alloc.free(dirty_lens); + @memset(dirty_ends, 0); + @memset(dirty_lens, 0); var inited: u32 = 0; errdefer { var j: u32 = 0; @@ -162,17 +221,26 @@ pub const ParallelWAL = struct { .allocator = alloc, .flusher_thread = null, .running = std.atomic.Value(u8).init(0), + .uring = compat.initLinuxUring(URING_QUEUE_ENTRIES), + .dirty_ends = dirty_ends, + .dirty_lens = dirty_lens, }; return self; } pub fn deinit(self: *ParallelWAL) void { self.stopFlusher(); + if (self.uring) |*ring| { + compat.deinitLinuxUring(ring); + self.uring = null; + } var i: u32 = 0; while (i < self.n_segments) : (i += 1) { self.segments[i].deinit(self.allocator); self.allocator.destroy(self.segments[i]); } + self.allocator.free(self.dirty_ends); + self.allocator.free(self.dirty_lens); self.allocator.free(self.segments); self.allocator.destroy(self); } @@ -180,7 +248,7 @@ pub const ParallelWAL = struct { /// Select a segment for the calling thread — thread ID modulo n_segments. pub fn getSegment(self: *ParallelWAL) *WALSegment { const tid = std.Thread.getCurrentId(); - const idx = @as(u32, @intCast(@as(u64, @bitCast(tid)) % @as(u64, self.n_segments))); + const idx = @as(u32, @intCast(@as(u64, @intCast(tid)) % @as(u64, self.n_segments))); return self.segments[idx]; } @@ -190,9 +258,18 @@ pub const ParallelWAL = struct { return seg.append(op, key_hash, data); } + pub fn usingLinuxUring(self: *const ParallelWAL) bool { + return self.uring != null; + } + /// Group commit: flush + fsync every segment and advance the epoch. /// Called by the background flusher or manually in tests. pub fn groupCommit(self: *ParallelWAL) !void { + if (self.groupCommitWithUring()) { + _ = self.current_epoch.fetchAdd(1, .release); + return; + } + // Phase 1: flush dirty buffers to OS page cache. var i: u32 = 0; while (i < self.n_segments) : (i += 1) { @@ -209,6 +286,93 @@ pub const ParallelWAL = struct { _ = self.current_epoch.fetchAdd(1, .release); } + fn groupCommitWithUring(self: *ParallelWAL) bool { + if (comptime builtin.os.tag != .linux) return false; + + if (self.n_segments == 0 or self.n_segments > MAX_URING_SEGMENTS) return false; + const ring = if (self.uring) |*r| r else return false; + + @memset(self.dirty_lens, 0); + var dirty_segments: u32 = 0; + var i: u32 = 0; + while (i < self.n_segments) : (i += 1) { + const seg = self.segments[i]; + const current = seg.published_pos.load(.acquire); + self.dirty_ends[i] = current; + if (current <= seg.flushed_pos) continue; + + const dirty = seg.buf[seg.flushed_pos..current]; + self.dirty_lens[i] = dirty.len; + const write_sqe = ring.write(writeUserData(i), seg.fd.handle, dirty, seg.flushed_pos) catch return self.disableUring(); + write_sqe.flags |= linux.IOSQE_IO_LINK; + _ = ring.fsync(syncUserData(i), seg.fd.handle, linux.IORING_FSYNC_DATASYNC) catch return self.disableUring(); + dirty_segments += 1; + } + + if (dirty_segments == 0) return true; + _ = ring.submit_and_wait(dirty_segments * 2) catch return self.disableUring(); + if (!self.collectLinkedUringCompletions(dirty_segments * 2)) return self.disableUring(); + + i = 0; + while (i < self.n_segments) : (i += 1) { + if (self.dirty_lens[i] == 0) continue; + self.segments[i].flushed_pos = self.dirty_ends[i]; + } + return true; + } + + fn collectLinkedUringCompletions(self: *ParallelWAL, expected: u32) bool { + const ring = if (self.uring) |*r| r else return false; + var completed: u32 = 0; + while (completed < expected) : (completed += 1) { + const cqe = ring.copy_cqe() catch return false; + if (isSyncUserData(cqe.user_data)) { + _ = self.cqeSyncSegmentIndex(cqe.user_data) orelse return false; + if (cqe.res < 0) return false; + } else { + const idx = self.cqeWriteSegmentIndex(cqe.user_data) orelse return false; + if (cqe.res < 0) return false; + const n: usize = @intCast(cqe.res); + if (n != self.dirty_lens[idx]) return false; + } + } + return true; + } + + fn writeUserData(segment_index: u32) u64 { + return @as(u64, segment_index) + 1; + } + + fn syncUserData(segment_index: u32) u64 { + return SYNC_USER_DATA_BASE + @as(u64, segment_index); + } + + fn isSyncUserData(user_data: u64) bool { + return user_data >= SYNC_USER_DATA_BASE; + } + + fn cqeWriteSegmentIndex(self: *const ParallelWAL, user_data: u64) ?usize { + if (user_data == 0 or isSyncUserData(user_data)) return null; + const idx: usize = @intCast(user_data - 1); + if (idx >= self.n_segments) return null; + return idx; + } + + fn cqeSyncSegmentIndex(self: *const ParallelWAL, user_data: u64) ?usize { + if (!isSyncUserData(user_data)) return null; + const idx: usize = @intCast(user_data - SYNC_USER_DATA_BASE); + if (idx >= self.n_segments) return null; + return idx; + } + + fn disableUring(self: *ParallelWAL) bool { + if (self.uring) |*ring| { + compat.deinitLinuxUring(ring); + self.uring = null; + } + return false; + } + /// Start the background flusher thread (~1 ms group commit interval). pub fn startFlusher(self: *ParallelWAL) !void { if (self.running.load(.acquire) == 1) return; @@ -229,7 +393,7 @@ pub const ParallelWAL = struct { fn flusherLoop(self: *ParallelWAL) void { while (self.running.load(.acquire) == 1) { self.groupCommit() catch {}; - std.Thread.sleep(1 * std.time.ns_per_ms); + compat.sleep(1 * std.time.ns_per_ms); } // Final flush on shutdown. self.groupCommit() catch {}; @@ -240,13 +404,17 @@ pub const ParallelWAL = struct { // Tests // --------------------------------------------------------------------------- +fn testingTmpPath(alloc: Allocator, tmp: *const std.testing.TmpDir) ![]u8 { + return std.fmt.allocPrint(alloc, ".zig-cache/tmp/{s}", .{tmp.sub_path}); +} + test "WALSegment single-threaded append and read" { const alloc = std.testing.allocator; // Use a temp directory. var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + const tmp_path = try testingTmpPath(alloc, &tmp); defer alloc.free(tmp_path); var seg = try WALSegment.init(alloc, tmp_path, 0); @@ -268,12 +436,88 @@ test "WALSegment single-threaded append and read" { try std.testing.expectEqualStrings("world", e2.data); } +test "WALSegment publish requires contiguous offsets" { + const alloc = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try testingTmpPath(alloc, &tmp); + defer alloc.free(tmp_path); + + var seg = try WALSegment.init(alloc, tmp_path, 0); + defer seg.deinit(alloc); + + const first_total: u32 = ENTRY_HEADER_SIZE + 5; + const second_total: u32 = ENTRY_HEADER_SIZE + 6; + + try std.testing.expect(!seg.tryPublish(first_total, first_total + second_total)); + try std.testing.expectEqual(@as(u32, 0), seg.published_pos.load(.acquire)); + + try std.testing.expect(seg.tryPublish(0, first_total)); + try std.testing.expectEqual(first_total, seg.published_pos.load(.acquire)); + + try std.testing.expect(seg.tryPublish(first_total, first_total + second_total)); + try std.testing.expectEqual(first_total + second_total, seg.published_pos.load(.acquire)); +} + +test "WALSegment flush ignores reserved unpublished bytes" { + const alloc = std.testing.allocator; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const tmp_path = try testingTmpPath(alloc, &tmp); + defer alloc.free(tmp_path); + + var seg = try WALSegment.init(alloc, tmp_path, 0); + defer seg.deinit(alloc); + + const payload = "complete"; + const data_len: u32 = payload.len; + const total: u32 = ENTRY_HEADER_SIZE + data_len; + const key_hash: u64 = 0xABCDEF; + + seg.pos.store(total, .release); + seg.buf[0] = @intFromEnum(OpType.put); + @memcpy(seg.buf[1..9], std.mem.asBytes(&key_hash)); + @memcpy(seg.buf[9..13], std.mem.asBytes(&data_len)); + @memcpy(seg.buf[ENTRY_HEADER_SIZE..@as(usize, total)], payload); + + try std.testing.expectError(error.InvalidOffset, seg.readEntry(0)); + + try seg.flush(); + try std.testing.expectEqual(@as(u32, 0), seg.flushed_pos); + var stat = try seg.fd.stat(); + try std.testing.expectEqual(@as(u64, 0), stat.size); + + seg.published_pos.store(total, .release); + + const entry = try seg.readEntry(0); + try std.testing.expectEqual(OpType.put, entry.op); + try std.testing.expectEqual(key_hash, entry.key_hash); + try std.testing.expectEqualStrings(payload, entry.data); + + try seg.flush(); + try std.testing.expectEqual(total, seg.flushed_pos); + stat = try seg.fd.stat(); + try std.testing.expectEqual(@as(u64, total), stat.size); + + var persisted: [ENTRY_HEADER_SIZE + payload.len]u8 = undefined; + var path_buf: [512]u8 = undefined; + const wal_path = try std.fmt.bufPrint(&path_buf, "{s}/wal_seg_{d:0>4}", .{ tmp_path, 0 }); + var read_file = try compat.cwd().openFile(wal_path, .{}); + defer read_file.close(); + + const read = try read_file.readAll(persisted[0..]); + try std.testing.expectEqual(@as(usize, total), read); + try std.testing.expectEqualStrings(payload, persisted[ENTRY_HEADER_SIZE..]); +} + test "ParallelWAL multi-threaded writes" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + const tmp_path = try testingTmpPath(alloc, &tmp); defer alloc.free(tmp_path); const n_segments: u32 = 4; @@ -309,13 +553,16 @@ test "ParallelWAL multi-threaded writes" { // Verify: total bytes written across all segments should account for // N_THREADS * WRITES_PER_THREAD entries. var total_bytes: u64 = 0; + var total_published_bytes: u64 = 0; var i: u32 = 0; while (i < n_segments) : (i += 1) { total_bytes += wal.segments[i].pos.load(.acquire); + total_published_bytes += wal.segments[i].published_pos.load(.acquire); } const expected_bytes: u64 = N_THREADS * WRITES_PER_THREAD * (ENTRY_HEADER_SIZE + 4); // "data" = 4 bytes try std.testing.expectEqual(expected_bytes, total_bytes); + try std.testing.expectEqual(expected_bytes, total_published_bytes); } test "ParallelWAL group commit advances epoch" { @@ -323,7 +570,7 @@ test "ParallelWAL group commit advances epoch" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + const tmp_path = try testingTmpPath(alloc, &tmp); defer alloc.free(tmp_path); var wal = try ParallelWAL.init(alloc, tmp_path, 2); @@ -345,7 +592,7 @@ test "ParallelWAL background flusher runs" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const tmp_path = try tmp.dir.realpathAlloc(alloc, "."); + const tmp_path = try testingTmpPath(alloc, &tmp); defer alloc.free(tmp_path); var wal = try ParallelWAL.init(alloc, tmp_path, 2); @@ -357,7 +604,7 @@ test "ParallelWAL background flusher runs" { _ = try wal.write(.put, 99, "flusher-test"); // Sleep a bit to let the flusher run at least once (~1 ms interval). - std.Thread.sleep(10 * std.time.ns_per_ms); + compat.sleep(10 * std.time.ns_per_ms); const epoch = wal.current_epoch.load(.acquire); try std.testing.expect(epoch >= 1); diff --git a/src/storage/wal.zig b/src/storage/wal.zig index f5ee3b1..cefbe75 100644 --- a/src/storage/wal.zig +++ b/src/storage/wal.zig @@ -27,60 +27,67 @@ //! 3. Replays only committed transactions via the caller-supplied apply_fn. //! 4. Truncates the tail of any partial (unfinished) entry at the end. const std = @import("std"); +const compat = @import("compat"); // ── Entry header (32 bytes, cache-line harmless) ────────────────────────────── pub const OpCode = enum(u8) { - nop = 0x00, + nop = 0x00, // Transaction control - txn_begin = 0x40, - txn_commit = 0x41, - txn_abort = 0x42, - checkpoint = 0xF0, + txn_begin = 0x40, + txn_commit = 0x41, + txn_abort = 0x42, + checkpoint = 0xF0, // Graph ops - node_insert = 0x01, - node_update = 0x02, - node_delete = 0x03, - edge_insert = 0x10, - edge_delete = 0x11, + node_insert = 0x01, + node_update = 0x02, + node_delete = 0x03, + edge_insert = 0x10, + edge_delete = 0x11, // Document ops - doc_insert = 0x20, - doc_update = 0x21, - doc_delete = 0x22, + doc_insert = 0x20, + doc_update = 0x21, + doc_delete = 0x22, // Schema ops - create_coll = 0x30, - drop_coll = 0x31, + create_coll = 0x30, + drop_coll = 0x31, _, }; pub const DB_TAG_GRAPH: u8 = 0x01; -pub const DB_TAG_DOC: u8 = 0x02; -pub const FLAG_COMMIT: u16 = 0x0001; +pub const DB_TAG_DOC: u8 = 0x02; +pub const FLAG_COMMIT: u16 = 0x0001; pub const EntryHeader = extern struct { - lsn: u64 align(1), - length: u32 align(1), - crc32: u32 align(1), - op_code: u8, - db_tag: u8, - flags: u16 align(1), - txn_id: u64 align(1), + lsn: u64 align(1), + length: u32 align(1), + crc32: u32 align(1), + op_code: u8, + db_tag: u8, + flags: u16 align(1), + txn_id: u64 align(1), reserved: u32 align(1), - comptime { std.debug.assert(@sizeOf(EntryHeader) == 32); } + comptime { + std.debug.assert(@sizeOf(EntryHeader) == 32); + } }; pub const HEADER_SIZE: usize = @sizeOf(EntryHeader); +pub const MAX_ENTRY_PAYLOAD: usize = 64 * 1024 * 1024; +const MAX_ENTRY_PAYLOAD_U32: u32 = @intCast(MAX_ENTRY_PAYLOAD); pub const Entry = struct { - lsn: u64, - txn_id: u64, + lsn: u64, + txn_id: u64, op_code: OpCode, - db_tag: u8, - flags: u16, - payload: []const u8, // slice into caller-owned buffer + db_tag: u8, + flags: u16, + payload: []const u8, // slice into caller-owned buffer }; +pub const BatchPayload = []const u8; + // ── CRC-32 (IEEE polynomial) ────────────────────────────────────────────────── fn crc32(data: []const u8) u32 { @@ -92,9 +99,9 @@ fn crc32(data: []const u8) u32 { fn entryChecksum(header_bytes: []const u8, payload: []const u8) u32 { // CRC over header (with crc field zeroed) ++ payload var h = std.hash.crc.Crc32.init(); - h.update(header_bytes[0..12]); // lsn + length - h.update(&[_]u8{0,0,0,0}); // zero-substitute crc32 field - h.update(header_bytes[16..]); // op..reserved + h.update(header_bytes[0..12]); // lsn + length + h.update(&[_]u8{ 0, 0, 0, 0 }); // zero-substitute crc32 field + h.update(header_bytes[16..]); // op..reserved h.update(payload); return h.final(); } @@ -102,43 +109,74 @@ fn entryChecksum(header_bytes: []const u8, payload: []const u8) u32 { // ── WAL ─────────────────────────────────────────────────────────────────────── pub const WAL = struct { - file: std.fs.File, - write_buf: std.ArrayList(u8), // pending (not yet flushed) - next_lsn: std.atomic.Value(u64), + file: compat.File, + uring: ?compat.LinuxUring, + write_buf: std.ArrayList(u8), // pending (not yet flushed) + spare_buf: std.ArrayList(u8), // reusable buffer from the previous flush + next_lsn: std.atomic.Value(u64), checkpoint_lsn: u64, - allocator: std.mem.Allocator, + buffered_lsn: u64, + append_offset: u64, + uring_flushes: usize, + sync_flushes: usize, + uring_min_write_and_sync: usize, + max_write_buf: usize, + allocator: std.mem.Allocator, // Group commit state — guarded by mu - mu: std.Thread.Mutex, - cond: std.Thread.Condition, - synced_lsn: u64, - flushing: bool, + mu: compat.Mutex, + cond: compat.Condition, + synced_lsn: u64, + flushing: bool, + last_flush_error: ?anyerror, // Background flusher flush_thread: ?std.Thread, flush_running: std.atomic.Value(bool), + /// Backpressure: block writers if pending buffer exceeds this. + const DEFAULT_MAX_WRITE_BUF: usize = 32 * 1024 * 1024; // 32 MiB + const DEFAULT_INITIAL_WRITE_BUF: usize = 1024 * 1024; // 1 MiB + const DEFAULT_URING_MIN_WRITE_AND_SYNC: usize = 32 * 1024; + // ── Lifecycle ───────────────────────────────────────────────────────────── pub fn open(path: [:0]const u8, allocator: std.mem.Allocator) !WAL { - const f = try std.fs.createFileAbsolute(path, .{ - .read = true, .truncate = false, .exclusive = false, - }); + const f = blk: { + const fd = std.c.open(path, .{ .ACCMODE = .RDWR, .CREAT = true }, @as(std.c.mode_t, 0o644)); + if (fd < 0) return error.CreateFileError; + break :blk compat.File{ .handle = fd }; + }; + const end = std.c.lseek(f.handle, 0, std.c.SEEK.END); var wal = WAL{ - .file = f, - .write_buf = .empty, - .next_lsn = std.atomic.Value(u64).init(1), + .file = f, + .uring = compat.initLinuxUring(8), + .write_buf = .empty, + .spare_buf = .empty, + .next_lsn = std.atomic.Value(u64).init(1), .checkpoint_lsn = 0, - .allocator = allocator, - .mu = .{}, - .cond = .{}, - .synced_lsn = 0, - .flushing = false, - .flush_thread = null, + .buffered_lsn = 0, + .append_offset = if (end >= 0) @intCast(end) else 0, + .uring_flushes = 0, + .sync_flushes = 0, + .uring_min_write_and_sync = compat.envUsize("TURBODB_IO_URING_MIN_BYTES", DEFAULT_URING_MIN_WRITE_AND_SYNC), + .max_write_buf = compat.envUsize("TURBODB_WAL_MAX_BUFFER_BYTES", DEFAULT_MAX_WRITE_BUF), + .allocator = allocator, + .mu = .{}, + .cond = .{}, + .synced_lsn = 0, + .flushing = false, + .last_flush_error = null, + .flush_thread = null, .flush_running = std.atomic.Value(bool).init(false), }; - // Seek to end (append mode) - try wal.file.seekFromEnd(0); + const initial_write_buf = compat.envUsize("TURBODB_WAL_INITIAL_BUFFER_BYTES", DEFAULT_INITIAL_WRITE_BUF); + if (initial_write_buf > 0) { + wal.write_buf.ensureTotalCapacity(allocator, initial_write_buf) catch |e| { + wal.close(); + return e; + }; + } return wal; } @@ -151,7 +189,7 @@ pub const WAL = struct { fn flushLoop(self: *WAL) void { while (self.flush_running.load(.acquire)) { - std.Thread.sleep(1_000_000); // 1ms + compat.sleep(1_000_000); // 1ms self.flushPending(); } // Final flush on shutdown @@ -161,6 +199,10 @@ pub const WAL = struct { /// Flush any pending WAL entries to disk (non-blocking for callers). pub fn flushPending(self: *WAL) void { self.mu.lock(); + if (self.last_flush_error != null) { + self.mu.unlock(); + return; + } if (self.write_buf.items.len == 0) { self.mu.unlock(); return; @@ -170,22 +212,39 @@ pub const WAL = struct { return; } self.flushing = true; - var to_write = self.write_buf; - const target = self.next_lsn.load(.monotonic) -| 1; - self.write_buf = .empty; + const target = self.buffered_lsn; + var to_write = self.takeWriteBufferForFlush(); self.mu.unlock(); - self.file.writeAll(to_write.items) catch {}; - to_write.deinit(self.allocator); - self.file.sync() catch {}; + const io_err = self.writeAndSync(to_write.items); self.mu.lock(); - self.synced_lsn = target; + self.recycleFlushedBuffer(&to_write); + if (io_err) |e| { + self.last_flush_error = e; + } else { + self.synced_lsn = target; + self.last_flush_error = null; + } self.flushing = false; self.cond.broadcast(); self.mu.unlock(); } + pub fn lastFlushError(self: *WAL) ?anyerror { + self.mu.lock(); + defer self.mu.unlock(); + return self.last_flush_error; + } + + pub fn usingLinuxUring(self: *const WAL) bool { + return self.uring != null; + } + + pub fn usedLinuxUring(self: *const WAL) bool { + return self.uring_flushes > 0; + } + pub fn close(self: *WAL) void { if (self.flush_running.load(.acquire)) { self.flush_running.store(false, .release); @@ -194,96 +253,256 @@ pub const WAL = struct { // Flush any remaining entries self.flushPending(); self.write_buf.deinit(self.allocator); + self.spare_buf.deinit(self.allocator); + if (self.uring) |*ring| { + compat.deinitLinuxUring(ring); + self.uring = null; + } self.file.close(); } // ── Write ───────────────────────────────────────────────────────────────── /// Encode an entry into the shared write buffer and return its LSN. - /// Thread-safe. Does NOT guarantee durability — call commit(lsn) for that. + /// Thread-safe. Does NOT guarantee durability — call commit(txn_id, db_tag) for that. + /// Blocks if the write buffer exceeds the configured backpressure limit. pub fn write( - self: *WAL, - txn_id: u64, - op: OpCode, - db_tag: u8, - flags: u16, + self: *WAL, + txn_id: u64, + op: OpCode, + db_tag: u8, + flags: u16, payload: []const u8, ) !u64 { - const lsn = self.next_lsn.fetchAdd(1, .monotonic); + if (payload.len > MAX_ENTRY_PAYLOAD) return error.WALPayloadTooLarge; + const pad = paddingTo8(HEADER_SIZE + payload.len); + const entry_size = HEADER_SIZE + payload.len + pad; + self.mu.lock(); + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } + // Backpressure: wait until flusher drains the buffer below threshold. + // Use condition variable instead of yield-loop so the flusher can wake us. + while (self.write_buf.items.len >= self.maxWriteBuf()) { + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } + // Release lock and wait for flusher to signal. + self.cond.wait(&self.mu); + } + self.write_buf.ensureUnusedCapacity(self.allocator, entry_size) catch |e| { + self.mu.unlock(); + return e; + }; + const lsn = self.next_lsn.fetchAdd(1, .monotonic); var hdr = EntryHeader{ - .lsn = lsn, - .length = @intCast(payload.len), - .crc32 = 0, - .op_code = @intFromEnum(op), - .db_tag = db_tag, - .flags = flags, - .txn_id = txn_id, + .lsn = lsn, + .length = @intCast(payload.len), + .crc32 = 0, + .op_code = @intFromEnum(op), + .db_tag = db_tag, + .flags = flags, + .txn_id = txn_id, .reserved = 0, }; const hdr_bytes = std.mem.asBytes(&hdr); hdr.crc32 = entryChecksum(hdr_bytes, payload); - - self.mu.lock(); defer self.mu.unlock(); - try self.write_buf.appendSlice(self.allocator, std.mem.asBytes(&hdr)); - try self.write_buf.appendSlice(self.allocator, payload); - if (pad > 0) try self.write_buf.appendNTimes(self.allocator, 0, pad); + self.write_buf.appendSliceAssumeCapacity(std.mem.asBytes(&hdr)); + self.write_buf.appendSliceAssumeCapacity(payload); + if (pad > 0) self.write_buf.appendNTimesAssumeCapacity(0, pad); + self.buffered_lsn = lsn; return lsn; } + /// Append a single-entry committed operation without forcing durability. + /// The background flusher or a later commit/checkpoint is responsible for fsync. + /// Use this only when the txn_id is not shared with a multi-entry transaction. + pub fn writeCommitted( + self: *WAL, + txn_id: u64, + op: OpCode, + db_tag: u8, + payload: []const u8, + ) !u64 { + return self.write(txn_id, op, db_tag, FLAG_COMMIT, payload); + } + + /// Append many self-committed entries while holding the WAL mutex once. + /// Returns the last LSN appended. Call flushUpTo(last_lsn) before + /// acknowledging if the caller requires durable ingestion. + pub fn writeCommittedBatch( + self: *WAL, + op: OpCode, + db_tag: u8, + payloads: []const BatchPayload, + ) !u64 { + if (payloads.len == 0) return self.synced_lsn; + + var bytes_needed: usize = 0; + for (payloads) |payload| { + if (payload.len > MAX_ENTRY_PAYLOAD) return error.WALPayloadTooLarge; + const raw_len = HEADER_SIZE + payload.len; + bytes_needed += raw_len + paddingTo8(raw_len); + } + + self.mu.lock(); + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } + while (self.write_buf.items.len >= self.maxWriteBuf()) { + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } + self.cond.wait(&self.mu); + } + self.write_buf.ensureUnusedCapacity(self.allocator, bytes_needed) catch |e| { + self.mu.unlock(); + return e; + }; + + const start_lsn = self.next_lsn.fetchAdd(@intCast(payloads.len), .monotonic); + for (payloads, 0..) |payload, i| { + const lsn = start_lsn + @as(u64, @intCast(i)); + const pad = paddingTo8(HEADER_SIZE + payload.len); + var hdr = EntryHeader{ + .lsn = lsn, + .length = @intCast(payload.len), + .crc32 = 0, + .op_code = @intFromEnum(op), + .db_tag = db_tag, + .flags = FLAG_COMMIT, + .txn_id = lsn, + .reserved = 0, + }; + const hdr_bytes = std.mem.asBytes(&hdr); + hdr.crc32 = entryChecksum(hdr_bytes, payload); + self.write_buf.appendSliceAssumeCapacity(std.mem.asBytes(&hdr)); + self.write_buf.appendSliceAssumeCapacity(payload); + if (pad > 0) self.write_buf.appendNTimesAssumeCapacity(0, pad); + } + self.buffered_lsn = start_lsn + @as(u64, @intCast(payloads.len - 1)); + self.mu.unlock(); + + return start_lsn + @as(u64, @intCast(payloads.len - 1)); + } + + /// Ensure every entry up to `target_lsn` is durably flushed. + pub fn flushUpTo(self: *WAL, target_lsn: u64) !void { + self.mu.lock(); + while (self.synced_lsn < target_lsn) { + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } + + if (self.flushing) { + self.cond.wait(&self.mu); + continue; + } + + if (self.write_buf.items.len == 0) { + self.mu.unlock(); + return error.WALNotDurable; + } + + self.flushing = true; + const flushed_lsn = self.buffered_lsn; + var to_write = self.takeWriteBufferForFlush(); + self.mu.unlock(); + + const io_err = self.writeAndSync(to_write.items); + + self.mu.lock(); + self.recycleFlushedBuffer(&to_write); + if (io_err) |e| { + self.last_flush_error = e; + } else { + self.synced_lsn = flushed_lsn; + self.last_flush_error = null; + } + self.flushing = false; + self.cond.broadcast(); + + if (io_err) |e| { + self.mu.unlock(); + return e; + } + } + self.mu.unlock(); + } + /// Mark a transaction committed. Returns only after the entry is durable. /// Implements group commit: the first caller flushes for everyone. pub fn commit(self: *WAL, txn_id: u64, db_tag: u8) !void { // Write COMMIT entry to buffer var commit_payload: [16]u8 = undefined; - std.mem.writeInt(u64, commit_payload[0..8], txn_id, .little); - std.mem.writeInt(u64, commit_payload[8..16], @as(u64, @truncate(@as(u128, @bitCast(std.time.nanoTimestamp())))), .little); + std.mem.writeInt(u64, commit_payload[0..8], txn_id, .little); + std.mem.writeInt(u64, commit_payload[8..16], @as(u64, @truncate(@as(u128, @bitCast(compat.nanoTimestamp())))), .little); const lsn = try self.write(txn_id, .txn_commit, db_tag, FLAG_COMMIT, &commit_payload); // Group commit: become flusher or wait self.mu.lock(); - if (self.synced_lsn >= lsn) { - self.mu.unlock(); - return; - } + while (self.synced_lsn < lsn) { + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } - if (self.flushing) { - // Wait for current flusher - while (self.synced_lsn < lsn) self.cond.wait(&self.mu); - self.mu.unlock(); - return; - } + if (self.flushing) { + // Wait for the current flusher, then either observe its LSN + // advance or become the next flusher for entries it missed. + self.cond.wait(&self.mu); + continue; + } - // We are the flusher - self.flushing = true; - // Snapshot the buffer and target LSN under the lock - var to_write: std.ArrayList(u8) = self.write_buf; - const target = self.next_lsn.load(.monotonic) - 1; - self.write_buf = .empty; - self.mu.unlock(); + if (self.write_buf.items.len == 0) { + self.mu.unlock(); + return error.WALNotDurable; + } - // ── I/O outside lock ────────────────────────────────────────────────── - var io_err: ?anyerror = null; - self.file.writeAll(to_write.items) catch |e| { io_err = e; }; - to_write.deinit(self.allocator); - if (io_err == null) self.file.sync() catch |e| { io_err = e; }; - // ───────────────────────────────────────────────────────────────────── + // We are the flusher + self.flushing = true; + // Snapshot the buffer and target LSN under the lock + const target = self.buffered_lsn; + var to_write = self.takeWriteBufferForFlush(); + self.mu.unlock(); - self.mu.lock(); - if (io_err == null) self.synced_lsn = target; - self.flushing = false; - self.cond.broadcast(); - self.mu.unlock(); + // ── I/O outside lock ────────────────────────────────────────────── + const io_err = self.writeAndSync(to_write.items); + // ───────────────────────────────────────────────────────────────── + + self.mu.lock(); + self.recycleFlushedBuffer(&to_write); + if (io_err) |e| { + self.last_flush_error = e; + } else { + self.synced_lsn = target; + self.last_flush_error = null; + } + self.flushing = false; + self.cond.broadcast(); - if (io_err) |e| return e; + if (io_err) |e| { + self.mu.unlock(); + return e; + } + } + self.mu.unlock(); } // ── Checkpoint ──────────────────────────────────────────────────────────── - /// Write a checkpoint barrier entry and update checkpoint_lsn. + /// Write a checkpoint barrier entry, flush, then truncate the WAL file. + /// After checkpoint all data is durable in page files, so the WAL can be + /// safely cleared to reclaim disk space. pub fn checkpoint(self: *WAL, db_tag: u8) !void { var p: [8]u8 = undefined; std.mem.writeInt(u64, &p, self.checkpoint_lsn, .little); @@ -291,15 +510,33 @@ pub const WAL = struct { self.checkpoint_lsn = lsn; // Force flush self.mu.lock(); + if (self.last_flush_error) |e| { + self.mu.unlock(); + return e; + } self.flushing = true; - var to_write = self.write_buf; - self.write_buf = .empty; + var to_write = self.takeWriteBufferForFlush(); self.mu.unlock(); - self.file.writeAll(to_write.items) catch {}; - to_write.deinit(self.allocator); - try self.file.sync(); + const io_err = self.writeAndSync(to_write.items); + if (io_err) |e| { + self.mu.lock(); + self.recycleFlushedBuffer(&to_write); + self.last_flush_error = e; + self.flushing = false; + self.cond.broadcast(); + self.mu.unlock(); + return e; + } + + // Truncate WAL — all data is checkpointed to page files. + self.file.seekTo(0) catch {}; + self.file.setEndPos(0) catch {}; + self.append_offset = 0; + self.mu.lock(); + self.recycleFlushedBuffer(&to_write); self.synced_lsn = lsn; + self.last_flush_error = null; self.flushing = false; self.cond.broadcast(); self.mu.unlock(); @@ -311,10 +548,10 @@ pub const WAL = struct { /// entry with lsn > skip_before_lsn. On return the file pointer is at EOF /// and ready for new appends. pub fn recover( - self: *WAL, + self: *WAL, skip_before_lsn: u64, - apply_fn: *const fn (entry: Entry) anyerror!void, - allocator: std.mem.Allocator, + apply_fn: *const fn (entry: Entry) anyerror!void, + allocator: std.mem.Allocator, ) !void { try self.file.seekTo(0); @@ -333,6 +570,7 @@ pub const WAL = struct { // ── Pass 2: replay committed entries ───────────────────────────────── try self.file.seekTo(0); var max_lsn: u64 = 0; + var valid_end: u64 = 0; { var it = EntryIterator.init(self.file, allocator); defer it.deinit(); @@ -341,15 +579,20 @@ pub const WAL = struct { if (e.lsn <= skip_before_lsn) continue; if (!committed.contains(e.txn_id)) continue; if (e.op_code == .txn_commit or e.op_code == .txn_begin or - e.op_code == .txn_abort or e.op_code == .checkpoint) continue; + e.op_code == .txn_abort or e.op_code == .checkpoint) continue; try apply_fn(e); } + valid_end = it.valid_end; } + const stat = try self.file.stat(); + if (valid_end < stat.size) try self.file.setEndPos(valid_end); + // ── Advance LSN counter and seek to EOF for new writes ──────────────── self.next_lsn.store(max_lsn + 1, .release); self.synced_lsn = max_lsn; - try self.file.seekFromEnd(0); + self.append_offset = valid_end; + try self.file.seekTo(valid_end); std.log.info("WAL recovery: replayed up to lsn={d}", .{max_lsn}); } @@ -358,20 +601,71 @@ pub const WAL = struct { inline fn paddingTo8(n: usize) usize { return (8 - (n % 8)) % 8; } + + fn maxWriteBuf(self: *const WAL) usize { + return @max(self.max_write_buf, HEADER_SIZE + 1); + } + + fn takeWriteBufferForFlush(self: *WAL) std.ArrayList(u8) { + const to_write = self.write_buf; + self.write_buf = self.spare_buf; + self.spare_buf = .empty; + self.write_buf.clearRetainingCapacity(); + self.cond.broadcast(); + return to_write; + } + + fn recycleFlushedBuffer(self: *WAL, flushed: *std.ArrayList(u8)) void { + flushed.clearRetainingCapacity(); + if (self.write_buf.items.len == 0) { + self.write_buf.deinit(self.allocator); + self.write_buf = flushed.*; + } else { + self.spare_buf.deinit(self.allocator); + self.spare_buf = flushed.*; + } + flushed.* = .empty; + } + + fn writeAndSync(self: *WAL, bytes: []const u8) ?anyerror { + if (bytes.len >= self.uring_min_write_and_sync) { + if (self.uring) |*ring| { + compat.uringWriteAndSyncAt(ring, self.file, bytes, self.append_offset) catch { + compat.deinitLinuxUring(ring); + self.uring = null; + }; + if (self.uring != null) { + self.append_offset += bytes.len; + self.uring_flushes += 1; + return null; + } + } + } + + self.file.pwriteAll(bytes, self.append_offset) catch |e| return e; + self.file.syncData() catch |e| return e; + self.append_offset += bytes.len; + self.sync_flushes += 1; + return null; + } }; // ── Entry iterator (for recovery) ──────────────────────────────────────────── const EntryIterator = struct { - reader: std.io.BufferedReader(4096, std.fs.File.Reader), - buf: []u8, + reader: compat.GenericBufferedReader(4096, compat.File.Reader), + buf: []u8, allocator: std.mem.Allocator, + pos: u64, + valid_end: u64, - fn init(file: std.fs.File, allocator: std.mem.Allocator) EntryIterator { + fn init(file: compat.File, allocator: std.mem.Allocator) EntryIterator { return .{ - .reader = std.io.bufferedReader(file.reader()), - .buf = &.{}, + .reader = compat.bufferedReader(file.reader()), + .buf = &.{}, .allocator = allocator, + .pos = 0, + .valid_end = 0, }; } @@ -382,42 +676,332 @@ const EntryIterator = struct { /// Returns null at EOF or on a corrupt entry. fn next(self: *EntryIterator) !?Entry { var hdr_buf: [HEADER_SIZE]u8 = undefined; + const entry_start = self.pos; const n = self.reader.reader().readAll(&hdr_buf) catch return null; + self.pos += n; + if (n == 0) return null; if (n < HEADER_SIZE) return null; const hdr: *const EntryHeader = @ptrCast(&hdr_buf); if (hdr.lsn == 0) return null; // padding / sentinel + if (hdr.length > MAX_ENTRY_PAYLOAD_U32) return null; // absurd/corrupt length // Read payload - if (hdr.length > 0) { - if (self.buf.len < hdr.length) { + const payload_len: usize = @intCast(hdr.length); + if (payload_len > 0) { + if (self.buf.len < payload_len) { if (self.buf.len > 0) self.allocator.free(self.buf); - self.buf = try self.allocator.alloc(u8, hdr.length); + self.buf = try self.allocator.alloc(u8, payload_len); } - const p = self.reader.reader().readAll(self.buf[0..hdr.length]) catch return null; - if (p < hdr.length) return null; + const p = self.reader.reader().readAll(self.buf[0..payload_len]) catch return null; + self.pos += p; + if (p < payload_len) return null; } - const payload: []const u8 = if (hdr.length > 0) self.buf[0..hdr.length] else &.{}; + const payload: []const u8 = if (payload_len > 0) self.buf[0..payload_len] else &.{}; // Verify CRC const expected = entryChecksum(&hdr_buf, payload); if (expected != hdr.crc32) return null; // corrupt — stop recovery // Skip padding - const pad = WAL.paddingTo8(HEADER_SIZE + hdr.length); + const pad = WAL.paddingTo8(HEADER_SIZE + payload_len); if (pad > 0) { var skip: [7]u8 = undefined; - _ = self.reader.reader().readAll(skip[0..pad]) catch {}; + const skipped = self.reader.reader().readAll(skip[0..pad]) catch return null; + self.pos += skipped; + if (skipped < pad) return null; } + self.valid_end = entry_start + HEADER_SIZE + payload_len + pad; return Entry{ - .lsn = hdr.lsn, - .txn_id = hdr.txn_id, + .lsn = hdr.lsn, + .txn_id = hdr.txn_id, .op_code = @enumFromInt(hdr.op_code), - .db_tag = hdr.db_tag, - .flags = hdr.flags, + .db_tag = hdr.db_tag, + .flags = hdr.flags, .payload = payload, }; } }; + +// ── Tests ─────────────────────────────────────────────────────────────────── + +fn testingWalPath(allocator: std.mem.Allocator, tmp: *const std.testing.TmpDir, name: []const u8) ![:0]u8 { + return std.fmt.allocPrintSentinel(allocator, ".zig-cache/tmp/{s}/{s}", .{ tmp.sub_path, name }, 0); +} + +const ReplayProbe = struct { + var count: usize = 0; + var last_lsn: u64 = 0; + var last_txn_id: u64 = 0; + var last_op: OpCode = .nop; + var last_flags: u16 = 0; + var payload_buf: [128]u8 = undefined; + var payload_len: usize = 0; + + fn reset() void { + count = 0; + last_lsn = 0; + last_txn_id = 0; + last_op = .nop; + last_flags = 0; + payload_len = 0; + } + + fn apply(entry: Entry) !void { + if (entry.payload.len > payload_buf.len) return error.PayloadTooLarge; + count += 1; + last_lsn = entry.lsn; + last_txn_id = entry.txn_id; + last_op = entry.op_code; + last_flags = entry.flags; + payload_len = entry.payload.len; + @memcpy(payload_buf[0..payload_len], entry.payload); + } + + fn payload() []const u8 { + return payload_buf[0..payload_len]; + } +}; + +test "WAL recovery replays committed entries only" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "committed-only.wal"); + defer allocator.free(path); + + { + var wal = try WAL.open(path, allocator); + _ = try wal.write(10, .doc_insert, DB_TAG_DOC, 0, "committed"); + try wal.commit(10, DB_TAG_DOC); + _ = try wal.write(11, .doc_insert, DB_TAG_DOC, 0, "uncommitted"); + wal.close(); + } + + { + var wal = try WAL.open(path, allocator); + defer wal.close(); + + ReplayProbe.reset(); + try wal.recover(0, ReplayProbe.apply, allocator); + + try std.testing.expectEqual(@as(usize, 1), ReplayProbe.count); + try std.testing.expectEqual(OpCode.doc_insert, ReplayProbe.last_op); + try std.testing.expectEqual(@as(u64, 10), ReplayProbe.last_txn_id); + try std.testing.expectEqualStrings("committed", ReplayProbe.payload()); + } +} + +test "writeCommitted buffers committed entry without forcing fsync" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "write-committed.wal"); + defer allocator.free(path); + + { + var wal = try WAL.open(path, allocator); + const lsn = try wal.writeCommitted(40, .doc_insert, DB_TAG_DOC, "inline"); + + try std.testing.expectEqual(@as(u64, 1), lsn); + try std.testing.expectEqual(@as(u64, 0), wal.synced_lsn); + try std.testing.expect(wal.write_buf.items.len > 0); + + wal.flushPending(); + try std.testing.expectEqual(lsn, wal.synced_lsn); + wal.close(); + } + + { + var wal = try WAL.open(path, allocator); + defer wal.close(); + + ReplayProbe.reset(); + try wal.recover(0, ReplayProbe.apply, allocator); + + try std.testing.expectEqual(@as(usize, 1), ReplayProbe.count); + try std.testing.expectEqual(OpCode.doc_insert, ReplayProbe.last_op); + try std.testing.expectEqual(@as(u64, 40), ReplayProbe.last_txn_id); + try std.testing.expect((ReplayProbe.last_flags & FLAG_COMMIT) != 0); + try std.testing.expectEqualStrings("inline", ReplayProbe.payload()); + } +} + +test "flushPending only marks buffered LSNs durable" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "buffered-target.wal"); + defer allocator.free(path); + + var wal = try WAL.open(path, allocator); + const lsn = try wal.writeCommitted(41, .doc_insert, DB_TAG_DOC, "inline"); + + _ = wal.next_lsn.fetchAdd(5, .monotonic); + wal.flushPending(); + + try std.testing.expectEqual(lsn, wal.synced_lsn); + try std.testing.expectEqual(@as(usize, 0), wal.write_buf.items.len); + + wal.close(); +} + +test "writeCommittedBatch plus flushUpTo durably replays all entries" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "batch-committed.wal"); + defer allocator.free(path); + + { + var wal = try WAL.open(path, allocator); + defer wal.close(); + + const payloads = [_]BatchPayload{ "one", "two", "three" }; + const last_lsn = try wal.writeCommittedBatch(.doc_insert, DB_TAG_DOC, payloads[0..]); + try wal.flushUpTo(last_lsn); + try std.testing.expect(wal.synced_lsn >= last_lsn); + } + + { + var wal = try WAL.open(path, allocator); + defer wal.close(); + + ReplayProbe.reset(); + try wal.recover(0, ReplayProbe.apply, allocator); + try std.testing.expectEqual(@as(usize, 3), ReplayProbe.count); + try std.testing.expectEqual(OpCode.doc_insert, ReplayProbe.last_op); + try std.testing.expectEqual(@as(u64, 3), ReplayProbe.last_txn_id); + try std.testing.expect((ReplayProbe.last_flags & FLAG_COMMIT) != 0); + try std.testing.expectEqualStrings("three", ReplayProbe.payload()); + } +} + +test "commit still forces durable transaction commit record" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "commit-durable.wal"); + defer allocator.free(path); + + var wal = try WAL.open(path, allocator); + _ = try wal.write(50, .doc_insert, DB_TAG_DOC, 0, "durable"); + try wal.commit(50, DB_TAG_DOC); + + try std.testing.expectEqual(@as(u64, 2), wal.synced_lsn); + try std.testing.expectEqual(@as(usize, 0), wal.write_buf.items.len); + + wal.close(); +} + +test "WAL recovery truncates torn tail after committed entries" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "torn-tail.wal"); + defer allocator.free(path); + + { + var wal = try WAL.open(path, allocator); + _ = try wal.write(20, .doc_insert, DB_TAG_DOC, 0, "kept"); + try wal.commit(20, DB_TAG_DOC); + wal.close(); + } + + var before_size: u64 = 0; + { + var file = try compat.openFileAbsolute(path, .{}); + defer file.close(); + _ = std.c.lseek(file.handle, 0, std.c.SEEK.END); + try file.writeAll("torn-tail"); + try file.sync(); + before_size = (try file.stat()).size; + } + + { + var wal = try WAL.open(path, allocator); + defer wal.close(); + + ReplayProbe.reset(); + try wal.recover(0, ReplayProbe.apply, allocator); + + const after_size = (try wal.file.stat()).size; + try std.testing.expect(after_size < before_size); + try std.testing.expectEqual(@as(usize, 1), ReplayProbe.count); + try std.testing.expectEqualStrings("kept", ReplayProbe.payload()); + try std.testing.expectEqual(@as(u64, 3), wal.next_lsn.load(.acquire)); + try std.testing.expectEqual(@as(u64, 2), wal.synced_lsn); + } +} + +test "WAL recovery treats oversized payload length as corrupt tail" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "oversized-tail.wal"); + defer allocator.free(path); + + { + var file = try compat.createFileAbsolute(path, .{}); + defer file.close(); + + var hdr = EntryHeader{ + .lsn = 1, + .length = MAX_ENTRY_PAYLOAD_U32 + 1, + .crc32 = 0, + .op_code = @intFromEnum(OpCode.doc_insert), + .db_tag = DB_TAG_DOC, + .flags = FLAG_COMMIT, + .txn_id = 60, + .reserved = 0, + }; + hdr.crc32 = entryChecksum(std.mem.asBytes(&hdr), &.{}); + try file.writeAll(std.mem.asBytes(&hdr)); + try file.sync(); + } + + { + var wal = try WAL.open(path, allocator); + defer wal.close(); + + ReplayProbe.reset(); + try wal.recover(0, ReplayProbe.apply, allocator); + + const after_size = (try wal.file.stat()).size; + try std.testing.expectEqual(@as(usize, 0), ReplayProbe.count); + try std.testing.expectEqual(@as(u64, 0), after_size); + try std.testing.expectEqual(@as(u64, 1), wal.next_lsn.load(.acquire)); + try std.testing.expectEqual(@as(u64, 0), wal.synced_lsn); + } +} + +test "flushPending records write failure without advancing synced lsn" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + const path = try testingWalPath(allocator, &tmp, "flush-error.wal"); + defer allocator.free(path); + + var wal = try WAL.open(path, allocator); + const lsn = try wal.write(30, .doc_insert, DB_TAG_DOC, 0, "not-durable"); + + wal.file.close(); + wal.file.handle = -1; + wal.flushPending(); + + try std.testing.expect(wal.synced_lsn < lsn); + try std.testing.expect(wal.lastFlushError() != null); + try std.testing.expect(wal.lastFlushError().? == error.WriteError); + + wal.write_buf.deinit(allocator); +} diff --git a/src/tdb.zig b/src/tdb.zig index f336637..7386d8c 100644 --- a/src/tdb.zig +++ b/src/tdb.zig @@ -11,6 +11,7 @@ /// tdb --data data directory (default: ./turbodb_data) /// const std = @import("std"); +const compat = @import("compat"); const collection_mod = @import("collection.zig"); const codeindex = @import("codeindex.zig"); const Database = collection_mod.Database; @@ -22,13 +23,13 @@ const EXTS = [_][]const u8{ ".json", ".toml", ".yaml", ".yml", ".md", }; -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); +pub fn main(init: std.process.Init) !void { + // GPA replaced by smp_allocator for Zig 0.16 - const args = try std.process.argsAlloc(alloc); - defer std.process.argsFree(alloc, args); + const alloc = std.heap.smp_allocator; + + const args = try compat.argsAlloc(alloc, init); + defer compat.argsFree(alloc, args); var data_dir: []const u8 = "./turbodb_data"; var col_name: []const u8 = "code"; @@ -62,7 +63,7 @@ pub fn main() !void { } // Ensure data dir - std.fs.cwd().makeDir(data_dir) catch |e| switch (e) { + compat.cwd().makeDir(data_dir) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return e, }; @@ -119,7 +120,7 @@ pub fn main() !void { fn cmdIndex(db: *Database, col_name: []const u8, dir_path: []const u8, alloc: std.mem.Allocator) !void { const col = try db.collection(col_name); - var dir = std.fs.cwd().openDir(dir_path, .{ .iterate = true }) catch |e| { + var dir = compat.cwd().openDir(dir_path, .{ .iterate = true }) catch |e| { std.debug.print("Cannot open directory: {any}\n", .{e}); return; }; @@ -127,17 +128,22 @@ fn cmdIndex(db: *Database, col_name: []const u8, dir_path: []const u8, alloc: st var indexed: u64 = 0; var skipped: u64 = 0; - const t0 = std.time.nanoTimestamp(); + const t0 = compat.nanoTimestamp(); - var walker = try dir.walk(alloc); - defer walker.deinit(); + var walker = dir.iterate(); + // iterate needs no deinit while (try walker.next()) |entry| { if (entry.kind != .file) continue; - if (!hasValidExt(entry.basename)) continue; + if (!hasValidExt(entry.name)) continue; // Read file content (first 8KB for indexing) - var file = dir.openFile(entry.path, .{}) catch { + var path_buf2: [4096]u8 = undefined; + const full_name = std.fmt.bufPrint(&path_buf2, "{s}/{s}", .{ dir_path, entry.name }) catch { + skipped += 1; + continue; + }; + var file = compat.cwd().openFile(full_name, .{}) catch { skipped += 1; continue; }; @@ -153,7 +159,7 @@ fn cmdIndex(db: *Database, col_name: []const u8, dir_path: []const u8, alloc: st continue; } // Dupe path — walker buffer is reused, but trigram index stores pointers - const stable_path = alloc.dupe(u8, entry.path) catch { + const stable_path = alloc.dupe(u8, entry.name) catch { skipped += 1; continue; }; @@ -164,14 +170,14 @@ fn cmdIndex(db: *Database, col_name: []const u8, dir_path: []const u8, alloc: st indexed += 1; if (indexed % 1000 == 0) { - const elapsed_ns = std.time.nanoTimestamp() - t0; + const elapsed_ns = compat.nanoTimestamp() - t0; const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / 1e9; const rate = @as(f64, @floatFromInt(indexed)) / elapsed_s; std.debug.print("\r Indexed {d} files ({d:.0} files/s)...", .{ indexed, rate }); } } - const elapsed_ns = std.time.nanoTimestamp() - t0; + const elapsed_ns = compat.nanoTimestamp() - t0; const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / 1e9; const rate = @as(f64, @floatFromInt(indexed)) / elapsed_s; @@ -183,10 +189,10 @@ fn cmdIndex(db: *Database, col_name: []const u8, dir_path: []const u8, alloc: st fn cmdSearch(db: *Database, col_name: []const u8, query: []const u8, alloc: std.mem.Allocator) !void { const col = try db.collection(col_name); - const t0 = std.time.nanoTimestamp(); + const t0 = compat.nanoTimestamp(); const result = try col.searchText(query, 20, alloc); defer result.deinit(); - const elapsed_ns = std.time.nanoTimestamp() - t0; + const elapsed_ns = compat.nanoTimestamp() - t0; const elapsed_us = @as(f64, @floatFromInt(elapsed_ns)) / 1e3; const n_cand = result.candidate_paths.len; @@ -250,9 +256,9 @@ fn cmdBench(db: *Database, col_name: []const u8, dir_path: []const u8, alloc: st var total_candidates: u64 = 0; for (queries) |q| { - const t0 = std.time.nanoTimestamp(); + const t0 = compat.nanoTimestamp(); const result = try col.searchText(q, 50, alloc); - const elapsed_ns = std.time.nanoTimestamp() - t0; + const elapsed_ns = compat.nanoTimestamp() - t0; const elapsed_us = @as(f64, @floatFromInt(elapsed_ns)) / 1e3; total_us += elapsed_us; total_hits += result.docs.len; @@ -330,7 +336,7 @@ fn findSnippet(value: []const u8, query: []const u8) []const u8 { if (value[ls] == '\n' and ls < vi) ls += 1; var le = end; while (le < value.len and value[le] != '\n') le += 1; - return std.mem.trimRight(u8, value[ls..le], " \t\r\n"); + return std.mem.trimEnd(u8, value[ls..le], " \t\r\n"); } } return ""; diff --git a/src/test_calvin_e2e.zig b/src/test_calvin_e2e.zig index 82445b0..c67c7c6 100644 --- a/src/test_calvin_e2e.zig +++ b/src/test_calvin_e2e.zig @@ -7,6 +7,7 @@ /// 4. Both nodes execute deterministically /// 5. Verify both databases have identical state const std = @import("std"); +const compat = @import("compat"); const collection_mod = @import("collection.zig"); const doc_mod = @import("doc.zig"); const sequencer = @import("replication/sequencer.zig"); @@ -89,17 +90,17 @@ fn makeTxn(alloc: std.mem.Allocator, txn_id: u64, txn_type: sequencer.TxnType, c } pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const alloc = gpa.allocator(); + // GPA replaced by smp_allocator for Zig 0.16 + + const alloc = std.heap.smp_allocator; // Clean up any previous test data - std.fs.cwd().deleteTree("/tmp/calvin_test_leader") catch {}; - std.fs.cwd().deleteTree("/tmp/calvin_test_replica") catch {}; + compat.cwd().deleteTree("/tmp/calvin_test_leader") catch {}; + compat.cwd().deleteTree("/tmp/calvin_test_replica") catch {}; // Create data directories - std.fs.makeDirAbsolute("/tmp/calvin_test_leader") catch {}; - std.fs.makeDirAbsolute("/tmp/calvin_test_replica") catch {}; + compat.cwd().makeDir("/tmp/calvin_test_leader") catch {}; + compat.cwd().makeDir("/tmp/calvin_test_replica") catch {}; // ── Step 1: Open two separate databases ────────────────────────────── std.debug.print("\n=== TurboDB Calvin Replication E2E Test ===\n\n", .{}); @@ -236,6 +237,6 @@ pub fn main() !void { } // Cleanup - std.fs.cwd().deleteTree("/tmp/calvin_test_leader") catch {}; - std.fs.cwd().deleteTree("/tmp/calvin_test_replica") catch {}; + compat.cwd().deleteTree("/tmp/calvin_test_leader") catch {}; + compat.cwd().deleteTree("/tmp/calvin_test_replica") catch {}; } diff --git a/src/trigram.zig b/src/trigram.zig index d5f9c20..12670e9 100644 --- a/src/trigram.zig +++ b/src/trigram.zig @@ -24,6 +24,11 @@ pub const TrigramIndex = struct { doc_count: u64, trigram_count: u64, + /// Maximum posting list length per trigram. Trigrams appearing in more docs + /// than this are too common to be useful for search — stop tracking them to + /// bound memory. 10K docs × 8 bytes = 80KB per trigram, manageable. + const MAX_POSTINGS_PER_TRIGRAM: usize = 10_000; + pub fn init(alloc: std.mem.Allocator) TrigramIndex { return .{ .postings = std.AutoHashMap(u24, std.ArrayList(u64)).init(alloc), @@ -53,6 +58,8 @@ pub const TrigramIndex = struct { self.trigram_count += 1; } const list = gop.value_ptr; + // Skip overly common trigrams to bound memory. + if (list.items.len >= MAX_POSTINGS_PER_TRIGRAM) continue; if (list.items.len == 0 or list.items[list.items.len - 1] != doc_id) { try list.append(self.alloc, doc_id); } diff --git a/src/ttl.zig b/src/ttl.zig index f985251..78a9c40 100644 --- a/src/ttl.zig +++ b/src/ttl.zig @@ -8,6 +8,7 @@ /// separate hash map keyed by doc_id. This avoids modifying the 32-byte /// DocHeader format. const std = @import("std"); +const compat = @import("compat"); const Allocator = std.mem.Allocator; /// A TTL entry: document ID → expiry timestamp. @@ -19,7 +20,7 @@ const TTLEntry = struct { /// TTL index for a single collection. pub const TTLIndex = struct { entries: std.ArrayListUnmanaged(TTLEntry) = .empty, - lock: std.Thread.RwLock = .{}, + lock: compat.RwLock = .{}, pub fn deinit(self: *TTLIndex, alloc: Allocator) void { self.entries.deinit(alloc); @@ -27,7 +28,7 @@ pub const TTLIndex = struct { /// Set TTL for a document. `ttl_seconds` is relative to now. pub fn setTTL(self: *TTLIndex, alloc: Allocator, doc_id: u64, ttl_seconds: u64) !void { - const now: u64 = @intCast(@divFloor(std.time.milliTimestamp(), 1000)); + const now: u64 = @intCast(@divFloor(compat.milliTimestamp(), 1000)); const expires_at = now + ttl_seconds; self.lock.lock(); @@ -74,7 +75,7 @@ pub const TTLIndex = struct { /// Collect all expired doc_ids. Caller owns the returned slice. pub fn collectExpired(self: *TTLIndex, alloc: Allocator) ![]u64 { - const now: u64 = @intCast(@divFloor(std.time.milliTimestamp(), 1000)); + const now: u64 = @intCast(@divFloor(compat.milliTimestamp(), 1000)); var expired: std.ArrayListUnmanaged(u64) = .empty; self.lock.lockShared(); @@ -90,7 +91,7 @@ pub const TTLIndex = struct { /// Purge expired entries from the index (call after deleting the docs). pub fn purgeExpired(self: *TTLIndex) void { - const now: u64 = @intCast(@divFloor(std.time.milliTimestamp(), 1000)); + const now: u64 = @intCast(@divFloor(compat.milliTimestamp(), 1000)); self.lock.lock(); defer self.lock.unlock(); diff --git a/src/wire.zig b/src/wire.zig index 3e0553b..fd9f66f 100644 --- a/src/wire.zig +++ b/src/wire.zig @@ -3,10 +3,20 @@ /// Frame: [len:u32 BE][op:u8][payload...] /// len includes the 5-byte header. /// -/// Ops: INSERT=0x01 GET=0x02 UPDATE=0x03 DELETE=0x04 SCAN=0x05 PING=0x06 +/// Ops: INSERT=0x01 GET=0x02 UPDATE=0x03 DELETE=0x04 SCAN=0x05 PING=0x06 BATCH=0x07 /// Status: OK=0x00 NOT_FOUND=0x01 ERROR=0x02 +/// +/// Batch v1 request payload: [count:u16 LE] repeated [op:u8][payload_len:u32 LE][payload]. +/// Batch v1 response payload: [status:u8][count:u16 LE] repeated [op:u8][status:u8][payload_len:u32 LE][payload]. +/// +/// Wire v2 rides inside the same outer frame with op=0x20 so v1 clients can +/// keep using the same listener. V2 payload: +/// request: [ver:u8=2][flags:u8][request_id:u64 LE][op:u8][reserved:u8][body_len:u32 LE][body] +/// response: [ver:u8=2][flags:u8][request_id:u64 LE][op:u8][status:u8][body_len:u32 LE][body] const std = @import("std"); +const compat = @import("compat"); const activity = @import("activity.zig"); +const auth = @import("auth.zig"); const collection_mod = @import("collection.zig"); const Database = collection_mod.Database; const Collection = collection_mod.Collection; @@ -17,65 +27,106 @@ const OP_UPDATE: u8 = 0x03; const OP_DELETE: u8 = 0x04; const OP_SCAN: u8 = 0x05; const OP_PING: u8 = 0x06; +const OP_BATCH: u8 = 0x07; +const OP_WIRE2: u8 = 0x20; + +pub const WIRE2_VERSION: u8 = 2; +pub const WIRE2_OP_HELLO: u8 = 0x00; +pub const WIRE2_OP_PING: u8 = 0x01; +pub const WIRE2_OP_GET: u8 = 0x02; +pub const WIRE2_OP_PUT: u8 = 0x03; +pub const WIRE2_OP_INSERT: u8 = 0x04; +pub const WIRE2_OP_DELETE: u8 = 0x05; +pub const WIRE2_OP_BATCH_GET: u8 = 0x06; +pub const WIRE2_OP_BULK_INSERT: u8 = 0x07; +pub const WIRE2_OP_BULK_UPSERT: u8 = 0x08; +pub const WIRE2_OP_BULK_DELETE: u8 = 0x09; +pub const WIRE2_OP_AUTH: u8 = 0x0A; const STATUS_OK: u8 = 0x00; const STATUS_NOT_FOUND: u8 = 0x01; const STATUS_ERROR: u8 = 0x02; +const STATUS_UNAUTHORIZED: u8 = 0x03; +const STATUS_FORBIDDEN: u8 = 0x04; +const STATUS_TOO_LARGE: u8 = 0x05; const HDR: usize = 5; -const MAX_FRAME: usize = 1048576; const RD_BUF: usize = 65536; -const WR_BUF: usize = 131072; +const WR_BUF: usize = 4 * 1024 * 1024; +const MAX_FRAME: usize = 64 * 1024 * 1024 + HDR; +const BATCH_REQ_HDR: usize = 2; +const BATCH_ITEM_HDR: usize = 5; +const BATCH_RESP_HDR: usize = HDR + 1 + 2; +const BATCH_ITEM_RESP_HDR: usize = 1 + 1 + 4; +const WIRE2_HDR: usize = 16; +const WIRE2_FEATURE_AUTH: u32 = 1 << 0; +const WIRE2_FEATURE_PIPELINING: u32 = 1 << 1; +const WIRE2_FEATURE_BATCH_GET: u32 = 1 << 2; +const WIRE2_FEATURE_BULK_WRITE: u32 = 1 << 3; +const WIRE2_BULK_INSERT_CHUNK_ROWS: usize = 16384; +const WIRE2_BULK_INSERT_CHUNK_BYTES: usize = 16 * 1024 * 1024; +const CONN_THREAD_STACK_SIZE = 1024 * 1024; pub const WireServer = struct { db: *Database, port: u16, running: std.atomic.Value(bool), activity: activity.ActivityTracker, + op_mu: compat.RwLock, pub fn init(db: *Database, port: u16) WireServer { - return .{ .db = db, .port = port, .running = std.atomic.Value(bool).init(false), .activity = activity.ActivityTracker.init() }; + return .{ + .db = db, + .port = port, + .running = std.atomic.Value(bool).init(false), + .activity = activity.ActivityTracker.init(), + .op_mu = .{}, + }; } pub fn run(self: *WireServer) !void { - const addr = try std.net.Address.parseIp("0.0.0.0", self.port); + const addr = try compat.net.Address.parseIp("0.0.0.0", self.port); var listener = try addr.listen(.{ .reuse_address = true, .kernel_backlog = 1024 }); defer listener.deinit(); self.running.store(true, .release); std.log.info("TurboDB wire protocol on :{d}", .{self.port}); while (self.running.load(.acquire)) { const conn = listener.accept() catch continue; - const t = std.Thread.spawn(.{}, handleConn, .{ self, conn }) catch continue; + const t = std.Thread.spawn(.{ .stack_size = CONN_THREAD_STACK_SIZE }, handleConn, .{ self, conn }) catch continue; t.detach(); } } pub fn runUnix(self: *WireServer, path: []const u8) !void { - // Remove any existing socket file - // Remove any existing socket file - std.posix.unlink(path) catch {}; - const fd = try std.posix.socket(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0); - defer std.posix.close(fd); + // Remove existing socket + { + var zbuf: [256]u8 = undefined; + @memcpy(zbuf[0..path.len], path); + zbuf[path.len] = 0; + _ = std.c.unlink(@ptrCast(&zbuf)); + } + const fd = std.c.socket(1, 1, 0); // AF_UNIX=1, SOCK_STREAM=1 + if (fd < 0) return error.SocketError; + defer _ = std.c.close(fd); // Construct sockaddr_un - var addr: std.posix.sockaddr.un = .{ .family = std.posix.AF.UNIX, .path = undefined }; + var addr: extern struct { family: u16, path: [104]u8 } = .{ .family = 1, .path = undefined }; @memset(&addr.path, 0); if (path.len >= addr.path.len) return error.PathTooLong; @memcpy(addr.path[0..path.len], path); - try std.posix.bind(fd, @ptrCast(&addr), @sizeOf(std.posix.sockaddr.un)); - try std.posix.listen(fd, 1024); + if (std.c.bind(fd, @ptrCast(&addr), @sizeOf(@TypeOf(addr))) != 0) return error.BindError; + if (std.c.listen(fd, 1024) != 0) return error.ListenError; self.running.store(true, .release); std.log.info("TurboDB wire protocol on unix:{s}", .{path}); while (self.running.load(.acquire)) { - var client_addr: std.posix.sockaddr.un = undefined; - var addr_len: std.posix.socklen_t = @sizeOf(std.posix.sockaddr.un); - const client_fd = std.posix.accept(fd, @ptrCast(&client_addr), &addr_len, 0) catch continue; - const stream = std.net.Stream{ .handle = client_fd }; - const conn = std.net.Server.Connection{ .stream = stream, .address = std.net.Address.initUnix(path) catch continue }; - const t = std.Thread.spawn(.{}, handleConn, .{ self, conn }) catch continue; + const client_fd = std.c.accept(fd, null, null); + if (client_fd < 0) continue; + const stream = compat.net.Stream{ .handle = client_fd }; + const conn = compat.net.Server.Connection{ .stream = stream, .address = compat.net.Address.initUnix(path) catch continue }; + const t = std.Thread.spawn(.{ .stack_size = CONN_THREAD_STACK_SIZE }, handleConn, .{ self, conn }) catch continue; t.detach(); } } @@ -87,10 +138,16 @@ pub const WireServer = struct { const Bufs = struct { rd: [RD_BUF]u8, wr: [WR_BUF]u8 }; -fn handleConn(srv: *WireServer, conn: std.net.Server.Connection) void { +const WireConnState = struct { + authenticated: bool = false, + auth_ctx: ?auth.AuthContext = null, +}; + +fn handleConn(srv: *WireServer, conn: compat.net.Server.Connection) void { defer conn.stream.close(); const bufs = std.heap.page_allocator.create(Bufs) catch return; defer std.heap.page_allocator.destroy(bufs); + var state = WireConnState{}; // Pre-resolve the most common collection for the fast path var cached_col: ?*collection_mod.Collection = null; @@ -100,7 +157,6 @@ fn handleConn(srv: *WireServer, conn: std.net.Server.Connection) void { var rp: usize = 0; while (true) { - if (rp >= RD_BUF) rp = 0; const n = conn.stream.read(bufs.rd[rp..]) catch return; if (n == 0) return; rp += n; @@ -109,24 +165,31 @@ fn handleConn(srv: *WireServer, conn: std.net.Server.Connection) void { while (consumed + HDR <= rp) { const flen = rdU32BE(bufs.rd[consumed..]); if (flen < HDR or flen > MAX_FRAME) return; - if (consumed + flen > rp) break; - const op = bufs.rd[consumed + 4]; - const payload = bufs.rd[consumed + HDR .. consumed + flen]; - // ── FAST PATH: inline GET with zero-copy ── - if (op == OP_GET) { - srv.activity.recordQuery(); - const wn = fastGet(srv, payload, &bufs.wr, &cached_col, &cached_col_name, &cached_col_len); - conn.stream.writeAll(bufs.wr[0..wn]) catch return; - } else { - // Invalidate collection cache on writes - if (op == OP_INSERT or op == OP_UPDATE or op == OP_DELETE) { - cached_col = null; + if (flen > RD_BUF) { + if (consumed != 0) break; + const big = std.heap.page_allocator.alloc(u8, flen) catch return; + defer std.heap.page_allocator.free(big); + + @memcpy(big[0..rp], bufs.rd[0..rp]); + var have = rp; + while (have < flen) { + const got = conn.stream.read(big[have..flen]) catch return; + if (got == 0) return; + have += got; } - srv.activity.recordQuery(); - const wn = dispatch(srv, op, payload, &bufs.wr); + + const wn = processFrame(srv, &state, big[0..flen], &bufs.wr, &cached_col, &cached_col_name, &cached_col_len); conn.stream.writeAll(bufs.wr[0..wn]) catch return; + rp = 0; + consumed = 0; + break; } + + if (consumed + flen > rp) break; + const frame = bufs.rd[consumed .. consumed + flen]; + const wn = processFrame(srv, &state, frame, &bufs.wr, &cached_col, &cached_col_name, &cached_col_len); + conn.stream.writeAll(bufs.wr[0..wn]) catch return; consumed += flen; } if (consumed > 0) { @@ -136,6 +199,56 @@ fn handleConn(srv: *WireServer, conn: std.net.Server.Connection) void { } } +fn processFrame( + srv: *WireServer, + state: *WireConnState, + frame: []const u8, + w: *[WR_BUF]u8, + cached_col: *?*collection_mod.Collection, + cached_name: *[128]u8, + cached_len: *usize, +) usize { + const op = frame[4]; + const payload = frame[HDR..]; + + if (op == OP_WIRE2) { + srv.activity.recordQuery(); + return dispatchWire2(srv, state, payload, w); + } + + // ── FAST PATH: inline GET with zero-copy ── + if (op == OP_GET) { + srv.activity.recordQuery(); + srv.op_mu.lockShared(); + defer srv.op_mu.unlockShared(); + return fastGet(srv, payload, w, cached_col, cached_name, cached_len); + } + + // Invalidate collection cache on writes. + if (op == OP_INSERT or op == OP_UPDATE or op == OP_DELETE or op == OP_BATCH) { + cached_col.* = null; + } + srv.activity.recordQuery(); + const lock_mutation = wireOpMutates(op); + if (lock_mutation) { + srv.op_mu.lock(); + } else if (wireOpReadsStorage(op)) { + srv.op_mu.lockShared(); + } + defer { + if (lock_mutation) srv.op_mu.unlock() else if (wireOpReadsStorage(op)) srv.op_mu.unlockShared(); + } + return dispatch(srv, op, payload, w); +} + +fn wireOpMutates(op: u8) bool { + return op == OP_INSERT or op == OP_UPDATE or op == OP_DELETE or op == OP_BATCH; +} + +fn wireOpReadsStorage(op: u8) bool { + return op == OP_SCAN; +} + /// Inlined GET: collection lookup is cached, response writes mmap'd bytes directly. fn fastGet( srv: *WireServer, @@ -184,6 +297,7 @@ fn dispatch(srv: *WireServer, op: u8, p: []const u8, w: *[WR_BUF]u8) usize { OP_DELETE => doDelete(srv, p, w), OP_SCAN => doScan(srv, p, w), OP_PING => doPing(w), + OP_BATCH => doBatch(srv, p, w), else => errResp(w, 0xFF, STATUS_ERROR), }; } @@ -191,72 +305,245 @@ fn dispatch(srv: *WireServer, op: u8, p: []const u8, w: *[WR_BUF]u8) usize { // ── INSERT ────────────────────────────────────────────────────────────────── fn doInsert(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { - const a = parseKV(p) orelse return errResp(w, OP_INSERT, STATUS_ERROR); - const ref = resolveCollectionRef(a.col); - srv.db.recordTenantOperation(ref.tenant_id) catch return errResp(w, OP_INSERT, STATUS_ERROR); - srv.db.ensureTenantStorageAvailable(ref.tenant_id, a.val.len) catch return errResp(w, OP_INSERT, STATUS_ERROR); - const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return errResp(w, OP_INSERT, STATUS_ERROR); - const doc_id = col.insert(a.key, a.val) catch return errResp(w, OP_INSERT, STATUS_ERROR); - // [len:4][op:1][status:1][doc_id:8] = 14 - wrU32BE(w, 14); + const r = execInsertPayload(srv, p, w[6..]); + if (r.status != STATUS_OK) return errResp(w, OP_INSERT, r.status); + wrU32BE(w, @intCast(HDR + 1 + r.payload_len)); w[4] = OP_INSERT; w[5] = STATUS_OK; - wrU64LE(w[6..14], doc_id); - return 14; + return HDR + 1 + r.payload_len; +} + +fn execInsertPayload(srv: *WireServer, p: []const u8, out: []u8) ItemResult { + const a = parseKV(p) orelse return .{ .status = STATUS_ERROR, .payload_len = 0 }; + if (out.len < 8) return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const ref = resolveCollectionRef(a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + srv.db.ensureTenantStorageAvailable(ref.tenant_id, a.val.len) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const doc_id = col.insert(a.key, a.val) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + wrU64LE(out[0..8], doc_id); + return .{ .status = STATUS_OK, .payload_len = 8 }; } // ── GET ───────────────────────────────────────────────────────────────────── fn doGet(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { - const a = parseKey(p) orelse return errResp(w, OP_GET, STATUS_ERROR); - const ref = resolveCollectionRef(a.col); - srv.db.recordTenantOperation(ref.tenant_id) catch return errResp(w, OP_GET, STATUS_ERROR); - const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return errResp(w, OP_GET, STATUS_ERROR); - const d = col.get(a.key) orelse return errResp(w, OP_GET, STATUS_NOT_FOUND); - // [len:4][op:1][status:1][doc_id:8][ver:1][val_len:4][val:N] - const rlen = HDR + 1 + 8 + 1 + 4 + d.value.len; - if (rlen > WR_BUF) return errResp(w, OP_GET, STATUS_ERROR); - wrU32BE(w, @intCast(rlen)); + const r = execGetPayload(srv, p, w[6..]); + if (r.status != STATUS_OK) return errResp(w, OP_GET, r.status); + wrU32BE(w, @intCast(HDR + 1 + r.payload_len)); w[4] = OP_GET; w[5] = STATUS_OK; - wrU64LE(w[6..14], d.header.doc_id); - w[14] = d.header.version; - wrU32LE(w[15..19], @intCast(d.value.len)); - if (d.value.len > 0) @memcpy(w[19..][0..d.value.len], d.value); - return rlen; + return HDR + 1 + r.payload_len; +} + +fn execGetPayload(srv: *WireServer, p: []const u8, out: []u8) ItemResult { + const a = parseKey(p) orelse return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const ref = resolveCollectionRef(a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const d = col.get(a.key) orelse return .{ .status = STATUS_NOT_FOUND, .payload_len = 0 }; + // [len:4][op:1][status:1][doc_id:8][ver:1][val_len:4][val:N] + const payload_len = 8 + 1 + 4 + d.value.len; + if (payload_len > out.len) return .{ .status = STATUS_ERROR, .payload_len = 0 }; + wrU64LE(out[0..8], d.header.doc_id); + out[8] = d.header.version; + wrU32LE(out[9..13], @intCast(d.value.len)); + if (d.value.len > 0) @memcpy(out[13..][0..d.value.len], d.value); + return .{ .status = STATUS_OK, .payload_len = payload_len }; } // ── UPDATE ────────────────────────────────────────────────────────────────── fn doUpdate(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { - const a = parseKV(p) orelse return errResp(w, OP_UPDATE, STATUS_ERROR); - const ref = resolveCollectionRef(a.col); - srv.db.recordTenantOperation(ref.tenant_id) catch return errResp(w, OP_UPDATE, STATUS_ERROR); - srv.db.ensureTenantStorageAvailable(ref.tenant_id, a.val.len) catch return errResp(w, OP_UPDATE, STATUS_ERROR); - const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return errResp(w, OP_UPDATE, STATUS_ERROR); - const ok = col.update(a.key, a.val) catch return errResp(w, OP_UPDATE, STATUS_ERROR); - if (!ok) return errResp(w, OP_UPDATE, STATUS_NOT_FOUND); + const r = execUpdatePayload(srv, p); + if (r.status != STATUS_OK) return errResp(w, OP_UPDATE, r.status); wrU32BE(w, HDR + 1); w[4] = OP_UPDATE; w[5] = STATUS_OK; return HDR + 1; } +fn execUpdatePayload(srv: *WireServer, p: []const u8) ItemResult { + const a = parseKV(p) orelse return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const ref = resolveCollectionRef(a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + srv.db.ensureTenantStorageAvailable(ref.tenant_id, a.val.len) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const ok = col.update(a.key, a.val) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + if (!ok) return .{ .status = STATUS_NOT_FOUND, .payload_len = 0 }; + return .{ .status = STATUS_OK, .payload_len = 0 }; +} + // ── DELETE ─────────────────────────────────────────────────────────────────── fn doDelete(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { - const a = parseKey(p) orelse return errResp(w, OP_DELETE, STATUS_ERROR); - const ref = resolveCollectionRef(a.col); - srv.db.recordTenantOperation(ref.tenant_id) catch return errResp(w, OP_DELETE, STATUS_ERROR); - const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return errResp(w, OP_DELETE, STATUS_ERROR); - const ok = col.delete(a.key) catch return errResp(w, OP_DELETE, STATUS_ERROR); - if (!ok) return errResp(w, OP_DELETE, STATUS_NOT_FOUND); + const r = execDeletePayload(srv, p); + if (r.status != STATUS_OK) return errResp(w, OP_DELETE, r.status); wrU32BE(w, HDR + 1); w[4] = OP_DELETE; w[5] = STATUS_OK; return HDR + 1; } +fn execDeletePayload(srv: *WireServer, p: []const u8) ItemResult { + const a = parseKey(p) orelse return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const ref = resolveCollectionRef(a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + const ok = col.delete(a.key) catch return .{ .status = STATUS_ERROR, .payload_len = 0 }; + if (!ok) return .{ .status = STATUS_NOT_FOUND, .payload_len = 0 }; + return .{ .status = STATUS_OK, .payload_len = 0 }; +} + +// ── BATCH ────────────────────────────────────────────────────────────────── + +const ItemResult = struct { status: u8, payload_len: usize }; +const BatchEnvelope = struct { count: u16 }; + +fn doBatch(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { + const env = validateBatchEnvelope(p) orelse return errResp(w, OP_BATCH, STATUS_ERROR); + + if (doInsertOnlyBatch(srv, p, env, w)) |written| return written; + + var req_pos: usize = BATCH_REQ_HDR; + var resp_pos: usize = BATCH_RESP_HDR; + w[4] = OP_BATCH; + w[5] = STATUS_OK; + wrU16LE(w[6..8], env.count); + + for (0..env.count) |idx| { + const op = p[req_pos]; + const payload_len: usize = rdU32LE(p[req_pos + 1 ..][0..4]); + const payload = p[req_pos + BATCH_ITEM_HDR ..][0..payload_len]; + + const remaining_items = @as(usize, env.count) - idx - 1; + const reserved = remaining_items * BATCH_ITEM_RESP_HDR; + const item_payload_cap = WR_BUF - resp_pos - BATCH_ITEM_RESP_HDR - reserved; + const item_payload_out = w[resp_pos + BATCH_ITEM_RESP_HDR ..][0..item_payload_cap]; + + w[resp_pos] = op; + w[resp_pos + 1] = STATUS_ERROR; + wrU32LE(w[resp_pos + 2 ..][0..4], 0); + + const result = execBatchItem(srv, op, payload, item_payload_out); + w[resp_pos + 1] = result.status; + wrU32LE(w[resp_pos + 2 ..][0..4], @intCast(result.payload_len)); + resp_pos += BATCH_ITEM_RESP_HDR + result.payload_len; + req_pos += BATCH_ITEM_HDR + payload_len; + } + + wrU32BE(w, @intCast(resp_pos)); + return resp_pos; +} + +fn doInsertOnlyBatch(srv: *WireServer, p: []const u8, env: BatchEnvelope, w: *[WR_BUF]u8) ?usize { + if (env.count == 0) return null; + + var rows: std.ArrayList(Collection.BulkInsertRow) = .empty; + defer rows.deinit(srv.db.alloc); + rows.ensureTotalCapacity(srv.db.alloc, env.count) catch return null; + + var req_pos: usize = BATCH_REQ_HDR; + var batch_col: []const u8 = ""; + var total_value_bytes: usize = 0; + for (0..env.count) |idx| { + const op = p[req_pos]; + if (op != OP_INSERT) return null; + const payload_len: usize = rdU32LE(p[req_pos + 1 ..][0..4]); + const payload = p[req_pos + BATCH_ITEM_HDR ..][0..payload_len]; + const kv = parseKV(payload) orelse return null; + + if (idx == 0) { + batch_col = kv.col; + } else if (!std.mem.eql(u8, batch_col, kv.col)) { + return null; + } + + if (kv.key.len == 0 or kv.key.len > 1024 or kv.val.len > 64 * 1024 * 1024) return null; + total_value_bytes = std.math.add(usize, total_value_bytes, kv.val.len) catch return null; + rows.appendAssumeCapacity(.{ + .key = kv.key, + .value = kv.val, + .line_len = kv.key.len + kv.val.len, + }); + req_pos += BATCH_ITEM_HDR + payload_len; + } + + const ref = resolveCollectionRef(batch_col); + srv.db.recordTenantOperation(ref.tenant_id) catch return allBatchItemsError(w, env.count); + srv.db.ensureTenantStorageAvailable(ref.tenant_id, total_value_bytes) catch return allBatchItemsError(w, env.count); + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return allBatchItemsError(w, env.count); + const result = col.insertBulk(rows.items) catch return allBatchItemsError(w, env.count); + if (result.errors != 0 or result.inserted != env.count) return allBatchItemsError(w, env.count); + + w[4] = OP_BATCH; + w[5] = STATUS_OK; + wrU16LE(w[6..8], env.count); + var resp_pos: usize = BATCH_RESP_HDR; + for (0..env.count) |i| { + w[resp_pos] = OP_INSERT; + w[resp_pos + 1] = STATUS_OK; + wrU32LE(w[resp_pos + 2 ..][0..4], 8); + wrU64LE(w[resp_pos + BATCH_ITEM_RESP_HDR ..][0..8], result.first_doc_id + @as(u64, @intCast(i))); + resp_pos += BATCH_ITEM_RESP_HDR + 8; + } + wrU32BE(w, @intCast(resp_pos)); + return resp_pos; +} + +fn allBatchItemsError(w: *[WR_BUF]u8, count: u16) usize { + w[4] = OP_BATCH; + w[5] = STATUS_OK; + wrU16LE(w[6..8], count); + var resp_pos: usize = BATCH_RESP_HDR; + for (0..count) |_| { + w[resp_pos] = OP_INSERT; + w[resp_pos + 1] = STATUS_ERROR; + wrU32LE(w[resp_pos + 2 ..][0..4], 0); + resp_pos += BATCH_ITEM_RESP_HDR; + } + wrU32BE(w, @intCast(resp_pos)); + return resp_pos; +} + +fn execBatchItem(srv: *WireServer, op: u8, p: []const u8, out: []u8) ItemResult { + return switch (op) { + OP_INSERT => execInsertPayload(srv, p, out), + OP_GET => execGetPayload(srv, p, out), + OP_UPDATE => execUpdatePayload(srv, p), + OP_DELETE => execDeletePayload(srv, p), + else => .{ .status = STATUS_ERROR, .payload_len = 0 }, + }; +} + +fn validateBatchEnvelope(p: []const u8) ?BatchEnvelope { + if (p.len < BATCH_REQ_HDR) return null; + const count = rdU16LE(p[0..2]); + const min_resp_len = BATCH_RESP_HDR + @as(usize, count) * BATCH_ITEM_RESP_HDR; + if (min_resp_len > WR_BUF) return null; + + var pos: usize = BATCH_REQ_HDR; + for (0..count) |_| { + if (pos + BATCH_ITEM_HDR > p.len) return null; + const op = p[pos]; + const payload_len: usize = rdU32LE(p[pos + 1 ..][0..4]); + if (payload_len > p.len - pos - BATCH_ITEM_HDR) return null; + const payload = p[pos + BATCH_ITEM_HDR ..][0..payload_len]; + if (!validBatchPayload(op, payload)) return null; + pos += BATCH_ITEM_HDR + payload_len; + } + if (pos != p.len) return null; + return .{ .count = count }; +} + +fn validBatchPayload(op: u8, p: []const u8) bool { + return switch (op) { + OP_INSERT, OP_UPDATE => parseKVExact(p), + OP_GET, OP_DELETE => parseKeyExact(p), + else => false, + }; +} + // ── SCAN ──────────────────────────────────────────────────────────────────── fn doScan(srv: *WireServer, p: []const u8, w: *[WR_BUF]u8) usize { @@ -302,6 +589,429 @@ fn doPing(w: *[WR_BUF]u8) usize { return HDR + 1; } +// ── Wire v2 ──────────────────────────────────────────────────────────────── + +const Wire2Request = struct { + flags: u8, + request_id: u64, + op: u8, + body: []const u8, +}; + +const Wire2Result = struct { + status: u8, + payload_len: usize = 0, +}; + +const Wire2CollectionBody = struct { + col: []const u8, + count: u32, + pos: usize, +}; + +const Wire2Stats = struct { + inserted: u32 = 0, + updated: u32 = 0, + deleted: u32 = 0, + missing: u32 = 0, + errors: u32 = 0, + bytes: u64 = 0, +}; + +fn dispatchWire2(srv: *WireServer, state: *WireConnState, p: []const u8, w: *[WR_BUF]u8) usize { + const req = parseWire2Request(p) orelse { + const fallback = Wire2Request{ .flags = 0, .request_id = 0, .op = 0, .body = &.{} }; + return writeWire2Response(w, fallback, STATUS_ERROR, 0); + }; + + if (req.op == WIRE2_OP_HELLO) return doWire2Hello(req, w); + if (req.op == WIRE2_OP_AUTH) return doWire2Auth(srv, state, req, w); + + if (srv.db.auth.isEnabled() and !state.authenticated) { + return writeWire2Response(w, req, STATUS_UNAUTHORIZED, 0); + } + + const out = wire2BodyOut(w); + const lock_mutation = wire2OpMutates(req.op); + if (lock_mutation) { + srv.op_mu.lock(); + } else if (wire2OpReadsStorage(req.op)) { + srv.op_mu.lockShared(); + } + defer { + if (lock_mutation) srv.op_mu.unlock() else if (wire2OpReadsStorage(req.op)) srv.op_mu.unlockShared(); + } + const result = switch (req.op) { + WIRE2_OP_PING => Wire2Result{ .status = STATUS_OK }, + WIRE2_OP_GET => doWire2Get(srv, state, req.body, out), + WIRE2_OP_PUT => doWire2Put(srv, state, req.body, out), + WIRE2_OP_INSERT => doWire2Insert(srv, state, req.body, out), + WIRE2_OP_DELETE => doWire2Delete(srv, state, req.body), + WIRE2_OP_BATCH_GET => doWire2BatchGet(srv, state, req.body, out), + WIRE2_OP_BULK_INSERT => doWire2BulkInsert(srv, state, req.body, out), + WIRE2_OP_BULK_UPSERT => doWire2BulkUpsert(srv, state, req.body, out), + WIRE2_OP_BULK_DELETE => doWire2BulkDelete(srv, state, req.body, out), + else => Wire2Result{ .status = STATUS_ERROR }, + }; + return writeWire2Response(w, req, result.status, result.payload_len); +} + +fn wire2OpMutates(op: u8) bool { + return switch (op) { + WIRE2_OP_PUT, + WIRE2_OP_INSERT, + WIRE2_OP_DELETE, + WIRE2_OP_BULK_INSERT, + WIRE2_OP_BULK_UPSERT, + WIRE2_OP_BULK_DELETE, + => true, + else => false, + }; +} + +fn wire2OpReadsStorage(op: u8) bool { + return op == WIRE2_OP_GET or op == WIRE2_OP_BATCH_GET; +} + +fn parseWire2Request(p: []const u8) ?Wire2Request { + if (p.len < WIRE2_HDR) return null; + if (p[0] != WIRE2_VERSION) return null; + const body_len: usize = rdU32LE(p[12..16]); + if (WIRE2_HDR + body_len != p.len) return null; + return .{ + .flags = p[1], + .request_id = rdU64LE(p[2..10]), + .op = p[10], + .body = p[WIRE2_HDR..], + }; +} + +fn wire2BodyOut(w: *[WR_BUF]u8) []u8 { + return w[HDR + WIRE2_HDR ..]; +} + +fn writeWire2Response(w: *[WR_BUF]u8, req: Wire2Request, status: u8, payload_len: usize) usize { + if (payload_len > WR_BUF - HDR - WIRE2_HDR) { + return writeWire2Response(w, req, STATUS_TOO_LARGE, 0); + } + const total_len = HDR + WIRE2_HDR + payload_len; + wrU32BE(w, @intCast(total_len)); + w[4] = OP_WIRE2; + w[5] = WIRE2_VERSION; + w[6] = req.flags; + wrU64LE(w[7..15], req.request_id); + w[15] = req.op; + w[16] = status; + wrU32LE(w[17..21], @intCast(payload_len)); + return total_len; +} + +fn doWire2Hello(req: Wire2Request, w: *[WR_BUF]u8) usize { + const out = wire2BodyOut(w); + wrU32LE(out[0..4], @intCast(MAX_FRAME)); + wrU32LE(out[4..8], @intCast(WR_BUF - HDR - WIRE2_HDR)); + wrU32LE(out[8..12], WIRE2_FEATURE_AUTH | WIRE2_FEATURE_PIPELINING | WIRE2_FEATURE_BATCH_GET | WIRE2_FEATURE_BULK_WRITE); + return writeWire2Response(w, req, STATUS_OK, 12); +} + +fn doWire2Auth(srv: *WireServer, state: *WireConnState, req: Wire2Request, w: *[WR_BUF]u8) usize { + if (req.body.len < 2) return writeWire2Response(w, req, STATUS_ERROR, 0); + const key_len: usize = rdU16LE(req.body[0..2]); + if (2 + key_len != req.body.len) return writeWire2Response(w, req, STATUS_ERROR, 0); + const key = req.body[2..][0..key_len]; + + const ctx = srv.db.auth.resolve(key) orelse return writeWire2Response(w, req, STATUS_UNAUTHORIZED, 0); + state.authenticated = true; + state.auth_ctx = ctx; + + const out = wire2BodyOut(w); + const tenant = ctx.tenant(); + if (3 + tenant.len > out.len) return writeWire2Response(w, req, STATUS_TOO_LARGE, 0); + out[0] = @intFromEnum(ctx.perm); + wrU16LE(out[1..3], @intCast(tenant.len)); + if (tenant.len > 0) @memcpy(out[3..][0..tenant.len], tenant); + return writeWire2Response(w, req, STATUS_OK, 3 + tenant.len); +} + +fn doWire2Get(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + if (!parseKeyExact(body)) return .{ .status = STATUS_ERROR }; + const a = parseKey(body).?; + const col = wire2Collection(srv, state, a.col) catch return .{ .status = STATUS_ERROR }; + const d = col.get(a.key) orelse return .{ .status = STATUS_NOT_FOUND }; + const payload_len = 8 + 1 + 4 + d.value.len; + if (payload_len > out.len) return .{ .status = STATUS_TOO_LARGE }; + wrU64LE(out[0..8], d.header.doc_id); + out[8] = d.header.version; + wrU32LE(out[9..13], @intCast(d.value.len)); + if (d.value.len > 0) @memcpy(out[13..][0..d.value.len], d.value); + return .{ .status = STATUS_OK, .payload_len = payload_len }; +} + +fn doWire2Insert(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + if (!wire2CanWrite(state)) return .{ .status = STATUS_FORBIDDEN }; + if (!parseKVExact(body)) return .{ .status = STATUS_ERROR }; + if (out.len < 8) return .{ .status = STATUS_TOO_LARGE }; + const a = parseKV(body).?; + const ref = wire2ResolveCollectionRef(state, a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR }; + srv.db.ensureTenantStorageAvailable(ref.tenant_id, a.val.len) catch return .{ .status = STATUS_ERROR }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR }; + const doc_id = col.insert(a.key, a.val) catch return .{ .status = STATUS_ERROR }; + wrU64LE(out[0..8], doc_id); + return .{ .status = STATUS_OK, .payload_len = 8 }; +} + +fn doWire2Put(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + if (!wire2CanWrite(state)) return .{ .status = STATUS_FORBIDDEN }; + if (!parseKVExact(body)) return .{ .status = STATUS_ERROR }; + const a = parseKV(body).?; + const ref = wire2ResolveCollectionRef(state, a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR }; + srv.db.ensureTenantStorageAvailable(ref.tenant_id, a.val.len) catch return .{ .status = STATUS_ERROR }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR }; + + var stats = Wire2Stats{ .bytes = a.key.len + a.val.len }; + const updated = col.update(a.key, a.val) catch return .{ .status = STATUS_ERROR }; + if (updated) { + stats.updated = 1; + } else { + _ = col.insert(a.key, a.val) catch return .{ .status = STATUS_ERROR }; + stats.inserted = 1; + } + return writeWire2Stats(out, stats); +} + +fn doWire2Delete(srv: *WireServer, state: *const WireConnState, body: []const u8) Wire2Result { + if (!wire2CanWrite(state)) return .{ .status = STATUS_FORBIDDEN }; + if (!parseKeyExact(body)) return .{ .status = STATUS_ERROR }; + const a = parseKey(body).?; + const ref = wire2ResolveCollectionRef(state, a.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR }; + const deleted = col.delete(a.key) catch return .{ .status = STATUS_ERROR }; + return .{ .status = if (deleted) STATUS_OK else STATUS_NOT_FOUND }; +} + +fn doWire2BatchGet(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + const parsed = parseWire2CollectionBody(body) orelse return .{ .status = STATUS_ERROR }; + const col = wire2Collection(srv, state, parsed.col) catch return .{ .status = STATUS_ERROR }; + + if (out.len < 4) return .{ .status = STATUS_TOO_LARGE }; + wrU32LE(out[0..4], parsed.count); + var out_pos: usize = 4; + var pos = parsed.pos; + for (0..parsed.count) |_| { + const key = nextWire2Key(body, &pos) orelse return .{ .status = STATUS_ERROR }; + const base_len: usize = 1 + 8 + 1 + 4; + if (out_pos + base_len > out.len) return .{ .status = STATUS_TOO_LARGE }; + + if (col.get(key)) |d| { + const item_len = base_len + d.value.len; + if (out_pos + item_len > out.len) return .{ .status = STATUS_TOO_LARGE }; + out[out_pos] = STATUS_OK; + wrU64LE(out[out_pos + 1 ..][0..8], d.header.doc_id); + out[out_pos + 9] = d.header.version; + wrU32LE(out[out_pos + 10 ..][0..4], @intCast(d.value.len)); + if (d.value.len > 0) @memcpy(out[out_pos + base_len ..][0..d.value.len], d.value); + out_pos += item_len; + } else { + out[out_pos] = STATUS_NOT_FOUND; + wrU64LE(out[out_pos + 1 ..][0..8], 0); + out[out_pos + 9] = 0; + wrU32LE(out[out_pos + 10 ..][0..4], 0); + out_pos += base_len; + } + } + if (pos != body.len) return .{ .status = STATUS_ERROR }; + return .{ .status = STATUS_OK, .payload_len = out_pos }; +} + +fn doWire2BulkInsert(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + if (!wire2CanWrite(state)) return .{ .status = STATUS_FORBIDDEN }; + const parsed = parseWire2CollectionBody(body) orelse return .{ .status = STATUS_ERROR }; + const ref = wire2ResolveCollectionRef(state, parsed.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR }; + srv.db.ensureTenantStorageAvailable(ref.tenant_id, body.len) catch return .{ .status = STATUS_ERROR }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR }; + + var rows: std.ArrayList(Collection.BulkInsertRow) = .empty; + defer rows.deinit(srv.db.alloc); + rows.ensureTotalCapacity(srv.db.alloc, @min(WIRE2_BULK_INSERT_CHUNK_ROWS, @max(@as(usize, 1), body.len / 80))) catch + return .{ .status = STATUS_TOO_LARGE }; + + var stats = Wire2Stats{}; + var chunk_bytes: usize = 0; + var pos = parsed.pos; + var malformed = false; + const Flush = struct { + fn run(col_arg: *Collection, rows_arg: *std.ArrayList(Collection.BulkInsertRow), stats_arg: *Wire2Stats, chunk_bytes_arg: *usize) !void { + if (rows_arg.items.len == 0) return; + const bulk = try col_arg.insertBulk(rows_arg.items); + stats_arg.inserted += bulk.inserted; + stats_arg.errors += bulk.errors; + stats_arg.bytes += bulk.bytes; + rows_arg.clearRetainingCapacity(); + chunk_bytes_arg.* = 0; + } + }; + + for (0..parsed.count) |_| { + const rec = nextWire2KeyValue(body, &pos) orelse { + stats.errors += 1; + malformed = true; + break; + }; + const row_bytes = rec.key.len + rec.val.len + 128; + if (rows.items.len > 0 and chunk_bytes + row_bytes > WIRE2_BULK_INSERT_CHUNK_BYTES) { + Flush.run(col, &rows, &stats, &chunk_bytes) catch return .{ .status = STATUS_ERROR }; + } + rows.append(srv.db.alloc, .{ .key = rec.key, .value = rec.val, .line_len = 6 + rec.key.len + rec.val.len }) catch + return .{ .status = STATUS_TOO_LARGE }; + chunk_bytes += row_bytes; + if (rows.items.len >= WIRE2_BULK_INSERT_CHUNK_ROWS) { + Flush.run(col, &rows, &stats, &chunk_bytes) catch return .{ .status = STATUS_ERROR }; + } + } + if (!malformed and pos != body.len) stats.errors += 1; + Flush.run(col, &rows, &stats, &chunk_bytes) catch return .{ .status = STATUS_ERROR }; + return writeWire2Stats(out, stats); +} + +fn doWire2BulkUpsert(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + if (!wire2CanWrite(state)) return .{ .status = STATUS_FORBIDDEN }; + const parsed = parseWire2CollectionBody(body) orelse return .{ .status = STATUS_ERROR }; + const ref = wire2ResolveCollectionRef(state, parsed.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR }; + srv.db.ensureTenantStorageAvailable(ref.tenant_id, body.len) catch return .{ .status = STATUS_ERROR }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR }; + + var stats = Wire2Stats{}; + var pos = parsed.pos; + var malformed = false; + for (0..parsed.count) |_| { + const rec = nextWire2KeyValue(body, &pos) orelse { + stats.errors += 1; + malformed = true; + break; + }; + const updated = col.update(rec.key, rec.val) catch { + stats.errors += 1; + continue; + }; + if (updated) { + stats.updated += 1; + } else { + _ = col.insert(rec.key, rec.val) catch { + stats.errors += 1; + continue; + }; + stats.inserted += 1; + } + stats.bytes += rec.key.len + rec.val.len; + } + if (!malformed and pos != body.len) stats.errors += 1; + return writeWire2Stats(out, stats); +} + +fn doWire2BulkDelete(srv: *WireServer, state: *const WireConnState, body: []const u8, out: []u8) Wire2Result { + if (!wire2CanWrite(state)) return .{ .status = STATUS_FORBIDDEN }; + const parsed = parseWire2CollectionBody(body) orelse return .{ .status = STATUS_ERROR }; + const ref = wire2ResolveCollectionRef(state, parsed.col); + srv.db.recordTenantOperation(ref.tenant_id) catch return .{ .status = STATUS_ERROR }; + const col = srv.db.collectionForTenant(ref.tenant_id, ref.collection_name) catch return .{ .status = STATUS_ERROR }; + + var stats = Wire2Stats{}; + var pos = parsed.pos; + var malformed = false; + for (0..parsed.count) |_| { + const key = nextWire2Key(body, &pos) orelse { + stats.errors += 1; + malformed = true; + break; + }; + const deleted = col.delete(key) catch { + stats.errors += 1; + continue; + }; + if (deleted) stats.deleted += 1 else stats.missing += 1; + stats.bytes += key.len; + } + if (!malformed and pos != body.len) stats.errors += 1; + return writeWire2Stats(out, stats); +} + +fn writeWire2Stats(out: []u8, stats: Wire2Stats) Wire2Result { + const payload_len: usize = 28; + if (out.len < payload_len) return .{ .status = STATUS_TOO_LARGE }; + wrU32LE(out[0..4], stats.inserted); + wrU32LE(out[4..8], stats.updated); + wrU32LE(out[8..12], stats.deleted); + wrU32LE(out[12..16], stats.missing); + wrU32LE(out[16..20], stats.errors); + wrU64LE(out[20..28], stats.bytes); + return .{ .status = STATUS_OK, .payload_len = payload_len }; +} + +fn parseWire2CollectionBody(body: []const u8) ?Wire2CollectionBody { + if (body.len < 6) return null; + const col_len: usize = rdU16LE(body[0..2]); + if (col_len == 0 or 2 + col_len + 4 > body.len) return null; + const count = rdU32LE(body[2 + col_len ..][0..4]); + return .{ + .col = body[2..][0..col_len], + .count = count, + .pos = 2 + col_len + 4, + }; +} + +fn nextWire2Key(body: []const u8, pos: *usize) ?[]const u8 { + if (pos.* + 2 > body.len) return null; + const key_len: usize = rdU16LE(body[pos.*..][0..2]); + pos.* += 2; + if (key_len == 0 or pos.* + key_len > body.len) return null; + const key = body[pos.*..][0..key_len]; + pos.* += key_len; + return key; +} + +fn nextWire2KeyValue(body: []const u8, pos: *usize) ?struct { key: []const u8, val: []const u8 } { + if (pos.* + 6 > body.len) return null; + const key_len: usize = rdU16LE(body[pos.*..][0..2]); + const val_len: usize = rdU32LE(body[pos.* + 2 ..][0..4]); + pos.* += 6; + if (key_len == 0 or pos.* + key_len > body.len) return null; + const key = body[pos.*..][0..key_len]; + pos.* += key_len; + if (pos.* + val_len > body.len) return null; + const val = body[pos.*..][0..val_len]; + pos.* += val_len; + return .{ .key = key, .val = val }; +} + +fn wire2Collection(srv: *WireServer, state: *const WireConnState, full_name: []const u8) !*Collection { + const ref = wire2ResolveCollectionRef(state, full_name); + try srv.db.recordTenantOperation(ref.tenant_id); + return srv.db.collectionForTenant(ref.tenant_id, ref.collection_name); +} + +fn wire2ResolveCollectionRef(state: *const WireConnState, full_name: []const u8) collection_mod.TenantCollectionRef { + if (state.auth_ctx) |ctx| { + if (ctx.perm != .admin) { + const tenant_id = ctx.tenant(); + return .{ + .tenant_id = if (tenant_id.len > 0) tenant_id else collection_mod.DEFAULT_TENANT, + .collection_name = full_name, + }; + } + } + return resolveCollectionRef(full_name); +} + +fn wire2CanWrite(state: *const WireConnState) bool { + const ctx = state.auth_ctx orelse return true; + return ctx.perm != .read_only; +} + // ── Helpers ───────────────────────────────────────────────────────────────── fn errResp(w: *[WR_BUF]u8, op: u8, status: u8) usize { @@ -327,6 +1037,18 @@ fn parseKV(p: []const u8) ?KV { return KV{ .col = p[2..][0..cl], .key = p[ko + 2 ..][0..kl], .val = p[vo + 4 ..][0..vl] }; } +fn parseKVExact(p: []const u8) bool { + if (p.len < 8) return false; + const cl: usize = rdU16LE(p[0..2]); + if (2 + cl + 2 > p.len) return false; + const ko = 2 + cl; + const kl: usize = rdU16LE(p[ko..][0..2]); + const vo = ko + 2 + kl; + if (vo + 4 > p.len) return false; + const vl: usize = rdU32LE(p[vo..][0..4]); + return vo + 4 + vl == p.len; +} + fn parseKey(p: []const u8) ?Key { if (p.len < 4) return null; const cl: usize = rdU16LE(p[0..2]); @@ -337,6 +1059,15 @@ fn parseKey(p: []const u8) ?Key { return Key{ .col = p[2..][0..cl], .key = p[ko + 2 ..][0..kl] }; } +fn parseKeyExact(p: []const u8) bool { + if (p.len < 4) return false; + const cl: usize = rdU16LE(p[0..2]); + if (2 + cl + 2 > p.len) return false; + const ko = 2 + cl; + const kl: usize = rdU16LE(p[ko..][0..2]); + return ko + 2 + kl == p.len; +} + // ── Binary encoding ───────────────────────────────────────────────────────── fn rdU32BE(b: []const u8) u32 { @@ -348,6 +1079,16 @@ fn rdU16LE(b: []const u8) u16 { fn rdU32LE(b: []const u8) u32 { return @as(u32, b[0]) | (@as(u32, b[1]) << 8) | (@as(u32, b[2]) << 16) | (@as(u32, b[3]) << 24); } +fn rdU64LE(b: []const u8) u64 { + return @as(u64, b[0]) | + (@as(u64, b[1]) << 8) | + (@as(u64, b[2]) << 16) | + (@as(u64, b[3]) << 24) | + (@as(u64, b[4]) << 32) | + (@as(u64, b[5]) << 40) | + (@as(u64, b[6]) << 48) | + (@as(u64, b[7]) << 56); +} fn wrU32BE(w: *[WR_BUF]u8, v: u32) void { w[0] = @intCast(v >> 24); w[1] = @intCast((v >> 16) & 0xFF); @@ -376,3 +1117,202 @@ fn resolveCollectionRef(full_name: []const u8) collection_mod.TenantCollectionRe .collection_name = full_name, }; } + +test "wire batch envelope accepts supported exact items" { + const get_payload = [_]u8{ 1, 0, 'c', 1, 0, 'k' }; + const insert_payload = [_]u8{ 1, 0, 'c', 1, 0, 'k', 1, 0, 0, 0, 'v' }; + + var buf: [128]u8 = undefined; + wrU16LE(buf[0..2], 2); + var pos: usize = BATCH_REQ_HDR; + + buf[pos] = OP_GET; + wrU32LE(buf[pos + 1 ..][0..4], get_payload.len); + @memcpy(buf[pos + BATCH_ITEM_HDR ..][0..get_payload.len], &get_payload); + pos += BATCH_ITEM_HDR + get_payload.len; + + buf[pos] = OP_INSERT; + wrU32LE(buf[pos + 1 ..][0..4], insert_payload.len); + @memcpy(buf[pos + BATCH_ITEM_HDR ..][0..insert_payload.len], &insert_payload); + pos += BATCH_ITEM_HDR + insert_payload.len; + + const env = validateBatchEnvelope(buf[0..pos]) orelse return error.TestExpectedBatchEnvelope; + try std.testing.expectEqual(@as(u16, 2), env.count); +} + +test "wire batch envelope rejects unsupported and trailing payloads" { + var unsupported: [BATCH_REQ_HDR + BATCH_ITEM_HDR]u8 = undefined; + wrU16LE(unsupported[0..2], 1); + unsupported[BATCH_REQ_HDR] = OP_SCAN; + wrU32LE(unsupported[BATCH_REQ_HDR + 1 ..][0..4], 0); + try std.testing.expect(validateBatchEnvelope(&unsupported) == null); + + const trailing = [_]u8{ 0, 0, 0 }; + try std.testing.expect(validateBatchEnvelope(&trailing) == null); + + const get_payload_with_trailing = [_]u8{ 1, 0, 'c', 1, 0, 'k', 0 }; + var bad_item: [BATCH_REQ_HDR + BATCH_ITEM_HDR + get_payload_with_trailing.len]u8 = undefined; + wrU16LE(bad_item[0..2], 1); + bad_item[BATCH_REQ_HDR] = OP_GET; + wrU32LE(bad_item[BATCH_REQ_HDR + 1 ..][0..4], get_payload_with_trailing.len); + @memcpy(bad_item[BATCH_REQ_HDR + BATCH_ITEM_HDR ..], &get_payload_with_trailing); + try std.testing.expect(validateBatchEnvelope(&bad_item) == null); +} + +test "wire2 envelope parses request id and rejects mismatched body length" { + var payload: [WIRE2_HDR + 3]u8 = undefined; + payload[0] = WIRE2_VERSION; + payload[1] = 0; + wrU64LE(payload[2..10], 0xAABBCCDD); + payload[10] = WIRE2_OP_PING; + payload[11] = 0; + wrU32LE(payload[12..16], 3); + @memcpy(payload[16..19], "abc"); + + const req = parseWire2Request(&payload).?; + try std.testing.expectEqual(@as(u64, 0xAABBCCDD), req.request_id); + try std.testing.expectEqual(WIRE2_OP_PING, req.op); + try std.testing.expectEqualStrings("abc", req.body); + + wrU32LE(payload[12..16], 2); + try std.testing.expect(parseWire2Request(&payload) == null); +} + +test "wire2 auth gates writes for read-only keys" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_wire2_auth_test"; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + _ = db.auth.addKeyForTenant("reader-key", "reader", "tenant-a", .read_only); + + var srv = WireServer.init(db, 27017); + var state = WireConnState{}; + const resp = try std.heap.page_allocator.create([WR_BUF]u8); + defer std.heap.page_allocator.destroy(resp); + + var body: [128]u8 = undefined; + var body_len: usize = 0; + wrU16LE(body[0..2], 5); + @memcpy(body[2..7], "users"); + wrU16LE(body[7..9], 2); + @memcpy(body[9..11], "u1"); + body_len = 11; + + var req_buf: [256]u8 = undefined; + const get_req = makeWire2TestRequest(&req_buf, 1, WIRE2_OP_GET, body[0..body_len]); + _ = dispatchWire2(&srv, &state, get_req, resp); + try std.testing.expectEqual(STATUS_UNAUTHORIZED, resp[16]); + + wrU16LE(body[0..2], 10); + @memcpy(body[2..12], "reader-key"); + const auth_req = makeWire2TestRequest(&req_buf, 2, WIRE2_OP_AUTH, body[0..12]); + _ = dispatchWire2(&srv, &state, auth_req, resp); + try std.testing.expectEqual(STATUS_OK, resp[16]); + try std.testing.expect(state.authenticated); + + body_len = encodeWire2TestKeyValue(&body, "users", "u1", "{\"x\":1}"); + const put_req = makeWire2TestRequest(&req_buf, 3, WIRE2_OP_PUT, body[0..body_len]); + _ = dispatchWire2(&srv, &state, put_req, resp); + try std.testing.expectEqual(STATUS_FORBIDDEN, resp[16]); +} + +test "wire2 bulk insert and batch get use binary bodies" { + const alloc = std.testing.allocator; + const tmp_dir = "/tmp/turbodb_wire2_bulk_test"; + compat.cwd().deleteTree(tmp_dir) catch {}; + defer compat.cwd().deleteTree(tmp_dir) catch {}; + + const db = try Database.open(alloc, tmp_dir); + defer db.close(); + + var srv = WireServer.init(db, 27017); + var state = WireConnState{}; + const resp = try std.heap.page_allocator.create([WR_BUF]u8); + defer std.heap.page_allocator.destroy(resp); + + var body: [512]u8 = undefined; + var pos: usize = 0; + pos += encodeWire2TestCollectionBody(body[pos..], "events", 2); + pos += encodeWire2TestKeyValueRecord(body[pos..], "a", "{\"n\":1}"); + pos += encodeWire2TestKeyValueRecord(body[pos..], "b", "{\"n\":2}"); + + var req_buf: [768]u8 = undefined; + const bulk_req = makeWire2TestRequest(&req_buf, 10, WIRE2_OP_BULK_INSERT, body[0..pos]); + _ = dispatchWire2(&srv, &state, bulk_req, resp); + try std.testing.expectEqual(STATUS_OK, resp[16]); + const bulk_body = wire2TestResponseBody(resp); + try std.testing.expectEqual(@as(u32, 2), rdU32LE(bulk_body[0..4])); // inserted + try std.testing.expectEqual(@as(u32, 0), rdU32LE(bulk_body[16..20])); // errors + + pos = 0; + pos += encodeWire2TestCollectionBody(body[pos..], "events", 3); + pos += encodeWire2TestKeyRecord(body[pos..], "a"); + pos += encodeWire2TestKeyRecord(body[pos..], "b"); + pos += encodeWire2TestKeyRecord(body[pos..], "missing"); + const get_req = makeWire2TestRequest(&req_buf, 11, WIRE2_OP_BATCH_GET, body[0..pos]); + _ = dispatchWire2(&srv, &state, get_req, resp); + try std.testing.expectEqual(STATUS_OK, resp[16]); + + const get_body = wire2TestResponseBody(resp); + try std.testing.expectEqual(@as(u32, 3), rdU32LE(get_body[0..4])); + var rp: usize = 4; + try std.testing.expectEqual(STATUS_OK, get_body[rp]); + const first_len = rdU32LE(get_body[rp + 10 ..][0..4]); + rp += 14 + first_len; + try std.testing.expectEqual(STATUS_OK, get_body[rp]); + const second_len = rdU32LE(get_body[rp + 10 ..][0..4]); + rp += 14 + second_len; + try std.testing.expectEqual(STATUS_NOT_FOUND, get_body[rp]); +} + +fn makeWire2TestRequest(buf: []u8, request_id: u64, op: u8, body: []const u8) []const u8 { + buf[0] = WIRE2_VERSION; + buf[1] = 0; + wrU64LE(buf[2..10], request_id); + buf[10] = op; + buf[11] = 0; + wrU32LE(buf[12..16], @intCast(body.len)); + @memcpy(buf[16..][0..body.len], body); + return buf[0 .. WIRE2_HDR + body.len]; +} + +fn wire2TestResponseBody(resp: *[WR_BUF]u8) []const u8 { + const body_len: usize = rdU32LE(resp[17..21]); + return resp[21 .. 21 + body_len]; +} + +fn encodeWire2TestCollectionBody(out: []u8, col: []const u8, count: u32) usize { + wrU16LE(out[0..2], @intCast(col.len)); + @memcpy(out[2..][0..col.len], col); + wrU32LE(out[2 + col.len ..][0..4], count); + return 2 + col.len + 4; +} + +fn encodeWire2TestKeyRecord(out: []u8, key: []const u8) usize { + wrU16LE(out[0..2], @intCast(key.len)); + @memcpy(out[2..][0..key.len], key); + return 2 + key.len; +} + +fn encodeWire2TestKeyValueRecord(out: []u8, key: []const u8, value: []const u8) usize { + wrU16LE(out[0..2], @intCast(key.len)); + wrU32LE(out[2..6], @intCast(value.len)); + @memcpy(out[6..][0..key.len], key); + @memcpy(out[6 + key.len ..][0..value.len], value); + return 6 + key.len + value.len; +} + +fn encodeWire2TestKeyValue(out: []u8, col: []const u8, key: []const u8, value: []const u8) usize { + wrU16LE(out[0..2], @intCast(col.len)); + @memcpy(out[2..][0..col.len], col); + const key_off = 2 + col.len; + wrU16LE(out[key_off..][0..2], @intCast(key.len)); + @memcpy(out[key_off + 2 ..][0..key.len], key); + const val_off = key_off + 2 + key.len; + wrU32LE(out[val_off..][0..4], @intCast(value.len)); + @memcpy(out[val_off + 4 ..][0..value.len], value); + return val_off + 4 + value.len; +}