195 lines
9.9 KiB
Markdown
195 lines
9.9 KiB
Markdown
# 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
|
|
|
|
```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.
|