Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Running the HTTP Server

Reach for lunaris-server when an agent harness — or a non-Rust service — needs to talk to a shared Lunaris memory engine over HTTP/SSE instead of linking the crate. It is the reference implementation of MemoryProtocol 0.1: an axum 0.8 binary exposing the blueprint verbs plus a Prometheus /metrics endpoint.

The crate is lunaris-server; config lives in crates/lunaris-server/src/config.rs. The full env-var / feature-flag matrix is in the Configuration Reference — this page covers operating the binary.

Quick start

cargo build -p lunaris-server          # → target/debug/lunaris-server

LUNARIS_STORAGE=moon://localhost:6380 \
LUNARIS_TOKENS_FILE=/etc/lunaris/tokens.json \
  target/debug/lunaris-server --bind 127.0.0.1:8080

On startup the binary prints its bound address to stderr as LISTENING_ON <addr> (e.g. LISTENING_ON 127.0.0.1:8080) — the conformance subprocess runner parses this; see crates/lunaris-server/src/main.rs. Then probe it:

curl -s localhost:8080/healthz                                  # {"ok":true,"version":"0.2.1"}  (version = CARGO_PKG_VERSION)
curl -s localhost:8080/readyz                                   # {"ready":true,"version":...,"checks":{"ping":"ok","canary":"ok","embedder":"ok"}}
curl -s -H 'Authorization: Bearer <token>' \
     -H 'Content-Type: application/json' \
     -d '{"source":"demo","content":"Alice loves chocolate."}' \
     localhost:8080/v1/ingest

Configuration

Every flag has a matching LUNARIS_* env var (12-factor; CLI flag wins over env). Source: crates/lunaris-server/src/config.rs.

Flag / env varDefaultMeaning
--bind / LUNARIS_BIND0.0.0.0:8080Listen address
--storage / LUNARIS_STORAGE(required)Storage URL — moon://host:port, the only accepted scheme (see The Storage Backend)
--tokens-file / LUNARIS_TOKENS_FILE(required)Path to the bearer-token map JSON (below)
--rate-per-second / LUNARIS_RATE_PER_SECOND60Per-tenant sustained request rate
--rate-burst / LUNARIS_RATE_BURST120Per-tenant burst budget
--cors-origins / LUNARIS_CORS_ORIGINS*CORS allow-list — * or a comma-separated origin list
--shutdown-grace-secs / LUNARIS_SHUTDOWN_GRACE_SECS30Graceful-shutdown drain window (a ceiling — see Deployment notes)
--http-timeout-secs / LUNARIS_HTTP_TIMEOUT_SECS30Per-request wall-clock budget; over-budget → 408. 0 disables
--http-concurrency / LUNARIS_HTTP_CONCURRENCY256Max requests served concurrently; arrivals beyond the cap are shed with 503 + Retry-After, never queued. 0 disables
--metrics-disabled(off)Remove the /metrics endpoint (no env var)

The same LUNARIS_EMBEDDER_DIR / LUNARIS_EMBEDDER_GGUF / LUNARIS_GRAPH_ENABLED / verifier / consolidator env vars that Lunaris::open reads also apply here — see Configuration Reference §2.

The bearer-token map (LUNARIS_TOKENS_FILE)

A JSON object mapping opaque bearer tokens to a tenant id + scope set (CONTEXT.md D-07):

{
  "<opaque-bearer-token>": { "tenant": "acme",   "scopes": ["ingest", "recall", "forget"] },
  "<another-token>":       { "tenant": "globex", "scopes": ["recall"] }
}
  • tenant is the partition scope for the token (typed and validated as a Scope) — the only source of truth for it. Route handlers consume the token-bound scope and ignore any scope / tenant field on the request body; every public DTO carries #[serde(deny_unknown_fields)], so a request body that contains such a field is rejected outright (HTTP 422).
  • scopes is the verb-permission set for the token — which of ingest / recall / forget it may call. A request whose route requires a verb the token doesn’t carry → 403 Forbidden.
  • A missing or invalid token → 401 Unauthorized.

Heads-up on forget under real scopes. In v0.2.x, Lunaris::forget still routes through Scope::dev() internally for its atomic_write / read_as_of / scan_range calls — a forget issued under any non-_dev_ scope silently returns rows_written = 0, rows_deleted = 0 (the Moon SCAN prefix filters everything out). It emits a tracing::warn! on every call. The real per-scope routing — ScopedLunaris::forget(target) with a 403/404 cross-scope contract — is a v0.3 deliverable (RFC 0001 §11.6, CHANGELOG.md “Known issues”). See Forgetting and docs/migration/0.1-to-0.2.md §10.2.

Endpoints (operational view)

The wire spec is MemoryProtocol 0.1; the operational summary:

RouteRequired scopeWhat it does
POST /v1/ingestingestIngest one Episode; server chunks + embeds + does one atomic_write. Returns {lsn, queue_lag_warn}.
POST /v1/recallrecallHybrid retrieval (Vector + BM25 + RRF + optional rerank). Accept: application/json → array of hits; Accept: text/event-stream → SSE stream (event: hitevent: done, 15 s keep-alive). mode: "graph" needs a graph-capable backend or GraphPipeline::enable() (else 501).
POST /v1/forgetforgetSingle-target / by-source / temporal-bound purge. Two-step hard-delete rail: dry_run:true → preview receipt; then hard:true + confirmation_token: <serialized prior receipt> → real delete. hard:true without the token → 428 Precondition Required.
GET /v1/snapshot/{lsn}recallStreams every primitive at the given Hlc (<wall_ms>.<counter>[.<node_id>]) as application/x-ndjson. Returns 404 snapshot_out_of_range if the wall_ms is strictly in the future; an empty past snapshot is 200 + empty body.
GET /v1/episode/{id}recallFetch a single episode by ULID from the caller’s scope. 200 + JSON on hit; 400 invalid_episode_id on malformed ULID; 404 episode_not_found when absent.
GET /healthz(none)Liveness probe — {"ok":true,"version":...}; 503 when the storage PING fails. No auth, not rate-limited, never writes.
GET /readyz(none)Readiness probe — storage PING + a write canary + embedder state. 200 {"ready":true,"checks":{...}} / 503 {"ready":false,...}. No auth, not rate-limited.
GET /metrics(none)Prometheus text exposition. No auth — front it with a network ACL or reverse-proxy auth. 404 when --metrics-disabled.

Liveness vs readiness

Wire them to the matching Kubernetes probes — they answer different questions and have different remedies:

ProbeEndpointAsksFailure remedy
livenessProbe/healthzIs the process up and is storage reachable?Restart the pod
readinessProbe/readyzCan the process actually serve?Remove the pod from the LB

/readyz runs three checks, each bounded at 2 s, and reports them individually in checks:

  1. pingStoragePort::health_check (Moon PING);
  2. canary — a KvPut + KvDelete of the fixed reserved key lunaris:__health__:canary in the reserved __health__ scope;
  3. embedder — the embedder is configured with a usable dimension. This is a structural check: a probe must never run inference, because a wedged ggml pool cannot be cancelled and the probe itself would wedge.

The canary check is the point of the endpoint. Both production wedges we have hit (the MA1 write-stall, the dashtable recovery crash-loop) answered PING happily while refusing every write, so a PING-only probe kept traffic flowing into a store that could not accept a byte. A canary of timeout is that exact signature. Note that liveness deliberately stays green in that case: restarting the process does not un-wedge the backend, it just adds a cold start to the incident.

Readiness results are cached for 5 s and probes single-flight, so at most one canary write reaches the store every 5 s regardless of probe rate — probe traffic can never become write traffic. lunaris_ready (1/0) exports the last verdict for alerting.

$ curl -s localhost:8080/readyz | jq
{ "ready": true, "version": "0.6.0", "checks": { "ping": "ok", "canary": "ok", "embedder": "ok" } }

Request timeout, concurrency limit and load shedding

Applied to every route (probes included), outside the per-route auth / rate-limit stack:

  • a request that exceeds --http-timeout-secs is cut off with 408 and the {error:"request_timeout"} envelope, and counted in lunaris_http_timeout_total. The budget covers producing the response, not streaming its body — an SSE /v1/recall stream is never severed mid-flight;
  • a request arriving while --http-concurrency requests are already in flight is shed with 503 + Retry-After: 1 and the {error:"overloaded"} envelope, counted in lunaris_http_shed_total. It is never queued: queueing in front of a slow backend is how a latency blip becomes an OOM.

Order on the request path is CORS → load-shed(concurrency) → in-flight gauge → timeout → auth/rate-limit → handler, so the worst-case backlog is bounded by --http-concurrency × --http-timeout-secs rather than by client patience. Rationale for that order lives in crates/lunaris-server/src/middleware/resilience.rs.

Rate limiting

Per-tenant, applied to every /v1/* request (key = the tenant claim). Exceeded → 429 Too Many Requests with a Retry-After: <seconds> header. Un-authenticated routes (/healthz, /metrics) are not rate-limited.

Metrics

/metrics exposes (Plan 05-05; CONTEXT.md D-25):

MetricTypeLabels
lunaris_ingest_totalcountertenant, status
lunaris_ingest_duration_secondshistogramtenant
lunaris_recall_totalcountertenant, mode, status
lunaris_recall_duration_secondshistogramtenant, mode
lunaris_forget_totalcountertenant, target_kind, hard
lunaris_verify_queue_depthgaugetopic
lunaris_consolidator_queue_depthgaugetopic
lunaris_error_totalcounterkind (cardinality cap ≤ 10)
lunaris_eval_scoregaugeharness
lunaris_http_in_flightgauge(none)
lunaris_http_shed_totalcounter(none)
lunaris_http_timeout_totalcounter(none)
lunaris_readygauge(none)

Time-series count grows linearly with tenant count (the tokens-file map size), not with traffic. Content-Type is the standard text/plain; version=0.0.4; charset=utf-8.

Deployment notes

  • Stateless process. Every byte of durable state lives in the backend; the server holds nothing on disk. Scale horizontally; restarts are free. See Durability & Recovery.
  • Graceful shutdown. On SIGTERM/SIGINT the server stops accepting new connections and drains in-flight requests for at most --shutdown-grace-secs (default 30 s) before exiting. The window is a ceiling, not a sleep: an idle server exits in milliseconds. If the window expires the server logs WARN shutdown grace window expired; abandoning in-flight requests aborted_in_flight=<n> (n from the lunaris_http_in_flight gauge) and exits anyway — so a wedged backend can no longer pin the process until the orchestrator escalates to SIGKILL. Set your orchestrator’s termination grace period a few seconds ABOVE --shutdown-grace-secs.
  • HTTP-only image. A cargo build --no-default-features -p lunaris build links neither the native embedder nor the reranker stack — useful when the server uses the Ollama HTTP escape hatch (--features embed-remote, LUNARIS_EMBEDDER_OLLAMA_URL) and you want a small image.
  • TLS / OAuth. v0 is plain HTTP with opaque bearer tokens; terminate TLS and do OAuth2/OIDC issuance at a reverse proxy. Managed-cloud JWT issuance is a v1 gate (DEPLOY-V1-01).
  • Errors. The HTTP status ↔ LunarisError mapping lives in crates/lunaris-server/src/middleware/error.rs::map_error; the full table is in the protocol spec and Error Taxonomy.

See also