File size: 20,058 Bytes
3270181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
"""

Memory management utilities for the text2sql agent.

"""
import gc
import os
import time
import psutil
import threading
import weakref
from contextlib import contextmanager
from typing import Optional, Callable, Dict, Any

class MemoryManager:
    """Memory manager for monitoring and managing system memory."""
    
    def __init__(self, threshold_mb: float = 1000.0, critical_mb: float = 300.0,

                 check_interval: float = 0.5, monitor_active: bool = True):
        """Initialize the memory manager.



        Args:

            threshold_mb: Memory threshold in MB below which to take action

            critical_mb: Critical memory level in MB below which to abort operations

            check_interval: Interval in seconds for background memory checks

            monitor_active: Whether to start the background monitor thread

        """
        # Allow environment overrides to tune aggressiveness
        try:
            _env_threshold = os.environ.get("MEM_THRESHOLD_MB")
            _env_critical = os.environ.get("MEM_CRITICAL_MB")
            if _env_threshold: threshold_mb = float(_env_threshold)
            if _env_critical: critical_mb = float(_env_critical)
        except Exception:
            pass
        self.threshold_mb = threshold_mb
        self.critical_mb = critical_mb
        self.check_interval = check_interval
        self._lock = threading.RLock()
        self._monitor_thread = None
        self._keep_monitoring = False

        # Track instances with weak references when possible to avoid holding
        # strong references that prevent GC. Keep a fallback list for objects
        # that are not weak-referenceable.
        self._active_llm_refs = []  # list of weakref.ref where possible
        self._active_llm_strong = []  # fallback strong refs
        self._instance_count = 0

        # Soft cap to avoid accidentally registering many parallel LLMs
        self.max_instances = 1

        # Margin (MB) above critical required to allow new registrations
        self.registration_margin_mb = 200.0

        # Track last emergency cleanup time to avoid repeated rapid-fire releases
        self._last_emergency = 0.0
        self._emergency_cooldown = 10.0  # seconds between emergency attempts
        # Counter-based suspension flag to prevent emergency releases while in critical sections
        self._suspend_emergency_depth = 0

        # Minimum available memory (MB) at which we will STILL retain a single LLM instance
        # even if below critical, to avoid thrashing (re-init cost is high). Configurable.
        try:
            self.min_retain_mb = float(os.environ.get("MIN_RETAIN_LLM_MB", "250"))
        except Exception:
            self.min_retain_mb = 250.0

        # Start memory monitor if requested
        if monitor_active:
            self.start_monitor()

    def _prune_active_instances(self):
        """Remove dead weakrefs and return list of live instances."""
        live = []
        # Prune weak refs
        for ref in list(self._active_llm_refs):
            inst = ref()
            if inst is None:
                try:
                    self._active_llm_refs.remove(ref)
                except ValueError:
                    pass
            else:
                live.append(inst)
        # Add strong refs
        for inst in list(self._active_llm_strong):
            if inst is not None:
                live.append(inst)
        return live
    
    def register_llm_instance(self, llm_instance):
        """Register an LLM instance for tracking.



        This will refuse registration (and attempt to release the provided

        instance) if the system memory is too low. It prefers weak references

        so that the manager itself does not keep objects alive.

        """
        with self._lock:
            # Prune dead weakrefs first
            self._prune_active_instances()

            stats = self.get_memory_stats()
            # Relax policy: always allow the FIRST instance as long as we are above critical.
            # Only enforce margin for additional instances (even though max_instances is 1 today).
            if self._instance_count == 0:
                if stats['available_mb'] < self.critical_mb:
                    # Too dangerous even for first instance
                    try:
                        if hasattr(llm_instance, 'close'):
                            llm_instance.close()
                        elif hasattr(llm_instance, 'release'):
                            llm_instance.release()
                    except Exception:
                        pass
                    raise MemoryError(f"Refusing first LLM registration: below critical ({stats['available_mb']:.1f}MB < {self.critical_mb}MB)")
            else:
                # For subsequent instances keep the stricter margin
                if stats['available_mb'] < (self.critical_mb + self.registration_margin_mb):
                    try:
                        if hasattr(llm_instance, 'close'):
                            llm_instance.close()
                        elif hasattr(llm_instance, 'release'):
                            llm_instance.release()
                    except Exception:
                        pass
                    raise MemoryError(f"Refusing to register additional LLM (avail={stats['available_mb']:.1f}MB; need >= {self.critical_mb + self.registration_margin_mb:.1f}MB)")

            # Enforce max instances cap
            if self._instance_count >= self.max_instances:
                raise MemoryError(f"Refusing to register LLM instance: max_instances={self.max_instances} reached")

            # Try to create a weakref to avoid holding a strong reference
            try:
                ref = weakref.ref(llm_instance, lambda r: None)
                self._active_llm_refs.append(ref)
            except TypeError:
                # Object not weak-referenceable; store strong ref but warn
                self._active_llm_strong.append(llm_instance)

            self._instance_count += 1
            print(f"LLM instance registered (total: {self._instance_count})")
    
    def unregister_llm_instance(self, llm_instance):
        """Unregister an LLM instance from tracking."""
        with self._lock:
            # Remove from weak refs
            removed = False
            for ref in list(self._active_llm_refs):
                inst = ref()
                if inst is None:
                    # Dead ref; prune
                    try:
                        self._active_llm_refs.remove(ref)
                    except ValueError:
                        pass
                    continue
                if inst is llm_instance:
                    try:
                        self._active_llm_refs.remove(ref)
                    except ValueError:
                        pass
                    removed = True
                    break

            # Remove from strong refs
            if not removed:
                for i, instance in enumerate(list(self._active_llm_strong)):
                    if instance is llm_instance:
                        del self._active_llm_strong[i]
                        removed = True
                        break

            if removed:
                self._instance_count = max(0, self._instance_count - 1)
                print(f"LLM instance unregistered (remaining: {self._instance_count})")
    
    def get_memory_stats(self) -> Dict[str, float]:
        """Get current memory statistics."""
        vm = psutil.virtual_memory()
        return {
            "total_mb": vm.total / (1024 * 1024),
            "available_mb": vm.available / (1024 * 1024),
            "used_mb": vm.used / (1024 * 1024),
            "percent_used": vm.percent
        }
    
    def is_memory_safe(self) -> bool:
        """Check if memory is at a safe level."""
        return self.get_memory_stats()["available_mb"] >= self.threshold_mb
    
    def is_memory_critical(self) -> bool:
        """Check if memory is at a critical level."""
        stats = self.get_memory_stats()
        available = stats["available_mb"]
        is_critical = available <= self.critical_mb
        
        # Debug logging for memory state
        if is_critical:
            print(f"🚨 [MEMORY_DEBUG] Critical state: {available:.1f}MB <= {self.critical_mb}MB threshold")
        
        return is_critical
    
    def force_collect_garbage(self):
        """Force aggressive garbage collection."""
        # Run multiple collection cycles
        for i in range(3):
            gc.collect(i)
        
        # Clear any reference cycles
        if hasattr(gc, 'freeze'):
            gc.freeze()
        
        # Clear module caches if possible
        if hasattr(gc, 'clear_caches'):
            gc.clear_caches()
            
        # Log memory status after cleanup
        stats = self.get_memory_stats()
        #print(f"After garbage collection: {stats['available_mb']:.2f}MB available ({stats['percent_used']}% used)")
    
    def _monitor_memory(self):
        """Background thread for memory monitoring."""
        while self._keep_monitoring:
            stats = self.get_memory_stats()
            
            # Check for critical memory conditions
            if stats["available_mb"] <= self.critical_mb:
                #print(f"⚠️ CRITICAL MEMORY WARNING: Only {stats['available_mb']:.2f}MB available!")
                self.force_collect_garbage()
                
                # If still critical after cleanup, take more drastic measures
                if self.is_memory_critical():
                    now = time.time()
                    # Only attempt emergency release if cooldown passed
                    if now - self._last_emergency > self._emergency_cooldown:
                        # Check if we have tracked instances (prune dead refs first)
                        live = self._prune_active_instances()
                        if live:
                            # If only a single LLM instance and we still have above min_retain_mb, skip releasing to prevent constant re-init.
                            if len(live) == 1 and stats["available_mb"] > self.min_retain_mb:
                                print(f"⚠️ Critical memory but retaining single LLM (avail={stats['available_mb']:.1f}MB > {self.min_retain_mb}MB safeguard)")
                            else:
                                # Skip emergency release while suspended
                                if self._suspend_emergency_depth > 0:
                                    print(f"🚫 Emergency release suppressed (depth={self._suspend_emergency_depth}) during critical section")
                                else:
                                    self._emergency_release_llm_instances()
                        else:
                            # Log once per cooldown when no instances are registered
                            print("🚨 Emergency memory state detected but no LLM instances registered to release")
                        self._last_emergency = now
                    else:
                        # Cooldown active; skip repeated emergency attempts
                        pass
            
            # Regular check for low memory
            elif stats["available_mb"] <= self.threshold_mb:
                #print(f"⚠️ Low memory warning: {stats['available_mb']:.2f}MB available")
                self.force_collect_garbage()
            
            # Sleep for the check interval
            time.sleep(self.check_interval)
    
    def _emergency_release_llm_instances(self):
        """Emergency release of LLM instances to free memory."""
        with self._lock:
            # Build list of live instances from weak and strong refs
            instances = self._prune_active_instances()
            print(f"Attempting to release {len(instances)} LLM instances")

            for llm in instances:
                try:
                    # Try to close or release resources
                    if hasattr(llm, 'close'):
                        llm.close()
                    elif hasattr(llm, 'release'):
                        llm.release()
                    
                    # Set to None to break references
                    if hasattr(llm, '_client'):
                        llm._client = None
                    if hasattr(llm, '_model'):
                        llm._model = None
                except Exception as e:
                    print(f"Error releasing LLM instance: {str(e)}")
            
            # Clear our tracking lists
            self._active_llm_refs.clear()
            self._active_llm_strong.clear()
            self._instance_count = 0

            # Force garbage collection after cleanup and attempt to return pages
            self.force_collect_garbage()
            try:
                # Best-effort working set trim to return pages to OS (Windows)
                self.trim_working_set()
            except Exception:
                pass

    # ---------------- Public high-level cleanup APIs -----------------
    def release_all_llms(self):
        """Public wrapper to release all tracked LLM instances (non-emergency manual call)."""
        self._emergency_release_llm_instances()

    def trim_working_set(self):
        """Attempt to return unused pages to OS (best-effort, platform-specific)."""
        try:
            import platform, ctypes
            if platform.system().lower() == 'windows':
                PROCESS_SET_QUOTA = 0x0100
                PROCESS_QUERY_INFORMATION = 0x0400
                kernel32 = ctypes.windll.kernel32
                psapi = ctypes.windll.psapi
                GetCurrentProcess = kernel32.GetCurrentProcess
                hProc = GetCurrentProcess()
                psapi.EmptyWorkingSet(hProc)
            else:
                # For *nix, reading /proc/self might encourage trimming after gc
                pass
        except Exception as e:
            print(f"Working set trim not supported/failed: {e}")

    def perform_full_cleanup(self) -> Dict[str, float]:
        """Comprehensive cleanup: release LLMs, GC, trim working set, return new stats."""
        before = self.get_memory_stats()
        self.release_all_llms()
        self.force_collect_garbage()
        self.trim_working_set()
        after = self.get_memory_stats()
        delta = after['available_mb'] - before['available_mb']
        print(f"Post-query full cleanup reclaimed {delta:.2f}MB (avail: {after['available_mb']:.2f}MB)")
        return after

    # --------------- Critical section helpers ---------------
    @contextmanager
    def suspend_emergency(self, reason: str = ""):
        """Context manager to temporarily suspend emergency LLM releases.



        Use around critical sections (e.g., active LLM generation) to prevent

        the monitor thread from tearing down the only LLM mid-call, which can

        cause intermittent generation failures.

        """
        with self._lock:
            self._suspend_emergency_depth += 1
            if reason:
                print(f"⏸️ Suspended emergency releases (depth={self._suspend_emergency_depth}) reason={reason}")
        try:
            yield
        finally:
            with self._lock:
                self._suspend_emergency_depth = max(0, self._suspend_emergency_depth - 1)
                if reason:
                    print(f"▶️ Resumed emergency releases (depth={self._suspend_emergency_depth}) reason={reason}")
    
    def start_monitor(self):
        """Start the background memory monitor."""
        with self._lock:
            if self._monitor_thread is None or not self._monitor_thread.is_alive():
                self._keep_monitoring = True
                self._monitor_thread = threading.Thread(
                    target=self._monitor_memory,
                    daemon=True,
                    name="MemoryMonitorThread"
                )
                self._monitor_thread.start()
                print("Memory monitor started in background")
    
    def stop_monitor(self):
        """Stop the background memory monitor."""
        with self._lock:
            self._keep_monitoring = False
            if self._monitor_thread and self._monitor_thread.is_alive():
                self._monitor_thread.join(timeout=1.0)
                print("Memory monitor stopped")

# Create a global memory manager instance
memory_manager = MemoryManager(
    threshold_mb=500.0,   # 500MB — warning/action threshold (reduced from 1000MB)
    critical_mb=300.0,    # 300MB — BSOD-protection hard floor (unchanged)
    check_interval=1.0,   # Check every second
    monitor_active=True   # Start monitoring immediately
)

class TimeoutManager:
    """Manages operation timeouts with proper cleanup."""
    
    @staticmethod
    def run_with_timeout(func: Callable, timeout: float, *args, **kwargs) -> Any:
        """

        Run a function with a timeout.

        

        Args:

            func: Function to run

            timeout: Timeout in seconds

            *args, **kwargs: Arguments to pass to the function

            

        Returns:

            The result of the function

            

        Raises:

            TimeoutError: If the function times out

        """
        result = None
        exception = None
        completed = threading.Event()
        
        # Thread function
        def worker():
            nonlocal result, exception
            try:
                result = func(*args, **kwargs)
            except Exception as e:
                exception = e
            finally:
                completed.set()
        
        # Start the worker thread
        thread = threading.Thread(target=worker, daemon=True)
        thread.start()
        
        # Wait for completion or timeout
        if not completed.wait(timeout):
            # Force garbage collection before raising timeout
            memory_manager.force_collect_garbage()
            
            # Raise timeout error
            raise TimeoutError(f"Operation timed out after {timeout} seconds")
        
        # If there was an exception, raise it
        if exception is not None:
            raise exception
        
        return result

def safe_check_memory(threshold_mb: Optional[float] = None) -> bool:
    """

    Safely check if there's enough memory available.

    Also performs garbage collection if memory is low.

    

    Args:

        threshold_mb: Optional threshold in MB (uses memory manager's threshold if None)

        

    Returns:

        True if memory is safe, False if it's below threshold

    """
    # Use memory manager's threshold if none provided
    if threshold_mb is None:
        threshold_mb = memory_manager.threshold_mb
    
    # Get current memory stats
    stats = memory_manager.get_memory_stats()
    
    # Log memory status
    print(f"Memory status: {stats['available_mb']:.2f}MB available of {stats['total_mb']:.2f}MB total")
    
    # If memory is below threshold, run garbage collection
    if stats['available_mb'] < threshold_mb:
        memory_manager.force_collect_garbage()
        
        # Get updated stats after garbage collection
        stats = memory_manager.get_memory_stats()
        
        # If still below threshold, return False
        if stats['available_mb'] < threshold_mb:
            print(f"⚠️ Low memory warning: Only {stats['available_mb']:.2f}MB available, need at least {threshold_mb:.2f}MB")
            return False
    
    return True