9.9 KiB
Main Thread CPU Offload Plan
Problem Statement
The main c_relay_pg 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
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:
- Main thread: Parse JSON (fast), extract event ID for dedup check, then submit a job to a new "event processing" worker thread
- Worker thread: Signature verification (expensive crypto), store_event (already goes to DB thread), prepare broadcast payload
- Main thread callback: Receive result via
wake_loop_cb+lws_cancel_service, send OK response and broadcast to subscribers
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:
- Main thread: Parse filters, build SQL, create subscription, submit query job
- DB reader thread: Execute query (already happens, but currently blocks main thread via
_sync) - 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_readdirectly instead ofthread_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
expirationcolumn 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:
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:
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:
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
-
nostr_relay_callbackEVENT path: After JSON parse and dedup check, instead of callingnostr_validate_unified_request+store_event+broadcast_event_to_subscriptionssynchronously, submit anevent_work_item_tto the event worker queue and return 0 immediately. -
store_event: No changes needed — it already usesthread_pool_execute_store_event_syncwhich will run on the DB writer thread. The event worker thread will call it. -
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 callsqueue_message_take_ownership(requires lws thread).
Thread Safety Considerations
- The
cJSON* eventobject must be fully owned by the worker thread during processing. The main thread must not access it after submission. - The
wsiandpsspointers 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
psscan detect stale references.
What Cannot Be Offloaded
lws_write/queue_message: Must happen on the lws service threadlws_callback_on_writable: Must happen on the lws service threadlws_close_reason: Must happen on the lws service thread- Subscription list iteration for broadcast: Accesses
lws_wsi_userwhich 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.