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.
- aetherscan/__init__.py +45 -0
- aetherscan/benchmark.py +179 -0
- aetherscan/candidate_figures.py +328 -0
- aetherscan/cli.py +3032 -0
- aetherscan/config.py +1002 -0
- aetherscan/dashboard.py +709 -0
- aetherscan/dashboard_cli.py +51 -0
- aetherscan/dashboard_launcher.py +194 -0
- aetherscan/data_generation.py +1448 -0
- aetherscan/db/__init__.py +21 -0
- aetherscan/db/db.py +2789 -0
- aetherscan/hf_hub.py +547 -0
- aetherscan/inference.py +912 -0
- aetherscan/inference_viz.py +1494 -0
- aetherscan/latent_gif.py +512 -0
- aetherscan/latent_variants.py +352 -0
- aetherscan/logger/__init__.py +21 -0
- aetherscan/logger/logger.py +467 -0
- aetherscan/logger/slack_handler.py +760 -0
- aetherscan/main.py +1269 -0
- aetherscan/manager/__init__.py +21 -0
- aetherscan/manager/manager.py +781 -0
- aetherscan/models/__init__.py +21 -0
- aetherscan/models/random_forest.py +171 -0
- aetherscan/models/vae.py +849 -0
- aetherscan/monitor/__init__.py +19 -0
- aetherscan/monitor/monitor.py +935 -0
- aetherscan/pfb.py +161 -0
- aetherscan/preprocessing.py +2718 -0
- aetherscan/rf_metrics.py +100 -0
- aetherscan/round_data.py +932 -0
- aetherscan/run_state.py +277 -0
- aetherscan/seeding.py +160 -0
- aetherscan/shap_parallel.py +238 -0
- aetherscan/tag_guards.py +206 -0
- aetherscan/train.py +7681 -0
- aetherscan-1.0.0.dist-info/METADATA +1187 -0
- aetherscan-1.0.0.dist-info/RECORD +41 -0
- aetherscan-1.0.0.dist-info/WHEEL +4 -0
- aetherscan-1.0.0.dist-info/entry_points.txt +2 -0
- aetherscan-1.0.0.dist-info/licenses/LICENSE +13 -0
|
@@ -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")
|