Skip to main content

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.

How-to~5 min

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.

  1. Install the binary
    Download 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
  2. Start the server
    Run 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
  3. Send your first query
    POST 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
  4. Add persistence
    Pass --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
  5. Secure the endpoint
    lora-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:

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:

FlagEnv varDefaultDescription
--host <ADDR>LORA_SERVER_HOST127.0.0.1Bind address.
--port <PORT>LORA_SERVER_PORT4747Bind port.
--snapshot-path <PATH>LORA_SERVER_SNAPSHOT_PATHunsetDefault file for the admin snapshot endpoints. Also gates whether they are mounted — unset = 404.
--restore-from <PATH>—unsetLoad a snapshot at boot, before accepting queries.
--wal-dir <DIR>LORA_SERVER_WAL_DIRunsetAttach a write-ahead log at this directory and enable the WAL admin routes.
--wal-sync-mode <MODE>LORA_SERVER_WAL_SYNC_MODEgroup-syncWAL durability cadence: group-sync.
--wal-truncate-corrupt-tailLORA_SERVER_WAL_TRUNCATE_CORRUPT_TAILoffIncident 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_MS30000Time budget for each /query and /profile request. 0 disables it.
--max-concurrent-queries <N>LORA_SERVER_MAX_CONCURRENT_QUERIEStwice the CPU countHow many /query, /explain and /profile requests run at once. 0 means unlimited.
--max-body-bytes <BYTES>LORA_SERVER_MAX_BODY_BYTES2097152Largest accepted request body. Larger bodies get 413.
--max-result-rows <N>LORA_SERVER_MAX_RESULT_ROWS1000000Most 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_BYTES268435456 (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_MS10000Time a connection gets to send a request's headers before it is closed. 0 disables it.
--admin-path-root <DIR>LORA_SERVER_ADMIN_PATH_ROOTunsetAllow 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 /query or /profile request, 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 is 408 with code LORA_TIMEOUT. 0 turns 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 /query result (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 as UNWIND range(1, 10000000) AS i RETURN i or 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 a WHERE, an aggregation or a LIMIT) are bounded only by --query-timeout-ms. 0 turns the cap off.
  • --max-response-bytes <BYTES> caps the size of a /query response 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. 0 turns 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. 0 turns 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> (or LORA_SERVER_SNAPSHOT_PATH) enables the admin endpoints POST /admin/snapshot/save and POST /admin/snapshot/load, and supplies the default file they operate on. If unset, the admin routes return 404.

  • --wal-dir <DIR> (or LORA_SERVER_WAL_DIR) attaches a write-ahead log at that directory and enables POST /admin/checkpoint, POST /admin/wal/status, and POST /admin/wal/truncate.

  • --wal-sync-mode <MODE> chooses when the WAL fsyncs: group-sync is 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-dir is 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).

Security

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:

StatusMeaning
200Query executed successfully; body is a QueryResult
400Parse / semantic / read-only / config error; body is structured as shown below
408Cooperative timeout
409Constraint violation
422Invalid params or vector value
500 / 503Storage, 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).

POST /admin/checkpoint (opt-in)​

Mounted when the server is started with --wal-dir <DIR>. Uses --snapshot-path as the default target when configured; otherwise the request body must supply { "path": "..." } inside --admin-path-root.

POST /admin/wal/status (opt-in)​

Mounted when the server is started with --wal-dir <DIR>. Returns the current durable LSN, next LSN, active and oldest segment ids, and any latched background fsync failure.

POST /admin/wal/truncate (opt-in)​

Mounted when the server is started with --wal-dir <DIR>. Drops sealed WAL segments up to a fence LSN. With no body, the server truncates up to the current durable LSN.

Common Patterns​

Seed via stdin​

cat seed.cypher | while IFS= read -r q; do
curl -s http://127.0.0.1:4747/query \
-H 'content-type: application/json' \
--data-binary "$(jq -n --arg q "$q" '{query:$q}')" > /dev/null
done

Where seed.cypher has one Cypher statement per line.

Health check script​

status=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:4747/health)
[ "$status" = 200 ] && echo 'ok' || echo 'down'

Embedding with custom routes​

use axum::routing::get;
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))
.route("/version", get(|| async { "loradb custom" }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:4747").await?;
axum::serve(listener, app).await?;
Ok(())
}

Multiple graphs​

One process serves exactly one graph. Run multiple processes on different ports and put a reverse proxy in front when you need isolation.

Error Handling​

SymptomLikely causeFix
Address already in usePort held by another processSee Troubleshooting → Server
400 on every requestMissing content-type: application/jsonAdd the header
Silent empty rowsQuery targets a label that doesn't exist yetSeed before reading

What's not here​

  • Authentication, TLS, rate limiting — none. Bind to 127.0.0.1 or put it behind a reverse proxy. The admin snapshot and WAL endpoints also ship without auth — see Snapshots, WAL, and restore.
  • Multi-query HTTP transactions — each /query call is its own statement or document execution. Use an in-process binding when your application needs explicit transaction handles.
  • Multiple databases — one process serves exactly one graph. Run multiple processes on different ports if you need isolation.

Performance / Best Practices​

  • Put the server behind a reverse proxy (nginx, Caddy, Traefik) for TLS and rate limiting — the built-in server has none.
  • Bind to 127.0.0.1 unless you control the network.
  • For a polyglot stack, embed build_app(db) into a larger Axum process rather than running a separate lora-server.

See also​