Running LoraDB as an HTTP Server
Overview
lora-server wraps the Rust engine in a small Axum HTTP server —
useful for probing the engine with curl, serving a polyglot stack,
or running demos. One process serves exactly one graph. The graph
lives in memory while the process runs, and can optionally be paired
with snapshots and a WAL for recovery across restarts.
Run LoraDB as an HTTP server
Install lora-server, start it on a bind address, send a Cypher query over HTTP, and enable snapshot or WAL persistence.
- Install the binaryDownload a prebuilt archive from the latest GitHub release, or run cargo install lora-server from crates.io. From a workspace checkout, cargo run --release -p lora-server boots straight from source.Jump to section
- Start the serverRun lora-server (defaults to 127.0.0.1:4747). Override with --host 0.0.0.0 --port 8080 or the LORA_SERVER_HOST / LORA_SERVER_PORT env vars. The graph lives in memory while the process runs.Jump to section
- Send your first queryPOST a JSON body to /query: curl -X POST http://127.0.0.1:4747/query -d '{"query": "CREATE (:Person {name: \"Ada\"})"}'. Follow up with a MATCH to read it back. Always send user input through params instead of string-concatenating into the query field.Jump to section
- Add persistencePass --snapshot-path graph.bin to enable the admin snapshot endpoints, --restore-from graph.bin to load a snapshot at boot, and --wal-dir ./wal to attach a write-ahead log for continuous durability between checkpoints.Jump to section
- Secure the endpointlora-server has no built-in auth. Keep it on 127.0.0.1, or front it with a reverse proxy that handles TLS, authentication, and rate limiting before exposing it to the network.Jump to section
Installation / Setup
Install
Fastest binary path:
- Download a prebuilt
lora-serverarchive from the latest GitHub release.
From crates.io:
cargo install lora-server
From a local checkout:
cargo install --path crates/lora-server
Or, inside the workspace:
cargo run --release -p lora-server
Configure
lora-server # 127.0.0.1:4747
lora-server --host 0.0.0.0 --port 8080
LORA_SERVER_HOST=0.0.0.0 LORA_SERVER_PORT=8080 lora-server
Precedence (first match wins): CLI flags → environment variables →
built-in defaults (127.0.0.1:4747).
Most runtime flags also have an env-var equivalent:
| Flag | Env var | Default | Description |
|---|---|---|---|
--host <ADDR> | LORA_SERVER_HOST | 127.0.0.1 | Bind address. |
--port <PORT> | LORA_SERVER_PORT | 4747 | Bind port. |
--snapshot-path <PATH> | LORA_SERVER_SNAPSHOT_PATH | unset | Default file for the admin snapshot endpoints. Also gates whether they are mounted — unset = 404. |
--restore-from <PATH> | — | unset | Load a snapshot at boot, before accepting queries. |
--wal-dir <DIR> | LORA_SERVER_WAL_DIR | unset | Attach a write-ahead log at this directory and enable the WAL admin routes. |
--wal-sync-mode <MODE> | LORA_SERVER_WAL_SYNC_MODE | group-sync | WAL durability cadence: group-sync. |
--wal-truncate-corrupt-tail | LORA_SERVER_WAL_TRUNCATE_CORRUPT_TAIL | off | Incident recovery: start from a WAL that is corrupt mid-log by cutting it at the first bad record. See Corrupt WAL. |
--query-timeout-ms <MS> | LORA_SERVER_QUERY_TIMEOUT_MS | 30000 | Time budget for each /query and /profile request. 0 disables it. |
--max-concurrent-queries <N> | LORA_SERVER_MAX_CONCURRENT_QUERIES | twice the CPU count | How many /query, /explain and /profile requests run at once. 0 means unlimited. |
--max-body-bytes <BYTES> | LORA_SERVER_MAX_BODY_BYTES | 2097152 | Largest accepted request body. Larger bodies get 413. |
--max-result-rows <N> | LORA_SERVER_MAX_RESULT_ROWS | 1000000 | Most rows a /query result may have. Larger results get 422 LORA_RESULT_TOO_LARGE. 0 means unlimited. |
--max-response-bytes <BYTES> | LORA_SERVER_MAX_RESPONSE_BYTES | 268435456 (256 MiB) | Largest /query response body. Larger responses get 422 LORA_RESULT_TOO_LARGE. 0 means unlimited. |
--header-read-timeout-ms <MS> | LORA_SERVER_HEADER_READ_TIMEOUT_MS | 10000 | Time a connection gets to send a request's headers before it is closed. 0 disables it. |
--admin-path-root <DIR> | LORA_SERVER_ADMIN_PATH_ROOT | unset | Allow admin requests to send a path inside this directory. Unset = every path override is rejected. |
Query limits
Each query runs on a worker thread outside the request loop, so a slow
query does not hold up /health or other requests.
--query-timeout-ms <MS>bounds a/queryor/profilerequest, including any time spent waiting for a free query slot. When the budget runs out the query is cancelled at its next check point, any write it made is rolled back, and the response is408with codeLORA_TIMEOUT.0turns the timeout off.- Client disconnects cancel the query. If the HTTP client hangs up before the response is ready, the server cancels the query the same way, so abandoned requests do not keep running.
--max-concurrent-queries <N>caps how many query requests run at once. Further requests wait for a slot (the wait counts toward the timeout).--max-body-bytes <BYTES>caps the request body.--max-result-rows <N>caps the rows of a/queryresult (default 1,000,000). The engine counts rows while it builds the result and stops as soon as the result goes over the cap, so a query such asUNWIND range(1, 10000000) AS i RETURN ior an accidental cartesian product fails within moments instead of filling memory. Any write the query made is rolled back, as with a timeout. Only the rows the query returns count: a query that reads millions of rows and returns a count or a filtered handful is not affected. Rows built on the way to the result (before aWHERE, an aggregation or aLIMIT) are bounded only by--query-timeout-ms.0turns the cap off.--max-response-bytes <BYTES>caps the size of a/queryresponse body (default 256 MiB), for results with few rows that hold very large values. The response is encoded against the cap and dropped once it goes over. The query has already finished by then, so unlike the row cap, a write behind an oversized response stays committed.0turns the cap off.--header-read-timeout-ms <MS>closes a connection that has not sent a complete request head (request line and headers) within this time, so clients that open connections and send headers a byte at a time cannot hold them open. It also bounds how long an idle keep-alive connection waits for its next request. The request body is not covered.0turns it off.
lora-server --query-timeout-ms 5000 --max-concurrent-queries 16
Result size limits
Start a server with small caps to see them at work:
lora-server --max-result-rows 1000 --max-response-bytes 65536
A read that would return ten million rows stops at row 1001:
curl -s -i -X POST http://127.0.0.1:4747/query \
-H 'Content-Type: application/json' \
-d '{"query":"UNWIND range(1, 10000000) AS i RETURN i"}'
HTTP/1.1 422 Unprocessable Entity
content-type: application/json
content-length: 163
{"error":{"code":"LORA_RESULT_TOO_LARGE","message":"query result has more than 1000 rows; add a LIMIT or page through it with SKIP and LIMIT","category":"client"}}
A write whose result goes over the cap is rolled back, so nothing it created is left behind:
curl -s -X POST http://127.0.0.1:4747/query \
-H 'Content-Type: application/json' \
-d '{"query":"UNWIND range(1, 5000) AS i CREATE (p:Person {id: i}) RETURN p.id AS id"}'
# 422, same body as above
curl -s -X POST http://127.0.0.1:4747/query \
-H 'Content-Type: application/json' \
-d '{"query":"MATCH (p:Person) RETURN count(p) AS people","format":"rows"}'
{"rows":[{"people":0}]}
Drop the RETURN (or return a count) to run the same write: a query
that returns no rows is not capped, however much it writes.
A single row can still be large. Here one 100 kB string goes over the 64 kB response cap:
curl -s -i -X POST http://127.0.0.1:4747/query \
-H 'Content-Type: application/json' \
-d "{\"query\":\"RETURN reduce(s = '', i IN range(1, 10000) | s + '0123456789') AS big\",\"format\":\"rows\"}"
HTTP/1.1 422 Unprocessable Entity
content-type: application/json
content-length: 196
{"error":{"code":"LORA_RESULT_TOO_LARGE","message":"query response is larger than 65536 bytes; return fewer or smaller values, or page through the result with SKIP and LIMIT","category":"client"}}
Read a large result in pages instead, with a stable ORDER BY:
curl -s -X POST http://127.0.0.1:4747/query \
-H 'Content-Type: application/json' \
-d '{"query":"UNWIND range(1, 2000) AS i RETURN i ORDER BY i SKIP 0 LIMIT 3","format":"rows"}'
{"rows":[{"i":1},{"i":2},{"i":3}]}
/explain returns no rows, and /profile returns metrics instead of
rows, so neither is capped; /profile still builds the result in
memory and is bounded by --query-timeout-ms.
Snapshots, WAL, and restore
LoraDB can persist the live graph in two complementary ways:
-
Snapshots for explicit point-in-time save / load.
-
WAL for replaying committed writes after a crash or restart.
-
--snapshot-path <PATH>(orLORA_SERVER_SNAPSHOT_PATH) enables the admin endpointsPOST /admin/snapshot/saveandPOST /admin/snapshot/load, and supplies the default file they operate on. If unset, the admin routes return404. -
--wal-dir <DIR>(orLORA_SERVER_WAL_DIR) attaches a write-ahead log at that directory and enablesPOST /admin/checkpoint,POST /admin/wal/status, andPOST /admin/wal/truncate. -
--wal-sync-mode <MODE>chooses when the WALfsyncs:group-syncis the supported mode. -
--restore-from <PATH>loads a snapshot at startup. A missing file is fine — the server starts with an empty graph and logs a message. A malformed file is fatal. When--wal-diris also set, committed WAL records newer than the snapshot fence are replayed before the server begins accepting queries.
Typical cron-friendly setup — boot from, and save back to, the same file:
lora-server \
--host 127.0.0.1 --port 4747 \
--snapshot-path /var/lib/lora/db.bin \
--restore-from /var/lib/lora/db.bin
WAL-only setup:
lora-server \
--host 127.0.0.1 --port 4747 \
--wal-dir /var/lib/lora/wal
With only --wal-dir, WAL recovery works and the WAL admin routes are
mounted. Checkpoints need an explicit path in the request body because
there is no configured snapshot default, and a body path is only
accepted inside --admin-path-root:
lora-server \
--host 127.0.0.1 --port 4747 \
--wal-dir /var/lib/lora/wal \
--admin-path-root /var/lib/lora
curl -sX POST http://127.0.0.1:4747/admin/checkpoint \
-H 'content-type: application/json' \
-d '{"path":"/var/lib/lora/checkpoint.bin"}'
WAL plus checkpoint default path:
lora-server \
--host 127.0.0.1 --port 4747 \
--wal-dir /var/lib/lora/wal \
--snapshot-path /var/lib/lora/db.bin \
--restore-from /var/lib/lora/db.bin
Now a body-less checkpoint writes to --snapshot-path:
curl -sX POST http://127.0.0.1:4747/admin/checkpoint
Inspect WAL state:
curl -sX POST http://127.0.0.1:4747/admin/wal/status
Save on demand:
curl -sX POST http://127.0.0.1:4747/admin/snapshot/save
# => {"formatVersion":1,"nodeCount":1024,"relationshipCount":4096,"walLsn":null,"path":"/var/lib/lora/db.bin"}
Or save to an ad-hoc override path for a single call. This needs
--admin-path-root (here /var/backups/lora), and the path must stay
inside that directory:
curl -sX POST http://127.0.0.1:4747/admin/snapshot/save \
-H 'content-type: application/json' \
-d '{"path":"/var/backups/lora/2026-04-24.bin"}'
Load (publishes the restored graph for new queries):
curl -sX POST http://127.0.0.1:4747/admin/snapshot/load
--restore-from is independent of --snapshot-path. You can restore
from a read-only seed (/var/lib/lora/seed.bin) and snapshot to a
writable path (/var/lib/lora/runtime.bin).
The admin endpoints have no authentication. Anyone who can reach
the admin port can overwrite the snapshot, swap the live graph with
load, or truncate the WAL. A path body field is rejected unless
the server runs with --admin-path-root, and then it must stay inside
that directory, which narrows which files a caller can touch. It is
not access control. The same warning applies to /admin/checkpoint. See
Limitations → HTTP server and the
HTTP API reference before
exposing them.
See also the canonical Snapshots guide for the metadata shape, file format, and every binding's save / load API, and WAL and checkpoints for recovery and checkpoint semantics.
Creating a Client / Connection
The client is any HTTP client. Verify the server is alive before sending queries:
curl http://127.0.0.1:4747/health
# { "status": "ok" }
Running Your First Query
curl -s http://127.0.0.1:4747/query \
-H 'content-type: application/json' \
-d '{"query":"CREATE (:Person {name: \"Ada\"})"}'
Then read it back:
curl -s http://127.0.0.1:4747/query \
-H 'content-type: application/json' \
-d '{"query":"MATCH (p:Person) RETURN p.name AS name","format":"rows"}'
Examples
Minimal working example with curl
Shown above. Two POST /query calls.
Parameterised query
Send a params object next to the query. JSON scalars, arrays, and
objects map to LoraDB scalar, list, and map values:
curl -s http://127.0.0.1:4747/query \
-H 'content-type: application/json' \
-d '{"query":"MATCH (p:Person) WHERE p.name = $name RETURN p.name AS name","format":"rows","params":{"name":"Ada"}}'
For typed values that JSON cannot represent directly, cast the parameter in the query:
curl -s http://127.0.0.1:4747/query \
-H 'content-type: application/json' \
-d '{"query":"RETURN $when::DATE AS d, $embedding::VECTOR<FLOAT32>(3) AS v","format":"rows","params":{"when":"2026-05-26","embedding":[0.1,0.2,0.3]}}'
Structured result handling with jq
curl -s http://127.0.0.1:4747/query \
-H 'content-type: application/json' \
-d '{"query":"MATCH (p:Person) RETURN p.name AS name","format":"rows"}' \
| jq '.rows[].name'
Node client example
async function runQuery(query: string) {
const res = await fetch('http://127.0.0.1:4747/query', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, format: 'rows' }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `http ${res.status}`);
}
return res.json() as Promise<{ columns: string[]; rows: any[] }>;
}
const { rows } = await runQuery('MATCH (p:Person) RETURN count(*) AS n');
console.log(rows[0].n);
Handle errors
HTTP status codes:
| Status | Meaning |
|---|---|
200 | Query executed successfully; body is a QueryResult |
400 | Parse / semantic / read-only / config error; body is structured as shown below |
408 | Cooperative timeout |
409 | Constraint violation |
422 | Invalid params or vector value |
500 / 503 | Storage, snapshot, WAL, or internal error |
{
"error": {
"code": "LORA_PARSE",
"message": "parse error: expected ...",
"category": "client"
}
}
Handle both explicitly; never assume 200 on a mis-typed query.
Embedding in a larger Axum app
lora-server is also a library — embed it in a larger Axum
application, or run several processes on different ports for
isolation:
use std::sync::Arc;
use lora_database::Database;
use lora_server::build_app;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let db = Arc::new(Database::in_memory());
let app = build_app(Arc::clone(&db));
let listener = tokio::net::TcpListener::bind("127.0.0.1:4747").await?;
axum::serve(listener, app).await?;
Ok(())
}
Mount build_app(db) under any sub-path, combine it with your own
routes, add middleware — it's a standard Axum Router.
Endpoints
GET /health
Liveness check.
curl http://127.0.0.1:4747/health
# { "status": "ok" }
POST /query
Request body:
{
"query": "MATCH (n) RETURN n",
"format": "rowArrays"
}
query— Cypher string (required).format— one of"rows","rowArrays","graph","combined"(optional; defaults to"graph"). See Result formats for the full shape of each.
POST /admin/snapshot/save (opt-in)
POST /admin/snapshot/load (opt-in)
Both are mounted only when the server is started with
--snapshot-path <PATH> (or LORA_SERVER_SNAPSHOT_PATH). Otherwise
they return 404. See Snapshots, WAL, and restore
above, and the full reference in
HTTP API → Admin endpoints (opt-in).