# Database Architecture Analysis: Thread Pool vs Client-Server DB vs LMDB ## Current Architecture Problem The c-relay runs a **single-threaded event loop** via `lws_service()` in [`start_websocket_relay()`](src/websockets.c:2637). Every WebSocket callback — including [`handle_req_message()`](src/main.c:1101) and [`store_event()`](src/main.c:787) — executes **synchronously** inside this loop. When a REQ query takes 672ms (as documented in your [query analysis report](query_analysis_report.md:39)), **every other connected client is frozen** for that duration. ### The Blocking Chain ```mermaid sequenceDiagram participant C1 as Client 1 participant LWS as lws_service loop participant DB as SQLite g_db participant C2 as Client 2 C1->>LWS: REQ with tag filter LWS->>DB: sqlite3_prepare + step loop Note over LWS,DB: BLOCKED 50-672ms C2->>LWS: EVENT submission Note over C2: Waiting... event loop frozen DB-->>LWS: Results returned LWS-->>C1: EVENT responses + EOSE LWS->>DB: store_event for C2 Note over LWS,DB: BLOCKED again for write DB-->>LWS: Write complete LWS-->>C2: OK response ``` ### What the Numbers Tell Us From your production data: - **2.7 GB database** but only **186 MB actual data** — 98% is indexes and overhead - **4 million `event_tags` rows** with 3 indexes = ~2 GB of index space - **Average query: 10.4ms**, worst case: **672ms** - **98% of queries are REQ reads**, only 2% are writes - **~120 new subscriptions/minute** = 2 queries/second minimum hitting the DB The single `sqlite3* g_db` connection is shared across all operations with **no mutex protection on the connection itself** — it works only because everything runs on one thread. This is the fundamental bottleneck. --- ## Option 2: SQLite Thread Pool ### How It Works Create a pool of N worker threads, each with its own `sqlite3*` connection to the same database file. SQLite WAL mode (already enabled) supports **concurrent readers**. The architecture becomes: ```mermaid flowchart TD LWS[lws_service event loop] -->|REQ arrives| Q[Thread-safe job queue] LWS -->|EVENT arrives| WQ[Write queue - single writer] Q --> T1[Reader Thread 1 - own sqlite3*] Q --> T2[Reader Thread 2 - own sqlite3*] Q --> T3[Reader Thread 3 - own sqlite3*] Q --> T4[Reader Thread 4 - own sqlite3*] WQ --> TW[Writer Thread - own sqlite3*] T1 -->|results| CB[Callback to lws event loop] T2 -->|results| CB T3 -->|results| CB T4 -->|results| CB TW -->|OK/error| CB CB -->|queue_message| LWS ``` ### Key Design Points 1. **Read path**: REQ queries dispatched to thread pool. Each worker opens its own `sqlite3*` connection. SQLite WAL allows unlimited concurrent readers. 2. **Write path**: EVENT inserts go through a single dedicated writer thread. SQLite only allows one writer at a time anyway — this serializes writes cleanly. 3. **Result delivery**: Worker threads cannot call `lws_write()` directly (libwebsockets is not thread-safe). Instead, they push results into a per-session message queue and call `lws_cancel_service()` to wake the event loop, which then drains the queue. 4. **Connection lifecycle**: Each thread opens its own connection with `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout=5000`. ### What Changes in the Codebase | Component | Current | Thread Pool | |-----------|---------|-------------| | [`g_db`](src/main.c:49) | Single global connection | One per thread + writer connection | | [`handle_req_message()`](src/main.c:1101) | Synchronous SQL in callback | Package filter into job, dispatch to pool | | [`store_event()`](src/main.c:787) | Synchronous INSERT in callback | Dispatch to writer thread | | [`handle_count_message()`](src/websockets.c:2863) | Synchronous COUNT in callback | Dispatch to pool | | Result delivery | Direct `queue_message()` | Worker pushes to queue + `lws_cancel_service()` | | Config reads | Direct `sqlite3_prepare` on `g_db` | Can stay synchronous with own connection or cache | ### Performance Characteristics | Metric | Value | |--------|-------| | **Concurrent reads** | N readers in parallel (N = thread count, typically 4-8) | | **Write throughput** | Same as current — SQLite serializes writes regardless | | **Event loop latency** | Near-zero — REQ no longer blocks the loop | | **Max theoretical read throughput** | ~4-8x current (limited by disk I/O, not CPU) | | **Memory overhead** | ~50-100 MB per connection (page cache) | | **Latency per query** | Same as current per-query, but no head-of-line blocking | ### Limitations - **Write contention**: SQLite still allows only ONE writer at a time. With WAL, readers don't block writers and writers don't block readers, but two simultaneous writes will serialize. At your write rate (~56 events/hour), this is a non-issue. - **Database size**: The 2.7 GB index bloat problem remains. Thread pool doesn't fix the schema — it fixes the concurrency. - **Scaling ceiling**: Beyond ~8 reader threads, you hit diminishing returns due to disk I/O contention on a single SQLite file. - **Complexity**: Need a proper job queue, thread lifecycle management, and careful handling of the lws ↔ worker thread boundary. --- ## Option 3: Client-Server Database (PostgreSQL) ### How It Works Replace SQLite with PostgreSQL. The relay connects via `libpq` (PostgreSQL C client library). PostgreSQL runs as a separate process with its own connection pooling, query planner, and MVCC concurrency. ```mermaid flowchart TD LWS[lws_service event loop] -->|async query| PG[libpq async connection pool] PG -->|TCP/Unix socket| PGS[PostgreSQL Server] PGS --> D1[Disk - WAL] PGS --> D2[Shared Buffers - RAM cache] PGS --> W1[Worker Process 1] PGS --> W2[Worker Process 2] PGS --> WN[Worker Process N] W1 -->|results| PG PG -->|callback| LWS ``` ### Performance Characteristics | Metric | Value | |--------|-------| | **Concurrent reads** | Unlimited — each query gets its own backend process | | **Concurrent writes** | True concurrent writes with row-level locking | | **Event loop latency** | Near-zero with async `libpq` | | **Max theoretical throughput** | 10-100x SQLite depending on hardware | | **Memory overhead** | ~10 MB per PostgreSQL backend + shared_buffers | | **Latency per query** | Slightly higher for simple queries due to TCP/IPC overhead, but much better for complex queries due to superior query planner | | **Index efficiency** | Far superior — PostgreSQL B-tree indexes are more space-efficient, supports partial indexes, GIN indexes for JSON | ### What Changes in the Codebase **Everything touching `sqlite3*` must be rewritten.** This is ~258 call sites across 8 files: | File | sqlite3 calls | Scope of change | |------|--------------|-----------------| | [`src/main.c`](src/main.c) | ~80 | Complete rewrite of store_event, handle_req_message, retrieve_event, init_database | | [`src/config.c`](src/config.c) | ~60 | All config table operations | | [`src/websockets.c`](src/websockets.c) | ~15 | COUNT queries, connection tracking | | [`src/api.c`](src/api.c) | ~40 | All monitoring/stats queries | | [`src/dm_admin.c`](src/dm_admin.c) | ~20 | Auth rules, WoT sync | | [`src/subscriptions.c`](src/subscriptions.c) | ~15 | Subscription logging | | [`src/nip009.c`](src/nip009.c) | ~10 | Event deletion | | [`src/ip_ban.c`](src/ip_ban.c) | ~15 | IP ban persistence | | [`src/request_validator.c`](src/request_validator.c) | ~8 | Auth rule checks | ### Advantages Over Thread Pool 1. **True write concurrency**: PostgreSQL handles concurrent writes with row-level locking. If your relay grows to thousands of events/hour, this matters. 2. **Superior query planner**: PostgreSQL's cost-based optimizer is far more sophisticated than SQLite's. Complex tag queries with JOINs will be faster. 3. **Index efficiency**: PostgreSQL's indexes are more compact. Your 2 GB of SQLite indexes would likely be ~500 MB in PostgreSQL. GIN indexes on JSONB would eliminate the `event_tags` table entirely. 4. **JSONB native**: PostgreSQL has native JSONB with indexing. You could store tags as JSONB and query them directly with `@>` operator — no denormalized `event_tags` table needed. 5. **Connection pooling**: PgBouncer or built-in pooling handles thousands of concurrent connections efficiently. 6. **Operational tooling**: `pg_stat_statements`, `EXPLAIN ANALYZE`, `pg_dump`, replication, etc. 7. **Horizontal scaling**: Read replicas possible for future growth. ### Disadvantages vs Thread Pool 1. **Massive rewrite**: ~258 call sites across 8 files. This is essentially rewriting the entire data layer. 2. **External dependency**: PostgreSQL must be installed, configured, and maintained. SQLite is zero-config embedded. 3. **Deployment complexity**: Your current deployment is a single binary + SQLite file. PostgreSQL adds a service dependency. 4. **Latency for simple queries**: A simple `SELECT 1 FROM events WHERE id=?` is ~0.1ms in SQLite vs ~0.5ms in PostgreSQL due to IPC overhead. For your 98% read workload, this adds up. 5. **Memory footprint**: PostgreSQL server uses 100-500 MB baseline. SQLite uses ~50 MB. 6. **Testing complexity**: Tests need a running PostgreSQL instance. --- ## Option 4: LMDB (Lightning Memory-Mapped Database) ### What Is LMDB? LMDB is an embedded key-value store created by Howard Chu for OpenLDAP. It's what **strfry** — the fastest known Nostr relay — uses as its storage engine. It's fundamentally different from both SQLite and PostgreSQL: - **Memory-mapped**: The entire database is `mmap()`'d into the process address space. Reads are literally pointer dereferences — no syscalls, no copies, no serialization. - **B+ tree on disk**: Data is stored in a B+ tree that maps directly to memory pages. The OS page cache IS the database cache. - **MVCC with copy-on-write**: Readers never block writers, writers never block readers. No locks needed for reads at all. - **Zero-copy reads**: When you read a value, you get a pointer directly into the mmap'd region. No `malloc`, no `memcpy`. - **Single-writer, multiple-reader**: Like SQLite WAL, but implemented at a much lower level with near-zero overhead. ### How It Would Work for c-relay LMDB is a **key-value store**, not a relational database. There's no SQL. You design your own indexes as separate "databases" (sub-B-trees within the same file): ```mermaid flowchart TD subgraph LMDB Environment DB1[events_by_id: event_id -> event_json] DB2[events_by_pubkey: pubkey+created_at -> event_id] DB3[events_by_kind: kind+created_at -> event_id] DB4[events_by_tag: tag_name+tag_value -> event_id] DB5[events_by_time: created_at -> event_id] DB6[config: key -> value] DB7[auth_rules: rule_id -> rule_data] end REQ[REQ query] -->|lookup| DB3 REQ -->|lookup| DB4 DB3 -->|get event_id| DB1 DB4 -->|get event_id| DB1 DB1 -->|zero-copy pointer| Response[Send to client] ``` ### The Key Insight: Why strfry Is Fast strfry doesn't use SQL at all. When a REQ comes in with `kinds: [1], #p: [pubkey123]`, strfry: 1. Opens a read-only LMDB transaction (no locks, no copies) 2. Seeks to `tag_p:pubkey123` in the tag index B-tree 3. Iterates matching event IDs 4. For each ID, does a direct pointer lookup in the events B-tree 5. Returns the raw bytes — zero-copy, no JSON parsing, no serialization Compare this to your current c-relay flow: 1. Build SQL string with parameter binding 2. `sqlite3_prepare_v2()` — parse SQL, build query plan 3. `sqlite3_step()` — traverse B-tree, copy data to SQLite's page cache, then copy to your buffer 4. `sqlite3_column_text()` — copy string out of SQLite's internal format 5. `cJSON_Parse()` — parse the JSON string back into objects (for expiration check) 6. Build EVENT message string 7. Queue for sending Steps 2-5 are **completely eliminated** with LMDB. The event JSON bytes go straight from the mmap'd file to the WebSocket send buffer. ### Performance Characteristics | Metric | SQLite (current) | SQLite Thread Pool | PostgreSQL | LMDB | |--------|-----------------|-------------------|------------|------| | **Read latency** | 0.1-672ms | Same per-query | 0.5-50ms | **0.001-0.1ms** | | **Concurrent reads** | 1 (single thread) | 4-8 | Unlimited | **Unlimited** (lock-free) | | **Write latency** | 1-10ms | Same | 0.5-5ms | **0.01-1ms** | | **Concurrent writes** | 1 | 1 | Many | **1** (single writer) | | **Memory copies per read** | 3-4 | 3-4 | 2-3 | **0** (zero-copy) | | **Index overhead** | ~2 GB for 186 MB data | Same | ~500 MB | **~200-400 MB** | | **CPU per query** | High (SQL parse + JSON parse) | Same | Medium (SQL parse) | **Minimal** (pointer math) | ### What Changes in the Codebase This is the **largest rewrite** of all options because you're replacing SQL with manual index management: | Component | Current (SQLite) | LMDB Equivalent | |-----------|-----------------|------------------| | Schema definition | SQL DDL in [`sql_schema.h`](src/sql_schema.h) | C code defining named databases + key formats | | [`store_event()`](src/main.c:787) | SQL INSERT with 9 bound params | `mdb_put()` into events db + `mdb_put()` into each index db | | [`handle_req_message()`](src/main.c:1101) | Dynamic SQL builder (80 lines) | Manual cursor iteration across index databases with set intersection | | Tag queries | `SELECT ... FROM event_tags WHERE tag_name=? AND tag_value IN (...)` | `mdb_cursor_get()` on tag index with `MDB_SET_RANGE` | | Config reads | `SELECT value FROM config WHERE key=?` | `mdb_get()` on config database | | Views/triggers | SQL views for analytics, triggers for replaceable events | Manual C code for all of it | | Expiration check | `cJSON_Parse()` on every row | Can store expiration as separate indexed field — skip expired during cursor iteration | ### Advantages 1. **Raw speed**: 100-1000x faster reads than SQLite for your workload. strfry handles 10,000+ concurrent connections on modest hardware. 2. **Zero-copy**: Event JSON goes from disk → mmap → WebSocket buffer with no intermediate copies. 3. **No SQL overhead**: No query parsing, no query planning, no result materialization. 4. **Embedded**: Like SQLite, it's a library linked into your binary. No external server process. 5. **Proven for Nostr**: strfry demonstrates this works at scale for exactly this use case. 6. **Compact storage**: B+ tree is more space-efficient than SQLite's B-tree + WAL + journal overhead. 7. **Crash-safe**: MVCC with copy-on-write means the database is always consistent, even after power loss. 8. **No event loop blocking**: Read transactions are so fast (microseconds) they can run inline in the lws callback without needing a thread pool. ### Disadvantages 1. **No SQL**: You lose the ability to write ad-hoc queries. All query patterns must be pre-designed as index databases. Your admin SQL query API ([`api.c`](src/api.c) `execute_admin_sql_query()`) would need to be completely rethought. 2. **Manual index management**: Every query pattern needs its own index database. Adding a new filter type means adding a new index and backfilling it. 3. **Largest rewrite**: More code changes than even PostgreSQL, because you're replacing a query language with manual data structure operations. 4. **Single writer**: Like SQLite, only one write transaction at a time. Fine for your 56 events/hour, but a hard ceiling. 5. **No complex queries**: Queries like "top 10 pubkeys by event count" or "events per kind distribution" require manual aggregation in C code. Your monitoring views ([`event_kinds_view`](src/sql_schema.h:276), [`time_stats_view`](src/sql_schema.h:297)) would need C implementations. 6. **Memory mapping limits**: The database size is limited by virtual address space. On 64-bit systems this is effectively unlimited, but you must set `mapsize` at open time. 7. **Learning curve**: LMDB's API is low-level. Cursor management, transaction scoping, and key design require careful thought. 8. **Loss of admin SQL API**: Your current admin API supports arbitrary SQL queries via DM commands. This would be impossible with LMDB — you'd need to build specific query endpoints for each operation. ### The strfry Precedent strfry's architecture is worth studying: - Uses LMDB with custom indexes for each Nostr filter type - Handles tag queries by maintaining a `tag_name:tag_value → event_id` index - Supports NIP-01 filters by intersecting results from multiple index cursors - Achieves sub-millisecond query times even with millions of events - Single-threaded event loop (like your current design) but doesn't need a thread pool because reads are microseconds However, strfry is **purpose-built** around LMDB from day one. Retrofitting LMDB into an existing SQLite-based codebase is significantly harder than starting fresh. --- ## Option 5: Document/JSON Databases Since Nostr events are JSON documents, document databases seem like a natural fit. Let's evaluate the relevant options. ### CouchDB CouchDB stores JSON documents natively, uses HTTP as its protocol, and builds indexes via JavaScript map-reduce views. You have experience with it. **How it would work**: Each Nostr event becomes a CouchDB document. You'd create views for each query pattern — by kind, by pubkey, by tag, etc. CouchDB's MVCC model means readers never block writers. **Why it's NOT a good fit for a Nostr relay**: | Factor | Assessment | |--------|-----------| | **Latency** | HTTP API adds 1-5ms per request. Your current SQLite averages 10ms, so CouchDB wouldn't be faster for simple queries. | | **C integration** | CouchDB is an Erlang server with an HTTP API. From C, every query is an HTTP request via libcurl. This is far heavier than a direct function call to SQLite or LMDB. | | **View building** | Map-reduce views are written in JavaScript and built lazily. First query after data changes triggers a full view rebuild — this would cause massive latency spikes. | | **Deployment** | Requires Erlang runtime + CouchDB server. Much heavier than PostgreSQL. | | **Concurrency** | Good MVCC, but the HTTP overhead negates the benefit for an embedded relay. | | **Real-time** | CouchDB's `_changes` feed could be useful for subscription broadcasting, but the HTTP polling model adds latency vs in-process callbacks. | **Verdict**: CouchDB is excellent for web applications where you're already using HTTP, but for a C relay that needs microsecond-level response times, the HTTP layer is a dealbreaker. ### MongoDB MongoDB is the most popular document database. It stores BSON (binary JSON), has rich query operators, and supports secondary indexes on any field including nested JSON paths. **How it would work**: Events stored as BSON documents. Create indexes on `kind`, `pubkey`, `created_at`, and `tags` (using multikey indexes on arrays). Queries use MongoDB's query language which maps well to Nostr filters. **Why it's a mixed fit**: | Factor | Assessment | |--------|-----------| | **Query model** | Excellent — MongoDB's query operators map almost 1:1 to Nostr filters. `{kind: {$in: [1,7]}, tags: {$elemMatch: {0: "p", 1: "pubkey123"}}}` | | **C driver** | `libmongoc` is mature and well-maintained. Supports async operations. | | **Performance** | 0.5-5ms per query — faster than your current SQLite for complex queries, slower for simple ones. | | **Index efficiency** | Multikey indexes on the tags array would eliminate the `event_tags` table problem. | | **Deployment** | Requires MongoDB server. Heavy — 500 MB+ RAM baseline. | | **Operational** | Needs replica set for durability. Single-node MongoDB is not crash-safe by default. | | **Memory** | WiredTiger engine uses ~50% of RAM for cache by default. On a small VPS this is aggressive. | **Verdict**: MongoDB's query model is the best match for Nostr filters of any database, but the operational overhead is massive for a single relay. It's designed for clusters, not embedded use. ### RethinkDB RethinkDB is a document database with built-in real-time push via "changefeeds" — when a document changes, subscribed queries automatically receive the update. **Why it's interesting for Nostr**: The changefeed model maps directly to Nostr subscriptions. When a new event is stored, RethinkDB could automatically push it to all matching subscriptions without your relay needing to do the [`broadcast_event_to_subscriptions()`](src/subscriptions.c:806) logic. **Why it's NOT practical**: - RethinkDB development has slowed significantly (community-maintained since 2017) - C driver is not officially supported - Deployment complexity similar to MongoDB - Query performance is slower than MongoDB for simple lookups **Verdict**: Interesting concept but not production-viable for a C relay. ### UnQLite — The Embedded Document Store UnQLite is worth a closer look. It's an **embedded** key-value and document store — like SQLite but for JSON. Single C file, no external dependencies, public domain license. **How it would work**: ```c // Store event unqlite_kv_store(pDb, event_id, 64, event_json, event_json_len); // Retrieve event by ID unqlite_kv_fetch(pDb, event_id, 64, buffer, &buf_len); // For queries: use Jx9 scripting engine (embedded JavaScript-like language) // Or: maintain manual indexes like LMDB ``` | Factor | Assessment | |--------|-----------| | **Integration** | Single C file, embeds like SQLite. No external dependencies. | | **Performance** | Faster than SQLite for key-value operations, but Jx9 scripting is slow for complex queries. | | **Query model** | Jx9 scripting language for document queries — but it's interpreted and slow. Manual KV indexes are fast but require same work as LMDB. | | **Maturity** | Less battle-tested than SQLite or LMDB. Smaller community. | | **Concurrency** | Single-writer, multiple-reader (like SQLite). | **Verdict**: UnQLite is essentially a less mature, less performant LMDB with an optional (slow) document query layer. If you're going embedded KV, LMDB is the better choice. ### Summary: Document DBs for Nostr Relays | Database | Query Fit | C Integration | Performance | Deployment | Verdict | |----------|-----------|---------------|-------------|------------|---------| | **CouchDB** | Good | Poor (HTTP) | Slow (HTTP overhead) | Heavy (Erlang) | ❌ Wrong paradigm for embedded relay | | **MongoDB** | Excellent | Good (libmongoc) | Good | Heavy (server + replica set) | ⚠️ Great queries, bad deployment model | | **RethinkDB** | Interesting | Poor (no C driver) | Medium | Heavy | ❌ Not production-viable | | **UnQLite** | Medium | Excellent (single .c) | Medium | None (embedded) | ⚠️ Less mature LMDB alternative | | **PostgreSQL JSONB** | Excellent | Good (libpq) | Good | Medium (server) | ✅ Best SQL + JSON hybrid | | **LMDB** | Manual | Excellent (C library) | Best | None (embedded) | ✅ Best raw performance | **The key insight**: For a Nostr relay, the "document database" that actually wins is either: - **PostgreSQL with JSONB** — if you want SQL + JSON indexing + server model - **LMDB with manual indexes** — if you want maximum performance + embedded model The dedicated document databases (CouchDB, MongoDB) are designed for web application backends where HTTP latency is acceptable and operational complexity is managed by a team. For a single-binary C relay, they add overhead without proportional benefit. --- ## The Dashboard Problem: Separate Read Path A real-time web dashboard that queries event statistics, subscription analytics, and connection metrics is **exactly the kind of workload that kills a single-process SQLite relay**. Here's why, and how each architecture handles it. ### Current Architecture: Dashboard Kills the Relay Right now, dashboard queries run through the same [`g_db`](src/main.c:49) connection inside the same `lws_service()` event loop. When the dashboard runs analytics from [`event_kinds_view`](src/sql_schema.h:276) or [`time_stats_view`](src/sql_schema.h:297), these are **full table scans** on 65K+ events. Each one takes 100-500ms. While they run, every WebSocket client is frozen. Running these every few seconds for a "real-time" dashboard would make the relay unusable. ### How Each Architecture Solves This ```mermaid flowchart LR subgraph Current - Everything Shares One Thread WS1[WebSocket Clients] --> EL[Event Loop] DASH1[Dashboard Queries] --> EL EL --> DB1[SQLite g_db] end ``` ```mermaid flowchart LR subgraph PostgreSQL - True Separation WS2[WebSocket Clients] --> RELAY[Relay Process] RELAY -->|connection pool| PG[PostgreSQL] DASHWEB[Dashboard Web Server] -->|own connection pool| PG end ``` ```mermaid flowchart LR subgraph LMDB - Multi-Process Reads WS3[WebSocket Clients] --> RELAY2[Relay Process] RELAY2 -->|mmap read txn| LMDB1[LMDB File] DASHWEB2[Dashboard Process] -->|own mmap read txn| LMDB1 end ``` ```mermaid flowchart LR subgraph SQLite Thread Pool - Partial Separation WS4[WebSocket Clients] --> EL2[Event Loop] EL2 -->|dispatch| POOL[Thread Pool] POOL -->|own connections| DB2[SQLite WAL] DASH2[Dashboard] -->|also in pool| DB2 end ``` ### Detailed Comparison for Dashboard Use Case | Factor | SQLite Thread Pool | PostgreSQL | LMDB | |--------|-------------------|------------|------| | **Separate dashboard process** | No — SQLite multi-process access is fragile | Yes — any number of processes can connect | Yes — multiple processes can mmap the same file for reads | | **Dashboard query language** | SQL | SQL | No SQL — must build custom analytics endpoints in C | | **Real-time refresh cost** | Medium — analytics queries compete with relay queries in the pool | Low — PostgreSQL handles concurrent analytics + relay queries independently | Low for simple metrics, High for complex aggregations | | **Separate web server** | Difficult — SQLite file locking issues | Easy — any web framework connects to PostgreSQL | Possible — separate process opens LMDB read-only, but must build custom API | | **Dashboard tech stack** | Must be embedded in relay | **Any**: Grafana, React, Python Flask, Go — all connect to PostgreSQL | Must build custom: LMDB has no standard query interface | ### This Changes the Recommendation The dashboard requirement is a **strong argument for PostgreSQL**: 1. **True process isolation**: The relay and dashboard are completely separate processes. A heavy dashboard query never affects WebSocket latency. 2. **Use existing tools**: You could point **Grafana** directly at PostgreSQL and get a beautiful real-time dashboard with zero custom code. Or use any web framework to build a custom one. 3. **SQL for analytics**: Dashboard queries are inherently analytical — aggregations, time-series, top-N. SQL is the right tool. With LMDB, you'd hand-code every aggregation in C. 4. **Materialized views**: PostgreSQL can pre-compute expensive analytics: ```sql -- Pre-computed, refreshed every 30 seconds in background CREATE MATERIALIZED VIEW event_stats_mv AS SELECT kind, COUNT(*) as count, ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM events), 2) as pct FROM events GROUP BY kind; -- Dashboard reads this instantly, no table scan SELECT * FROM event_stats_mv ORDER BY count DESC; ``` ### LMDB + PostgreSQL Hybrid (Best of Both Worlds) If you want LMDB's raw performance for the relay hot path AND a rich dashboard: ```mermaid flowchart TD subgraph Relay Process WS[WebSocket Handler] -->|store and query| LMDB2[LMDB - Events] WS -->|async write| PG2[PostgreSQL - Analytics] end subgraph Dashboard Process DASH[Web Dashboard] -->|read only| PG2 end ``` - **LMDB**: Handles all real-time event storage and REQ queries at microsecond latency - **PostgreSQL**: Receives async copies of events for analytics/dashboard queries - **Dashboard**: Queries PostgreSQL exclusively, never touches LMDB This gives the best of both worlds but adds complexity — two databases to maintain plus async replication logic. --- ## Horizontal Scaling: Multiple Relay Instances in Practice With a client-server database, you unlock something fundamentally impossible with SQLite or LMDB: **multiple relay instances sharing the same database**. This section goes deep on how this works in practice. ### Architecture Overview ```mermaid flowchart TD INTERNET[Internet - Nostr Clients] -->|wss://relay.example.com| NGINX[nginx reverse proxy - TLS termination + load balancing] NGINX -->|ws://localhost:8888| R1[c-relay Instance 1 - port 8888] NGINX -->|ws://localhost:8889| R2[c-relay Instance 2 - port 8889] NGINX -->|ws://localhost:8890| R3[c-relay Instance 3 - port 8890] NGINX -->|http://localhost:3000| DASHWEB[Dashboard Web Server] R1 -->|libpq connection pool| PGPOOL[PgBouncer - connection pooler] R2 -->|libpq connection pool| PGPOOL R3 -->|libpq connection pool| PGPOOL PGPOOL -->|pooled connections| PG[PostgreSQL Primary] DASHWEB -->|read queries| PG PG -->|streaming replication| REPLICA[Read Replica - optional] DASHWEB -.->|heavy analytics| REPLICA ``` ### How Load Balancing Works in Practice nginx already handles TLS termination for most Nostr relays. Adding WebSocket load balancing is a small configuration change: ```nginx # nginx.conf - WebSocket load balancing for c-relay upstream relay_backends { # ip_hash ensures a client always hits the same instance # (important for WebSocket session stickiness) ip_hash; server 127.0.0.1:8888; server 127.0.0.1:8889; server 127.0.0.1:8890; } server { listen 443 ssl; server_name relay.example.com; # TLS config... location / { proxy_pass http://relay_backends; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 86400; # Keep WebSocket alive for 24h } # Dashboard on separate path location /dashboard { proxy_pass http://127.0.0.1:3000; } } ``` **Session stickiness** (`ip_hash`) ensures that once a client connects to Instance 2, all their subsequent WebSocket frames go to Instance 2. This is important because subscriptions are held in-memory per instance. ### Starting Multiple Instances Each instance is the same binary, just on a different port, all pointing to the same PostgreSQL: ```bash # Instance 1 ./build/c_relay_x86 --port 8888 --db-host localhost --db-name crelay & # Instance 2 ./build/c_relay_x86 --port 8889 --db-host localhost --db-name crelay & # Instance 3 ./build/c_relay_x86 --port 8890 --db-host localhost --db-name crelay & ``` Or with systemd, you'd use a template unit: ```ini # /etc/systemd/system/c-relay@.service [Unit] Description=C-Relay Nostr Instance %i After=postgresql.service [Service] ExecStart=/opt/c-relay/c_relay_x86 --port %i --db-host localhost --db-name crelay Restart=always User=c-relay [Install] WantedBy=multi-user.target ``` ```bash systemctl enable c-relay@8888 c-relay@8889 c-relay@8890 systemctl start c-relay@8888 c-relay@8889 c-relay@8890 ``` ### The Subscription Broadcasting Challenge (Detailed) This is the most important technical challenge with multiple instances. When Instance 1 receives a new EVENT and stores it in PostgreSQL, Instance 2 and Instance 3 need to know about it so they can broadcast to their connected subscribers. ```mermaid sequenceDiagram participant Client_A as Client A - connected to Instance 1 participant I1 as Instance 1 participant PG as PostgreSQL participant I2 as Instance 2 participant I3 as Instance 3 participant Client_B as Client B - connected to Instance 2 participant Client_C as Client C - connected to Instance 3 Client_A->>I1: EVENT - new kind:1 note I1->>PG: INSERT INTO events... PG-->>I1: OK I1->>I1: broadcast to local subscribers I1->>PG: NOTIFY new_event with event_id Note over PG: PostgreSQL delivers notification to all listeners PG-->>I2: NOTIFY: new_event event_id PG-->>I3: NOTIFY: new_event event_id I2->>PG: SELECT event_json FROM events WHERE id = event_id PG-->>I2: event JSON I2->>I2: match against local subscriptions I2->>Client_B: EVENT message - if subscription matches I3->>PG: SELECT event_json FROM events WHERE id = event_id PG-->>I3: event JSON I3->>I3: match against local subscriptions I3->>Client_C: EVENT message - if subscription matches ``` #### Solution: PostgreSQL LISTEN/NOTIFY This is built into PostgreSQL — no additional infrastructure needed: ```c // === In the relay's event loop (modified lws_service loop) === // Setup: create a dedicated connection for LISTEN PGconn* notify_conn = PQconnectdb("host=localhost dbname=crelay"); PQexec(notify_conn, "LISTEN new_event"); int notify_fd = PQsocket(notify_conn); // Get the socket fd for poll() // After storing an event: void on_event_stored(PGconn* write_conn, const char* event_id) { char notify_cmd[128]; snprintf(notify_cmd, sizeof(notify_cmd), "NOTIFY new_event, '%s'", event_id); PQexec(write_conn, notify_cmd); } // In the main event loop (runs every lws_service iteration): void check_cross_instance_events(PGconn* notify_conn) { // Non-blocking check for notifications PQconsumeInput(notify_conn); PGnotify* notify; while ((notify = PQnotifies(notify_conn)) != NULL) { // Another instance stored a new event const char* event_id = notify->extra; // Fetch the event and check against local subscriptions cJSON* event = db_get_event_by_id(event_id); if (event) { broadcast_event_to_subscriptions(event); cJSON_Delete(event); } PQfreemem(notify); } } ``` **Performance**: LISTEN/NOTIFY adds ~1-5ms latency for cross-instance delivery. For a Nostr relay, this is imperceptible — clients already expect network latency. **Payload limit**: NOTIFY payloads are limited to 8000 bytes. For event IDs (64 hex chars), this is fine. For larger payloads, you'd store the event first and send just the ID. #### Alternative: Redis Pub/Sub If you later need even lower latency or more sophisticated routing: ```c // Using hiredis (Redis C client) redisContext* redis = redisConnect("127.0.0.1", 6379); // After storing event: redisCommand(redis, "PUBLISH new_event %s", event_json); // Subscriber (in each instance): redisCommand(redis, "SUBSCRIBE new_event"); // Then poll for messages in the event loop ``` Redis adds sub-millisecond pub/sub but requires running a Redis server. For most relays, PostgreSQL LISTEN/NOTIFY is sufficient. ### Zero-Downtime Deployment in Practice ```mermaid sequenceDiagram participant LB as nginx participant I1 as Instance 1 - v1.0 participant I2 as Instance 2 - v1.0 participant I3 as Instance 3 - v1.0 participant I1_NEW as Instance 1 - v1.1 Note over LB,I3: Normal operation: 3 instances serving traffic LB->>I1: Mark upstream as down Note over I1: Drain: wait for existing connections to close or timeout I1->>I1: Graceful shutdown Note over LB: Traffic now goes to I2 and I3 only I1_NEW->>I1_NEW: Start with new binary I1_NEW->>LB: Health check passes LB->>I1_NEW: Mark upstream as up Note over LB,I1_NEW: Instance 1 now running v1.1, repeat for I2 and I3 ``` In practice with nginx: ```bash #!/bin/bash # rolling_deploy.sh - Zero-downtime deployment for port in 8888 8889 8890; do echo "Deploying instance on port $port..." # 1. Tell nginx to stop sending new connections # (mark server as "down" in upstream config, reload nginx) sed -i "s/server 127.0.0.1:$port;/server 127.0.0.1:$port down;/" /etc/nginx/nginx.conf nginx -s reload # 2. Wait for existing connections to drain (30 seconds) sleep 30 # 3. Stop old instance systemctl stop c-relay@$port # 4. Deploy new binary cp ./build/c_relay_x86 /opt/c-relay/c_relay_x86 # 5. Start new instance systemctl start c-relay@$port # 6. Wait for health check sleep 5 # 7. Re-enable in nginx sed -i "s/server 127.0.0.1:$port down;/server 127.0.0.1:$port;/" /etc/nginx/nginx.conf nginx -s reload echo "Instance on port $port deployed successfully" done ``` ### Connection Pooling with PgBouncer Each relay instance needs multiple database connections (for concurrent queries). Without pooling, 3 instances × 10 connections = 30 PostgreSQL backend processes. With PgBouncer: ```ini # /etc/pgbouncer/pgbouncer.ini [databases] crelay = host=127.0.0.1 port=5432 dbname=crelay [pgbouncer] listen_port = 6432 listen_addr = 127.0.0.1 auth_type = md5 pool_mode = transaction # Return connection to pool after each transaction max_client_conn = 200 # Total connections from all relay instances default_pool_size = 20 # Actual PostgreSQL connections ``` Relay instances connect to PgBouncer (port 6432) instead of PostgreSQL directly (port 5432). PgBouncer multiplexes 200 client connections onto 20 actual PostgreSQL connections. ### Scaling Scenarios | Scenario | Instances | Connections | Events/hour | Setup | |----------|-----------|-------------|-------------|-------| | **Current** | 1 | ~1,200 | 56 | Single process + SQLite | | **Small upgrade** | 2 | ~2,500 | 500 | 2 instances + PostgreSQL | | **Medium relay** | 4 | ~5,000 | 5,000 | 4 instances + PostgreSQL + PgBouncer | | **Large relay** | 8 | ~10,000 | 50,000 | 8 instances + PostgreSQL + read replica | | **Multi-region** | 2-4 per region | ~50,000 | 500,000 | Multiple servers + PostgreSQL replication | ### Why This Is Impossible with SQLite/LMDB | Feature | SQLite | LMDB | PostgreSQL | MySQL | |---------|--------|------|------------|-------| | **Multiple writer processes** | No — file lock | No — single writer | Yes — row-level locking | Yes — row-level locking | | **Cross-process notifications** | No | No | Yes — LISTEN/NOTIFY | No built-in — need polling or external | | **Connection pooling** | N/A | N/A | Yes — PgBouncer | Yes — ProxySQL | | **Read replicas** | No | No | Yes — streaming | Yes — replication | | **Load balancing** | Impossible | Impossible | Natural fit | Natural fit | | **Max instances** | 1 | 1 | Unlimited | Unlimited | --- ## SQL Database Comparison: PostgreSQL vs MySQL vs MariaDB Since we're considering a client-server SQL database, let's compare the main contenders. ### PostgreSQL **The gold standard for data integrity and advanced features.** | Aspect | Details | |--------|---------| | **JSONB support** | Native binary JSON with GIN indexing. `SELECT * FROM events WHERE tags @> '[["p","pubkey123"]]'` — indexed, fast. Eliminates the `event_tags` table entirely. | | **LISTEN/NOTIFY** | Built-in pub/sub for cross-instance event broadcasting. No external dependencies. | | **Partial indexes** | `CREATE INDEX idx_active ON events(kind) WHERE kind < 20000 OR kind >= 30000` — index only non-ephemeral events, saving space. | | **MVCC** | True multi-version concurrency. Readers never block writers. No "locked" errors. | | **Query planner** | Cost-based optimizer with statistics. Automatically chooses the best index for each query. | | **C client library** | `libpq` — mature, well-documented, supports async queries. | | **Materialized views** | Pre-compute expensive analytics, refresh on schedule. Dashboard reads are instant. | | **Full-text search** | Built-in `tsvector` for NIP-50 search support. | | **Memory** | ~200-500 MB baseline. `shared_buffers` should be ~25% of RAM. | | **Nostr relay precedent** | Used by **nostream** (TypeScript relay) — proven at scale for Nostr. | ### MySQL / MariaDB **The most widely deployed database. Simpler than PostgreSQL but less feature-rich.** | Aspect | Details | |--------|---------| | **JSON support** | MySQL 5.7+ has JSON type with `JSON_CONTAINS()` and `JSON_EXTRACT()`. But **no GIN-equivalent index** — JSON queries do full scans or use generated columns with regular indexes. | | **Cross-instance notifications** | No built-in equivalent to LISTEN/NOTIFY. Would need Redis, polling, or MySQL's binlog streaming. | | **Partial indexes** | Not supported in MySQL. MariaDB has limited support. | | **Concurrency** | InnoDB has row-level locking and MVCC, similar to PostgreSQL. | | **Query planner** | Simpler than PostgreSQL's. Historically weaker for complex queries, but MySQL 8.0 improved significantly. | | **C client library** | `libmysqlclient` or `libmariadb` — mature, well-documented. | | **Materialized views** | Not supported natively. Must use tables + triggers or scheduled jobs. | | **Full-text search** | InnoDB full-text indexes available but less flexible than PostgreSQL's. | | **Memory** | ~100-300 MB baseline. Generally lighter than PostgreSQL. | | **Nostr relay precedent** | No major Nostr relay uses MySQL. | ### MariaDB MariaDB is a MySQL fork with some additional features: - Better JSON support than MySQL (but still no GIN indexes) - `CONNECT` engine for external data sources - `ColumnStore` engine for analytics (interesting for dashboard) - Generally compatible with MySQL client libraries ### Head-to-Head for Nostr Relay Use Case | Feature | PostgreSQL | MySQL 8.0 | MariaDB | |---------|-----------|-----------|---------| | **JSON tag indexing** | **GIN index on JSONB — O(log n) lookups** | Generated column + B-tree — works but manual | Similar to MySQL | | **Eliminates event_tags table** | **Yes — JSONB @> operator with GIN** | No — still need denormalized table or generated columns | No | | **Cross-instance pub/sub** | **LISTEN/NOTIFY — built-in** | Need external solution (Redis, polling) | Need external solution | | **Partial indexes** | **Yes — index only what matters** | No | Limited | | **Materialized views** | **Yes — instant dashboard reads** | No — manual implementation | No | | **NIP-50 full-text search** | **tsvector — powerful and indexed** | InnoDB FTS — adequate | InnoDB FTS — adequate | | **Async C client** | **libpq async mode** | libmysqlclient async (MySQL 8.0+) | libmariadb async | | **Connection pooling** | **PgBouncer — battle-tested** | ProxySQL — good | ProxySQL — good | | **Ease of setup** | Medium — more config options | Easy — simpler defaults | Easy — simpler defaults | | **Community/docs** | Excellent | Excellent | Good | | **Hosting availability** | Every cloud provider | Every cloud provider | Most cloud providers | ### The Killer Feature: JSONB + GIN Indexes This is why PostgreSQL wins for Nostr specifically. Your current schema has the `event_tags` table (4 million rows, ~424 MB data, ~2 GB indexes) solely because SQLite can't efficiently query JSON arrays. With PostgreSQL JSONB + GIN: ```sql -- PostgreSQL schema (no event_tags table needed!) CREATE TABLE events ( id TEXT PRIMARY KEY, pubkey TEXT NOT NULL, created_at BIGINT NOT NULL, kind INTEGER NOT NULL, content TEXT NOT NULL, sig TEXT NOT NULL, tags JSONB NOT NULL DEFAULT '[]', event_json TEXT NOT NULL ); -- GIN index on tags — handles ALL tag queries CREATE INDEX idx_events_tags ON events USING GIN (tags); -- Query: find events with #p tag matching a pubkey -- This uses the GIN index — O(log n), not a table scan SELECT event_json FROM events WHERE tags @> '[["p", "pubkey_hex_here"]]' AND kind IN (1, 6, 7) ORDER BY created_at DESC LIMIT 100; -- Query: find events with #e tag SELECT event_json FROM events WHERE tags @> '[["e", "event_id_here"]]' ORDER BY created_at DESC LIMIT 100; ``` **No `event_tags` table. No 4 million denormalized rows. No 2 GB of indexes.** The GIN index on the JSONB `tags` column handles everything in ~100-200 MB. Compare to your current SQLite approach in [`handle_req_message()`](src/main.c:1459): ```sql -- Current: subquery into event_tags table AND id IN (SELECT event_id FROM event_tags WHERE tag_name = ? AND tag_value IN (?)) ``` The PostgreSQL version is both simpler to write AND faster to execute. ### MySQL's JSON Workaround MySQL can't do GIN indexes on JSON. The workaround is generated columns: ```sql -- MySQL: must create generated columns for each tag type you want to index ALTER TABLE events ADD COLUMN tag_p JSON GENERATED ALWAYS AS ( JSON_EXTRACT(tags, '$[*]' ) -- complex extraction needed ) VIRTUAL; CREATE INDEX idx_tag_p ON events(tag_p); ``` This is fragile, requires a generated column per tag type, and doesn't handle arbitrary tag names like `#g` (geohash), `#t` (hashtag), `#type`, etc. You'd need to know all tag types in advance. ### Verdict: PostgreSQL Is the Clear Winner for Nostr PostgreSQL's JSONB + GIN indexes are **purpose-built** for the exact problem Nostr relays face: querying JSON arrays efficiently. No other SQL database matches this capability. Combined with LISTEN/NOTIFY for multi-instance broadcasting and materialized views for dashboards, PostgreSQL is the optimal SQL database for a Nostr relay. MySQL/MariaDB would work, but you'd still need the `event_tags` denormalization table, you'd need Redis or polling for cross-instance events, and you'd need manual materialized view implementations. It's more work for less capability. ### Cost Perspective Running multiple relay instances with PostgreSQL on a single VPS: - **PostgreSQL**: ~200-500 MB RAM baseline - **PgBouncer**: ~10 MB RAM - **Each relay instance**: ~50-100 MB RAM - **Dashboard web server**: ~50-100 MB RAM - **4 instances + PostgreSQL + PgBouncer + dashboard**: ~1-2 GB total RAM - A **$20/month VPS** with 4 cores and 4 GB RAM handles this easily - A **$40/month VPS** with 8 cores and 8 GB RAM handles 8 instances comfortably --- ## Head-to-Head Comparison for YOUR Workload Based on your actual production data (2.7 GB DB, 65K events, 120 REQ/min, 56 writes/hour): | Factor | SQLite Thread Pool | PostgreSQL | LMDB | |--------|-------------------|------------|------| | **Fixes event loop blocking** | Yes | Yes | Yes — reads so fast they don't block | | **Fixes 672ms worst-case queries** | Partially — queries still take 672ms but don't block others | Yes — better query planner + GIN indexes | **Yes — sub-millisecond reads** | | **Fixes 2 GB index bloat** | No — same schema | Yes — more efficient indexes, JSONB eliminates event_tags | **Yes — B+ tree is more compact** | | **Write throughput** | Adequate for 56 events/hour | Overkill for 56 events/hour | Adequate — single writer like SQLite | | **Code changes** | ~300-500 lines new code, ~200 lines modified | ~2000-3000 lines rewritten | **~3000-4000 lines rewritten** | | **Risk** | Low — additive change, SQLite stays | High — complete data layer replacement | **Highest — no SQL, manual indexes** | | **Deployment change** | None — still single binary | Major — need PostgreSQL server | **None — still embedded library** | | **Time to implement** | Moderate | Large | **Largest** | | **Future scaling ceiling** | ~1000 concurrent connections | ~10,000+ concurrent connections | **~10,000+ like strfry** | | **Rollback difficulty** | Easy — remove thread pool, back to synchronous | Very hard — different database entirely | **Very hard — different paradigm** | | **Keeps admin SQL API** | Yes | Yes (different SQL dialect) | **No — must rebuild as specific endpoints** | | **Raw read performance** | Same as current | 5-10x faster | **100-1000x faster** | | **Operational simplicity** | Same as current | Needs DBA knowledge | **Same as current — single file** | --- ## Recommendation: Phased Approach ### Phase 1: Database Abstraction Layer (prerequisite for any path) Create a `db_ops.h` / `db_ops.c` that wraps all database operations behind a clean interface: ```c // db_ops.h - Database operations interface typedef struct db_result db_result_t; typedef struct db_event_filter db_event_filter_t; // Core event operations int db_store_event(const char* event_json, const char* id, const char* pubkey, int kind, long created_at, const char* tags_json); int db_event_exists(const char* event_id); int db_delete_event(const char* event_id); const char* db_get_event_json(const char* event_id); // returns pointer, caller must not free for LMDB // Query operations - backend-agnostic filter db_result_t* db_query_events(const db_event_filter_t* filter); int db_count_events(const db_event_filter_t* filter); const char* db_result_next(db_result_t* result); // returns event_json pointer void db_result_free(db_result_t* result); // Config operations const char* db_get_config(const char* key); int db_set_config(const char* key, const char* value); // Lifecycle int db_init(const char* path); void db_close(void); ``` This consolidates the 258 scattered `sqlite3_*` calls into one file. The interface is designed to work with **any** backend: - SQLite: `db_result_next()` copies from `sqlite3_column_text()` - LMDB: `db_result_next()` returns a zero-copy pointer into mmap'd memory - PostgreSQL: `db_result_next()` returns from `PQgetvalue()` ### Phase 2: Choose Your Backend #### Path A: Thread Pool (fastest to implement, lowest risk) - Keep SQLite, add worker threads for reads - **Best if**: You want immediate relief and minimal disruption - **Gets you**: Event loop unblocked, 4-8x effective throughput #### Path B: LMDB (maximum performance, proven for Nostr) - Replace SQLite with LMDB behind the abstraction layer - **Best if**: You want strfry-level performance and are willing to invest in the rewrite - **Gets you**: Sub-millisecond reads, zero-copy, 100-1000x faster queries, compact storage - **Loses**: Admin SQL query API, ad-hoc analytics queries #### Path C: PostgreSQL (maximum flexibility, operational overhead) - Replace SQLite with PostgreSQL behind the abstraction layer - **Best if**: You want SQL + concurrency + operational tooling - **Gets you**: True concurrent writes, superior query planner, GIN indexes, replication - **Loses**: Embedded simplicity, single-binary deployment ### Phase 3 (if LMDB chosen): Rebuild Admin Analytics Replace the SQL-based admin API with purpose-built LMDB query endpoints: - Event kind distribution → iterate events db, aggregate in C - Top pubkeys → maintain a separate counter database - Time-based stats → range scan on time index - Or: keep a small SQLite database alongside LMDB just for analytics/config (hybrid approach) --- ## Bottom Line Here's how I'd rank the options for **your specific situation** (65K events, 2.7 GB DB, 120 REQ/min, medium traffic relay): ### If you want the fastest fix with lowest risk: **→ Thread Pool (Path A)**. Unblocks the event loop immediately. Your 672ms queries still take 672ms, but they no longer freeze every other client. This buys you time to plan a bigger change. ### If you want the best long-term architecture: **→ LMDB (Path B)**. This is what the fastest Nostr relay in existence uses, and for good reason. Your 672ms queries become sub-millisecond. Your 2.7 GB database becomes ~400 MB. You don't need a thread pool because reads are so fast they can run inline. The downside is it's the biggest rewrite and you lose SQL flexibility. ### If you want SQL + performance: **→ PostgreSQL (Path C)**. You keep SQL, get concurrent writes, get a better query planner, and get operational tooling. But you add an external dependency and deployment complexity. ### The pragmatic path: **Phase 1 (abstraction layer) → Phase 2A (thread pool for immediate relief) → Phase 2B (LMDB migration behind the abstraction layer)**. This gives you immediate improvement while building toward the optimal architecture. The abstraction layer from Phase 1 makes the LMDB migration a contained change rather than a scattered rewrite across 8 files. **Regardless of which backend you choose, Phase 1 (abstraction layer) is the right first step.** It cleans up the codebase, makes testing easier, and enables any future backend swap.