Key-value server for Discord bots · Rust

Memory above. PostgreSQL below.

HorizonDB answers every read and write from memory, then settles your JSON into PostgreSQL in batches. One WebSocket, one tiny Bun client.

"horizondb": "git+https://gitlab.com/ihrz/db.git#main"
Run it locally

Write it once

Every mutation answers with its result

Dot keys address inside one JSON document per base key. add returns the new number, delete whether the key existed, push the array. Your bot never needs a second round trip to know where it stands.

bot.tsBun SDK
import { HorizonDB } from "horizondb";

const db = await HorizonDB.connect({
  url: "ws://127.0.0.1:8080",
  username: "admin", password: "secret",
});

await db.set("guild.42.settings.prefix", "!");
await db.add("guild.42.stats.messages");   // → 1
await db.push("guild.42.mods", "1234",
  { returnValue: true });                     // → ["1234"]
await db.cache("cooldown.42.daily", true, 86_400_000);

await db.get("guild.42");
// { settings: {…}, stats: { messages: 1 }, mods: ["1234"] }
the wireJSON over WebSocket
→ { "id": "1", "operation": "login",
    "login": "admin", "password": "secret" }
← { "id": "1", "type": "response",
    "data": { "success": true, "sessionId": "9f1c…" } }

→ { "id": "2", "operation": "add",
    "sessionId": "9f1c…", "table": "json",
    "key": "guild.42.stats.messages", "amount": 1,
    "timeoutMs": 10000 }
← { "id": "2", "type": "response",
    "data": { "success": true, "value": 2 } }

// any language with a WebSocket can be a client

Cross-section

Three layers, one line between them

A bot hammers a few large documents from many shards at once. A SQL round trip per add is too slow, and memory alone forgets on restart. HorizonDB keeps both and draws the line where it costs least.

Memory

above the horizon

Every get, set, add and push runs here. Each document sits behind its own lock: readers copy only the sub-tree they asked for, writers edit in place, so a push into a multi-megabyte guild costs microseconds. Keys not loaded yet are read once from PostgreSQL, single-flighted, then stay hot.

per-key RwLocknesting ≤ 32keys ≤ 255 BTTL ≤ 30 days

The flush

the horizon

Every flush_interval_secs and on SIGINT/SIGTERM, dirty keys go down per table: UNNEST upserts and ANY deletes. Dirty marks carry a generation, so a write that lands mid-flush stays dirty for the next cycle.

500 rows / 32 MiB per upsert5 000 ids per deletedefault 300 s

PostgreSQL

below the horizon

Plain tables, created on demand: an id and a JSON value. Query them, back them up, restore them with the tools you already run. Table names are validated before they ever reach SQL.

id VARCHAR(255)value TEXT (JSON)[A-Za-z_][A-Za-z0-9_]{0,62}

Built for load

Fast where it counts, strict everywhere else

No lost updates

Read-modify-write is serialized per key. Sixteen workers hitting one counter end on the exact sum, checked on every benchmark run.

Pipelined by design

Requests are correlated by id, so one socket carries up to 1 024 in flight. Past that the server stops reading and TCP pushes back.

Deadlines respected

A request carrying timeoutMs that already waited too long gets deadline exceeded instead of work nobody is waiting for.

Auth done properly

argon2id password_hash or constant-time comparison, UUID sessions that expire on their own.

A client that heals

The SDK re-logs once on an expired session, reconnects with backoff when a socket goes silent, and throws typed errors. Zero runtime dependencies.

Safe Rust, pedantic clean

unsafe is forbidden, clippy pedantic passes, internal errors stay in the logs and clients get a generic message.

Durability is a trade you choose

Until the next flush, writes live only in memory. A crash or kill -9 loses up to flush_interval_secs of them; a graceful stop flushes everything first, and the systemd unit gives it five minutes to do so. Lower the interval if that window is too wide for your data.

Measured, not promised

Tens of microseconds on the hot path

End to end through the SDK, against a real server and PostgreSQL, network included.

PathThroughputp50p95
hot get, one connection18.1 k ops/s42 µs67 µs
has, one connection20.1 k ops/s39 µs53 µs
login + logout17.2 k ops/s43 µs66 µs
raw WebSocket client, same server48 k ops/s——

Linux laptop, consumer NVMe, PostgreSQL 16 in a container, Bun, Rust debug build, --quick profile. Full table, method and known issues in BENCHMARKS.md. Reproduce with ./scripts/bench.sh --quick. Release-build latency, throughput and memory charts: benchmarks.

Quick start

From clone to first key

Start PostgreSQL and the server

The helper script runs a PostgreSQL container, writes config.toml, builds and starts the server.

git clone https://gitlab.com/ihrz/db.git horizondb && cd horizondb
./scripts/dev-up.sh          # ws://127.0.0.1:8080, logs in .server.log

Add the client to your bot

Bun runs the TypeScript straight from the repository. Pin a tag for reproducible builds.

"dependencies": { "horizondb": "git+https://gitlab.com/ihrz/db.git#main" }

Go to production

Copy example.config.toml, set an argon2id password_hash, size max_message_size above your largest document, and install horizondb.service.

cargo build --release
sudo cp horizondb.service /etc/systemd/system/ && sudo systemctl enable --now horizondb