AbstractIntegratedModule 0.9.5__tar.gz → 0.9.6__tar.gz

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.
Files changed (23) hide show
  1. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/AbstractIntegratedModule.egg-info/PKG-INFO +2 -2
  2. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/AbstractIntegratedModule.py +321 -149
  3. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/AbstractOptimizedModules.c +200 -200
  4. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/PKG-INFO +2 -2
  5. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/README.md +1 -1
  6. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/setup.py +1 -1
  7. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/AbstractIntegratedModule.egg-info/SOURCES.txt +0 -0
  8. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/AbstractIntegratedModule.egg-info/dependency_links.txt +0 -0
  9. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/AbstractIntegratedModule.egg-info/requires.txt +0 -0
  10. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/AbstractIntegratedModule.egg-info/top_level.txt +0 -0
  11. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/AbstractOptimizedModules.pyx +0 -0
  12. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/MANIFEST.in +0 -0
  13. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/abstract_model_storage/Cargo.toml +0 -0
  14. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/abstract_model_storage/pyproject.toml +0 -0
  15. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/abstract_model_storage/src/lib.rs +0 -0
  16. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/abstract_model_storage/target/debug/build/libsqlite3-sys-ed07b882cd2aa5e2/out/bindgen.rs +0 -0
  17. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/abstract_model_storage/target/debug/build/serde_core-ebc15f2e9cad7f5f/out/private.rs +0 -0
  18. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/abstract_model_storage/target/debug/build/target-lexicon-08527f45de28143d/out/host.rs +0 -0
  19. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/abstract_model_storage/target/release/build/libsqlite3-sys-bf0400df4523274c/out/bindgen.rs +0 -0
  20. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/abstract_model_storage/target/release/build/serde_core-5cdb76131825e4af/out/private.rs +0 -0
  21. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/abstract_model_storage/target/release/build/target-lexicon-43eb95a0588bf457/out/host.rs +0 -0
  22. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/pyproject.toml +0 -0
  23. {abstractintegratedmodule-0.9.5 → abstractintegratedmodule-0.9.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AbstractIntegratedModule
3
- Version: 0.9.5
3
+ Version: 0.9.6
4
4
  Summary: Library for Advanced Integrated Non-LLM AI Models - Optimized Backend Framework For Non-LLM AI Agent
5
5
  Author: Micro-Novelty
6
6
  Author-email: hernikpuspita5@gmail.com
@@ -42,7 +42,7 @@ https://github.com/Micro-Novelty/IntegratedPipeline-Specialized-Non-LLM-AI-Agent
42
42
  #### Note: The README here you are reading is a direct copy from my README Repository, to download the necessary files, you can visit my Repository with the provided link above.
43
43
 
44
44
  ### Library Short Description:
45
- - Development Stage: 0.9.5 Official Release.
45
+ - Development Stage: 0.9.6 Official Release.
46
46
  - Maintainer: Micro-Novelty.
47
47
  - library Source-Code is Open-sourced on github.
48
48
  - Purpose: Specifically Designed for providing Non-LLM AI Agent Framework for edge Devices, Optimized for ARM64 architecture.
@@ -2034,7 +2034,7 @@ class MLP:
2034
2034
  gradient = np.gradient(subnet.flatten())
2035
2035
 
2036
2036
  val = [np.linalg.norm(v) for v in gradient]
2037
- anisotropy = np.std(val) / np.mean(val) + eps
2037
+ anisotropy = np.std(val) + eps / np.mean(val)
2038
2038
  return anisotropy
2039
2039
 
2040
2040
  def adapt_predict_shape(self, y_pred, y_true):
@@ -6171,54 +6171,65 @@ class AsyncMessageQueue:
6171
6171
 
6172
6172
 
6173
6173
  class ThreadedMessageQueue:
6174
- # Thread-based message queue for synchronous code
6174
+ # Thread-based message queue for synchronous code.
6175
6175
  def __init__(self, max_size=1000, worker_threads=4):
6176
- self.queue = queue.Queue(maxsize=max_size)
6177
- self.results = {}
6178
- self.handlers = {}
6179
- self._running = False
6180
- self._workers = []
6176
+ self.queue = queue.Queue(maxsize=max_size)
6177
+ self.results = {}
6178
+ self.handlers = {}
6179
+ self._running = False
6180
+ self._workers = []
6181
6181
  self._worker_threads = worker_threads
6182
6182
  self._stats = {
6183
6183
  'messages_processed': 0,
6184
- 'messages_failed': 0,
6185
- 'active_workers': 0
6184
+ 'messages_failed' : 0,
6185
+ 'active_workers' : 0
6186
6186
  }
6187
6187
  self._lock = threading.Lock()
6188
-
6188
+
6189
+
6189
6190
  def register_handler(self, message_type: str, handler: Callable):
6190
- # Register a handler for specific message type
6191
6191
  self.handlers[message_type] = handler
6192
6192
  logger.info(f"[=] Registered handler for {message_type}")
6193
-
6193
+
6194
+
6194
6195
  def publish(self, message: Message, timeout: float = 30.0) -> Any:
6195
- # Publish a message and wait for response
6196
- result_container = {'result': None, 'error': None, 'ready': False}
6197
-
6196
+ # threading.Event.
6197
+ done_event = threading.Event()
6198
+ result_container = {'result': None, 'error': None}
6199
+
6198
6200
  def callback_wrapper(res):
6199
- result_container['result'] = res
6200
- result_container['ready'] = True
6201
-
6201
+ # distinguish success from failure
6202
+ if isinstance(res, Exception):
6203
+ result_container['error'] = res
6204
+ else:
6205
+ result_container['result'] = res
6206
+ done_event.set()
6207
+
6202
6208
  message.callback = callback_wrapper
6203
- self.queue.put(message)
6204
-
6205
- start_time = time.time()
6206
- while not result_container['ready'] and (time.time() - start_time) < timeout:
6207
- time.sleep(0.01)
6208
6209
 
6209
- if not result_container['ready']:
6210
+ try:
6211
+ self.queue.put(message, timeout=timeout)
6212
+ except queue.Full:
6213
+ raise TimeoutError(f"[!] Queue full, could not enqueue message {message.id}")
6214
+
6215
+ signaled = done_event.wait(timeout=timeout)
6216
+
6217
+ if not signaled:
6218
+ with self._lock:
6219
+ self._stats['messages_failed'] += 1 # actually tracked now
6210
6220
  raise TimeoutError(f"[!] Message {message.id} timed out")
6211
-
6212
- if result_container['error']:
6213
- raise result_container['error']
6214
-
6221
+
6222
+ if result_container['error'] is not None:
6223
+ with self._lock:
6224
+ self._stats['messages_failed'] += 1
6225
+ raise result_container['error'] # now actually raises
6226
+
6215
6227
  with self._lock:
6216
6228
  self._stats['messages_processed'] += 1
6217
-
6229
+
6218
6230
  return result_container['result']
6219
-
6231
+
6220
6232
  def publish_async(self, message: Message, callback: Optional[Callable] = None):
6221
- # Publish without waiting
6222
6233
  message.callback = callback
6223
6234
  try:
6224
6235
  self.queue.put(message, block=False)
@@ -6226,15 +6237,16 @@ class ThreadedMessageQueue:
6226
6237
  except queue.Full:
6227
6238
  logger.error(f"[=] Queue full, cannot publish message {message.id}")
6228
6239
  return False
6229
-
6230
- def _worker(self, worker_id: int):
6231
- # Worker thread processing messages.
6232
6240
 
6233
- print(f'[=] Worker started: {worker_id}')
6234
- while self._running:
6241
+
6242
+
6243
+ def _worker(self, worker_id: int, stop_event: threading.Event):
6244
+ logger.info(f'[=] Worker started: {worker_id}')
6245
+
6246
+ while self._running and not stop_event.is_set():
6235
6247
  try:
6236
6248
  message = self.queue.get(timeout=1)
6237
-
6249
+
6238
6250
  if message.type in self.handlers:
6239
6251
  try:
6240
6252
  result = self.handlers[message.type](message)
@@ -6242,57 +6254,90 @@ class ThreadedMessageQueue:
6242
6254
  message.callback(result)
6243
6255
  except Exception as e:
6244
6256
  logger.error(f"[=] Worker {worker_id} handler failed: {e}")
6257
+ with self._lock:
6258
+ self._stats['messages_failed'] += 1
6245
6259
  if message.callback:
6260
+ # callback_wrapper
6246
6261
  message.callback(e)
6247
6262
  else:
6248
6263
  logger.warning(f"[=] No handler for message type: {message.type}")
6249
-
6264
+
6250
6265
  except queue.Empty:
6251
6266
  continue
6252
6267
  except Exception as e:
6253
6268
  logger.error(f"[=] Worker {worker_id} error: {e}")
6254
-
6269
+ # track that this worker degraded, without killing loop entirely
6270
+ with self._lock:
6271
+ self._stats.setdefault('worker_errors', {})
6272
+ self._stats['worker_errors'][worker_id] = \
6273
+ self._stats['worker_errors'].get(worker_id, 0) + 1
6274
+
6275
+ logger.info(f'[=] Worker {worker_id} exiting cleanly')
6276
+ with self._lock:
6277
+ self._stats['active_workers'] = max(0, self._stats['active_workers'] - 1)
6278
+
6279
+
6255
6280
  def start(self):
6256
- # Start worker threads.
6257
6281
  if self._running:
6282
+ logger.warning('[=] ThreadedMessageQueue already running — ignoring duplicate start')
6258
6283
  return
6259
-
6260
- self._running = True
6284
+
6285
+ self._running = True
6286
+ self._stop_event = threading.Event() # allows prompt wake-up on stop
6287
+ self._workers = []
6288
+
6261
6289
  for i in range(self._worker_threads):
6262
- thread = threading.Thread(target=self._worker, args=(i,), daemon=True)
6290
+ thread = threading.Thread(
6291
+ target=self._worker, args=(i, self._stop_event), daemon=True
6292
+ )
6263
6293
  thread.start()
6264
6294
  self._workers.append(thread)
6265
-
6295
+
6266
6296
  with self._lock:
6267
6297
  self._stats['active_workers'] = len(self._workers)
6268
-
6298
+
6269
6299
  logger.info(f"[=] Threaded message queue started with {self._worker_threads} workers")
6270
-
6271
- async def stop(self):
6272
- # Stop worker threads gracefully.
6273
- await self.result_queue.stop()
6274
6300
 
6275
- # cancel and wait for all workers to exit cleanly
6301
+ def stop(self, timeout: float = 5.0):
6302
+ """
6303
+ completely rewritten. This is a threading-based class.
6304
+ """
6305
+ if not self._running:
6306
+ return
6307
+
6308
+ logger.info("[=] Stopping threaded message queue...")
6309
+ self._running = False
6310
+ self._stop_event.set() # signal immediately
6311
+
6276
6312
  for worker in self._workers:
6277
- worker.cancel()
6313
+ worker.join(timeout=timeout)
6314
+ if worker.is_alive():
6315
+ logger.warning(
6316
+ f'[!] Worker thread {worker.name} did not stop within '
6317
+ f'{timeout}s — it will be abandoned as a daemon thread '
6318
+ f'(Python cannot forcibly kill threads)'
6319
+ )
6278
6320
 
6279
- if self._workers:
6280
- await asyncio.gather(*self._workers, return_exceptions=True)
6321
+ self._workers.clear() # clear references regardless,
6322
+ # any stragglers are daemon threads that
6323
+ # die automatically when the process exits
6281
6324
 
6282
- self._workers.clear()
6325
+ with self._lock:
6326
+ self._stats['active_workers'] = 0
6283
6327
 
6284
6328
  logger.info("[=] Threaded message queue stopped")
6285
-
6329
+
6330
+
6286
6331
  def get_stats(self) -> Dict:
6287
- # Get queue statistics.
6288
6332
  with self._lock:
6289
6333
  return {
6290
6334
  **self._stats,
6291
6335
  'queue_size': self.queue.qsize(),
6292
- 'workers': len(self._workers),
6336
+ 'workers' : len(self._workers),
6293
6337
  'is_running': self._running
6294
6338
  }
6295
6339
 
6340
+
6296
6341
  # Integrated inference module that allows multiple agents to connect and share their predictions, attention maps, and confidence scores for ensemble decision making.
6297
6342
  # while also providing security features like authentication, rate limiting, and message validation.
6298
6343
  class AgentDistributedInference:
@@ -8735,73 +8780,145 @@ class QueryNode:
8735
8780
  # It collects requests over a short time window or until a maximum batch size is reached, then processes them together through the pipeline. This allows for improved throughput while still providing timely responses to individual requests.
8736
8781
  class AutoBatcherAutomation:
8737
8782
  def __init__(self, pipeline, max_batch_size=32, max_wait_ms=50):
8738
- self.pipeline = pipeline
8739
- self.dataset = None
8783
+ self.pipeline = pipeline
8740
8784
  self.max_batch_size = max_batch_size
8741
- self.max_wait_ms = max_wait_ms
8742
-
8743
- self.request_queue = deque()
8744
- self.processing = False
8745
- self.results = {}
8746
- self.next_id = 0
8747
-
8785
+ self.max_wait_ms = max_wait_ms
8786
+
8787
+ self.request_queue = deque()
8788
+ self.processing = False
8789
+ self.results = {}
8790
+ self.result_events = {} # per-request Event
8791
+ self.next_id = 0
8792
+
8793
+ self._state_lock = threading.Lock() # FIX 1 — guards processing flag + next_id
8794
+
8748
8795
  def add_request(self, text, callback=None):
8749
- request_id = self.next_id
8750
- self.next_id += 1
8751
-
8752
- self.request_queue.append({
8753
- 'id': request_id,
8754
- 'text': text,
8755
- 'callback': callback,
8756
- 'timestamp': time.time()
8757
- })
8758
-
8759
- if not self.processing:
8796
+ with self._state_lock:
8797
+ request_id = self.next_id
8798
+ self.next_id += 1
8799
+
8800
+ event = threading.Event()
8801
+ self.result_events[request_id] = event
8802
+
8803
+ self.request_queue.append({
8804
+ 'id' : request_id,
8805
+ 'text' : text,
8806
+ 'callback' : callback,
8807
+ 'timestamp': time.time()
8808
+ })
8809
+
8810
+ # check-and-set happens atomically under the same lock
8811
+ should_start = not self.processing
8812
+ if should_start:
8813
+ self.processing = True
8814
+
8815
+ if should_start:
8760
8816
  self._start_processing()
8761
-
8817
+
8762
8818
  return request_id
8763
-
8819
+
8764
8820
  def _start_processing(self):
8765
- self.processing = True
8766
8821
  thread = threading.Thread(target=self._process_batches, daemon=True)
8767
8822
  thread.start()
8768
-
8823
+
8769
8824
  def _process_batches(self):
8770
- while self.request_queue:
8771
- # Wait for more requests or max wait time
8772
- time.sleep(self.max_wait_ms / 1000)
8773
- dataset = self.dataset
8774
-
8775
- # Collect batch
8776
- batch = []
8777
- while self.request_queue and len(batch) < self.max_batch_size:
8778
- batch.append(self.request_queue.popleft())
8779
-
8780
- if batch:
8781
- self._process_batch(batch)
8782
-
8783
- self.processing = False
8784
-
8825
+ try:
8826
+ while True:
8827
+ with self._state_lock:
8828
+ if not self.request_queue:
8829
+ break
8830
+
8831
+ time.sleep(self.max_wait_ms / 1000)
8832
+
8833
+ batch = []
8834
+ with self._state_lock:
8835
+ while self.request_queue and len(batch) < self.max_batch_size:
8836
+ batch.append(self.request_queue.popleft())
8837
+
8838
+ if batch:
8839
+ # never let one bad batch kill the worker permanently here.
8840
+ try:
8841
+ self._process_batch(batch)
8842
+ except Exception as e:
8843
+ logger.error(f'[!] Batch processing failed: {e}')
8844
+ # deliver the failure to every waiter in this batch
8845
+ # instead of leaving them hanging forever
8846
+ for req in batch:
8847
+ self._deliver_result(req, None, error=e)
8848
+ finally:
8849
+ # always reset processing, even if something above
8850
+ # raised unexpectedly
8851
+ with self._state_lock:
8852
+ self.processing = False
8853
+
8854
+ # need catch the case where requests arrived after the
8855
+ # while-loop's last empty check but before processing=False landed
8856
+ with self._state_lock:
8857
+ still_pending = bool(self.request_queue)
8858
+ if still_pending and not self.processing:
8859
+ self.processing = True
8860
+ restart = True
8861
+ else:
8862
+ restart = False
8863
+ if restart:
8864
+ self._start_processing()
8865
+
8785
8866
  def _process_batch(self, batch):
8786
- texts = [req['text'] for req in batch]
8787
-
8867
+ texts = [req['text'] for req in batch]
8788
8868
  results = self.pipeline.prediction_batch(texts)
8789
-
8790
- # Send results back
8869
+
8791
8870
  for i, req in enumerate(batch):
8792
8871
  result = results[i] if i < len(results) else None
8793
- if req['callback']:
8794
- req['callback'](result)
8795
- else:
8796
- self.results[req['id']] = result
8797
-
8872
+ self._deliver_result(req, result)
8873
+
8874
+ def _deliver_result(self, req, result, error=None):
8875
+ """Single delivery path — callback or stored result, always signals."""
8876
+ if req['callback']:
8877
+ try:
8878
+ req['callback'](result if error is None else error)
8879
+ except Exception as cb_err:
8880
+ logger.error(f'[!] Callback failed for request {req["id"]}: {cb_err}')
8881
+ else:
8882
+ with self._state_lock:
8883
+ self.results[req['id']] = result if error is None else None
8884
+
8885
+ # signal the waiting event
8886
+ event = self.result_events.get(req['id'])
8887
+ if event:
8888
+ event.set()
8889
+
8798
8890
  def get_result(self, request_id, timeout=5):
8799
- start = time.time()
8800
- while request_id not in self.results:
8801
- if time.time() - start > timeout:
8802
- return None
8803
- time.sleep(0.01)
8804
- return self.results.pop(request_id)
8891
+ event = self.result_events.get(request_id)
8892
+ if event is None:
8893
+ return None
8894
+
8895
+ # blocks efficiently
8896
+ signaled = event.wait(timeout=timeout)
8897
+
8898
+ with self._state_lock:
8899
+ # always need clean up
8900
+ result = self.results.pop(request_id, None)
8901
+ self.result_events.pop(request_id, None)
8902
+
8903
+ return result if signaled else None
8904
+
8905
+ def cleanup_stale(self, max_age_seconds=300):
8906
+ """
8907
+ periodic sweep for requests that were never collected
8908
+ via get_result (e.g. caller crashed or forgot to call it).
8909
+ Call this periodically from a health check loop.
8910
+ """
8911
+ with self._state_lock:
8912
+ now = time.time()
8913
+ stale_ids = [
8914
+ rid for rid, event in self.result_events.items()
8915
+ if event.is_set() # already delivered but never collected
8916
+ ]
8917
+ for rid in stale_ids:
8918
+ self.results.pop(rid, None)
8919
+ self.result_events.pop(rid, None)
8920
+ if stale_ids:
8921
+ logger.info(f'[=] Cleaned up {len(stale_ids)} stale results')
8805
8922
 
8806
8923
 
8807
8924
  # The IntegratedPipeline class serves as the central component that integrates all the different modules and functionalities of the system.
@@ -13359,63 +13476,74 @@ class AsyncResultQueue:
13359
13476
  }
13360
13477
 
13361
13478
  class WorkerPool:
13362
- """
13363
- Worker pool that processes requests from the result queue.
13364
- """
13365
-
13366
- def __init__(self, result_queue: AsyncResultQueue, num_workers: int = 4):
13479
+ """Worker pool that processes requests from the result queue."""
13480
+
13481
+ def __init__(self, result_queue: AsyncResultQueue, num_workers: int = 4,
13482
+ max_consecutive_errors: int = 10):
13367
13483
  self.result_queue = result_queue
13368
- self.num_workers = num_workers
13484
+ self.num_workers = num_workers
13369
13485
  self._workers: List[asyncio.Task] = []
13370
- self._running = False
13371
-
13486
+ self._running = False
13487
+ self._start_lock = asyncio.Lock() # FIX 1 — prevent double-start
13488
+ self.max_consecutive_errors = max_consecutive_errors
13489
+
13490
+ # per-worker health tracking — FIX 4
13491
+ self._worker_error_counts = [0] * num_workers
13492
+ self._worker_last_active = [0.0] * num_workers
13493
+
13372
13494
  async def start(self, predict_func):
13373
- # Start worker pool with prediction function
13374
- self._running = True
13375
- self._workers = [
13376
- asyncio.create_task(self._worker(predict_func))
13377
- for _ in range(self.num_workers)
13378
- ]
13495
+ # guard against double-start leaking orphaned tasks
13496
+ async with self._start_lock:
13497
+ if self._running:
13498
+ logger.warning('[=] WorkerPool.start() called while already '
13499
+ 'running ignoring duplicate start')
13500
+ return
13501
+
13502
+ self._running = True
13503
+ self._worker_error_counts = [0] * self.num_workers
13504
+ self._worker_last_active = [time.time()] * self.num_workers
13505
+
13506
+ self._workers = [
13507
+ asyncio.create_task(self._worker(predict_func, worker_idx=i))
13508
+ for i in range(self.num_workers)
13509
+ ]
13510
+ await self.result_queue.start()
13511
+ logger.info(f'[=] WorkerPool started with {self.num_workers} workers')
13379
13512
 
13380
- await self.result_queue.start()
13381
-
13382
13513
  async def stop(self):
13383
- # Stop all workers
13384
13514
  self._running = False
13385
13515
  await self.result_queue.stop()
13386
13516
 
13387
- # cancel and wait for all workers to exit cleanly
13388
13517
  for worker in self._workers:
13389
13518
  worker.cancel()
13390
13519
 
13391
13520
  if self._workers:
13392
13521
  await asyncio.gather(*self._workers, return_exceptions=True)
13393
13522
 
13394
- self._workers.clear()
13523
+ self._workers.clear() # always clear, no stale references remain
13524
+ logger.info('[=] WorkerPool stopped, all workers cleared')
13395
13525
 
13526
+ async def _worker(self, predict_func, worker_idx: int):
13527
+ consecutive_errors = 0
13396
13528
 
13397
- async def _worker(self, predict_func):
13398
- # Worker that processes requests
13399
13529
  while self._running:
13400
13530
  try:
13401
- # Get next pending request
13402
13531
  try:
13403
13532
  request = await asyncio.wait_for(
13404
- self.result_queue.get_pending(),
13405
- timeout=1.0
13533
+ self.result_queue.get_pending(), timeout=1.0
13406
13534
  )
13407
13535
  except asyncio.TimeoutError:
13408
- continue # no request yet, re-check self._running
13536
+ continue
13409
13537
 
13410
13538
  if not request:
13411
13539
  continue
13412
-
13413
- # Mark as processing
13540
+
13541
+ self._worker_last_active[worker_idx] = time.time()
13542
+
13414
13543
  async with self.result_queue._lock:
13415
13544
  request.status = RequestStatus.PROCESSING
13416
-
13545
+
13417
13546
  try:
13418
- # Execute prediction (run sync function in thread pool)
13419
13547
  result = await asyncio.wait_for(
13420
13548
  asyncio.to_thread(
13421
13549
  predict_func,
@@ -13425,25 +13553,69 @@ class WorkerPool:
13425
13553
  ),
13426
13554
  timeout=30.0
13427
13555
  )
13428
-
13429
- # Mark as completed
13430
13556
  await self.result_queue.complete(request.request_id, result)
13557
+ consecutive_errors = 0
13558
+
13431
13559
  except asyncio.TimeoutError:
13432
- await self.result_queue._mark_failed(request.request_id, 'timeout')
13560
+ # log that the underlying thread may still be
13561
+ # running; can't force-kill it, but need at least
13562
+ # that this is a known limitation rather than silent
13563
+ logger.warning(
13564
+ f'[!] Worker {worker_idx}: request {request.request_id} '
13565
+ f'timed out after 30s. The underlying OS thread may '
13566
+ f'still be running in the background (Python threads '
13567
+ f'cannot be forcibly cancelled) — consider making '
13568
+ f'predict_func more responsive to avoid thread buildup.'
13569
+ )
13570
+
13571
+ await self.result_queue._mark_failed(request.request_id, 'timeout')
13572
+
13433
13573
  except asyncio.CancelledError:
13434
- # shutdown mid-prediction — mark failed and exit cleanly
13435
13574
  await self.result_queue._mark_failed(request.request_id, 'cancelled')
13436
- break
13575
+ break
13437
13576
 
13438
13577
  except Exception as e:
13439
- # Mark as failed
13440
13578
  await self.result_queue._mark_failed(request.request_id, str(e))
13441
-
13579
+ consecutive_errors = 0 # per-request errors don't count
13580
+ # toward the outer circuit breaker
13581
+
13442
13582
  except asyncio.CancelledError:
13443
13583
  break
13584
+
13444
13585
  except Exception as e:
13445
- logger.error(f"[=] Worker error: {e}")
13446
- await asyncio.sleep(0.1)
13586
+ consecutive_errors += 1
13587
+ self._worker_error_counts[worker_idx] = consecutive_errors
13588
+
13589
+ logger.error(f'[=] Worker {worker_idx} error '
13590
+ f'({consecutive_errors}/{self.max_consecutive_errors}): {e}')
13591
+
13592
+ # circuit breaker with exponential backoff
13593
+ if consecutive_errors >= self.max_consecutive_errors:
13594
+ logger.error(
13595
+ f'[!!] Worker {worker_idx} exceeded '
13596
+ f'{self.max_consecutive_errors} consecutive errors — '
13597
+ f'stopping this worker to avoid runaway resource use. '
13598
+ f'Pool now running with reduced capacity.'
13599
+ )
13600
+ break
13601
+
13602
+ backoff = min(0.1 * (2 ** consecutive_errors), 5.0)
13603
+ await asyncio.sleep(backoff)
13604
+
13605
+ def get_health(self) -> Dict:
13606
+ """visibility into worker pool health"""
13607
+ now = time.time()
13608
+ return {
13609
+ 'num_workers' : self.num_workers,
13610
+ 'active_workers' : sum(1 for t in self._workers if not t.done()),
13611
+ 'dead_workers' : sum(1 for t in self._workers if t.done()),
13612
+ 'worker_error_counts': list(self._worker_error_counts),
13613
+ 'worker_idle_seconds': [
13614
+ round(now - t, 1) for t in self._worker_last_active
13615
+ ],
13616
+ 'is_running' : self._running,
13617
+ }
13618
+
13447
13619
 
13448
13620
  class PipelineAsyncManager:
13449
13621
  """