aetherscan 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,781 @@
1
+ # BUG: sometimes cleanup_all hangs (is it cuz of slack logger)?
2
+ """
3
+ Resource manager for Aetherscan Pipeline
4
+ Centralizes orchestration of all system resources -- including multiprocessing pools, shared memory,
5
+ and background threads (e.g. database, monitor, logger)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import atexit
11
+ import contextlib
12
+ import logging
13
+ import multiprocessing
14
+ import os
15
+ import signal
16
+ import sys
17
+ import threading
18
+ import time
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass
21
+ from multiprocessing import Pool
22
+ from multiprocessing.shared_memory import SharedMemory
23
+
24
+ import psutil
25
+
26
+ from aetherscan.config import get_config
27
+ from aetherscan.logger import get_logger
28
+ from aetherscan.monitor import get_process_tree_stats
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ @dataclass
34
+ class ResourceStats:
35
+ """Statistics about managed resources"""
36
+
37
+ pools_active: int = 0
38
+ pools_closed: int = 0
39
+ processes_active: int = 0
40
+ processes_closed: int = 0
41
+ shared_memories_active: int = 0
42
+ shared_memories_cleaned: int = 0
43
+ total_memory_freed_gb: float = 0.0
44
+ cleanup_time_seconds: float = 0.0
45
+
46
+
47
+ def _reap_process_subtree(pid: int | None) -> None:
48
+ """Best-effort kill of every live descendant of `pid`. A process that is SIGKILLed (or
49
+ crashed before its own SIGTERM handler ran) gets no chance to reap its children (e.g. the
50
+ producer's pool workers), which would otherwise survive as orphans. Descendants of a
51
+ process that already died have reparented to init and can no longer be found through its
52
+ PID — the producer's own parent-death watch (round_data._producer_main) covers those."""
53
+ if pid is None:
54
+ return
55
+ with contextlib.suppress(Exception):
56
+ for child in psutil.Process(pid).children(recursive=True):
57
+ with contextlib.suppress(psutil.NoSuchProcess):
58
+ child.kill()
59
+
60
+
61
+ @dataclass
62
+ class ManagedProcess:
63
+ """Wrapper for a tracked multiprocessing.Process (e.g. the RoundDataProducer)"""
64
+
65
+ process: multiprocessing.Process
66
+ name: str
67
+ created_at: float
68
+ closed: bool = False
69
+
70
+ def close(self, timeout):
71
+ """Close the process with terminate -> join -> kill escalation (mirrors ManagedPool.close)"""
72
+ if self.closed:
73
+ return
74
+
75
+ try:
76
+ if self.process.is_alive():
77
+ logger.info(f"Terminating process '{self.name}' (PID {self.process.pid})")
78
+
79
+ # SIGTERM first: the process's handler gets a chance to clean up (e.g. the
80
+ # producer terminates its own worker pool before dying)
81
+ self.process.terminate()
82
+ self.process.join(timeout)
83
+
84
+ if self.process.is_alive():
85
+ logger.warning(f"Process '{self.name}' survived SIGTERM, escalating to SIGKILL")
86
+ # SIGKILL gives the process no chance to reap its own children (e.g. the
87
+ # producer's pool workers), so kill any survivors in its subtree first —
88
+ # otherwise they'd be orphaned and keep running
89
+ _reap_process_subtree(self.process.pid)
90
+ self.process.kill()
91
+ self.process.join(timeout)
92
+
93
+ if self.process.is_alive():
94
+ # Surviving SIGKILL is extremely rare (uninterruptible sleep state 'D').
95
+ # Log and proceed — the OS will clean up on exit
96
+ logger.error(
97
+ f"Process '{self.name}' survived SIGKILL (uninterruptible state?)"
98
+ )
99
+ else:
100
+ # Already dead on entry (e.g. it crashed before its own SIGTERM handler could
101
+ # terminate its pool) — the cleanup-ordering rationale ("processes before
102
+ # SHM") assumes the subtree is gone, so reap any children still findable
103
+ # rather than silently marking closed
104
+ _reap_process_subtree(self.process.pid)
105
+
106
+ self.closed = True
107
+ logger.info(f"Process '{self.name}' closed")
108
+
109
+ except Exception as e:
110
+ logger.warning(f"Error terminating process '{self.name}': {e}")
111
+ # Same subtree sweep as the normal escalation path: a bare kill() here would
112
+ # orphan the process's children
113
+ with contextlib.suppress(Exception):
114
+ _reap_process_subtree(self.process.pid)
115
+ self.process.kill()
116
+ self.closed = True
117
+
118
+
119
+ @dataclass
120
+ class ManagedPool:
121
+ """Wrapper for tracked multiprocessing Pool"""
122
+
123
+ pool: Pool
124
+ name: str
125
+ created_at: float
126
+ process_count: int
127
+ closed: bool = False
128
+
129
+ def close(self, timeout):
130
+ """Close the pool with terminate-or-fail policy"""
131
+ if self.closed:
132
+ return
133
+
134
+ try:
135
+ logger.info(f"Terminating pool '{self.name}'")
136
+
137
+ # NOTE: does terminating instead of closing lead to any issues with corrupted db writes?
138
+ # Forcefully kill all running processes & clear internal job queues with pool.terminate()
139
+ # Then, wait for parent to finish handling dead processes, and exit & close the pool
140
+ # with pool.join()
141
+ # Note, less forceful alternative is to use self.pool.close()
142
+ # Which stops accepting new jobs, but lets running processes finish current job queue
143
+ # Don't use both! Better to "terminate or fail". Inconsistent states are worse than leaks
144
+ # We wrap both terminate() and join() in daemon threads with timeout as a defensive
145
+ # measure against blocking on exit or hanging indefinitely
146
+ # If the thread timeout is exceeded, force-kill surviving workers
147
+
148
+ # Send SIGTERM to workers
149
+ terminate_thread = threading.Thread(target=self.pool.terminate, daemon=True)
150
+ terminate_thread.start()
151
+ terminate_thread.join(timeout=timeout)
152
+
153
+ if terminate_thread.is_alive():
154
+ logger.warning(f"Pool '{self.name}' terminate() timed out, escalating to SIGKILL")
155
+ self._force_kill_workers()
156
+ # NOTE: 0.1 seems arbitrary & can potentially add latency to shutdown. is there a more precise wall we can use?
157
+ time.sleep(0.1) # Brief wait for SIGKILL to take effect
158
+
159
+ # Join to reap processes & clean up Pool internals
160
+ # Should be fast if workers are dead (either from SIGTERM or SIGKILL)
161
+ join_thread = threading.Thread(target=self.pool.join, daemon=True)
162
+ join_thread.start()
163
+ join_thread.join(timeout=timeout)
164
+
165
+ if join_thread.is_alive():
166
+ # join() hanging after workers should be dead indicates Pool internal thread issues
167
+ # (feeder thread, result handler) - SIGKILL on workers won't help here, so just log
168
+ # a warning & move on
169
+ logger.warning(
170
+ f"Pool '{self.name}' join() timed out (Pool internal threads may be stuck)"
171
+ )
172
+
173
+ # Verify workers are gone
174
+ if self._check_alive():
175
+ logger.warning(f"Pool '{self.name}' workers still alive, forcing SIGKILL")
176
+ self._force_kill_workers()
177
+ # NOTE: 0.1 seems arbitrary & can potentially add latency to shutdown. is there a more precise wall we can use?
178
+ time.sleep(0.1) # Brief wait for SIGKILL to take effect
179
+
180
+ # Verify again after SIGKILL
181
+ if self._check_alive():
182
+ # Workers surviving SIGKILL is extremely rare (only possible with
183
+ # uninterruptible sleep state 'D'). Log and proceed -- OS will clean up on exit
184
+ # NOTE: will self.closed = True on repeated failure lead to downstream issues?
185
+ logger.error(
186
+ f"Pool '{self.name}' workers survived SIGKILL (uninterruptible state?)"
187
+ )
188
+
189
+ self.closed = True
190
+ logger.info(f"Pool '{self.name}' closed ({self.process_count} processes)")
191
+
192
+ except Exception as e:
193
+ logger.warning(f"Error terminating pool '{self.name}': {e}")
194
+ # Still try to force-kill on error, If it fails, let the OS clean up on exit
195
+ with contextlib.suppress(Exception):
196
+ self._force_kill_workers()
197
+ # NOTE: 0.1 seems arbitrary & can potentially add latency to shutdown. is there a more precise wall we can use?
198
+ time.sleep(0.1) # Brief wait for SIGKILL to take effect
199
+ # NOTE: should we add _check_alive() & error logging after failed SIGKILL?
200
+ # NOTE: will self.closed = True on repeated failure lead to downstream issues?
201
+ self.closed = True
202
+
203
+ def _check_alive(self):
204
+ """Check if any worker processes are still alive"""
205
+ try:
206
+ return any(p.is_alive() for p in getattr(self.pool, "_pool", []))
207
+ except Exception as e:
208
+ logger.info(f"_check_alive(): unable to inspect pool '{self.name}': {e}")
209
+ logger.info(f"Assuming pool '{self.name}' is gone and no remaining workers alive")
210
+ # NOTE: is there a more precise way to differentiate pool already destroyed vs internal state corrupted?
211
+ return False # pool already destroyed or internal state corrupted
212
+
213
+ def _force_kill_workers(self):
214
+ """Force-kill all worker processes with SIGKILL"""
215
+ killed_any = False
216
+
217
+ # Method 1: Via pool._pool attribute
218
+ try:
219
+ for worker in getattr(self.pool, "_pool", []):
220
+ if worker.is_alive():
221
+ pid = worker.pid
222
+ try:
223
+ process = psutil.Process(pid)
224
+ process.kill() # SIGKILL - cannot be ignored
225
+ logger.info(f"Force-killed worker PID {pid}")
226
+ killed_any = True
227
+ except psutil.NoSuchProcess:
228
+ pass # Already dead
229
+ except Exception as e:
230
+ logger.warning(f"Failed to kill worker PID {pid}: {e}")
231
+ except Exception as e:
232
+ logger.warning(f"Error accessing pool._pool: {e}")
233
+
234
+ # Method 2: Fallback - find & kill all child processes via psutil
235
+ # Useful for when getattr(self.pool, "_pool", []) raises an Exception, of if _pool is
236
+ # in an inconsistent state during cleanup
237
+ # Note, method 2 kills ALL child processes, not just the workers belonging to a specific
238
+ # Pool. This is acceptable in our current implementation, since pool closures either
239
+ # happen sequentially (PreProc Pool is opened & closed before DataGen, etc.), or
240
+ # indiscriminately during cleanup (atexit, SIGINT, SIGTERM, etc.).
241
+ # However, in the case where we have multiple long-lived pools or workers, and SIGTERM
242
+ # is escalated to SIGKILL, and pool._pool attribution fails, that innocent workers may
243
+ # be killed by accident. If this becomes an issue in the future, simply comment out
244
+ # method 2 and let the OS clean up on process exit
245
+ # Note, could alternatively try tracking worker PIDs by Pool proactively, though this
246
+ # requires architectural changes that aren't worth the time rn
247
+
248
+ # logger.info("Attempting fallback: killing ALL child processes directly")
249
+ # try:
250
+ # current = psutil.Process(os.getpid())
251
+ # children = current.children(recursive=True)
252
+ #
253
+ # if not children:
254
+ # logger.info("No child processes found")
255
+ # return
256
+ #
257
+ # for child in children:
258
+ # try:
259
+ # if child.is_running():
260
+ # child.kill()
261
+ # logger.info(f"Force-killed child process PID {child.pid}")
262
+ # killed_any = True
263
+ # except psutil.NoSuchProcess:
264
+ # pass # Already terminated
265
+ # except Exception as child_error:
266
+ # logger.warning(f"Failed to kill child PID {child.pid}: {child_error}")
267
+ # except Exception as fallback_error:
268
+ # logger.error(f"Fallback force-kill failed: {fallback_error}")
269
+
270
+ if not killed_any:
271
+ logger.info("No workers force-killed")
272
+
273
+
274
+ @dataclass
275
+ class ManagedSharedMemory:
276
+ """Wrapper for tracked shared memory"""
277
+
278
+ shm: SharedMemory
279
+ name: str # NOTE: this should just be self.shm.name?
280
+ created_at: float
281
+ size_gb: float
282
+ closed: bool = False
283
+
284
+ def close(self):
285
+ """Close and unlink shared memory"""
286
+ if self.closed:
287
+ return
288
+
289
+ try:
290
+ logger.info(f"Closing shared memory '{self.shm.name}'")
291
+ # Detach current process's reference to shared memory object
292
+ # Importantly, shm.close() doesn't remove the shared memory from the system
293
+ # It just closes the file descriptor / memory mapping in the current process
294
+ # Other processes with references to this shared memory can continue using it
295
+ self.shm.close()
296
+ except Exception as e:
297
+ logger.warning(f"Error closing '{self.shm.name}': {e}")
298
+
299
+ try:
300
+ logger.info(f"Unlinking shared memory '{self.shm.name}'")
301
+ # Remove shared memory object from system namespace
302
+ # Once all processes close their handles, the OS reclaims the memory
303
+ self.shm.unlink()
304
+ except FileNotFoundError:
305
+ pass # Already unlinked by another process
306
+ except Exception as e:
307
+ logger.warning(f"Error unlinking '{self.shm.name}': {e}")
308
+
309
+ # Verify after attempting both close() and unlink()
310
+ if not self._check_unlinked():
311
+ raise RuntimeError(f"Shared memory '{self.shm.name}' still exists after cleanup")
312
+
313
+ self.closed = True
314
+ logger.info(f"Shared memory '{self.shm.name}' cleaned ({self.size_gb:.2f} GB)")
315
+
316
+ def _check_unlinked(self):
317
+ """Check if shared memory can still be reattached to by name"""
318
+ try:
319
+ test_shm = SharedMemory(name=self.name)
320
+ test_shm.close()
321
+ return False # Reattached successfully, shared memory object still exists
322
+ except FileNotFoundError:
323
+ return True # Successfully unlinked
324
+ except Exception as e:
325
+ logger.info(f"_check_unlinked(): unable to inspect shared memory'{self.name}': {e}")
326
+ logger.info(f"Assuming shared memory '{self.name}' didn't close properly")
327
+ return False # Assume unexpected failure = close() didn't run properly
328
+
329
+
330
+ class ResourceManager:
331
+ """
332
+ Resource manager for centralized tracking & cleanup
333
+ Handles multiprocessing pools, shared memory, and background threads
334
+ """
335
+
336
+ _instance = None # Stores singleton instance
337
+ _lock = threading.Lock() # Ensures thread safety on object initialization
338
+
339
+ # __new__ allocates the object in memory (constructor at the object-creation level)
340
+ # __init__ initializes the object's attributes after it's created
341
+ # since __new__ is called before __init__ every time we instantiate a class,
342
+ # by overriding __new__, we can short-circuit object creation entirely, and control whether a
343
+ # new instance is created, or just return the existing instance
344
+ def __new__(cls):
345
+ # Double-checked locking pattern:
346
+ # First check if _instance is None, without lock (for performance)
347
+ if cls._instance is None:
348
+ # If None, acquire the lock to serialize the initialization path,
349
+ # preventing race conditions (2 threads violating singleton semantics)
350
+ with cls._lock:
351
+ # Check if _instance is None again inside the lock
352
+ # (since multiple threads can be calling simultaneously)
353
+ if cls._instance is None:
354
+ # If still None, only then we construct the singleton instance
355
+ cls._instance = super().__new__(cls)
356
+ cls._instance._initialized = False # Mark as not initialized (for __init__)
357
+ # Return the same instance for all subsequent constructor calls
358
+ return cls._instance
359
+
360
+ def __init__(self):
361
+ """Initialize manager"""
362
+ # Note, __init__ is triggered every time the class's constructor is called,
363
+ # even if __new__ returned the existing singleton instance
364
+ # Hence, we use the _initialized flag to make sure __init__ only runs once
365
+ if self._initialized:
366
+ return
367
+
368
+ self._initialized = True
369
+ self.config = get_config()
370
+ if self.config is None:
371
+ raise ValueError("get_config() returned None")
372
+
373
+ self._main_process_pid = os.getpid()
374
+ self._cleanup_executed = False
375
+ self._cleanup_lock = threading.Lock()
376
+ # Guards the signal handler against re-entrancy: a second Ctrl-C/SIGTERM while the
377
+ # first is still running cleanup_all() must not re-enter it (see _signal_handler).
378
+ self._shutdown_initiated = False
379
+
380
+ # NOTE: should these be strong or weak references? import weakref ...
381
+ # Track resources
382
+ self._pools: list[ManagedPool] = []
383
+ self._processes: list[ManagedProcess] = []
384
+ self._shared_memories: list[ManagedSharedMemory] = []
385
+
386
+ # Track threads
387
+ self._db = None
388
+ self._logger = None
389
+ self._monitor = None
390
+
391
+ # Track statistics
392
+ self.stats = ResourceStats()
393
+
394
+ # Register cleanup handlers
395
+ self._register_cleanup_handlers()
396
+
397
+ logger.info(f"ResourceManager initialized (PID: {self._main_process_pid})")
398
+ logger.info(f" Pools active: {self.stats.pools_active}")
399
+ logger.info(f" Shared memories active: {self.stats.shared_memories_active}")
400
+
401
+ @classmethod
402
+ def _reset(cls):
403
+ """
404
+ Teardown hook for thread-safe singleton
405
+ Resets the manager instance to None
406
+
407
+ WARNING: Only use for testing or restarting the application
408
+ Calling this while the manager is active will cause issues.
409
+ Do NOT call this method unless you know what you're doing
410
+ """
411
+ # Acquire lock to prevent race conditions
412
+ with cls._lock:
413
+ # Discard the singleton instance by removing the global reference
414
+ # Guarantees the next constructor call will produce a fresh instance
415
+ # Note, resources held by the old instance will remain alive unless explicitly closed beforehand
416
+ cls._instance = None
417
+ logger.info("Manager singleton instance reset")
418
+ logger.info(
419
+ "Note that resource cleanup has not been triggered, and will still run as expected on exit."
420
+ )
421
+
422
+ def _register_cleanup_handlers(self):
423
+ """Register atexit and signal handlers for cleanup"""
424
+ # Register cleanup handler to fire on system exit
425
+ atexit.register(self.cleanup_all)
426
+
427
+ # Register signal handlers to fire on interruptions and terminations
428
+ signal.signal(signal.SIGINT, self._signal_handler) # Ctrl+C
429
+ signal.signal(signal.SIGTERM, self._signal_handler) # kill/docker stop
430
+
431
+ logger.info("Cleanup handlers registered")
432
+
433
+ def _signal_handler(self, signum, frame):
434
+ """Handle SIGINT and SIGTERM.
435
+
436
+ Signals are delivered to the main thread and interrupt it wherever it is, so a
437
+ second signal arriving while the first is still inside cleanup_all() would re-enter
438
+ this handler on the same thread. cleanup_all() holds the non-reentrant
439
+ _cleanup_lock, so re-acquiring it from the same thread self-deadlocks — the process
440
+ then hangs until SIGKILL. To make repeated Ctrl-C behave predictably instead:
441
+
442
+ - First Ctrl-C / SIGTERM: run graceful cleanup once, then exit.
443
+ - Second: skip the (possibly still-running) cleanup and hard-exit immediately, and
444
+ reset both handlers to SIG_IGN so any further Ctrl-C is ignored rather than
445
+ re-triggering anything.
446
+ """
447
+ # Ignore signals from worker processes entirely
448
+ if os.getpid() != self._main_process_pid:
449
+ return
450
+
451
+ if self._shutdown_initiated:
452
+ # Second signal while the first cleanup is in flight: ignore all further
453
+ # signals and force-quit now rather than re-entering cleanup_all() (deadlock).
454
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
455
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
456
+ os._exit(130) # 128 + SIGINT(2); skip remaining Python teardown
457
+ self._shutdown_initiated = True
458
+
459
+ # TEST: This logger.info() violates CLAUDE.md's hard rule "Never log inside SIGTERM
460
+ # handlers (deadlock)". A signal interrupts the main thread wherever it is; if it lands
461
+ # while that thread already holds the logging lock (mid-logger call elsewhere), calling
462
+ # logger.info() here re-acquires the same non-reentrant lock on the same thread and
463
+ # self-deadlocks — hanging until SIGKILL. The contextlib.suppress(Exception) below does
464
+ # NOT help: a deadlock raises nothing. Verify whether this actually bites: under active
465
+ # logging load, send SIGINT/SIGTERM (plus a rapid second one) and confirm the process
466
+ # shuts down cleanly instead of hanging. If it does hang, drop the log from the handler —
467
+ # remove it, use a signal-safe os.write(2, b"...") to stderr, or defer the message to
468
+ # after cleanup_all() returns (outside the handler).
469
+ with contextlib.suppress(Exception):
470
+ logger.info(
471
+ f"Received signal {signum}, initiating cleanup (Ctrl-C again to force-quit - NOT RECOMMENDED)..."
472
+ )
473
+
474
+ self.cleanup_all()
475
+ # Properly terminate with sys.exit() after handling the signal inside main process
476
+ # Note, sys.exit() triggers the following cleanup handlers (in order):
477
+ # 1. finally blocks in active try/except statements
478
+ # 2. context managers (__exit__ methods)
479
+ # 3. atexit registered functions
480
+ # sys.exit(0): exit with successful termination status
481
+ # sys.exit(1): exit with failed termination status
482
+ sys.exit(0)
483
+
484
+ def cleanup_all(self):
485
+ """
486
+ Unified cleanup of all resources.
487
+ Strict order:
488
+ 1. Processes (before shared memory — e.g. the producer's workers attach to the
489
+ background-plate block)
490
+ 2. Pools
491
+ 3. SharedMemory
492
+ 4. Monitor
493
+ 5. DB
494
+ 6. Logger
495
+ """
496
+ # Skip if not main process
497
+ if os.getpid() != self._main_process_pid:
498
+ logger.info(f"Skipping cleanup in worker process (PID: {os.getpid()})")
499
+ return
500
+
501
+ # Idempotent cleanup (skip if already cleaned)
502
+ # Guarantees cleanup routine only runs once, even if multiple threads call it concurrently
503
+ # First, acquire a mutual-exclusion lock. Only one thread can enter this block at a time
504
+ # (prevents race conditions)
505
+ with self._cleanup_lock:
506
+ # Once inside the lock, the thread checks whether cleanup already occured
507
+ if self._cleanup_executed:
508
+ # If so, we return from the function immediately and do nothing
509
+ return
510
+ # Otherwise, the current thread marks cleanup as complete and executes the rest of the routine
511
+ self._cleanup_executed = True
512
+
513
+ logger.info("=" * 60)
514
+ logger.info("Starting ResourceManager cleanup...")
515
+
516
+ # Log initial stats
517
+ start_time = time.time()
518
+ initial_memory = self._get_memory_usage()
519
+
520
+ # Close managed processes
521
+ logger.info("Closing managed processes...")
522
+ for managed in list(self._processes):
523
+ if not managed.closed:
524
+ self._close_managed_process(managed)
525
+
526
+ # Close managed pools
527
+ logger.info("Closing multiprocessing pools...")
528
+ for managed in self._pools:
529
+ if not managed.closed:
530
+ self._close_managed_pool(managed)
531
+
532
+ # Close managed shared memory
533
+ logger.info("Closing shared memory...")
534
+ for managed in self._shared_memories:
535
+ if not managed.closed:
536
+ self._close_managed_shared_memory(managed)
537
+
538
+ # Shutdown monitor
539
+ if self._monitor:
540
+ logger.info("Shutting down monitor...")
541
+ try:
542
+ # Late import to avoid circular dependency (monitor imports from manager)
543
+ from aetherscan.monitor import shutdown_monitor # noqa: PLC0415
544
+
545
+ shutdown_monitor()
546
+ except Exception as e:
547
+ with contextlib.suppress(Exception):
548
+ logger.error(f"Error during monitor shutdown: {e}")
549
+
550
+ # Shutdown database
551
+ if self._db:
552
+ logger.info("Shutting down database...")
553
+ try:
554
+ # Late import to avoid circular dependency (db imports from manager)
555
+ from aetherscan.db import shutdown_db # noqa: PLC0415
556
+
557
+ shutdown_db()
558
+ except Exception as e:
559
+ with contextlib.suppress(Exception):
560
+ logger.error(f"Error during database shutdown: {e}")
561
+
562
+ # BUG: sometimes total_memory_freed_gb < 0
563
+ # Log final stats
564
+ final_memory = self._get_memory_usage()
565
+ self.stats.total_memory_freed_gb = initial_memory - final_memory
566
+
567
+ end_time = time.time()
568
+ self.stats.cleanup_time_seconds = end_time - start_time
569
+
570
+ logger.info("=" * 60)
571
+ logger.info("ResourceManager cleanup complete:")
572
+ logger.info(f" Pools closed: {self.stats.pools_closed}")
573
+ logger.info(f" Processes closed: {self.stats.processes_closed}")
574
+ logger.info(f" Shared memories cleaned: {self.stats.shared_memories_cleaned}")
575
+ logger.info(f" Memory freed: {self.stats.total_memory_freed_gb:.2f} GB")
576
+ logger.info(f" Cleanup time: {self.stats.cleanup_time_seconds:.2f} seconds")
577
+ logger.info("=" * 60)
578
+
579
+ # Shutdown logger
580
+ if self._logger:
581
+ with contextlib.suppress(Exception):
582
+ # Late import to avoid circular dependency (logger imports from manager)
583
+ from aetherscan.logger import shutdown_logger # noqa: PLC0415
584
+
585
+ shutdown_logger()
586
+ # Note, we can't log after stopping the listener thread, so no final message here
587
+
588
+ def _get_memory_usage(self) -> float:
589
+ """Get process tree's current memory usage (in Gb)"""
590
+ process = psutil.Process(self._main_process_pid)
591
+ stats = get_process_tree_stats(process)
592
+ return stats["ram_gb"]
593
+
594
+ def create_pool(
595
+ self,
596
+ n_processes: int,
597
+ name: str = "unnamed",
598
+ initializer: Callable | None = None,
599
+ initargs: tuple = (),
600
+ ) -> Pool:
601
+ """
602
+ Construct a multiprocessing.Pool with `n_processes` workers and register it for
603
+ lifecycle tracking; returns the raw Pool. `name` is a debugging label that surfaces in
604
+ manager logs and stats. `initializer` and `initargs` are forwarded to Pool() so each
605
+ worker runs that setup function at startup (commonly used to attach shared memory).
606
+ """
607
+ pool = Pool(processes=n_processes, initializer=initializer, initargs=initargs)
608
+
609
+ managed = ManagedPool(
610
+ pool=pool, name=name, created_at=time.time(), process_count=n_processes
611
+ )
612
+
613
+ self._pools.append(managed)
614
+ self.stats.pools_active += 1
615
+
616
+ logger.info(f"Created pool '{name}' with {n_processes} processes")
617
+ logger.info(f" Current total active: {self.stats.pools_active}")
618
+ return pool
619
+
620
+ def close_pool(self, pool: Pool):
621
+ """Explicitly close a specific pool by reference"""
622
+ for managed in self._pools:
623
+ if managed.pool is pool and not managed.closed:
624
+ self._close_managed_pool(managed)
625
+ return
626
+ logger.warning("ResourceManager.close_pool(): Pool not found in managed pools")
627
+
628
+ def _close_managed_pool(self, managed: ManagedPool):
629
+ """Internal method to close a ManagedPool"""
630
+ managed.close(timeout=self.config.manager.pool_terminate_timeout)
631
+ # Remove from tracking list to allow garbage collection of Pool object and its file descriptors
632
+ self._pools.remove(managed)
633
+ self.stats.pools_active -= 1
634
+ self.stats.pools_closed += 1
635
+
636
+ def register_process(self, process: multiprocessing.Process, name: str = "unnamed"):
637
+ """
638
+ Register an externally-created multiprocessing.Process (e.g. the RoundDataProducer)
639
+ for lifecycle tracking, so cleanup_all() can escalate terminate -> join -> kill on it.
640
+ """
641
+ managed = ManagedProcess(process=process, name=name, created_at=time.time())
642
+
643
+ self._processes.append(managed)
644
+ self.stats.processes_active += 1
645
+
646
+ logger.info(f"Registered process '{name}' (PID {process.pid})")
647
+ logger.info(f" Current total active: {self.stats.processes_active}")
648
+
649
+ def close_process(self, process: multiprocessing.Process):
650
+ """Explicitly close a specific tracked process by reference"""
651
+ for managed in self._processes:
652
+ if managed.process is process and not managed.closed:
653
+ self._close_managed_process(managed)
654
+ return
655
+ logger.warning("ResourceManager.close_process(): Process not found in managed processes")
656
+
657
+ def _close_managed_process(self, managed: ManagedProcess):
658
+ """Internal method to close a ManagedProcess"""
659
+ managed.close(timeout=self.config.manager.pool_terminate_timeout)
660
+ # Remove from tracking list to allow garbage collection of the Process object
661
+ self._processes.remove(managed)
662
+ self.stats.processes_active -= 1
663
+ self.stats.processes_closed += 1
664
+
665
+ def create_shared_memory(self, size: int, name: str = "unnamed") -> SharedMemory:
666
+ """
667
+ Allocate a POSIX shared-memory block of `size` bytes and register it for lifecycle
668
+ tracking; returns the raw SharedMemory. `name` is a debugging label that surfaces in
669
+ manager logs and stats (the OS-assigned shm name is separate and available via
670
+ shm.name).
671
+ """
672
+ shm = SharedMemory(create=True, size=size)
673
+
674
+ managed = ManagedSharedMemory(
675
+ shm=shm, name=name, created_at=time.time(), size_gb=size / 1e9
676
+ )
677
+
678
+ self._shared_memories.append(managed)
679
+ self.stats.shared_memories_active += 1
680
+
681
+ logger.info(f"Created shared memory '{name}' ({managed.size_gb:.2f} GB)")
682
+ logger.info(f" Current total active: {self.stats.shared_memories_active}")
683
+ return shm
684
+
685
+ def close_shared_memory(self, shm: SharedMemory):
686
+ """Explicitly close specific shared memory by reference"""
687
+ for managed in self._shared_memories:
688
+ if managed.shm is shm and not managed.closed:
689
+ self._close_managed_shared_memory(managed)
690
+ return
691
+ logger.warning(
692
+ "ResourceManager.close_shared_memory(): SharedMemory not found in managed memories"
693
+ )
694
+
695
+ def _close_managed_shared_memory(self, managed: ManagedSharedMemory):
696
+ """Internal method to close a ManagedSharedMemory"""
697
+ managed.close()
698
+ # Remove from tracking list to allow garbage collection of SharedMemory object and its file descriptors
699
+ self._shared_memories.remove(managed)
700
+ self.stats.shared_memories_active -= 1
701
+ self.stats.shared_memories_cleaned += 1
702
+
703
+ def set_db(self, db):
704
+ """Set database"""
705
+ self._db = db
706
+
707
+ def set_logger(self, logger):
708
+ """Set logger"""
709
+ self._logger = logger
710
+
711
+ def set_monitor(self, monitor):
712
+ """Set monitor"""
713
+ self._monitor = monitor
714
+
715
+ def get_stats(self) -> ResourceStats:
716
+ """Get current resource statistics"""
717
+ return self.stats
718
+
719
+
720
+ def init_manager() -> ResourceManager:
721
+ """
722
+ Initialize global manager instance (call once at startup)
723
+ """
724
+ manager = ResourceManager()
725
+ return manager
726
+
727
+
728
+ # NOTE: suppress None return type for now to reduce pyright errors
729
+ # def get_manager() -> ResourceManager | None:
730
+ def get_manager() -> ResourceManager:
731
+ """Get the global manager instance"""
732
+ manager = ResourceManager._instance
733
+
734
+ if manager is None:
735
+ logger.warning("No manager instance initialized")
736
+
737
+ return manager
738
+
739
+
740
+ def register_db(db):
741
+ """Register database instance to resource manager"""
742
+ manager = ResourceManager._instance
743
+
744
+ if manager is None:
745
+ logger.warning("Cannot register db, no manager instance initialized")
746
+ return
747
+
748
+ manager.set_db(db)
749
+ logger.info("Registered database")
750
+
751
+
752
+ def register_logger():
753
+ """Register logger instance to resource manager"""
754
+ manager = ResourceManager._instance
755
+
756
+ if manager is None:
757
+ logger.warning("Cannot register logger, no manager instance initialized")
758
+ return
759
+
760
+ # Note, unlike other modules, due to dependency chains,
761
+ # register_logger() is not baked into init_logger()
762
+ # Hence, we need to get the logger instance separately with get_logger()
763
+ logger_instance = get_logger()
764
+ if logger_instance is None:
765
+ logger.warning("Cannot register logger, no logger instance initialized")
766
+ return
767
+
768
+ manager.set_logger(logger_instance)
769
+ logger.info("Registered logger")
770
+
771
+
772
+ def register_monitor(monitor):
773
+ """Register monitor instance to resource manager"""
774
+ manager = ResourceManager._instance
775
+
776
+ if manager is None:
777
+ logger.warning("Cannot register monitor, no manager instance initialized")
778
+ return
779
+
780
+ manager.set_monitor(monitor)
781
+ logger.info("Registered monitor")