Target Duration: 2–4 minutes (~300–450 spoken words)
Focus: Pointwise verbal delivery covering multi-timer scheduling in the event loop, active TTL eviction via a binary min-heap with bidirectional tracking, idle connection pruning, and asynchronous deallocation via a background worker thread pool.
Opening & Scope:
"In an in-memory database, memory reclamation and timeouts must happen proactively without stalling the single-threaded event loop. In Radis, I engineered an active eviction engine using a binary min-heap with bidirectional index tracking, paired with a background worker thread pool for asynchronous deallocations."
Step 1: Multi-Timer Event Loop Scheduling (next_timer_ms):
"First, the event loop must know how long poll() can sleep without missing deadlines. In next_timer_ms() (server.cpp:745-769), the server checks two timer sources: the oldest idle connection at the head of the intrusive idle_list, and the earliest TTL expiration at the root of the min-heap. It calculates the delta between the earliest expiration and monotonic time, passing the exact millisecond timeout to poll(). This eliminates busy-polling CPU waste and prevents timer drift."
Step 2: Active TTL Tracking with a Binary Min-Heap:
"Second, relying solely on passive expiration—checking TTL only when a key is accessed—causes dead keys to leak memory indefinitely. Instead, Radis uses an active binary min-heap stored in a std::vector<HeapItem> (struct HeapItem heap.h:7-11), ordered by monotonic expiration timestamps (val). The root item always represents the key expiring next, providing an instant $O(1)$ check for pending expirations."
Step 3: Bidirectional Heap Index Tracking:
"Third, a major limitation of standard priority queues like std::priority_queue is that updating or deleting an existing entry requires an $O(N)$ linear scan. To solve this, HeapItem stores a reverse pointer: size_t *ref = &ent->heap_idx. Whenever heap_up() (heap.cpp:16-36) or heap_down() (heap.cpp:38-53) swaps items during a rebalance, it immediately updates *ref with the new index. This allows any key to update its TTL or delete itself via heap_update() (heap.cpp:55-61) in strict $O(\log N)$ time."
Step 4: Bounded Eviction Batches and Idle Connection Pruning:
"Fourth, inside process_timers() (server.cpp:771-798), idle connections exceeding 5 seconds of inactivity are detached and destroyed via conn_done(). For TTL keys, if thousands of keys expire at the exact same millisecond, evicting them all synchronously would cause a latency spike for clients. To prevent this, the server caps eviction at k_max_works (2,000 keys per loop tick), yielding control back to client I/O before clearing subsequent batches."
Step 5: Offloading Deallocations to a Background Thread Pool:
"Finally, when a large data structure like a Sorted Set with 100,000 members is deleted, executing thousands of free() calls synchronously can freeze the reactor for 50 milliseconds. To prevent this tail latency spike, entry_del() (server.cpp:296-318) checks container size. If it exceeds 1,000 elements (k_large_container_size), the entry is unlinked synchronously from the database and queued via thread_pool_queue() (thread_pool.cpp:40-47) to a 4-thread background worker ThreadPool (thread_pool.h:9-21) and worker() (thread_pool.cpp:25-38). The worker destroys the memory off the critical path, keeping reactor response times sub-millisecond."
| Step | What Was Done | How It Works | Why This Mechanism / Order | Code Reference |
|---|---|---|---|---|
| 1. Dynamic Poll Timeout | Computed next_timer_ms() for poll() |
Takes min(oldest_idle_conn, min_heap_root) - now_ms. |
Prevents CPU busy spinning while ensuring zero timer drift for expirations. | server.cpp:745 |
| 2. Active Min-Heap | Stored expire_at in binary heap |
Heap property ensures smallest timestamp is at root index 0. | Eliminates memory leaks from unaccessed keys without scanning the entire database. | heap.h:7, heap.cpp:16 |
| 3. Bidirectional Index | Maintained size_t *ref = &ent->heap_idx |
Swaps update *ref = pos during heap_up and heap_down. |
Enables $O(\log N)$ in-place TTL updates and deletions without $O(N)$ scans. | heap.cpp:55, server.cpp:280 |
| 4. Bounded Eviction | Enforced 2,000-work cap per tick | Breaks process_timers loop if work exceeds k_max_works. |
Protects request latency from degradation during mass simultaneous expirations. | server.cpp:771 |
| 5. Async Deallocation | Offloaded large containers to ThreadPool |
Containers $>1,000$ items dispatched to 4 worker threads via mutex/condvar. | Eliminates main-thread deallocation freezing when dropping large sets. | server.cpp:296, thread_pool.cpp:40 |
Answer: Passive expiration (checking TTL only when a client queries a key) leaks memory if keys are written once and never touched again. Periodic whole-table scanning wastes massive CPU ($O(N)$ overhead). Active min-heap provides $O(1)$ earliest-expiration inspection and $O(\log N)$ updates. For idle socket timeouts, all connections share an identical 5-second deadline, meaning the intrusive circular doubly-linked list (
idle_list) is naturally sorted by recency: bumping a connection on activity is $O(1)$ (dlist_detach+dlist_insert_beforeto tail), and checking expirations at head is $O(1)$. Combining both intonext_timer_ms()calculates the exact millisecondpoll()timeout, eliminating busy-wait CPU spinning entirely.
Answer: Memory reclamation races happen if a background worker attempts to deallocate memory while the reactor still holds references, or if worker threads deallocate nodes back to the OS while the main thread's memory allocator experiences lock contention. To guarantee complete safety: (1) In
entry_del(), the node is completely severed from the database hash table (h_del()), sorted set AVL tree, and TTL min-heap synchronously on the reactor thread before submitting tothread_pool_queue(). (2) The dispatched pointer is unreachable by any client lookup, giving the worker exclusive ownership. (3) Deletion is only offloaded for large structures (zset_del()with $>1000$ members) where recursive tree destruction would block the event loop for dozens of milliseconds. Smaller entries are deleted synchronously to avoid queue lock contention overhead.
HeapItem.ref pointer work during rebalances, and how did you verify it doesn't cause invalid memory writes?Answer: In standard
std::priority_queue, updating or deleting an element requires an $O(N)$ scan because items move during heapify without notifying their owners. In Radis,HeapItemembeds a pointersize_t *ref = &ent->heap_idx. Wheneverheap_up()orheap_down()swaps array elements, it immediately executes*a[pos].ref = pos. When an entry is deleted or its TTL is extended viaPEXPIRE, the code directly passesent->heap_idxintoheap_update(), performing an in-place reheapify in $O(\log N)$ without searching. To prevent invalid memory writes, whenever a key is removed from the heap,ent->heap_idxis set to(size_t)-1(sentinel), andheap_update()asserts bounds before dereferencingref. Validated under high-concurrency client mutations with AddressSanitizer.