Files
c-relay/plans/database_architecture_analysis.md
T

1056 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.