ergon-framework-python 0.3.0__py3-none-any.whl → 0.3.2__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.
ergon/__init__.py CHANGED
@@ -10,4 +10,4 @@ __all__ = [
10
10
  "manager",
11
11
  ]
12
12
 
13
- __version__ = "0.3.0"
13
+ __version__ = "0.3.2"
@@ -208,6 +208,10 @@ class ErgonPlatformChannelsConnector(Connector):
208
208
  if requeue and transaction.id:
209
209
  self._seen_event_ids.discard(transaction.id)
210
210
 
211
+ def release_transaction(self, transaction: Transaction) -> None:
212
+ """Give back a fetched but unstarted inbox event (worker shutdown): requeued with no delay."""
213
+ self.nack_transaction(transaction, requeue=True, delay_seconds=0)
214
+
211
215
  def download_attachments(
212
216
  self,
213
217
  transaction: Transaction,
@@ -159,6 +159,13 @@ class ErgonPlatformConnector(Connector):
159
159
  **fields,
160
160
  )
161
161
 
162
+ def release_transaction(self, transaction: Transaction) -> None:
163
+ """Give back a transaction that was fetched but never started (worker shutdown).
164
+
165
+ A plain release: no failure reason, no cooldown, so it is picked up again right away.
166
+ """
167
+ self.release_item(transaction.id)
168
+
162
169
  def nack_transaction(
163
170
  self,
164
171
  transaction: Transaction,
@@ -119,5 +119,10 @@ class NylasConnector(Connector):
119
119
  return
120
120
  logger.debug("nack_transaction is a no-op for Nylas; message %s remains available for refetch", transaction.id)
121
121
 
122
+ def release_transaction(self, transaction: Transaction) -> None:
123
+ """Give back a fetched but unstarted message (worker shutdown): unchanged, refetchable."""
124
+ self.service.reset_pagination()
125
+ logger.debug("Nylas message %s released unchanged", transaction.id)
126
+
122
127
  def close(self) -> None:
123
128
  self.service.close()
@@ -194,5 +194,10 @@ class OutlookGraphConnector(Connector):
194
194
  self.service.reset_pagination()
195
195
  logger.debug("Outlook message %s was made available for refetch", transaction.id)
196
196
 
197
+ def release_transaction(self, transaction: Transaction) -> None:
198
+ """Give back a fetched but unstarted message (worker shutdown): unchanged, refetchable."""
199
+ self.service.reset_pagination()
200
+ logger.debug("Outlook message %s released unchanged", transaction.id)
201
+
197
202
  def close(self) -> None:
198
203
  self.service.close()
ergon/task/helpers.py CHANGED
@@ -309,12 +309,17 @@ def run_fn(
309
309
  return run()
310
310
 
311
311
 
312
+ _STOP_POLL_SECONDS = 0.25
313
+
314
+
312
315
  def multithread_execute(
313
316
  submissions: Iterable[Callable[[], futures.Future]],
314
317
  *,
315
318
  concurrency: int,
316
319
  limit: Optional[int] = None,
317
320
  timeout: Optional[float] = None,
321
+ stop: Optional[Callable[[], bool]] = None,
322
+ on_stop: Optional[Callable[[], None]] = None,
318
323
  ) -> int:
319
324
  """
320
325
  Execute submitted callables with bounded concurrency and refill.
@@ -322,6 +327,8 @@ def multithread_execute(
322
327
  - submissions yields callables that RETURN a Future when called
323
328
  - this function manages in-flight futures only
324
329
  - no domain semantics, no retries, no tracing
330
+ - ``stop`` is polled while futures are in flight; once it returns True nothing more is
331
+ submitted, ``on_stop`` runs once right away, and the in-flight futures are still awaited
325
332
 
326
333
  Returns:
327
334
  Number of completed submissions
@@ -329,6 +336,10 @@ def multithread_execute(
329
336
 
330
337
  in_flight: dict[futures.Future, float] = {}
331
338
  completed = 0
339
+ stopping = False
340
+ wait_timeout = timeout
341
+ if stop is not None:
342
+ wait_timeout = _STOP_POLL_SECONDS if timeout is None else min(timeout, _STOP_POLL_SECONDS)
332
343
 
333
344
  submit_iter = iter(submissions)
334
345
 
@@ -363,7 +374,7 @@ def multithread_execute(
363
374
  done, _ = futures.wait(
364
375
  in_flight.keys(),
365
376
  return_when=futures.FIRST_COMPLETED,
366
- timeout=timeout,
377
+ timeout=wait_timeout,
367
378
  )
368
379
 
369
380
  # ----------------------------
@@ -391,6 +402,14 @@ def multithread_execute(
391
402
  in_flight.pop(fut)
392
403
  completed += 1
393
404
 
405
+ if not stopping and stop is not None and stop():
406
+ stopping = True
407
+ logger.info("Stop requested; waiting for %d in-flight submission(s), submitting no more", len(in_flight))
408
+ if on_stop is not None:
409
+ on_stop()
410
+ if stopping:
411
+ continue
412
+
394
413
  # ============================================================
395
414
  # REFILL
396
415
  # ============================================================
@@ -10,7 +10,7 @@ from async_timeout import timeout as timeout_after
10
10
  from opentelemetry import context as otel_context
11
11
 
12
12
  from ... import connector, telemetry
13
- from .. import base, exceptions, helpers, liveness, policies, utils
13
+ from .. import base, exceptions, helpers, liveness, policies, shutdown, utils
14
14
  from . import metrics as mixin_metrics
15
15
  from . import producer
16
16
 
@@ -304,6 +304,38 @@ class ConsumerMixin(ABC):
304
304
  return next(iter(self.connectors.values()))
305
305
  raise ValueError("Multiple connectors configured; specify one in policy")
306
306
 
307
+ # =====================================================================
308
+ # SHUTDOWN HAND-BACK
309
+ # =====================================================================
310
+ def _hand_back_unstarted(self, conn, transactions: List[connector.Transaction]) -> None:
311
+ """Give back fetched transactions that were never started because the process is stopping.
312
+
313
+ ``release_transaction`` (no verdict) when the connector has it, else
314
+ ``nack_transaction(requeue=True)``. Never raises: the stop must go on, and a
315
+ transaction that cannot be handed back returns when its claim or lease expires.
316
+ """
317
+ release = getattr(conn, "release_transaction", None)
318
+ nack = getattr(conn, "nack_transaction", None)
319
+ for tr in transactions:
320
+ try:
321
+ if callable(release):
322
+ release(tr)
323
+ elif callable(nack):
324
+ nack(tr, requeue=True)
325
+ else:
326
+ logger.warning(
327
+ "Connector %s cannot hand back transaction %s; it returns when its claim or lease expires",
328
+ conn.__class__.__name__,
329
+ tr.id,
330
+ )
331
+ continue
332
+ logger.info("Handed back unstarted transaction %s", tr.id)
333
+ except Exception:
334
+ logger.exception(
335
+ "Could not hand back transaction %s; it returns when its claim or lease expires",
336
+ tr.id,
337
+ )
338
+
307
339
  # =====================================================================
308
340
  # PUBLIC CONSUME LOOP
309
341
  # =====================================================================
@@ -338,6 +370,10 @@ class ConsumerMixin(ABC):
338
370
  )
339
371
 
340
372
  while True:
373
+ if shutdown.is_requested():
374
+ logger.info("Shutdown requested; consume loop stops before the next fetch")
375
+ break
376
+
341
377
  batch_number += 1
342
378
 
343
379
  # -------------------------
@@ -345,6 +381,9 @@ class ConsumerMixin(ABC):
345
381
  # -------------------------
346
382
  logger.debug(f"Fetching transactions batch with fetch policy: {policy.fetch.model_dump_json(indent=2)}")
347
383
  success, result = self._handle_fetch(conn, policy.fetch)
384
+ if not success and shutdown.is_requested():
385
+ logger.warning(f"Fetch failed while stopping; nothing was fetched, stopping → {result}")
386
+ break
348
387
  if not success:
349
388
  logger.error(f"Fetch failed → {result}")
350
389
  executor.shutdown(wait=False)
@@ -354,6 +393,14 @@ class ConsumerMixin(ABC):
354
393
 
355
394
  transactions = result
356
395
 
396
+ if transactions and shutdown.is_requested():
397
+ logger.info(
398
+ "Shutdown requested during fetch; handing back %d unstarted transaction(s)",
399
+ len(transactions),
400
+ )
401
+ self._hand_back_unstarted(conn, list(transactions))
402
+ break
403
+
357
404
  # -------------------------
358
405
  # EMPTY QUEUE HANDLING
359
406
  # -------------------------
@@ -375,6 +422,7 @@ class ConsumerMixin(ABC):
375
422
  policy.fetch.empty.backoff_multiplier,
376
423
  policy.fetch.empty.backoff_cap,
377
424
  empty_count,
425
+ interruptible=True,
378
426
  )
379
427
  empty_count += 1
380
428
  continue
@@ -417,11 +465,24 @@ class ConsumerMixin(ABC):
417
465
  "streaming": policy.loop.streaming,
418
466
  },
419
467
  ):
468
+ not_started = list(transactions)
420
469
 
421
470
  def submissions():
422
- for tr in transactions:
471
+ while not_started and not shutdown.is_requested():
472
+ tr = not_started.pop(0)
423
473
  yield lambda tr=tr: submit_start_processing(tr, policy)
424
474
 
475
+ def hand_back_not_started():
476
+ remaining = list(not_started)
477
+ not_started.clear()
478
+ if remaining:
479
+ logger.info(
480
+ "Shutdown requested; handing back %d unstarted transaction(s) of this batch, "
481
+ "in-flight ones will finish",
482
+ len(remaining),
483
+ )
484
+ self._hand_back_unstarted(conn, remaining)
485
+
425
486
  logger.debug(
426
487
  f"Submitting {len(transactions)} transactions for processing "
427
488
  f"with concurrency policy: {policy.loop.concurrency.model_dump_json(indent=2)}."
@@ -432,10 +493,17 @@ class ConsumerMixin(ABC):
432
493
  concurrency=policy.loop.concurrency.value,
433
494
  limit=policy.loop.limit,
434
495
  timeout=policy.transaction_runtime.timeout,
496
+ stop=shutdown.is_requested,
497
+ on_stop=hand_back_not_started,
435
498
  )
436
499
 
437
500
  processed += count
438
501
 
502
+ if shutdown.is_requested():
503
+ hand_back_not_started()
504
+ logger.info("Shutdown requested; consume loop stops after the in-flight transactions")
505
+ break
506
+
439
507
  if policy.loop.limit and processed >= policy.loop.limit:
440
508
  break
441
509
 
@@ -446,6 +514,7 @@ class ConsumerMixin(ABC):
446
514
  multiplier=policy.fetch.batch.interval.backoff_multiplier,
447
515
  cap=policy.fetch.batch.interval.backoff_cap,
448
516
  attempt=0,
517
+ interruptible=True,
449
518
  )
450
519
 
451
520
  executor.shutdown()
ergon/task/runner.py CHANGED
@@ -1,6 +1,8 @@
1
1
  import asyncio
2
2
  import inspect
3
3
  import logging as stdlib_logging
4
+ import multiprocessing
5
+ import multiprocessing.connection
4
6
  import os
5
7
  import signal
6
8
  import threading
@@ -8,13 +10,13 @@ import time
8
10
  import traceback
9
11
  import uuid
10
12
  from collections.abc import Callable
11
- from concurrent.futures import ProcessPoolExecutor
12
13
  from datetime import datetime
13
14
  from enum import IntEnum
14
15
  from typing import Any, Literal
15
16
 
16
17
  from ..connector import Transaction
17
18
  from ..telemetry import logging, metrics, tracing
19
+ from . import shutdown
18
20
  from .base import (
19
21
  BaseAsyncTask,
20
22
  BaseTask,
@@ -38,63 +40,24 @@ class ExitCode(IntEnum):
38
40
 
39
41
 
40
42
  # =============================================================
41
- # SHUTDOWN STATE (PROCESS-LOCAL)
43
+ # SHUTDOWN STATE (PROCESS-LOCAL, see ``shutdown``)
42
44
  # =============================================================
43
45
 
44
- _shutdown_event = threading.Event()
45
- _shutdown_signal: int | None = None
46
- _shutdown_callbacks: set[Callable[[], None]] = set()
47
- _shutdown_callbacks_lock = threading.Lock()
48
46
  _supervisor_logger = stdlib_logging.getLogger(__name__)
49
47
 
50
-
51
- def _signal_handler(signum, frame):
52
- global _shutdown_signal
53
- _shutdown_signal = signum
54
- _shutdown_event.set()
55
- with _shutdown_callbacks_lock:
56
- callbacks = tuple(_shutdown_callbacks)
57
- for callback in callbacks:
58
- try:
59
- callback()
60
- except Exception:
61
- _supervisor_logger.exception("Async shutdown callback failed")
62
-
63
-
64
- def _install_signal_handlers():
65
- signal.signal(signal.SIGINT, _signal_handler)
66
- signal.signal(signal.SIGTERM, _signal_handler)
67
-
68
-
69
- def _reset_shutdown_state() -> None:
70
- global _shutdown_signal
71
- _shutdown_event.clear()
72
- _shutdown_signal = None
73
- with _shutdown_callbacks_lock:
74
- _shutdown_callbacks.clear()
75
-
76
-
77
- def _register_shutdown_callback(callback: Callable[[], None]) -> None:
78
- with _shutdown_callbacks_lock:
79
- _shutdown_callbacks.add(callback)
80
- shutdown_requested = _shutdown_event.is_set()
81
- if shutdown_requested:
82
- callback()
83
-
84
-
85
- def _unregister_shutdown_callback(callback: Callable[[], None]) -> None:
86
- with _shutdown_callbacks_lock:
87
- _shutdown_callbacks.discard(callback)
88
-
89
-
90
- def is_shutdown_requested() -> bool:
91
- return _shutdown_event.is_set()
48
+ _signal_handler = shutdown._handle_signal
49
+ _install_signal_handlers = shutdown.install_handlers
50
+ _reset_shutdown_state = shutdown.reset
51
+ _register_shutdown_callback = shutdown.register_callback
52
+ _unregister_shutdown_callback = shutdown.unregister_callback
53
+ is_shutdown_requested = shutdown.is_requested
92
54
 
93
55
 
94
56
  def get_shutdown_exit_code() -> ExitCode:
95
- if _shutdown_signal == signal.SIGINT:
57
+ requested = shutdown.requested_signal()
58
+ if requested == signal.SIGINT:
96
59
  return ExitCode.SIGINT
97
- if _shutdown_signal == signal.SIGTERM:
60
+ if requested == signal.SIGTERM:
98
61
  return ExitCode.SIGTERM
99
62
  return ExitCode.ERROR
100
63
 
@@ -497,6 +460,103 @@ def __run_task_sync(
497
460
  instance.exit()
498
461
 
499
462
 
463
+ def _run_worker_sync(config: TaskConfig, mode: Literal["task", "transaction"] = "task", *args, **kwargs):
464
+ """Worker-process entrypoint: cooperative handlers, no escalation, keeps an early stop request."""
465
+ shutdown.prepare_worker()
466
+ if shutdown.is_requested():
467
+ _supervisor_logger.warning("Stop requested before worker %s started; not running it", kwargs.get("worker_id"))
468
+ return None
469
+ return __run_task_sync(config, mode, *args, **kwargs)
470
+
471
+
472
+ def _worker_signal_relays(worker_pids: Callable[[], list[int]]) -> tuple[Callable[[], None], Callable[[], None]]:
473
+ """(forward the parent's signal to the workers, SIGKILL the workers).
474
+
475
+ ``worker_pids`` is read on every call, so workers started after registration count.
476
+ ``kill_workers`` runs right before the process exits, so it does not log.
477
+ """
478
+
479
+ def _send(signum: int) -> list[tuple[int, OSError]]:
480
+ failures = []
481
+ for pid in worker_pids():
482
+ try:
483
+ os.kill(pid, signum)
484
+ except ProcessLookupError:
485
+ continue
486
+ except OSError as exc:
487
+ failures.append((pid, exc))
488
+ return failures
489
+
490
+ def forward_shutdown() -> None:
491
+ for pid, exc in _send(shutdown.requested_signal() or signal.SIGTERM):
492
+ _supervisor_logger.error("Could not signal worker process %s: %s", pid, exc)
493
+
494
+ def kill_workers() -> None:
495
+ _send(signal.SIGKILL)
496
+
497
+ return forward_shutdown, kill_workers
498
+
499
+
500
+ def _start_worker(config: TaskConfig, mode: Literal["task", "transaction"], args: tuple, worker_kwargs: dict):
501
+ process = multiprocessing.Process(
502
+ target=_run_worker_sync,
503
+ args=(config, mode, *args),
504
+ kwargs=worker_kwargs,
505
+ name=f"{config.name}-worker-{worker_kwargs['worker_id']}",
506
+ )
507
+ process.start()
508
+ return process
509
+
510
+
511
+ def _run_workers(config: TaskConfig, mode: Literal["task", "transaction"], args: tuple, kwargs: dict) -> bool:
512
+ """Run ``max_workers`` worker processes to completion; True when one of them failed.
513
+
514
+ Plain processes, not a pool: a worker whose task returns simply exits. A worker that
515
+ fails or dies stops its siblings cooperatively, as a broken pool used to.
516
+ """
517
+ processes: list[multiprocessing.Process] = []
518
+ has_error = False
519
+
520
+ def alive_pids() -> list[int]:
521
+ return [p.pid for p in processes if p.pid is not None and p.exitcode is None]
522
+
523
+ forward_shutdown, kill_workers = _worker_signal_relays(alive_pids)
524
+ shutdown.register_callback(forward_shutdown)
525
+ shutdown.register_escalation_callback(kill_workers)
526
+ try:
527
+ # Workers inherit the blocked mask, so a signal that arrives while they start
528
+ # stays pending until ``prepare_worker`` installed their handlers.
529
+ with shutdown.blocked_signals():
530
+ for worker_id in range(config.max_workers):
531
+ if is_shutdown_requested():
532
+ break
533
+ worker_kwargs = {**kwargs, "worker_id": worker_id, "total_workers": config.max_workers}
534
+ processes.append(_start_worker(config, mode, args, worker_kwargs))
535
+ if is_shutdown_requested():
536
+ forward_shutdown()
537
+
538
+ running = list(processes)
539
+ while running:
540
+ multiprocessing.connection.wait([p.sentinel for p in running], timeout=1.0)
541
+ for p in [p for p in running if p.exitcode is not None]:
542
+ running.remove(p)
543
+ p.join()
544
+ if p.exitcode == 0:
545
+ continue
546
+ has_error = True
547
+ _supervisor_logger.error("Worker process %s (pid %s) exited with %s", p.name, p.pid, p.exitcode)
548
+ for sibling in running:
549
+ if sibling.pid is not None:
550
+ try:
551
+ os.kill(sibling.pid, signal.SIGTERM)
552
+ except ProcessLookupError:
553
+ pass
554
+ finally:
555
+ shutdown.unregister_callback(forward_shutdown)
556
+ shutdown.unregister_escalation_callback(kill_workers)
557
+ return has_error
558
+
559
+
500
560
  # =============================================================
501
561
  # PUBLIC API — RUNNER
502
562
  # =============================================================
@@ -515,9 +575,10 @@ def run_task(
515
575
  Returns POSIX-compatible exit code.
516
576
  """
517
577
 
518
- _reset_shutdown_state()
519
- _install_signal_handlers()
520
578
  is_async = issubclass(config.task, BaseAsyncTask) # type: ignore[arg-type]
579
+ _reset_shutdown_state()
580
+ # Async: every signal re-cancels and the liveness supervisor owns the hard deadline.
581
+ _install_signal_handlers(escalate=not is_async)
521
582
 
522
583
  # ---------------------------------------------------------
523
584
  # SINGLE PROCESS
@@ -552,24 +613,7 @@ def run_task(
552
613
  if is_async:
553
614
  raise RuntimeError("Async tasks cannot be executed with multiple processes. Use debug=True or max_workers=1.")
554
615
 
555
- has_error = False
556
-
557
- with ProcessPoolExecutor(max_workers=config.max_workers) as executor:
558
- futures = []
559
- for worker_id in range(config.max_workers):
560
- worker_kwargs = {
561
- **kwargs,
562
- "worker_id": worker_id,
563
- "total_workers": config.max_workers,
564
- }
565
- futures.append(executor.submit(__run_task_sync, config, mode, *args, **worker_kwargs))
566
-
567
- for f in futures:
568
- try:
569
- f.result()
570
- except Exception:
571
- traceback.print_exc()
572
- has_error = True
616
+ has_error = _run_workers(config, mode, args, kwargs)
573
617
 
574
618
  if is_shutdown_requested():
575
619
  return int(get_shutdown_exit_code())
ergon/task/shutdown.py ADDED
@@ -0,0 +1,275 @@
1
+ """Process-wide shutdown state shared by the runner, the consume loops and the backoff sleeps.
2
+
3
+ The runner installs the signal handlers; everything else only reads this module, which
4
+ imports nothing from ``ergon.task`` so ``utils`` and the mixins can use it without a cycle.
5
+
6
+ First SIGINT/SIGTERM: a cooperative stop. Sync consume loops stop fetching, hand back
7
+ fetched-but-unstarted transactions and let the in-flight ones finish. With escalation on
8
+ (sync runner), a second signal while the stop is in progress exits at once with
9
+ ``128 + signum``, so an operator is never stuck waiting for a long transaction.
10
+
11
+ How a signal is processed:
12
+
13
+ - The kernel may deliver it to any thread that does not block it, and Python runs the
14
+ Python-level handler only on the main thread, at its next bytecode. A main thread asleep
15
+ in a lock (``Future.result()``, ``Event.wait``) would never notice. So the runner owns
16
+ ``signal.set_wakeup_fd``: CPython's C handler writes the signal number to a pipe from
17
+ whichever thread received it, and a watcher thread acts on it.
18
+ - The watcher is the only place that counts signals, sets the ``Event``, logs, runs the
19
+ callbacks and escalates. The Python handler only flips a flag so the main thread sees
20
+ the stop right away; it never takes a lock, because the main thread may hold one
21
+ (``threading.Event``, logging handlers, buffered stdio) when it runs.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import os
28
+ import signal
29
+ import threading
30
+ from collections.abc import Callable
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ _HANDLED_SIGNALS = (signal.SIGINT, signal.SIGTERM)
35
+
36
+ _requested = False
37
+ _signal: int | None = None
38
+ _seen = False
39
+ _event = threading.Event()
40
+
41
+ # Copy-on-write tuples: the watcher reads them without a lock.
42
+ _callbacks: tuple[Callable[[], None], ...] = ()
43
+ _escalation_callbacks: tuple[Callable[[], None], ...] = ()
44
+ _registry_lock = threading.Lock()
45
+
46
+ _handlers_installed = False
47
+ _escalate = True
48
+
49
+ _wakeup_read: int | None = None
50
+ _wakeup_write: int | None = None
51
+ _wakeup_installed = False
52
+ _watcher: threading.Thread | None = None
53
+
54
+
55
+ def _handle_signal(signum, frame) -> None:
56
+ global _requested, _signal
57
+ if _signal is None:
58
+ _signal = signum
59
+ _requested = True
60
+ if not _wakeup_installed:
61
+ # Direct call (tests), or a forked child that has not run ``prepare_worker`` yet.
62
+ _on_signal(signum)
63
+
64
+
65
+ def _on_signal(signum: int) -> None:
66
+ global _requested, _signal, _seen
67
+ if _seen and _handlers_installed and _escalate:
68
+ _escalate_now(signum)
69
+ return
70
+ _seen = True
71
+ if _signal is None:
72
+ _signal = signum
73
+ _requested = True
74
+ _event.set()
75
+ logger.warning(
76
+ "%s received; finishing in-flight work before exiting%s",
77
+ signal.Signals(signum).name,
78
+ " (send it again to exit now)" if _handlers_installed and _escalate else "",
79
+ )
80
+ for callback in _callbacks:
81
+ try:
82
+ callback()
83
+ except Exception:
84
+ logger.exception("Shutdown callback failed")
85
+
86
+
87
+ def _escalate_now(signum: int) -> None:
88
+ try:
89
+ os.write(2, f"Second {signal.Signals(signum).name} during shutdown; exiting immediately\n".encode())
90
+ except OSError:
91
+ pass
92
+ for callback in _escalation_callbacks:
93
+ try:
94
+ callback()
95
+ except Exception:
96
+ pass
97
+ os._exit(128 + signum)
98
+
99
+
100
+ def _watch(read_fd: int) -> None:
101
+ while True:
102
+ try:
103
+ data = os.read(read_fd, 64)
104
+ except OSError:
105
+ return
106
+ if not data:
107
+ return
108
+ for signum in data:
109
+ if signum in _HANDLED_SIGNALS:
110
+ _on_signal(signum)
111
+
112
+
113
+ def _start_watcher() -> None:
114
+ global _wakeup_read, _wakeup_write, _watcher
115
+ if _watcher is not None and _watcher.is_alive():
116
+ return
117
+ read_fd, write_fd = os.pipe()
118
+ os.set_blocking(write_fd, False)
119
+ _wakeup_read, _wakeup_write = read_fd, write_fd
120
+ _watcher = threading.Thread(target=_watch, args=(read_fd,), name="ergon-shutdown-watcher", daemon=True)
121
+ _watcher.start()
122
+
123
+
124
+ def install_handlers(*, escalate: bool = True) -> None:
125
+ """Route SIGINT/SIGTERM to the cooperative stop. Main thread only (``signal`` rule).
126
+
127
+ ``escalate=False`` keeps every signal cooperative. Worker processes use it because
128
+ systemd's ``KillMode=control-group`` signals every process and the parent forwards
129
+ its own signal too; the parent escalates for them (``register_escalation_callback``).
130
+ The async runner uses it as well: its supervisor owns the hard deadline.
131
+ """
132
+ global _handlers_installed, _escalate, _wakeup_installed
133
+ _escalate = escalate
134
+ _start_watcher()
135
+ assert _wakeup_write is not None
136
+ for signum in _HANDLED_SIGNALS:
137
+ signal.signal(signum, _handle_signal)
138
+ signal.set_wakeup_fd(_wakeup_write, warn_on_full_buffer=False)
139
+ _wakeup_installed = True
140
+ _handlers_installed = True
141
+
142
+
143
+ def restore_default_handlers() -> None:
144
+ global _handlers_installed, _wakeup_installed
145
+ signal.set_wakeup_fd(-1)
146
+ _wakeup_installed = False
147
+ signal.signal(signal.SIGINT, signal.default_int_handler)
148
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
149
+ _handlers_installed = False
150
+
151
+
152
+ def reset() -> None:
153
+ """Forget a previous stop request and all callbacks (start of a run, tests)."""
154
+ global _requested, _signal, _seen, _callbacks, _escalation_callbacks
155
+ _requested = False
156
+ _signal = None
157
+ _seen = False
158
+ _event.clear()
159
+ with _registry_lock:
160
+ _callbacks = ()
161
+ _escalation_callbacks = ()
162
+
163
+
164
+ def prepare_worker() -> None:
165
+ """Enter a worker process of the multiprocess runner.
166
+
167
+ Keeps a stop request that already arrived (inherited through fork, or delivered
168
+ while signals were blocked), drops the parent's callbacks and never escalates.
169
+ """
170
+ global _callbacks, _escalation_callbacks
171
+ with _registry_lock:
172
+ _callbacks = ()
173
+ _escalation_callbacks = ()
174
+ install_handlers(escalate=False)
175
+ if _requested:
176
+ _event.set()
177
+ unblock_signals()
178
+
179
+
180
+ def _after_fork_in_child() -> None:
181
+ global _escalate, _callbacks, _escalation_callbacks, _registry_lock
182
+ global _wakeup_read, _wakeup_write, _wakeup_installed, _watcher
183
+ # The parent's watcher thread does not exist here, and the parent's callbacks
184
+ # (forward to workers, SIGKILL workers) must never run in a child.
185
+ _escalate = False
186
+ _callbacks = ()
187
+ _escalation_callbacks = ()
188
+ _registry_lock = threading.Lock()
189
+ _wakeup_installed = False
190
+ try:
191
+ signal.set_wakeup_fd(-1)
192
+ except ValueError:
193
+ # Forked from a non-main thread: the C handler may still write to the old pipe,
194
+ # so leave it open rather than let the fd number be reused.
195
+ pass
196
+ else:
197
+ for fd in (_wakeup_read, _wakeup_write):
198
+ if fd is not None:
199
+ try:
200
+ os.close(fd)
201
+ except OSError:
202
+ pass
203
+ _wakeup_read = _wakeup_write = None
204
+ _watcher = None
205
+
206
+
207
+ if hasattr(os, "register_at_fork"):
208
+ os.register_at_fork(after_in_child=_after_fork_in_child)
209
+
210
+
211
+ def is_requested() -> bool:
212
+ return _requested
213
+
214
+
215
+ def requested_signal() -> int | None:
216
+ return _signal
217
+
218
+
219
+ def wait(timeout: float) -> bool:
220
+ """Sleep up to ``timeout`` seconds; return True as soon as a shutdown is requested."""
221
+ if _requested:
222
+ return True
223
+ return _event.wait(timeout) or _requested
224
+
225
+
226
+ def register_callback(callback: Callable[[], None]) -> None:
227
+ """Run ``callback`` on every stop request; immediately when a stop was already requested."""
228
+ global _callbacks
229
+ with _registry_lock:
230
+ _callbacks = (*_callbacks, callback)
231
+ if _requested:
232
+ callback()
233
+
234
+
235
+ def unregister_callback(callback: Callable[[], None]) -> None:
236
+ global _callbacks
237
+ with _registry_lock:
238
+ _callbacks = tuple(c for c in _callbacks if c is not callback)
239
+
240
+
241
+ def register_escalation_callback(callback: Callable[[], None]) -> None:
242
+ """Run ``callback`` right before a second signal ends the process (keep it to ``os.kill``-like work)."""
243
+ global _escalation_callbacks
244
+ with _registry_lock:
245
+ _escalation_callbacks = (*_escalation_callbacks, callback)
246
+
247
+
248
+ def unregister_escalation_callback(callback: Callable[[], None]) -> None:
249
+ global _escalation_callbacks
250
+ with _registry_lock:
251
+ _escalation_callbacks = tuple(c for c in _escalation_callbacks if c is not callback)
252
+
253
+
254
+ def blocked_signals():
255
+ """Context manager that defers SIGINT/SIGTERM for the calling thread (POSIX), e.g. while forking workers.
256
+
257
+ A child process inherits the mask, so a worker cannot be killed by a signal that
258
+ arrives before it installed its handlers; ``prepare_worker`` unblocks it.
259
+ """
260
+ return _BlockedSignals()
261
+
262
+
263
+ class _BlockedSignals:
264
+ def __enter__(self):
265
+ if hasattr(signal, "pthread_sigmask"):
266
+ signal.pthread_sigmask(signal.SIG_BLOCK, _HANDLED_SIGNALS)
267
+ return self
268
+
269
+ def __exit__(self, *exc) -> None:
270
+ unblock_signals()
271
+
272
+
273
+ def unblock_signals() -> None:
274
+ if hasattr(signal, "pthread_sigmask"):
275
+ signal.pthread_sigmask(signal.SIG_UNBLOCK, _HANDLED_SIGNALS)
ergon/task/utils.py CHANGED
@@ -6,6 +6,7 @@ import time
6
6
  from datetime import datetime
7
7
 
8
8
  from .. import telemetry
9
+ from . import shutdown
9
10
 
10
11
  logger = logging.getLogger(__name__)
11
12
  tracer = telemetry.tracing.get_tracer(__name__)
@@ -42,14 +43,24 @@ def compute_backoff(backoff: float, multiplier: float, cap: float, attempt: int)
42
43
  return computed_delay
43
44
 
44
45
 
45
- def backoff(backoff: float, multiplier: float, cap: float, attempt: int):
46
- """Blocking sleep with computed backoff."""
46
+ def backoff(backoff: float, multiplier: float, cap: float, attempt: int, *, interruptible: bool = False):
47
+ """Blocking sleep with computed backoff.
48
+
49
+ ``interruptible=True`` returns as soon as a shutdown is requested. Only idle waits use
50
+ it (empty queue, batch interval): a retry sleep inside a transaction must not turn
51
+ into an immediate retry just because the process is stopping.
52
+ """
47
53
  delay = compute_backoff(backoff, multiplier, cap, attempt)
48
54
  if delay > 0:
49
55
  with tracer.start_as_current_span("sleep", attributes={"delay": delay}):
50
56
  estimated_wake_time_iso = _get_wake_time_iso(delay)
51
57
  logger.info(f"Sleeping for {delay} seconds until {estimated_wake_time_iso}")
52
- time.sleep(delay)
58
+ if interruptible:
59
+ if shutdown.wait(delay):
60
+ logger.info("Woke up early: shutdown requested")
61
+ return
62
+ else:
63
+ time.sleep(delay)
53
64
  logger.info(f"Woke up from {delay} second{'' if delay == 1 else 's'} sleep")
54
65
 
55
66
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ergon-framework-python
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Ergon internal task-oriented project bootstrapper
5
5
  Author-email: Ergondata Technologies <anza.vossos@protonmail.com>
6
6
  License-Expression: MIT
@@ -29,7 +29,7 @@ Requires-Dist: aio-pika>=9.0.0; extra == "all"
29
29
  Requires-Dist: aiobotocore>=2.13.0; extra == "all"
30
30
  Requires-Dist: asyncpg>=0.29.0; extra == "all"
31
31
  Requires-Dist: boto3>=1.35.0; extra == "all"
32
- Requires-Dist: ergon-platform-sdk<0.6,>=0.5.1; extra == "all"
32
+ Requires-Dist: ergon-platform-sdk<1,>=0.5.1; extra == "all"
33
33
  Requires-Dist: httpx>=0.28.0; extra == "all"
34
34
  Requires-Dist: msal>=1.37.0; extra == "all"
35
35
  Requires-Dist: nylas>=6.0.0; extra == "all"
@@ -37,7 +37,7 @@ Requires-Dist: openpyxl>=3.1.5; extra == "all"
37
37
  Requires-Dist: pika>=1.3.0; extra == "all"
38
38
  Requires-Dist: requests>=2.32.5; extra == "all"
39
39
  Provides-Extra: ergon-platform
40
- Requires-Dist: ergon-platform-sdk<0.6,>=0.5.1; extra == "ergon-platform"
40
+ Requires-Dist: ergon-platform-sdk<1,>=0.5.1; extra == "ergon-platform"
41
41
  Requires-Dist: httpx>=0.28.0; extra == "ergon-platform"
42
42
  Provides-Extra: excel
43
43
  Requires-Dist: openpyxl>=3.1.5; extra == "excel"
@@ -1,4 +1,4 @@
1
- ergon/__init__.py,sha256=LI3pDbBIuX3yMUrCAeP5z6Kw96CA5xpjWKcYhSzA8Y4,218
1
+ ergon/__init__.py,sha256=KTd2qcgHy8oQQydByvjgW4Prc3-gviPXsE4yscZ262s,218
2
2
  ergon/cli.py,sha256=CF8PvOjIguR18a657aEBEyhFXUoSVtSRVXv5PfnuWtA,6021
3
3
  ergon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  ergon/bootstrap/src/__project__/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -35,7 +35,7 @@ ergon/connector/ergon_platform/utils.py,sha256=eZU8PHwarpz--EWgz74MkfPLD2AbKp5pm
35
35
  ergon/connector/ergon_platform/channels/__init__.py,sha256=wEpy-8p1mwQNZmosO17hOZ3DIPisX3P14T6WVbyMeBo,1480
36
36
  ergon/connector/ergon_platform/channels/adapters.py,sha256=LoylPshBCMi5R32Ms-IHe_H2rCO4QFrUWMvscRWkmQ8,8123
37
37
  ergon/connector/ergon_platform/channels/async_connector.py,sha256=iKqj-I_2k6DPRd-r0FZ7li659L0Rf3k5--lT3VCBd-8,12853
38
- ergon/connector/ergon_platform/channels/connector.py,sha256=UEgT7so-CWEVPUmxhBzkX4WnxC5PJVlhs-fDd1o1qYk,11637
38
+ ergon/connector/ergon_platform/channels/connector.py,sha256=yJTNiFHDx_aXtLrAwKUCGyHdwMG_OeHT9IvIOm25uCA,11884
39
39
  ergon/connector/ergon_platform/channels/models.py,sha256=00WTLbYdputWd9FHy9D5h3X4AAiAIBeYlBeH5cGY6vA,18292
40
40
  ergon/connector/ergon_platform/channels/services/__init__.py,sha256=EQBc2Sc2mL3gwitd1cPL2wpYnio7C7nHzAA1ml45UzQ,1091
41
41
  ergon/connector/ergon_platform/channels/services/activity.py,sha256=SxN3y2VZDulPxiqy6XNDipVvhCk9uDzsIeSXs9Hd6WA,9756
@@ -48,7 +48,7 @@ ergon/connector/ergon_platform/workflows/__init__.py,sha256=W5TDhYmldZqfQRJ4G8si
48
48
  ergon/connector/ergon_platform/workflows/_mode.py,sha256=ycgCprhZrJzLH67nxkM0hWIM85_0pmiOpXbHSd50gso,3309
49
49
  ergon/connector/ergon_platform/workflows/_operations.py,sha256=bOLc1XS3XGzp1OswwNPy6dViRigSyMdOI_SVnnFmJV8,13377
50
50
  ergon/connector/ergon_platform/workflows/async_connector.py,sha256=wrzDri3F04BD_-W-aoDNZly1XIsQrNhygELjBv9-Dng,12341
51
- ergon/connector/ergon_platform/workflows/connector.py,sha256=Y0Vl1EEfcyydanEK8hDuDV6DGdHw5en2Aua4YKQX6QI,11466
51
+ ergon/connector/ergon_platform/workflows/connector.py,sha256=pRC1AkMseNrfDupyBAEeKXvzbllfyqocbvblHL9uhwk,11774
52
52
  ergon/connector/ergon_platform/workflows/models.py,sha256=EsYilk19iqhy1cpOTtBx120lvXufMgGFH-sTWZFGchI,5063
53
53
  ergon/connector/ergon_platform/workflows/utils.py,sha256=---WoYGjyAqKLl-OWOqpkvFDK8HCeiApmz47nJ3z958,8779
54
54
  ergon/connector/excel/__init__.py,sha256=YHrcMRwW96DtvRs2CNpKK5GW3lJXZSdClLUWCDMgzsA,1013
@@ -60,14 +60,14 @@ ergon/connector/nylas/async_auth_service.py,sha256=sEMvoSCDjRFUSZgmN39L4e5lI2mqh
60
60
  ergon/connector/nylas/async_connector.py,sha256=VXSPY31iYJYn1KPmmxqwhfKi1OxwHXl94xYpXx9oNbY,4774
61
61
  ergon/connector/nylas/async_service.py,sha256=lkxWEAkaMCKD2X7E9ijyuE8CjnR3ITdB1sH3lj1wAFM,4797
62
62
  ergon/connector/nylas/auth_service.py,sha256=f2FguCE5AqqInig1su4Z-rMMoh6zXn9UY7M2eb3aYs0,1835
63
- ergon/connector/nylas/connector.py,sha256=4mIRdaQ6YH6j1gt4htAVeXVoKvZMYses6M3d2G6GCAs,4525
63
+ ergon/connector/nylas/connector.py,sha256=kIpn4yvrGr6ceFJad0m4MxgPXyjvv97MwCTS5KckQLU,4810
64
64
  ergon/connector/nylas/models.py,sha256=dqPG5xhM5ZBFKN1hPW0US7lDztSaV-CZwwIherrdDkA,10230
65
65
  ergon/connector/nylas/service.py,sha256=kj0zY0cvBWkqlne_gZyw4fFDDiIJ5ACB4PX7vYksE3Q,11525
66
66
  ergon/connector/nylas/utils.py,sha256=jP-_hslfSWQgS4Sh5FPOKKLnX-n68m38mkuHOcRVaFk,4715
67
67
  ergon/connector/outlook/__init__.py,sha256=lYLgvXtKTv-jxMyJaeGhd6MO_FWnd0N-1nxRGOmXLXs,1737
68
68
  ergon/connector/outlook/async_connector.py,sha256=aV60SXsFM9rDmbNItdnvpTNYqFy0iqqbvaxXfO9sOGM,7545
69
69
  ergon/connector/outlook/async_service.py,sha256=uC3ldsS8d-yN5inY6qA5-NOyWRaeAc3mxcX-hFslz6M,5618
70
- ergon/connector/outlook/connector.py,sha256=jX8kGV6glQ1z5cF9Spg5V1fXlYHaslcT7Q9xpuWEJlE,7190
70
+ ergon/connector/outlook/connector.py,sha256=G8mHK_VItYPLr1GN2aZAbtDmAlmqmlKFsmSITXvJDzY,7477
71
71
  ergon/connector/outlook/models.py,sha256=zIww_QBqSSnkWv3c13pdtMr97-LwQZn1S4eD5OWXRS0,11219
72
72
  ergon/connector/outlook/service.py,sha256=TdkTroNeQxZ-Sh5mcETRFkqb3OGEZuAKJtQfXCcgTNk,15695
73
73
  ergon/connector/outlook/utils.py,sha256=--IXPNJAG-Bxe4ATFaT0aAKMznMazAOlMpjRMke3KMQ,4102
@@ -100,14 +100,15 @@ ergon/service/service.py,sha256=X4XaeHTKokkz7TGqR0VaNM1nUQSytR91Zukxj1k48X4,384
100
100
  ergon/task/__init__.py,sha256=aXHg8flbZuQ5tFx_0kDjvhqmU0mJyXqcZoRmZK6Dl0s,406
101
101
  ergon/task/base.py,sha256=hmg1C3i-nEgeNv9q6nz5tGS1HOk-tNZdfrzOZygdNnU,6770
102
102
  ergon/task/exceptions.py,sha256=3bObJAkA9u34XgBXYdOUmrBtxPRN0sXM4Z-R-xMATRI,7043
103
- ergon/task/helpers.py,sha256=Fzb2WsP--5f54VRo_oaPH3Hq9kIYoykfEDOhpd_QHXY,22879
103
+ ergon/task/helpers.py,sha256=7YZpNfs9urTlqlWAuMVzGd4WvmkBXEhxQuCsgLbjMU8,23675
104
104
  ergon/task/liveness.py,sha256=TsytIfmi2ua_x3KEPjTdx4WbqHk0WXTqeCSiQUBKPsE,1314
105
105
  ergon/task/manager.py,sha256=3Xr8RPRjtCEoupMeyYb19uK0TQhoVehFlBZd8ZFZ-YA,2972
106
106
  ergon/task/policies.py,sha256=3x1SV-yt3FXNgmnATdQdpRtsDtlskjx582SpbC6UH6A,7358
107
- ergon/task/runner.py,sha256=xUaMSO6V_iOq6QJdiyW1IA5kfWuwQjh1IJl6HUh06hY,20344
108
- ergon/task/utils.py,sha256=OmlP2AhLM32V8O9OMky6DdSvxwmMNn9x2LTguf0y-M0,2501
107
+ ergon/task/runner.py,sha256=L-ExZmzUVqOuGaFPrxhhH_kt8M8yYTFhFbAOR2diXAw,23017
108
+ ergon/task/shutdown.py,sha256=LMm9x2xjGeazVDpSWYwjUGtivk1CvR9s3rd3I9046W8,9173
109
+ ergon/task/utils.py,sha256=lm5hWBQgLJnRILadMwEBTQP8jEu3wZMIau3ATP3fjvs,2995
109
110
  ergon/task/mixins/__init__.py,sha256=6A8vNgdjaQYNJvymWNF-kBN2oTaQBYRLctS3Sq1w_-Y,320
110
- ergon/task/mixins/consumer.py,sha256=LrCSbekQos5GU0xIY3F5_jUd69L09JV6FY_hMy2eol4,52467
111
+ ergon/task/mixins/consumer.py,sha256=JDAMIZbLAHxd4VRJ2wmJW3t0ham2rFZcVoOn48tL_yY,55837
111
112
  ergon/task/mixins/metrics.py,sha256=4bavwo8koWiN5ADD8t5qd2BVeVcgxo6ddzq-xOybeTg,13159
112
113
  ergon/task/mixins/producer.py,sha256=xENTZdZ7t0dpw5YsreQT20iTycSaj1EA4ny-60u-SWM,20427
113
114
  ergon/telemetry/__init__.py,sha256=8waeIAHEx6tMXWfw-zOEKgjrg7V8S1poG070MdAmi_8,100
@@ -117,9 +118,9 @@ ergon/telemetry/metrics.py,sha256=0T_CuNw9P5AG1U9k1-bNp1g7McyiNLUIt_zKyLOtrnA,28
117
118
  ergon/telemetry/tracing.py,sha256=_ktiZWAgB1DU-uN91Mzr0iYNXxbCN21LnKiRo5SOJxY,4454
118
119
  ergon/utils/__init__.py,sha256=M0_VCjMN7jvSPlRZLZHeFKTHAl1zu3BnAhjrzJjDfiI,57
119
120
  ergon/utils/env.py,sha256=lji4xENvfHqg2GTGnzG95VKpxxM4f3DHIKKP61xQG08,621
120
- ergon_framework_python-0.3.0.dist-info/licenses/LICENSE,sha256=bWCIhHzaadz_ecLdCdtwyMafeQ17s2d7_FSSuSuEcDs,1079
121
- ergon_framework_python-0.3.0.dist-info/METADATA,sha256=4jpSXaxJHbORTHYo64JiURQUqS2GcseCnLz9xuw8T8A,18381
122
- ergon_framework_python-0.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
123
- ergon_framework_python-0.3.0.dist-info/entry_points.txt,sha256=reZ934txb5OrtcNBgTGqCsXa1biUDQWubD2a9fGZynE,42
124
- ergon_framework_python-0.3.0.dist-info/top_level.txt,sha256=KSPt6N7RzsjU7PRAalWQ9Cul7YTUxbo84yAJ-y2vLm8,6
125
- ergon_framework_python-0.3.0.dist-info/RECORD,,
121
+ ergon_framework_python-0.3.2.dist-info/licenses/LICENSE,sha256=bWCIhHzaadz_ecLdCdtwyMafeQ17s2d7_FSSuSuEcDs,1079
122
+ ergon_framework_python-0.3.2.dist-info/METADATA,sha256=i2N5giC7tvqYI_JRFxopnfoDYQgadQCQ2X-lacx7t3c,18377
123
+ ergon_framework_python-0.3.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
124
+ ergon_framework_python-0.3.2.dist-info/entry_points.txt,sha256=reZ934txb5OrtcNBgTGqCsXa1biUDQWubD2a9fGZynE,42
125
+ ergon_framework_python-0.3.2.dist-info/top_level.txt,sha256=KSPt6N7RzsjU7PRAalWQ9Cul7YTUxbo84yAJ-y2vLm8,6
126
+ ergon_framework_python-0.3.2.dist-info/RECORD,,