openzeppelin_relayer/services/plugins/
config.rs

1//! Plugin Configuration
2//!
3//! Centralized configuration for the plugin system with auto-derivation.
4//!
5//! # Simple Usage (80% of users)
6//!
7//! Set one variable and everything else is auto-calculated:
8//!
9//! ```bash
10//! export PLUGIN_MAX_CONCURRENCY=3000
11//! ```
12//!
13//! # Advanced Usage (power users)
14//!
15//! Override individual settings when needed:
16//!
17//! ```bash
18//! export PLUGIN_MAX_CONCURRENCY=3000
19//! export PLUGIN_POOL_MAX_QUEUE_SIZE=10000  # Override just this one
20//! ```
21
22use crate::constants::{
23    CONCURRENT_TASKS_HEADROOM_MULTIPLIER, DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER,
24    DEFAULT_POOL_CONNECT_RETRIES, DEFAULT_POOL_HEALTH_CHECK_INTERVAL_SECS,
25    DEFAULT_POOL_IDLE_TIMEOUT_MS, DEFAULT_POOL_MAX_CONNECTIONS, DEFAULT_POOL_MAX_THREADS_FLOOR,
26    DEFAULT_POOL_MIN_THREADS, DEFAULT_POOL_QUEUE_SEND_TIMEOUT_MS,
27    DEFAULT_POOL_REQUEST_TIMEOUT_SECS, DEFAULT_POOL_SOCKET_BACKLOG,
28    DEFAULT_SOCKET_IDLE_TIMEOUT_SECS, DEFAULT_SOCKET_READ_TIMEOUT_SECS, DEFAULT_TRACE_TIMEOUT_MS,
29    MAX_CONCURRENT_TASKS_PER_WORKER,
30};
31use std::sync::OnceLock;
32
33/// Cached plugin configuration (computed once at startup)
34static CONFIG: OnceLock<PluginConfig> = OnceLock::new();
35
36/// Plugin system configuration with auto-derived values
37#[derive(Debug, Clone)]
38pub struct PluginConfig {
39    // === Primary scaling knob ===
40    /// Maximum concurrent plugin executions (the main knob users should adjust)
41    pub max_concurrency: usize,
42
43    // === Connection Pool (Rust side, auto-derived from max_concurrency) ===
44    /// Maximum connections to the Node.js pool server
45    pub pool_max_connections: usize,
46    /// Retry attempts when connecting to pool
47    pub pool_connect_retries: usize,
48    /// Request timeout in seconds
49    pub pool_request_timeout_secs: u64,
50
51    // === Request Queue (Rust side, auto-derived from max_concurrency) ===
52    /// Maximum queued requests
53    pub pool_max_queue_size: usize,
54    /// Wait time when queue is full before rejecting (ms)
55    pub pool_queue_send_timeout_ms: u64,
56    /// Number of queue workers (0 = auto based on CPU cores)
57    pub pool_workers: usize,
58
59    // === Socket Service (Rust side, auto-derived from max_concurrency) ===
60    /// Maximum concurrent socket connections
61    pub socket_max_connections: usize,
62    /// Idle timeout for connections (seconds)
63    pub socket_idle_timeout_secs: u64,
64    /// Read timeout per message (seconds)
65    pub socket_read_timeout_secs: u64,
66
67    // === Node.js Worker Pool (passed to pool-server.ts) ===
68    /// Minimum worker threads in Node.js pool
69    pub nodejs_pool_min_threads: usize,
70    /// Maximum worker threads in Node.js pool
71    pub nodejs_pool_max_threads: usize,
72    /// Concurrent tasks per worker thread
73    pub nodejs_pool_concurrent_tasks: usize,
74    /// Worker idle timeout in milliseconds
75    pub nodejs_pool_idle_timeout_ms: u64,
76    /// Worker thread heap size in MB (each worker handles concurrent_tasks contexts)
77    pub nodejs_worker_heap_mb: usize,
78
79    // === Socket Backlog (derived from max_concurrency) ===
80    /// Socket connection backlog for pending connections
81    pub pool_socket_backlog: usize,
82
83    // === Health & Monitoring ===
84    /// Minimum seconds between health checks
85    pub health_check_interval_secs: u64,
86    /// Trace collection timeout (ms)
87    pub trace_timeout_ms: u64,
88}
89
90impl PluginConfig {
91    /// Load configuration from environment variables with auto-derivation
92    pub fn from_env() -> Self {
93        // === Primary scaling knob ===
94        // If set, this drives the auto-derivation of other values
95        let max_concurrency = env_parse("PLUGIN_MAX_CONCURRENCY", DEFAULT_POOL_MAX_CONNECTIONS);
96
97        // === Auto-derived values (can be individually overridden) ===
98
99        // Pool connections = max_concurrency (1:1 ratio)
100        let pool_max_connections = env_parse("PLUGIN_POOL_MAX_CONNECTIONS", max_concurrency);
101
102        // Socket connections = 1.5x max_concurrency (headroom for connection churn)
103        let socket_max_connections = env_parse(
104            "PLUGIN_SOCKET_MAX_CONCURRENT_CONNECTIONS",
105            (max_concurrency as f64 * 1.5) as usize,
106        );
107
108        // Queue size = 2x max_concurrency (absorb bursts)
109        let pool_max_queue_size = env_parse("PLUGIN_POOL_MAX_QUEUE_SIZE", max_concurrency * 2);
110
111        // Calculate thread count early for queue timeout derivation
112        // NOTE: This must use the SAME formula as the actual thread calculation below
113        let cpu_count = std::thread::available_parallelism()
114            .map(|n| n.get())
115            .unwrap_or(4);
116
117        // Memory-aware estimation (same logic as actual calculation below)
118        // Assume 16GB default for estimation since we detect actual memory later
119        let estimated_memory_budget = 16384_u64 / 2; // 8GB budget
120        let estimated_memory_threads = (estimated_memory_budget / 1024).max(4) as usize;
121        let estimated_concurrency_threads = (max_concurrency / 200).max(cpu_count);
122        let estimated_max_threads = estimated_memory_threads
123            .min(estimated_concurrency_threads)
124            .clamp(DEFAULT_POOL_MAX_THREADS_FLOOR, 32); // Same cap as actual calculation
125
126        // Queue timeout scales with concurrency AND thread count
127        // Formula: base_timeout * (concurrency / threads) with caps
128        // This ensures timeout grows when there are more items per thread
129        let base_queue_timeout = DEFAULT_POOL_QUEUE_SEND_TIMEOUT_MS;
130        let workload_per_thread = max_concurrency / estimated_max_threads.max(1);
131        let derived_queue_timeout = if workload_per_thread > 100 {
132            // Heavy load per thread: allow more time
133            base_queue_timeout * 2 // 1000ms
134        } else if workload_per_thread > 50 {
135            // Medium load per thread
136            base_queue_timeout + 250 // 750ms
137        } else {
138            // Light load per thread
139            base_queue_timeout // 500ms default
140        };
141        let pool_queue_send_timeout_ms =
142            env_parse("PLUGIN_POOL_QUEUE_SEND_TIMEOUT_MS", derived_queue_timeout);
143
144        // Other settings with defaults
145        let pool_connect_retries =
146            env_parse("PLUGIN_POOL_CONNECT_RETRIES", DEFAULT_POOL_CONNECT_RETRIES);
147        let pool_request_timeout_secs = env_parse(
148            "PLUGIN_POOL_REQUEST_TIMEOUT_SECS",
149            DEFAULT_POOL_REQUEST_TIMEOUT_SECS,
150        );
151        let pool_workers = env_parse("PLUGIN_POOL_WORKERS", 0); // 0 = auto
152
153        let socket_idle_timeout_secs = env_parse(
154            "PLUGIN_SOCKET_IDLE_TIMEOUT_SECS",
155            DEFAULT_SOCKET_IDLE_TIMEOUT_SECS,
156        );
157        let socket_read_timeout_secs = env_parse(
158            "PLUGIN_SOCKET_READ_TIMEOUT_SECS",
159            DEFAULT_SOCKET_READ_TIMEOUT_SECS,
160        );
161
162        let health_check_interval_secs = env_parse(
163            "PLUGIN_POOL_HEALTH_CHECK_INTERVAL_SECS",
164            DEFAULT_POOL_HEALTH_CHECK_INTERVAL_SECS,
165        );
166        let trace_timeout_ms = env_parse("PLUGIN_TRACE_TIMEOUT_MS", DEFAULT_TRACE_TIMEOUT_MS);
167
168        // === Node.js Worker Pool settings (auto-derived from max_concurrency) ===
169        // These are passed to pool-server.ts when spawning the Node.js process
170        // Note: cpu_count and scaling_threads already calculated above for queue timeout
171
172        // minThreads = max(2, cpuCount / 2) - keeps some workers warm
173        let derived_min_threads = DEFAULT_POOL_MIN_THREADS.max(cpu_count / 2);
174        let nodejs_pool_min_threads = env_parse("PLUGIN_POOL_MIN_THREADS", derived_min_threads);
175
176        // === Memory-aware thread scaling ===
177        // The previous formula (concurrency / 50) was too aggressive and caused GC issues
178        // on systems with limited memory (e.g., laptops with 16-36GB RAM).
179        //
180        // New approach: Scale threads based on BOTH concurrency AND available memory
181        //
182        // Memory budget calculation:
183        //   - Each worker thread needs ~1-2GB heap for high concurrent task loads
184        //   - On a 16GB system, we shouldn't use more than ~8GB for workers (50%)
185        //   - On a 32GB system, we can use ~16GB for workers
186        //
187        // Thread limits based on system memory:
188        //   - 8GB RAM: max 4 threads (conservative)
189        //   - 16GB RAM: max 8 threads
190        //   - 32GB RAM: max 16 threads
191        //   - 64GB+ RAM: max 32 threads (hard cap for efficiency)
192        //
193        // This prevents the previous issue where 5000 VU would spawn 64 threads
194        // requiring 128GB+ of potential heap allocation.
195        let total_memory_mb = {
196            #[cfg(target_os = "macos")]
197            {
198                // On macOS, use sysctl to get total memory
199                use std::process::Command;
200                Command::new("sysctl")
201                    .args(["-n", "hw.memsize"])
202                    .output()
203                    .ok()
204                    .and_then(|o| String::from_utf8(o.stdout).ok())
205                    .and_then(|s| s.trim().parse::<u64>().ok())
206                    .map(|bytes| bytes / 1024 / 1024)
207                    .unwrap_or(16384) // Default to 16GB if detection fails
208            }
209            #[cfg(target_os = "linux")]
210            {
211                // On Linux, read from /proc/meminfo
212                std::fs::read_to_string("/proc/meminfo")
213                    .ok()
214                    .and_then(|contents| {
215                        contents
216                            .lines()
217                            .find(|l| l.starts_with("MemTotal:"))
218                            .and_then(|l| {
219                                l.split_whitespace()
220                                    .nth(1)
221                                    .and_then(|s| s.parse::<u64>().ok())
222                            })
223                    })
224                    .map(|kb| kb / 1024)
225                    .unwrap_or(16384) // Default to 16GB
226            }
227            #[cfg(not(any(target_os = "macos", target_os = "linux")))]
228            {
229                16384_u64 // Default to 16GB on other platforms
230            }
231        };
232
233        // Calculate memory-based thread limit
234        // Use ~50% of system memory for workers, with 1GB budget per worker
235        // (Workers with good GC pressure management don't actually use 2GB each)
236        let memory_budget_mb = total_memory_mb / 2;
237        let heap_per_worker_mb = 1024_u64; // ~1GB per worker (realistic with GC)
238        let memory_based_max_threads = (memory_budget_mb / heap_per_worker_mb).max(4) as usize;
239
240        // Concurrency-based thread scaling (more conservative than before)
241        // Changed from /50 to /200 - each thread can handle ~200 VUs with async I/O
242        // Example: 10,000 VUs / 200 = 50 threads (capped by memory)
243        let concurrency_based_threads = (max_concurrency / 200).max(cpu_count);
244
245        // Final thread count: minimum of memory-based and concurrency-based limits
246        // This ensures we don't exceed either memory or concurrency constraints
247        let derived_max_threads = memory_based_max_threads
248            .min(concurrency_based_threads)
249            .clamp(DEFAULT_POOL_MAX_THREADS_FLOOR, 32); // At least the floor, hard cap at 32
250
251        tracing::debug!(
252            total_memory_mb = total_memory_mb,
253            memory_based_max = memory_based_max_threads,
254            concurrency_based = concurrency_based_threads,
255            derived_max_threads = derived_max_threads,
256            "Thread scaling calculation"
257        );
258
259        let nodejs_pool_max_threads = env_parse("PLUGIN_POOL_MAX_THREADS", derived_max_threads);
260
261        // concurrentTasksPerWorker: Node.js async can handle many concurrent tasks
262        // Formula: (concurrency / max_threads) * CONCURRENT_TASKS_HEADROOM_MULTIPLIER for some headroom
263        // The multiplier provides headroom for:
264        //   - Queue buildup during traffic spikes
265        //   - Variable plugin execution latency
266        // Examples with new formula (on 16GB system with ~8 threads):
267        //   - 10000 VUs / 16 threads * 1.2 = 750, capped at MAX_CONCURRENT_TASKS_PER_WORKER
268        //   - 5000 VUs / 8 threads * 1.2 = 750, capped at MAX_CONCURRENT_TASKS_PER_WORKER
269        //   - 1000 VUs / 8 threads * 1.2 = 150
270        let base_tasks = max_concurrency / nodejs_pool_max_threads.max(1);
271        let derived_concurrent_tasks =
272            ((base_tasks as f64 * CONCURRENT_TASKS_HEADROOM_MULTIPLIER) as usize).clamp(
273                DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER,
274                MAX_CONCURRENT_TASKS_PER_WORKER,
275            );
276        let nodejs_pool_concurrent_tasks =
277            env_parse("PLUGIN_POOL_CONCURRENT_TASKS", derived_concurrent_tasks);
278
279        let nodejs_pool_idle_timeout_ms =
280            env_parse("PLUGIN_POOL_IDLE_TIMEOUT", DEFAULT_POOL_IDLE_TIMEOUT_MS);
281
282        // Worker heap size calculation
283        // Each vm.createContext() uses ~4-6MB, and we need headroom for GC
284        // Formula: base_heap + (concurrent_tasks * 5MB)
285        // This ensures workers can handle burst context creation without OOM
286        // Examples:
287        //   - 50 concurrent tasks: 512 + (50 * 5) = 762MB
288        //   - 150 concurrent tasks: 512 + (150 * 5) = 1262MB
289        //   - 250 concurrent tasks: 512 + (250 * 5) = 1762MB
290        let base_worker_heap = 512_usize;
291        let heap_per_task = 5_usize;
292        let derived_worker_heap_mb =
293            (base_worker_heap + (nodejs_pool_concurrent_tasks * heap_per_task)).clamp(1024, 2048); // At least 1GB, cap at 2GB
294        let nodejs_worker_heap_mb = env_parse("PLUGIN_WORKER_HEAP_MB", derived_worker_heap_mb);
295
296        // Socket backlog calculation
297        // Use max of concurrency or default backlog to handle connection bursts
298        // The 1.5x socket_max_connections provides headroom for connection churn:
299        //   - Client reconnections
300        //   - Connection pool cycling
301        //   - Load balancer health checks
302        // This ratio should be validated through load testing if workload characteristics change.
303        let default_backlog = DEFAULT_POOL_SOCKET_BACKLOG as usize;
304        let pool_socket_backlog = env_parse(
305            "PLUGIN_POOL_SOCKET_BACKLOG",
306            max_concurrency.max(default_backlog),
307        );
308
309        let config = Self {
310            max_concurrency,
311            pool_max_connections,
312            pool_connect_retries,
313            pool_request_timeout_secs,
314            pool_max_queue_size,
315            pool_queue_send_timeout_ms,
316            pool_workers,
317            socket_max_connections,
318            socket_idle_timeout_secs,
319            socket_read_timeout_secs,
320            nodejs_pool_min_threads,
321            nodejs_pool_max_threads,
322            nodejs_pool_concurrent_tasks,
323            nodejs_pool_idle_timeout_ms,
324            nodejs_worker_heap_mb,
325            pool_socket_backlog,
326            health_check_interval_secs,
327            trace_timeout_ms,
328        };
329
330        // Validate derived configuration
331        config.validate();
332
333        config
334    }
335
336    /// Validate that derived configuration values are sensible
337    fn validate(&self) {
338        // Critical invariants
339        assert!(
340            self.pool_max_connections <= self.socket_max_connections,
341            "pool_max_connections ({}) must be <= socket_max_connections ({})",
342            self.pool_max_connections,
343            self.socket_max_connections
344        );
345        assert!(
346            self.nodejs_pool_min_threads <= self.nodejs_pool_max_threads,
347            "nodejs_pool_min_threads ({}) must be <= nodejs_pool_max_threads ({})",
348            self.nodejs_pool_min_threads,
349            self.nodejs_pool_max_threads
350        );
351        assert!(
352            self.max_concurrency > 0,
353            "max_concurrency must be > 0, got {}",
354            self.max_concurrency
355        );
356        assert!(
357            self.nodejs_pool_max_threads > 0,
358            "nodejs_pool_max_threads must be > 0, got {}",
359            self.nodejs_pool_max_threads
360        );
361
362        // Warnings for potentially problematic configurations
363        if self.pool_max_queue_size < self.max_concurrency {
364            tracing::warn!(
365                "pool_max_queue_size ({}) is less than max_concurrency ({}). \
366                 This may cause request rejections under load.",
367                self.pool_max_queue_size,
368                self.max_concurrency
369            );
370        }
371        if self.nodejs_pool_concurrent_tasks > 500 {
372            tracing::warn!(
373                "nodejs_pool_concurrent_tasks ({}) is very high. \
374                 This may cause excessive memory usage per worker.",
375                self.nodejs_pool_concurrent_tasks
376            );
377        }
378    }
379
380    /// Log the effective configuration for debugging
381    pub fn log_config(&self) {
382        let tasks_per_thread = self.max_concurrency / self.nodejs_pool_max_threads.max(1);
383        let socket_ratio = self.socket_max_connections as f64 / self.max_concurrency as f64;
384        let queue_ratio = self.pool_max_queue_size as f64 / self.max_concurrency as f64;
385        let total_worker_heap_mb = self.nodejs_pool_max_threads * self.nodejs_worker_heap_mb;
386
387        tracing::info!(
388            max_concurrency = self.max_concurrency,
389            pool_max_connections = self.pool_max_connections,
390            pool_max_queue_size = self.pool_max_queue_size,
391            queue_timeout_ms = self.pool_queue_send_timeout_ms,
392            socket_max_connections = self.socket_max_connections,
393            socket_backlog = self.pool_socket_backlog,
394            nodejs_min_threads = self.nodejs_pool_min_threads,
395            nodejs_max_threads = self.nodejs_pool_max_threads,
396            nodejs_concurrent_tasks = self.nodejs_pool_concurrent_tasks,
397            nodejs_worker_heap_mb = self.nodejs_worker_heap_mb,
398            total_worker_heap_mb = total_worker_heap_mb,
399            tasks_per_thread = tasks_per_thread,
400            socket_multiplier = %format!("{:.2}x", socket_ratio),
401            queue_multiplier = %format!("{:.2}x", queue_ratio),
402            "Plugin configuration loaded (Rust + Node.js)"
403        );
404    }
405}
406
407impl Default for PluginConfig {
408    /// Default configuration uses the same derivation logic as from_env()
409    /// but without any environment variable overrides.
410    /// This ensures tests and production use consistent formulas.
411    fn default() -> Self {
412        // Use hardcoded defaults without reading environment variables
413        // Note: This differs from from_env() which reads env vars
414        let max_concurrency = DEFAULT_POOL_MAX_CONNECTIONS;
415        let cpu_count = std::thread::available_parallelism()
416            .map(|n| n.get())
417            .unwrap_or(4);
418
419        // Apply same formulas as from_env()
420        let pool_max_connections = max_concurrency;
421        let socket_max_connections = (max_concurrency as f64 * 1.5) as usize;
422        let pool_max_queue_size = max_concurrency * 2;
423
424        // Memory-aware thread scaling (same as from_env)
425        // Assume 16GB for default since we can't easily detect memory here
426        let assumed_memory_mb = 16384_u64;
427        let memory_budget_mb = assumed_memory_mb / 2;
428        let heap_per_worker_mb = 1024_u64; // ~1GB per worker
429        let memory_based_max_threads = (memory_budget_mb / heap_per_worker_mb).max(4) as usize;
430        let concurrency_based_threads = (max_concurrency / 200).max(cpu_count);
431
432        let nodejs_pool_max_threads = memory_based_max_threads
433            .min(concurrency_based_threads)
434            .clamp(DEFAULT_POOL_MAX_THREADS_FLOOR, 32);
435        let nodejs_pool_min_threads = DEFAULT_POOL_MIN_THREADS.max(cpu_count / 2);
436
437        let base_tasks = max_concurrency / nodejs_pool_max_threads.max(1);
438        let nodejs_pool_concurrent_tasks =
439            ((base_tasks as f64 * CONCURRENT_TASKS_HEADROOM_MULTIPLIER) as usize).clamp(
440                DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER,
441                MAX_CONCURRENT_TASKS_PER_WORKER,
442            );
443
444        // Worker heap for Default impl (same formula as from_env)
445        let base_worker_heap = 512_usize;
446        let heap_per_task = 5_usize;
447        let nodejs_worker_heap_mb =
448            (base_worker_heap + (nodejs_pool_concurrent_tasks * heap_per_task)).clamp(1024, 2048);
449
450        let default_backlog = DEFAULT_POOL_SOCKET_BACKLOG as usize;
451        let pool_socket_backlog = max_concurrency.max(default_backlog);
452
453        Self {
454            max_concurrency,
455            pool_max_connections,
456            pool_connect_retries: DEFAULT_POOL_CONNECT_RETRIES,
457            pool_request_timeout_secs: DEFAULT_POOL_REQUEST_TIMEOUT_SECS,
458            pool_max_queue_size,
459            pool_queue_send_timeout_ms: DEFAULT_POOL_QUEUE_SEND_TIMEOUT_MS,
460            pool_workers: 0,
461            socket_max_connections,
462            socket_idle_timeout_secs: DEFAULT_SOCKET_IDLE_TIMEOUT_SECS,
463            socket_read_timeout_secs: DEFAULT_SOCKET_READ_TIMEOUT_SECS,
464            nodejs_pool_min_threads,
465            nodejs_pool_max_threads,
466            nodejs_pool_concurrent_tasks,
467            nodejs_pool_idle_timeout_ms: DEFAULT_POOL_IDLE_TIMEOUT_MS,
468            nodejs_worker_heap_mb,
469            pool_socket_backlog,
470            health_check_interval_secs: DEFAULT_POOL_HEALTH_CHECK_INTERVAL_SECS,
471            trace_timeout_ms: DEFAULT_TRACE_TIMEOUT_MS,
472        }
473    }
474}
475
476/// Get the global plugin configuration (cached after first call)
477pub fn get_config() -> &'static PluginConfig {
478    CONFIG.get_or_init(|| {
479        let config = PluginConfig::from_env();
480        config.log_config();
481        config
482    })
483}
484
485/// Parse an environment variable or return default
486fn env_parse<T: std::str::FromStr>(name: &str, default: T) -> T {
487    std::env::var(name)
488        .ok()
489        .and_then(|s| s.parse().ok())
490        .unwrap_or(default)
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn test_default_config() {
499        let config = PluginConfig::default();
500        assert_eq!(config.max_concurrency, DEFAULT_POOL_MAX_CONNECTIONS);
501        assert_eq!(config.pool_max_connections, DEFAULT_POOL_MAX_CONNECTIONS);
502        // Validate derived ratios
503        assert_eq!(config.pool_max_queue_size, config.max_concurrency * 2);
504        assert!(
505            config.socket_max_connections >= config.pool_max_connections,
506            "socket connections should be >= pool connections"
507        );
508    }
509
510    #[test]
511    fn test_auto_derivation_ratios() {
512        // When max_concurrency is set, other values should be derived
513        let config = PluginConfig {
514            max_concurrency: 1000,
515            pool_max_connections: 1000,
516            socket_max_connections: 1500, // 1.5x
517            pool_max_queue_size: 2000,    // 2x
518            ..Default::default()
519        };
520
521        assert_eq!(
522            config.socket_max_connections,
523            config.max_concurrency * 3 / 2
524        );
525        assert_eq!(config.pool_max_queue_size, config.max_concurrency * 2);
526    }
527
528    #[test]
529    fn test_very_low_concurrency() {
530        // Test edge case: very low concurrency (10)
531        // We can't use from_env() in tests easily due to OnceLock caching,
532        // so we manually construct the config with the same logic
533        let max_concurrency = 10;
534        let cpu_count = std::thread::available_parallelism()
535            .map(|n| n.get())
536            .unwrap_or(4);
537
538        let pool_max_connections = max_concurrency;
539        let socket_max_connections = (max_concurrency as f64 * 1.5) as usize;
540        let pool_max_queue_size = max_concurrency * 2;
541
542        // New memory-aware formula (assuming 16GB)
543        let memory_budget_mb = 16384 / 2;
544        let memory_based_max = (memory_budget_mb / 1024).max(4);
545        let concurrency_based = (max_concurrency / 200).max(cpu_count);
546        let nodejs_pool_max_threads = memory_based_max
547            .min(concurrency_based)
548            .max(DEFAULT_POOL_MAX_THREADS_FLOOR)
549            .min(32);
550
551        assert_eq!(pool_max_connections, 10);
552        assert_eq!(socket_max_connections, 15); // 1.5x
553        assert_eq!(pool_max_queue_size, 20); // 2x
554
555        // Should still have reasonable thread count (warm pool)
556        assert!(nodejs_pool_max_threads >= DEFAULT_POOL_MAX_THREADS_FLOOR);
557    }
558
559    #[test]
560    fn test_medium_concurrency() {
561        // Test edge case: medium concurrency (1000)
562        let max_concurrency = 1000;
563        let cpu_count = std::thread::available_parallelism()
564            .map(|n| n.get())
565            .unwrap_or(4);
566
567        let socket_max_connections = (max_concurrency as f64 * 1.5) as usize;
568        let pool_max_queue_size = max_concurrency * 2;
569
570        // New memory-aware formula (assuming 16GB)
571        let memory_budget_mb = 16384 / 2;
572        let memory_based_max = (memory_budget_mb / 1024).max(4);
573        let concurrency_based = (max_concurrency / 200).max(cpu_count);
574        let nodejs_pool_max_threads = memory_based_max
575            .min(concurrency_based)
576            .max(DEFAULT_POOL_MAX_THREADS_FLOOR)
577            .min(32);
578
579        assert_eq!(socket_max_connections, 1500); // 1.5x
580        assert_eq!(pool_max_queue_size, 2000); // 2x
581
582        // With 16GB memory and 1000 concurrency:
583        // memory_based = 8, concurrency_based = max(5, cpu_count)
584        // Result should be reasonable (not 64!)
585        assert!(nodejs_pool_max_threads <= 16);
586    }
587
588    #[test]
589    fn test_high_concurrency() {
590        // Test edge case: high concurrency (10000)
591        // This simulates your load test scenario
592        let max_concurrency = 10000;
593
594        let socket_max_connections = (max_concurrency as f64 * 1.5) as usize;
595        let pool_max_queue_size = max_concurrency * 2;
596
597        let cpu_count = std::thread::available_parallelism()
598            .map(|n| n.get())
599            .unwrap_or(4);
600
601        // New memory-aware formula (assuming 16GB)
602        let memory_budget_mb = 16384 / 2;
603        let memory_based_max = (memory_budget_mb / 1024).max(4);
604        let concurrency_based = (max_concurrency / 200).max(cpu_count);
605        let nodejs_pool_max_threads = memory_based_max
606            .min(concurrency_based)
607            .max(DEFAULT_POOL_MAX_THREADS_FLOOR)
608            .min(32);
609
610        assert_eq!(socket_max_connections, 15000); // 1.5x
611        assert_eq!(pool_max_queue_size, 20000); // 2x
612
613        // With 16GB: memory_based=8, concurrency_based=50 -> result = 8
614        // Should NOT hit 64 threads anymore (memory-constrained)
615        assert!(nodejs_pool_max_threads <= 32);
616
617        // Concurrent tasks per worker
618        let base_tasks = max_concurrency / nodejs_pool_max_threads;
619        let derived_concurrent_tasks = ((base_tasks as f64 * CONCURRENT_TASKS_HEADROOM_MULTIPLIER)
620            as usize)
621            .max(DEFAULT_POOL_CONCURRENT_TASKS_PER_WORKER)
622            .min(MAX_CONCURRENT_TASKS_PER_WORKER);
623        // Should be capped at MAX_CONCURRENT_TASKS_PER_WORKER
624        assert!(derived_concurrent_tasks <= MAX_CONCURRENT_TASKS_PER_WORKER);
625    }
626
627    #[test]
628    fn test_validation_catches_invalid_config() {
629        let mut config = PluginConfig::default();
630
631        // Test that validation catches pool > socket connections
632        config.pool_max_connections = 1000;
633        config.socket_max_connections = 500;
634
635        let result = std::panic::catch_unwind(|| {
636            config.validate();
637        });
638        assert!(
639            result.is_err(),
640            "Should panic on invalid pool > socket connections"
641        );
642    }
643
644    #[test]
645    fn test_validation_catches_invalid_threads() {
646        let mut config = PluginConfig::default();
647
648        // Test that validation catches min > max threads
649        config.nodejs_pool_min_threads = 64;
650        config.nodejs_pool_max_threads = 8;
651
652        let result = std::panic::catch_unwind(|| {
653            config.validate();
654        });
655        assert!(result.is_err(), "Should panic on invalid min > max threads");
656    }
657
658    #[test]
659    fn test_overridden_values_respected() {
660        // Test that individual overrides work
661        // Note: Due to OnceLock caching in get_config(), we test the derivation logic directly
662        let max_concurrency = 1000;
663        let pool_max_queue_size = 5000; // What we'd override to
664        let pool_max_connections = 1000; // Auto-derived from max_concurrency
665
666        // Verify the override would be respected
667        assert_eq!(pool_max_connections, max_concurrency); // Auto-derived
668        assert_eq!(pool_max_queue_size, 5000); // Manual override (not 2000)
669
670        // Also test that auto-derivation would have given 2000
671        let auto_derived_queue = max_concurrency * 2;
672        assert_eq!(auto_derived_queue, 2000);
673        assert_ne!(pool_max_queue_size, auto_derived_queue); // Override is different
674    }
675}