# Main Thread CPU Offload Plan ## Problem Statement The main `c_relay` thread consumes ~56% CPU while the DB worker threads (`db-read-1..4`, `db-write`) sit near 0%. All protocol handling, JSON parsing, event validation, subscription matching, and message queueing happens synchronously inside `nostr_relay_callback()` on the main libwebsockets event-loop thread. ## Current Architecture ```mermaid flowchart TD LWS[lws_service - main thread] --> CB[nostr_relay_callback] CB --> PARSE[JSON parse - cJSON_Parse] CB --> VALIDATE[Signature verify - nostr_validate_unified_request] CB --> STORE[store_event - builds payload] STORE --> SYNC_WRITE[thread_pool_execute_store_event_sync] SYNC_WRITE --> |blocks main thread| DB_WRITE[db-write thread] DB_WRITE --> |signal| SYNC_WRITE CB --> REQ[handle_req_message - builds SQL] REQ --> SYNC_READ[thread_pool_execute_req_sync] SYNC_READ --> |blocks main thread| DB_READ[db-read-N thread] DB_READ --> |signal| SYNC_READ CB --> BROADCAST[broadcast_event_to_subscriptions] BROADCAST --> QUEUE[queue_message per subscriber] CB --> WRITEABLE[LWS_CALLBACK_SERVER_WRITEABLE] WRITEABLE --> DRAIN[process_message_queue - lws_write] ``` ### Why the main thread is hot | Work item | Where | Cost | |-----------|-------|------| | JSON parsing | `cJSON_Parse` in `LWS_CALLBACK_RECEIVE` | Medium - per message | | Signature verification | `nostr_validate_unified_request` - ed25519 crypto | **High** - per EVENT | | SQL query building | `handle_req_message` filter-to-SQL loop | Low-Medium | | Sync DB wait | `thread_pool_execute_*_sync` - pthread_cond_wait | Blocks but yields CPU | | Result iteration + expiration check | Row loop in `handle_req_message` with `cJSON_Parse` per row | Medium-High for large result sets | | Subscription matching | `broadcast_event_to_subscriptions` - filter matching | Medium - scales with subscriber count | | Message serialization + queueing | `snprintf` + `queue_message_take_ownership` per subscriber | Medium | | Config lookups | `get_config_int/bool` called repeatedly in hot paths | Low but frequent | ### Key constraint: libwebsockets is single-threaded libwebsockets requires that **all `lws_write`, `lws_callback_on_writable`, and `lws_close_reason` calls happen from the service thread** (the thread running `lws_service`). This means we cannot directly write to WebSocket connections from worker threads. However, we **can** do computation on worker threads and post results back to the main thread for I/O. ## Offload Strategy ### Phase 1: Async EVENT Processing (Highest Impact) Convert EVENT handling from synchronous to async. Currently the main thread does: parse → validate → store → broadcast, all blocking. Instead: 1. **Main thread**: Parse JSON (fast), extract event ID for dedup check, then submit a job to a new "event processing" worker thread 2. **Worker thread**: Signature verification (expensive crypto), store_event (already goes to DB thread), prepare broadcast payload 3. **Main thread callback**: Receive result via `wake_loop_cb` + `lws_cancel_service`, send OK response and broadcast to subscribers ```mermaid flowchart TD LWS[lws_service - main thread] --> RECEIVE[LWS_CALLBACK_RECEIVE] RECEIVE --> PARSE[JSON parse + dedup check] PARSE --> SUBMIT[Submit to event-worker queue] SUBMIT --> LWS WORKER[event-worker thread] --> VERIFY[Signature verification] VERIFY --> STORE_DB[store_event via DB pool] STORE_DB --> PREP[Prepare broadcast payload] PREP --> RESULT_Q[Push result to completion queue] RESULT_Q --> WAKE[lws_cancel_service] LWS2[lws_service wakes] --> POLL[Poll completion queue] POLL --> OK[Send OK response via queue_message] POLL --> BCAST[broadcast_event_to_subscriptions] ``` **What this offloads**: ed25519 signature verification (~the most expensive per-event operation), event classification, tag serialization, and the synchronous DB store wait. **What stays on main thread**: JSON parse (needed to extract event ID for dedup), OK response writing, broadcast fan-out (requires lws access). ### Phase 2: Async REQ Query Execution (Medium Impact) Convert REQ handling from sync to async: 1. **Main thread**: Parse filters, build SQL, create subscription, submit query job 2. **DB reader thread**: Execute query (already happens, but currently blocks main thread via `_sync`) 3. **Main thread callback**: Iterate results, queue EVENT messages, send EOSE This is simpler than Phase 1 because the thread pool already supports async submission via `thread_pool_submit_read` with a `result_cb`. The `_sync` wrappers just add a condvar wait on top. We need to: - Use `thread_pool_submit_read` directly instead of `thread_pool_execute_req_sync` - Store pending REQ context (sub_id, wsi, pss) so the callback can complete the work - In the result callback, push results to a completion queue and call `lws_cancel_service` - On the main thread, drain the completion queue and send EVENT + EOSE messages ### Phase 3: Reduce Per-Row Overhead in REQ Results (Low-Medium Impact) Currently each row from a REQ query gets `cJSON_Parse` just to check NIP-40 expiration. This is wasteful: - Option A: Add an `expiration` column to the events table so expiration filtering can be done in SQL - Option B: Store expiration timestamp in a fast-parse format (extract during INSERT, store as integer column) - Option C: Use string search on the raw JSON for the expiration tag instead of full parse ### Phase 4: Config Value Caching (Low Impact, Easy Win) `get_config_int` and `get_config_bool` are called on every message in hot paths. These do SQLite queries. Cache config values in memory with a TTL or invalidation signal, so the main loop only refreshes them periodically (already done for `debug_level` every 60s — extend to all hot-path config values). ## Implementation Priority | Phase | Impact | Risk | Complexity | |-------|--------|------|------------| | Phase 1: Async EVENT | **High** - removes crypto from main thread | Medium - async state management | Medium-High | | Phase 2: Async REQ | **Medium** - unblocks main thread during queries | Low - infrastructure exists | Medium | | Phase 3: Expiration optimization | **Low-Medium** - reduces per-row parse cost | Low | Low | | Phase 4: Config caching | **Low** - reduces DB round-trips | Very Low | Low | ## Detailed Design: Phase 1 (Async EVENT Processing) ### New Components #### Completion Queue (`src/completion_queue.h/.c`) A thread-safe FIFO queue for posting results from worker threads back to the main thread: ```c typedef struct { int type; // COMPLETION_TYPE_EVENT_RESULT, COMPLETION_TYPE_REQ_RESULT, etc. void* data; // Type-specific result data struct lws* wsi; // Target WebSocket connection void* pss; // Per-session data } completion_item_t; int completion_queue_init(void); int completion_queue_push(completion_item_t* item); completion_item_t* completion_queue_pop(void); // Non-blocking void completion_queue_shutdown(void); ``` #### Event Worker Thread A dedicated pthread that processes EVENT validation/storage: ```c typedef struct { cJSON* event; // Parsed event JSON - ownership transferred cJSON* full_message; // Full message JSON for context struct lws* wsi; void* pss; char sub_id[64]; // For response routing } event_work_item_t; ``` #### Main Loop Integration Add a completion queue drain step to the main event loop. After `lws_service` returns (either from timeout or `lws_cancel_service` wake), check the completion queue: ```c while (g_server_running && !g_shutdown_flag) { int result = lws_service(ws_context, 1000); // NEW: Drain completion queue completion_item_t* item; while ((item = completion_queue_pop()) != NULL) { process_completion(item); // Send OK, broadcast, etc. free(item); } // ... existing periodic checks ... } ``` ### Changes to Existing Code 1. **`nostr_relay_callback` EVENT path**: After JSON parse and dedup check, instead of calling `nostr_validate_unified_request` + `store_event` + `broadcast_event_to_subscriptions` synchronously, submit an `event_work_item_t` to the event worker queue and return 0 immediately. 2. **`store_event`**: No changes needed — it already uses `thread_pool_execute_store_event_sync` which will run on the DB writer thread. The event worker thread will call it. 3. **`broadcast_event_to_subscriptions`**: No changes needed — it will be called from the main thread when processing the completion item, which is correct since it calls `queue_message_take_ownership` (requires lws thread). ### Thread Safety Considerations - The `cJSON* event` object must be fully owned by the worker thread during processing. The main thread must not access it after submission. - The `wsi` and `pss` pointers could become invalid if the client disconnects while the event is being processed. The completion handler must validate that the connection is still alive before sending the OK response. - A generation counter or epoch on `pss` can detect stale references. ## What Cannot Be Offloaded - **`lws_write` / `queue_message`**: Must happen on the lws service thread - **`lws_callback_on_writable`**: Must happen on the lws service thread - **`lws_close_reason`**: Must happen on the lws service thread - **Subscription list iteration for broadcast**: Accesses `lws_wsi_user` which is lws-internal These are fundamental libwebsockets constraints. The pattern is always: do computation off-thread, post result to completion queue, wake main thread, do I/O on main thread. ## Expected Impact With Phase 1 alone, the main thread would no longer perform: - ed25519 signature verification (~100-500μs per event depending on CPU) - Synchronous DB store wait (~50-200μs per event) - Event classification, tag serialization, JSON serialization for storage This should reduce main-thread CPU by roughly 30-50% for EVENT-heavy workloads, shifting that work to the event worker thread and DB threads.