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 var | Default | Meaning |
|---|---|---|
--bind / LUNARIS_BIND | 0.0.0.0:8080 | Listen 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_SECOND | 60 | Per-tenant sustained request rate |
--rate-burst / LUNARIS_RATE_BURST | 120 | Per-tenant burst budget |
--cors-origins / LUNARIS_CORS_ORIGINS | * | CORS allow-list — * or a comma-separated origin list |
--shutdown-grace-secs / LUNARIS_SHUTDOWN_GRACE_SECS | 30 | Graceful-shutdown drain window (a ceiling — see Deployment notes) |
--http-timeout-secs / LUNARIS_HTTP_TIMEOUT_SECS | 30 | Per-request wall-clock budget; over-budget → 408. 0 disables |
--http-concurrency / LUNARIS_HTTP_CONCURRENCY | 256 | Max 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"] }
}
tenantis the partition scope for the token (typed and validated as aScope) — the only source of truth for it. Route handlers consume the token-bound scope and ignore anyscope/tenantfield 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).scopesis the verb-permission set for the token — which ofingest/recall/forgetit 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
forgetunder real scopes. In v0.2.x,Lunaris::forgetstill routes throughScope::dev()internally for itsatomic_write/read_as_of/scan_rangecalls — aforgetissued under any non-_dev_scope silently returnsrows_written = 0,rows_deleted = 0(the Moon SCAN prefix filters everything out). It emits atracing::warn!on every call. The real per-scope routing —ScopedLunaris::forget(target)with a403/404cross-scope contract — is a v0.3 deliverable (RFC 0001 §11.6,CHANGELOG.md“Known issues”). See Forgetting anddocs/migration/0.1-to-0.2.md§10.2.
Endpoints (operational view)
The wire spec is MemoryProtocol 0.1; the operational summary:
| Route | Required scope | What it does |
|---|---|---|
POST /v1/ingest | ingest | Ingest one Episode; server chunks + embeds + does one atomic_write. Returns {lsn, queue_lag_warn}. |
POST /v1/recall | recall | Hybrid retrieval (Vector + BM25 + RRF + optional rerank). Accept: application/json → array of hits; Accept: text/event-stream → SSE stream (event: hit … event: done, 15 s keep-alive). mode: "graph" needs a graph-capable backend or GraphPipeline::enable() (else 501). |
POST /v1/forget | forget | Single-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} | recall | Streams 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} | recall | Fetch 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:
| Probe | Endpoint | Asks | Failure remedy |
|---|---|---|---|
livenessProbe | /healthz | Is the process up and is storage reachable? | Restart the pod |
readinessProbe | /readyz | Can 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:
ping—StoragePort::health_check(MoonPING);canary— aKvPut+KvDeleteof the fixed reserved keylunaris:__health__:canaryin the reserved__health__scope;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-secsis cut off with408and the{error:"request_timeout"}envelope, and counted inlunaris_http_timeout_total. The budget covers producing the response, not streaming its body — an SSE/v1/recallstream is never severed mid-flight; - a request arriving while
--http-concurrencyrequests are already in flight is shed with503+Retry-After: 1and the{error:"overloaded"}envelope, counted inlunaris_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):
| Metric | Type | Labels |
|---|---|---|
lunaris_ingest_total | counter | tenant, status |
lunaris_ingest_duration_seconds | histogram | tenant |
lunaris_recall_total | counter | tenant, mode, status |
lunaris_recall_duration_seconds | histogram | tenant, mode |
lunaris_forget_total | counter | tenant, target_kind, hard |
lunaris_verify_queue_depth | gauge | topic |
lunaris_consolidator_queue_depth | gauge | topic |
lunaris_error_total | counter | kind (cardinality cap ≤ 10) |
lunaris_eval_score | gauge | harness |
lunaris_http_in_flight | gauge | (none) |
lunaris_http_shed_total | counter | (none) |
lunaris_http_timeout_total | counter | (none) |
lunaris_ready | gauge | (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/SIGINTthe 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 logsWARN shutdown grace window expired; abandoning in-flight requests aborted_in_flight=<n>(n from thelunaris_http_in_flightgauge) 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 lunarisbuild 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 ↔
LunarisErrormapping lives incrates/lunaris-server/src/middleware/error.rs::map_error; the full table is in the protocol spec and Error Taxonomy.
See also
- MemoryProtocol 0.1 — the wire spec
- Conformance — certifying a server
- The Storage Backend — Moon setup and its honest limits
- Configuration Reference — every flag/var