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 @@
-
+
@@ -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;
+}