# Thread Pool PostgreSQL Optimization Plan ## Current State ### Thread Inventory | Thread | Name | Role | PG Connection | Config Key | |--------|------|------|--------------|------------| | lws-main | `lws-main` | WebSocket event loop, HTTP, NIP-11, admin events, subscription matching, broadcast, monitoring event generation | `g_pg_conn` (main) | N/A — always runs | | reader-1 | `db-read-1` | Async REQ and COUNT query execution | `g_thread_pg_conn` (TLS) | `thread_pool_readers` (default: 1) | | writer | `db-write` | Async EVENT store, delete, sub logging, IP ban save, WAL checkpoint | `g_thread_pg_conn` (TLS) | N/A — always 1 | | event-worker | `event-worker` | Async EVENT ingestion — duplicate check, signature validation, `store_event_core()`, ephemeral event handling | Own `worker_db` via `db_open_worker_connection()` | N/A — always 1 | **Total: 4 threads, 4 PG connections** ### event-worker Thread (websockets.c — separate from thread pool) The `event-worker` is a dedicated async pipeline for incoming EVENT messages, defined in [`src/websockets.c`](src/websockets.c:543). It is **not** part of the thread pool — it has its own job queue, mutex, and condvar. **What it does for each incoming EVENT:** 1. Checks if event ID already exists in DB (`event_id_exists_in_db()`) 2. Validates the event with `nostr_validate_unified_request()` (signature check) 3. For ephemeral events (kind 20000-29999): marks for broadcast only, no storage 4. For regular events: calls `store_event_core()` to insert into PostgreSQL 5. Pushes a completion struct and wakes `lws-main` via `lws_cancel_service()` 6. `lws-main` picks up the completion, sends OK response, and broadcasts to subscribers **Why it exists separately from the thread pool writer:** - The thread pool writer handles generic DB operations (sub logging, IP bans, etc.) - The event-worker handles the full EVENT ingestion pipeline including validation and duplicate detection — not just the DB insert - It was added to move CPU-intensive signature verification off the main thread **Relationship to thread pool writer:** There is overlap — both can write to the events table. The event-worker calls `store_event_core()` directly, while the thread pool writer uses `db_insert_event_with_json()`. Some EVENT paths still go through the thread pool writer (sync path for admin/DM/protected events). ### What Runs on lws-main Today (Shouldn't Block) - WebSocket accept/read/write for all Nostr clients - JSON parsing of REQ/EVENT/CLOSE/COUNT messages - Event signature verification (CPU-bound) - Subscription matching and broadcast (iterates all subscriptions) - NIP-11 HTTP responses - NIP-42 auth challenge generation - Admin event processing (kind 23456) — includes NIP-44 decrypt + config changes - **Monitoring event generation** — queries DB, signs events, broadcasts (BLOCKS) - **Sync config reads** — `get_config_value()` hits PG on main thread (BLOCKS) ### SQLite-Era Constraints (No Longer Apply) - Single writer thread was required (SQLite serializes writes) - WAL checkpoint job was required (SQLite-specific) - Reader/writer separation was critical for avoiding SQLITE_BUSY - `db_wal_checkpoint_truncate()` on writer shutdown — SQLite-only --- ## Changes for PostgreSQL ### Phase 1: Increase Reader Thread Count **What:** Change default reader count from 1 to 4. **Why:** With SQLite, multiple readers had diminishing returns due to file-level locking. PostgreSQL handles concurrent reads perfectly — each reader has its own connection and queries execute in parallel. **Files:** - `src/main.c` line ~2524: Change default from 1 to 4 - Config key `thread_pool_readers` already exists — just change the default **Risk:** Low. Each reader opens its own PG connection via `db_open_worker_connection()`. PostgreSQL default `max_connections = 100` easily handles 4-8 readers. ### Phase 2: Allow Multiple Writer Threads **What:** Make writer thread count configurable (default: 2). Convert the single `pthread_t writer` to an array like readers. **Why:** PostgreSQL supports concurrent writes with row-level locking. The single-writer bottleneck was a SQLite constraint. With multiple writers, EVENT inserts from different clients can execute in parallel. **Files:** - `src/thread_pool.h`: Add `int writer_threads` to `thread_pool_config_t` - `src/thread_pool.c`: - Change `pthread_t writer` to `pthread_t* writers` + `int writer_count` - `thread_pool_init()`: create N writer threads - `thread_pool_shutdown()`: join all writer threads - `writer_worker_main()`: set thread name to `db-write-N` - `src/main.c`: Add config key `thread_pool_writers` (default: 2) **Risk:** Medium. Need to verify that no write job assumes serialized execution. Current write jobs: - `STORE_EVENT` — PG handles concurrent inserts with ON CONFLICT - `LOG_SUB_CREATED/CLOSED/DISCONNECTED` — independent rows, safe - `UPDATE_SUB_EVENTS_SENT` — updates by sub_id, safe - `IP_BAN_SAVE` — writes to ip_bans table, safe - `WAL_CHECKPOINT` — SQLite-only, skip for PG (see Phase 3) All write jobs operate on independent rows or use conflict resolution. Multiple writers are safe. ### Phase 3: Remove SQLite-Specific WAL Checkpoint Logic **What:** Make WAL checkpoint a no-op when running PostgreSQL backend. **Why:** PostgreSQL manages its own WAL internally. The `db_wal_checkpoint_passive()` and `db_wal_checkpoint_truncate()` calls are SQLite-specific. **Files:** - `src/thread_pool.c`: - `execute_wal_checkpoint_job()`: Return OK immediately if PG backend - `writer_worker_main()` shutdown path: Skip `db_wal_checkpoint_truncate()` if PG backend - `src/db_ops.c` or `src/db_ops_postgres.c`: Ensure WAL checkpoint functions are no-ops for PG **Risk:** Low. These are already effectively no-ops since the PG backend doesn't implement them, but making it explicit avoids log noise. ### Phase 4: Move Monitoring Event Generation Off Main Thread **What:** Run monitoring queries and event generation on a reader thread instead of lws-main. **Why:** The monitoring system (`generate_monitoring_event_for_type()` in `src/api.c`) currently: 1. Queries PostgreSQL for stats (blocks main thread 5-50ms) 2. Creates and signs a Nostr event (CPU work on main thread) 3. Broadcasts to subscriptions (main thread) Step 1 and 2 should happen on a worker thread. Only step 3 (broadcast) needs to happen on lws-main. **Approach:** Add a new job type `THREAD_POOL_JOB_MONITORING_QUERY` to the read queue. The reader thread executes the query and prepares the event JSON. The completion callback on lws-main just broadcasts the pre-built event. **Files:** - `src/thread_pool.h`: Add `THREAD_POOL_JOB_MONITORING_QUERY` job type and payload struct - `src/thread_pool.c`: Add `execute_monitoring_query_job()` handler - `src/api.c`: Refactor `generate_monitoring_event_for_type()` to submit async job - `src/websockets.c`: Handle monitoring completion in the event loop callback **Risk:** Medium. The monitoring event signing requires the relay private key. Need to pass it to the worker thread or pre-compute the unsigned event and sign on completion. ### Phase 5: Add Configurable Thread Pool Sizing via Config Events **What:** Allow thread pool reader/writer counts to be set via the admin config event system. **Config keys:** - `thread_pool_readers` — number of reader threads (default: 4) - `thread_pool_writers` — number of writer threads (default: 2) - `thread_pool_max_queue_depth` — max pending jobs per queue (default: 4096) **Note:** Thread count changes require relay restart (cannot dynamically resize thread pool). **Files:** - `src/default_config_event.h`: Add defaults for new config keys - `src/main.c`: Read config values when initializing thread pool **Risk:** Low. Config keys already work this way for `thread_pool_readers`. ### Phase 6: Fix Error Messages (Cosmetic) **What:** Update SQLite-specific error messages in thread pool to be backend-neutral. **Files:** - `src/thread_pool.c` line 508: "Reader worker failed to open SQLite connection" → "Reader worker failed to open database connection" - `src/thread_pool.c` line 530: "Writer worker failed to open SQLite connection" → "Writer worker failed to open database connection" **Risk:** None. --- ## Target Thread Layout After Implementation ``` c_relay_pg process ├── lws-main — WebSocket event loop (never blocks on DB) ├── db-read-1 — Async REQ/COUNT/monitoring queries ├── db-read-2 — Async REQ/COUNT/monitoring queries ├── db-read-3 — Async REQ/COUNT/monitoring queries ├── db-read-4 — Async REQ/COUNT/monitoring queries ├── event-worker — Async EVENT ingestion (validate + store) ├── db-write-1 — Async sub logging, IP bans, misc writes └── db-write-2 — Async sub logging, IP bans, misc writes ``` **Total: 8 threads, 8 PG connections** (configurable) --- ## PostgreSQL Connection Budget | Component | Connections | Notes | |-----------|------------|-------| | lws-main | 1 | Sync config reads, admin event processing | | Reader threads (4) | 4 | One per reader | | event-worker | 1 | EVENT ingestion pipeline | | Writer threads (2) | 2 | Sub logging, IP bans, misc | | **Total per instance** | **8** | Well within PG default max_connections=100 | | Future: admin-ws | +1 | When/if added later | | Future: pg-listener | +1 | When/if added later | | **Future total** | **10** | Still very comfortable | For load balancing with N relay instances: N × 8 connections. With 10 instances = 80 connections. PgBouncer recommended at that scale. --- ## Implementation Order | Phase | Description | Complexity | Impact | |-------|-------------|-----------|--------| | 1 | Increase reader count to 4 | Trivial — one line change | High — parallel query execution | | 2 | Multiple writer threads | Medium — refactor writer array | Medium — parallel event inserts | | 3 | Remove WAL checkpoint logic | Low — conditional no-op | Low — cleaner code | | 4 | Move monitoring off main thread | Medium — new job type + async flow | High — unblocks event loop | | 5 | Config event integration | Low — add config keys | Low — operational convenience | | 6 | Fix error messages | Trivial | None — cosmetic | **Recommended start:** Phase 1 + 6 (trivial, immediate benefit), then Phase 3 (cleanup), then Phase 4 (biggest architectural improvement), then Phase 2 (nice-to-have), then Phase 5 (polish). --- ## Future: Load Balancing Considerations The thread pool design is already compatible with horizontal scaling because: 1. **No shared in-process state between instances** — all state is in PostgreSQL 2. **Subscription matching is per-instance** — each instance tracks its own connected clients 3. **Cross-instance event notification** via PostgreSQL LISTEN/NOTIFY (future phase) 4. **Connection pooling** via PgBouncer when connection count becomes a concern The only addition needed for multi-instance is a LISTEN/NOTIFY subscriber thread that receives "new event stored" notifications from PostgreSQL and triggers subscription matching on the local instance. This is the `pg-listener` thread discussed earlier — it can be added independently of the thread pool changes in this plan.