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