|
| 1 | +# SMP Server Postgres: Slow Query Analysis |
| 2 | + |
| 3 | +Data from three production servers (A, B, C) over a multi-day observation window. |
| 4 | + |
| 5 | +## Verified fixes |
| 6 | + |
| 7 | +### 1. getEntityCounts: replace ③④ with SUM(queue_count) |
| 8 | + |
| 9 | +**Query** (`QueueStore/Postgres.hs:160-167`): |
| 10 | + |
| 11 | +```sql |
| 12 | +SELECT |
| 13 | + (SELECT COUNT(1) FROM msg_queues WHERE deleted_at IS NULL) AS queue_count, -- full scan |
| 14 | + (SELECT COUNT(1) FROM msg_queues WHERE deleted_at IS NULL AND notifier_id IS NOT NULL) AS notifier_count, -- full scan |
| 15 | + (SELECT COUNT(1) FROM services WHERE service_role = ?) AS rcv_service_count, -- trivial (<10 rows) |
| 16 | + (SELECT COUNT(1) FROM services WHERE service_role = ?) AS ntf_service_count, -- trivial (<10 rows) |
| 17 | + (SELECT COUNT(1) FROM msg_queues WHERE rcv_service_id IS NOT NULL AND deleted_at IS NULL) AS rcv_service_queues_count, -- ③ full scan |
| 18 | + (SELECT COUNT(1) FROM msg_queues WHERE ntf_service_id IS NOT NULL AND deleted_at IS NULL) AS ntf_service_queues_count -- ④ full scan |
| 19 | +``` |
| 20 | + |
| 21 | +**Performance**: ~315ms avg, ~2s max, ~2500 calls. #1 slow query by total time (~800s). |
| 22 | + |
| 23 | +**Problem**: 4 full scans of `msg_queues`. No index covers `rcv_service_id IS NOT NULL` or |
| 24 | +`ntf_service_id IS NOT NULL` combined with `deleted_at IS NULL`. |
| 25 | + |
| 26 | +**Fix**: Replace ③ and ④: |
| 27 | + |
| 28 | +```sql |
| 29 | +COALESCE((SELECT SUM(queue_count) FROM services WHERE service_role = 'M'), 0) AS rcv_service_queues_count, |
| 30 | +COALESCE((SELECT SUM(queue_count) FROM services WHERE service_role = 'N'), 0) AS ntf_service_queues_count |
| 31 | +``` |
| 32 | + |
| 33 | +**Verification**: |
| 34 | +- Trigger logic traced for all transitions (NULL→set, change, soft-delete, physical delete) — correct. |
| 35 | +- FK `rcv_service_id REFERENCES services(service_id)` guarantees every non-NULL value maps to a row. |
| 36 | +- `queue_count + p_change` is atomic under READ COMMITTED — concurrent-safe. |
| 37 | +- `update_all_aggregates()` exists as repair mechanism. |
| 38 | +- `services` table has <10 rows — SUM is O(1) vs full table scan. |
| 39 | + |
| 40 | +**Savings**: Eliminates 2 of 4 msg_queues scans. Exact per-subquery cost needs `EXPLAIN ANALYZE`. |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +### 2. expire_old_messages: remove trailing COUNTs |
| 45 | + |
| 46 | +**Stored procedure** (`Migrations.hs`), at the end of `expire_old_messages`: |
| 47 | + |
| 48 | +```sql |
| 49 | +r_expired_msgs_count := total_deleted; |
| 50 | +r_stored_msgs_count := (SELECT COUNT(1) FROM messages); -- 0.7-1.1s per call |
| 51 | +r_stored_queues := (SELECT COUNT(1) FROM msg_queues WHERE deleted_at IS NULL); -- 5-7s per call |
| 52 | +``` |
| 53 | + |
| 54 | +**Performance**: 10 calls per observation window. These two COUNTs add **5.7-8.1s per call** |
| 55 | +to a procedure that takes 7-12s total — they're 50-80% of the cost. |
| 56 | + |
| 57 | +**How results are used** (`Server.hs:485-488`): |
| 58 | + |
| 59 | +```haskell |
| 60 | +Right msgStats@MessageStats {storedMsgsCount = stored, expiredMsgsCount = expired} -> do |
| 61 | + atomicWriteIORef (msgCount stats) stored -- resets msgCount from storedMsgsCount |
| 62 | + atomicModifyIORef'_ (msgExpired stats) (+ expired) |
| 63 | + printMessageStats "STORE: messages" msgStats -- logs all three fields |
| 64 | +``` |
| 65 | + |
| 66 | +- `expiredMsgsCount` — computed incrementally in the loop. **Needed, already cheap.** |
| 67 | +- `storedMsgsCount` — used to reset `msgCount` stat. But `msgCount` is also maintained |
| 68 | + incrementally (`+1` on send at line 1963, `-1` on ACK at line 1916). The reset corrects drift. |
| 69 | +- `storedQueues` — **used only for logging**. Same value available from `getEntityCounts` |
| 70 | + which runs every ~60s via Prometheus. |
| 71 | + |
| 72 | +**Fix**: Remove both COUNTs from the stored procedure. Return only `r_expired_msgs_count`. |
| 73 | + |
| 74 | +For `storedMsgsCount`: either trust the incremental `msgCount` counter, or query |
| 75 | +`SELECT COUNT(1) FROM messages` separately (in parallel, not blocking the procedure). |
| 76 | + |
| 77 | +For `storedQueues`: use the value from the most recent `getEntityCounts` call. |
| 78 | + |
| 79 | +**Verification**: Traced all usages of `MessageStats` fields from `expireOldMessages` in |
| 80 | +`Server.hs`. `storedQueues` is only logged. `storedMsgsCount` resets a counter that's already |
| 81 | +maintained incrementally — removing the reset means potential drift, but the counter is |
| 82 | +corrected on next server restart anyway. |
| 83 | + |
| 84 | +**Savings**: 5.7-8.1s per expiration cycle × 10 cycles = **57-81s total**. |
| 85 | + |
| 86 | +--- |
| 87 | + |
| 88 | +### 3. Trigger WHEN clause: skip function call for non-service updates |
| 89 | + |
| 90 | +**Current trigger** (`Migrations.hs:566-568`): |
| 91 | + |
| 92 | +```sql |
| 93 | +CREATE TRIGGER tr_queue_update |
| 94 | +AFTER UPDATE ON msg_queues |
| 95 | +FOR EACH ROW EXECUTE PROCEDURE on_queue_update(); |
| 96 | +``` |
| 97 | + |
| 98 | +Fires on **every UPDATE** to `msg_queues`. From Server C data, ~1.2M updates per observation |
| 99 | +window, but only ~105K (8.7%) actually change service-related fields: |
| 100 | + |
| 101 | +| UPDATE pattern | Calls | Changes service fields? | |
| 102 | +|---|---|---| |
| 103 | +| SET updated_at | ~427K | No | |
| 104 | +| SET msg_can_write/size/expire (write_message) | ~336K | No | |
| 105 | +| SET msg_can_write/size/expire (try_del_*) | ~196K | No | |
| 106 | +| SET msg_can_write/size/expire (delete_expired) | ~136K | No | |
| 107 | +| SET sender_key | ~8K | No | |
| 108 | +| SET status | ~2K | No | |
| 109 | +| **SET rcv_service_id** | **~101K** | **Yes** | |
| 110 | +| **SET deleted_at** | **~5K** | **Yes** | |
| 111 | + |
| 112 | +The `on_queue_update()` PL/pgSQL function evaluates 8-12 boolean conditions on every call, |
| 113 | +then returns without calling `update_aggregates` for 91% of invocations. |
| 114 | + |
| 115 | +**Fix**: Add a `WHEN` clause to the trigger definition. PostgreSQL evaluates `WHEN` in C code |
| 116 | +before calling the PL/pgSQL function — no function entry overhead at all: |
| 117 | + |
| 118 | +```sql |
| 119 | +CREATE TRIGGER tr_queue_update |
| 120 | +AFTER UPDATE ON msg_queues |
| 121 | +FOR EACH ROW |
| 122 | +WHEN ( |
| 123 | + OLD.deleted_at IS DISTINCT FROM NEW.deleted_at |
| 124 | + OR OLD.rcv_service_id IS DISTINCT FROM NEW.rcv_service_id |
| 125 | + OR OLD.ntf_service_id IS DISTINCT FROM NEW.ntf_service_id |
| 126 | + OR OLD.notifier_id IS DISTINCT FROM NEW.notifier_id |
| 127 | +) |
| 128 | +EXECUTE PROCEDURE on_queue_update(); |
| 129 | +``` |
| 130 | + |
| 131 | +**Verification**: |
| 132 | +- PostgreSQL supports `OLD`/`NEW` in `WHEN` clauses for `AFTER UPDATE` triggers. |
| 133 | +- The 4 conditions match exactly the fields checked inside `on_queue_update()`. |
| 134 | +- When WHEN is false, the function is **never called** — zero PL/pgSQL overhead. |
| 135 | +- When WHEN is true, the function runs identically to today. |
| 136 | +- Behavioral change: **none** — same aggregates updated in same cases. |
| 137 | + |
| 138 | +**Savings**: ~1.1M PL/pgSQL function calls avoided. Each call has fixed overhead |
| 139 | +(function entry, OLD/NEW row extraction, condition evaluation, return). Exact savings |
| 140 | +need measurement, but function call overhead is non-trivial at this volume. |
| 141 | + |
| 142 | +--- |
| 143 | + |
| 144 | +## Fixes that need EXPLAIN ANALYZE |
| 145 | + |
| 146 | +### 4. Partial indexes for getEntityCounts ① and ② |
| 147 | + |
| 148 | +Subqueries ① (queue_count) and ② (notifier_count) still do full msg_queues scans. |
| 149 | +No existing index covers `deleted_at IS NULL` alone, or combined with `notifier_id IS NOT NULL`. |
| 150 | + |
| 151 | +Candidate indexes: |
| 152 | + |
| 153 | +```sql |
| 154 | +-- For ① queue_count: enables index-only COUNT |
| 155 | +CREATE INDEX idx_msg_queues_live ON msg_queues ((1)) WHERE deleted_at IS NULL; |
| 156 | + |
| 157 | +-- For ② notifier_count: enables index-only COUNT |
| 158 | +CREATE INDEX idx_msg_queues_live_notifier ON msg_queues ((1)) WHERE deleted_at IS NULL AND notifier_id IS NOT NULL; |
| 159 | +``` |
| 160 | + |
| 161 | +**Trade-off**: Each index adds write overhead on every INSERT/UPDATE/DELETE touching the |
| 162 | +filtered columns. Need `EXPLAIN ANALYZE` to confirm the COUNT actually uses the index |
| 163 | +(PostgreSQL may choose seq scan if the partial index covers most rows). |
| 164 | + |
| 165 | +--- |
| 166 | + |
| 167 | +## Not fixable (architectural) |
| 168 | + |
| 169 | +- **write_message / try_del_peek_msg max times (490-523ms)**: Lock contention on |
| 170 | + `FOR UPDATE` of the same `recipient_id` row. Inherent to concurrent queue access — cannot |
| 171 | + use `SKIP LOCKED` because these operations require the lock for correctness. |
| 172 | + |
| 173 | +- **UPDATE msg_queues SET updated_at (~430K calls, 83-90s total, 0.20ms avg)**: Per-call cost |
| 174 | + is already minimal. Trigger does zero aggregate work for this pattern (verified — all |
| 175 | + IS DISTINCT FROM checks fail, no `update_aggregates` called). Fix #3 eliminates even |
| 176 | + the function call overhead. |
| 177 | + |
| 178 | +--- |
| 179 | + |
| 180 | +## Summary |
| 181 | + |
| 182 | +| # | Fix | Before | After | Verified | |
| 183 | +|---|-----|--------|-------|----------| |
| 184 | +| 1 | getEntityCounts ③④ → `SUM(queue_count)` | ~315ms/call (4 full scans) | ~160-215ms/call (2 full scans + 2 trivial) | Yes | |
| 185 | +| 2 | Remove trailing COUNTs from expire_old_messages | 7-12s/call (50-80% is COUNTs) | 1.5-4s/call (actual expiration work only) | Yes | |
| 186 | +| 3 | Add WHEN clause to tr_queue_update | ~1.2M PL/pgSQL calls (91% no-op) | ~105K PL/pgSQL calls (only when needed) | Yes | |
| 187 | +| 4 | Partial indexes for ①② | ~160-215ms/call (2 full scans remain) | Needs EXPLAIN ANALYZE | No | |
0 commit comments