11 KiB
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. It is not part of the thread pool — it has its own job queue, mutex, and condvar.
What it does for each incoming EVENT:
- Checks if event ID already exists in DB (
event_id_exists_in_db()) - Validates the event with
nostr_validate_unified_request()(signature check) - For ephemeral events (kind 20000-29999): marks for broadcast only, no storage
- For regular events: calls
store_event_core()to insert into PostgreSQL - Pushes a completion struct and wakes
lws-mainvialws_cancel_service() lws-mainpicks 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.cline ~2524: Change default from 1 to 4- Config key
thread_pool_readersalready 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: Addint writer_threadstothread_pool_config_tsrc/thread_pool.c:- Change
pthread_t writertopthread_t* writers+int writer_count thread_pool_init(): create N writer threadsthread_pool_shutdown(): join all writer threadswriter_worker_main(): set thread name todb-write-N
- Change
src/main.c: Add config keythread_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 CONFLICTLOG_SUB_CREATED/CLOSED/DISCONNECTED— independent rows, safeUPDATE_SUB_EVENTS_SENT— updates by sub_id, safeIP_BAN_SAVE— writes to ip_bans table, safeWAL_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 backendwriter_worker_main()shutdown path: Skipdb_wal_checkpoint_truncate()if PG backend
src/db_ops.corsrc/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:
- Queries PostgreSQL for stats (blocks main thread 5-50ms)
- Creates and signs a Nostr event (CPU work on main thread)
- 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: AddTHREAD_POOL_JOB_MONITORING_QUERYjob type and payload structsrc/thread_pool.c: Addexecute_monitoring_query_job()handlersrc/api.c: Refactorgenerate_monitoring_event_for_type()to submit async jobsrc/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 keyssrc/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.cline 508: "Reader worker failed to open SQLite connection" → "Reader worker failed to open database connection"src/thread_pool.cline 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:
- No shared in-process state between instances — all state is in PostgreSQL
- Subscription matching is per-instance — each instance tracks its own connected clients
- Cross-instance event notification via PostgreSQL LISTEN/NOTIFY (future phase)
- 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.