# Database Architecture Analysis: Thread Pool vs Client-Server DB vs LMDB

## Current Architecture Problem

The c-relay-pg 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-pg

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-pg 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-pg Instance 1 - port 8888]
    NGINX -->|ws://localhost:8889| R2[c-relay-pg Instance 2 - port 8889]
    NGINX -->|ws://localhost:8890| R3[c-relay-pg 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-pg
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_pg_x86 --port 8888 --db-host localhost --db-name crelay &

# Instance 2
./build/c_relay_pg_x86 --port 8889 --db-host localhost --db-name crelay &

# Instance 3
./build/c_relay_pg_x86 --port 8890 --db-host localhost --db-name crelay &
```

Or with systemd, you'd use a template unit:

```ini
# /etc/systemd/system/c-relay-pg@.service
[Unit]
Description=C-Relay-PG Nostr Instance %i
After=postgresql.service

[Service]
ExecStart=/opt/c-relay-pg/c_relay_pg_x86 --port %i --db-host localhost --db-name crelay
Restart=always
User=c-relay-pg

[Install]
WantedBy=multi-user.target
```

```bash
systemctl enable c-relay-pg@8888 c-relay-pg@8889 c-relay-pg@8890
systemctl start c-relay-pg@8888 c-relay-pg@8889 c-relay-pg@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-pg@$port
    
    # 4. Deploy new binary
    cp ./build/c_relay_pg_x86 /opt/c-relay-pg/c_relay_pg_x86
    
    # 5. Start new instance
    systemctl start c-relay-pg@$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.
