queuerPy 0.1.0__tar.gz → 0.3.0__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.
- {queuerpy-0.1.0 → queuerpy-0.3.0}/MANIFEST.in +7 -0
- {queuerpy-0.1.0/queuerPy.egg-info → queuerpy-0.3.0}/PKG-INFO +2 -16
- {queuerpy-0.1.0 → queuerpy-0.3.0}/_version.py +1 -1
- queuerpy-0.3.0/core/__init__.py +19 -0
- queuerpy-0.3.0/core/broadcaster.py +77 -0
- queuerpy-0.3.0/core/listener.py +116 -0
- queuerpy-0.3.0/core/retryer.py +86 -0
- queuerpy-0.3.0/core/runner.py +274 -0
- queuerpy-0.3.0/core/scheduler.py +77 -0
- queuerpy-0.3.0/core/ticker.py +137 -0
- queuerpy-0.3.0/database/__init__.py +48 -0
- queuerpy-0.3.0/database/db_job.py +670 -0
- queuerpy-0.3.0/database/db_listener.py +159 -0
- queuerpy-0.3.0/database/db_worker.py +339 -0
- queuerpy-0.3.0/helper/__init__.py +69 -0
- queuerpy-0.3.0/helper/database.py +489 -0
- queuerpy-0.3.0/helper/error.py +39 -0
- queuerpy-0.3.0/helper/logging.py +259 -0
- queuerpy-0.3.0/helper/sql.py +178 -0
- queuerpy-0.3.0/helper/task.py +188 -0
- queuerpy-0.3.0/model/batch_job.py +20 -0
- queuerpy-0.3.0/model/connection.py +30 -0
- queuerpy-0.3.0/model/job.py +233 -0
- queuerpy-0.3.0/model/options.py +130 -0
- queuerpy-0.3.0/model/options_on_error.py +112 -0
- queuerpy-0.3.0/model/task.py +80 -0
- queuerpy-0.3.0/model/worker.py +147 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/pyproject.toml +9 -4
- {queuerpy-0.1.0 → queuerpy-0.3.0/queuerPy.egg-info}/PKG-INFO +2 -16
- {queuerpy-0.1.0 → queuerpy-0.3.0}/queuerPy.egg-info/SOURCES.txt +27 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/LICENSE +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/README.md +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/__init__.py +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/queuer.py +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/queuerPy.egg-info/dependency_links.txt +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/queuerPy.egg-info/requires.txt +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/queuerPy.egg-info/top_level.txt +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/queuer_global.py +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/queuer_job.py +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/queuer_listener.py +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/queuer_next_interval.py +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/queuer_task.py +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/setup.cfg +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/sql/job.sql +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/sql/notify.sql +0 -0
- {queuerpy-0.1.0 → queuerpy-0.3.0}/sql/worker.sql +0 -0
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
include README.md
|
|
3
3
|
include LICENSE
|
|
4
4
|
include pyproject.toml
|
|
5
|
+
include _version.py
|
|
6
|
+
|
|
7
|
+
# Include all Python modules and packages
|
|
8
|
+
recursive-include core *.py
|
|
9
|
+
recursive-include database *.py
|
|
10
|
+
recursive-include helper *.py
|
|
11
|
+
recursive-include model *.py
|
|
5
12
|
|
|
6
13
|
# Include SQL files that are needed for database setup
|
|
7
14
|
recursive-include sql *.sql
|
|
@@ -1,23 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queuerPy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A Python implementation of the queuer system - a job queuing and processing system with PostgreSQL backend
|
|
5
5
|
Author-email: Simon Herrmann <siherrmann@users.noreply.github.com>
|
|
6
6
|
Maintainer-email: Simon Herrmann <siherrmann@users.noreply.github.com>
|
|
7
|
-
License:
|
|
8
|
-
|
|
9
|
-
Licensed under the Apache License, Version 2.0 (the "License");
|
|
10
|
-
you may not use this file except in compliance with the License.
|
|
11
|
-
You may obtain a copy of the License at
|
|
12
|
-
|
|
13
|
-
http://www.apache.org/licenses/LICENSE-2.0
|
|
14
|
-
|
|
15
|
-
Unless required by applicable law or agreed to in writing, software
|
|
16
|
-
distributed under the License is distributed on an "AS IS" BASIS,
|
|
17
|
-
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
18
|
-
See the License for the specific language governing permissions and
|
|
19
|
-
limitations under the License.
|
|
20
|
-
|
|
7
|
+
License-Expression: Apache-2.0
|
|
21
8
|
Project-URL: Homepage, https://github.com/siherrmann/queuerPy
|
|
22
9
|
Project-URL: Repository, https://github.com/siherrmann/queuerPy
|
|
23
10
|
Project-URL: Issues, https://github.com/siherrmann/queuerPy/issues
|
|
@@ -25,7 +12,6 @@ Project-URL: Documentation, https://github.com/siherrmann/queuerPy#readme
|
|
|
25
12
|
Keywords: queue,job,task,postgresql,worker,async
|
|
26
13
|
Classifier: Development Status :: 4 - Beta
|
|
27
14
|
Classifier: Intended Audience :: Developers
|
|
28
|
-
Classifier: License :: OSI Approved :: Apache Software License
|
|
29
15
|
Classifier: Operating System :: OS Independent
|
|
30
16
|
Classifier: Programming Language :: Python :: 3
|
|
31
17
|
Classifier: Programming Language :: Python :: 3.8
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core components using goless channels - Python port of Go queuer core.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .broadcaster import Broadcaster
|
|
6
|
+
from .listener import Listener
|
|
7
|
+
from .runner import Runner
|
|
8
|
+
from .scheduler import Scheduler
|
|
9
|
+
from .ticker import Ticker
|
|
10
|
+
from .retryer import Retryer
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
'Broadcaster',
|
|
14
|
+
'Listener',
|
|
15
|
+
'Runner',
|
|
16
|
+
'Scheduler',
|
|
17
|
+
'Ticker',
|
|
18
|
+
'Retryer'
|
|
19
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Broadcaster using asyncio for fully async-aware notifications.
|
|
3
|
+
|
|
4
|
+
This implementation uses asyncio.Queue for consistent async behavior and follows
|
|
5
|
+
the singleton broadcaster pattern for sharing broadcasters across the system.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import Any, TypeVar, Generic, Dict
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
# Global registry for singleton broadcasters
|
|
15
|
+
broadcaster_registry: Dict[str, "Broadcaster[Any]"] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BroadcasterQueue(asyncio.Queue[T]):
|
|
19
|
+
"""An asyncio.Queue with a broadcaster ID for tracking."""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
super().__init__()
|
|
23
|
+
self.broadcaster_id: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Broadcaster(Generic[T]):
|
|
27
|
+
"""A broadcaster that manages async listeners and broadcasts messages."""
|
|
28
|
+
|
|
29
|
+
def __new__(cls, name: str) -> "Broadcaster[T]":
|
|
30
|
+
"""Singleton pattern: return existing instance if it exists."""
|
|
31
|
+
if name not in broadcaster_registry:
|
|
32
|
+
instance = super().__new__(cls)
|
|
33
|
+
broadcaster_registry[name] = instance
|
|
34
|
+
return instance
|
|
35
|
+
return broadcaster_registry[name]
|
|
36
|
+
|
|
37
|
+
def __init__(self, name: str):
|
|
38
|
+
"""Initialize a new broadcaster (only called once per unique name)."""
|
|
39
|
+
if not hasattr(self, "name"):
|
|
40
|
+
self.name = name
|
|
41
|
+
self.listeners: Dict[str, asyncio.Queue[T]] = {}
|
|
42
|
+
self._lock = asyncio.Lock()
|
|
43
|
+
self.broadcaster_id: str = ""
|
|
44
|
+
|
|
45
|
+
async def subscribe(self) -> BroadcasterQueue[T]:
|
|
46
|
+
"""Subscribe to broadcasts and return a queue for receiving messages."""
|
|
47
|
+
queue = BroadcasterQueue[T]()
|
|
48
|
+
queue.broadcaster_id = str(uuid.uuid4())
|
|
49
|
+
|
|
50
|
+
async with self._lock:
|
|
51
|
+
self.listeners[queue.broadcaster_id] = queue
|
|
52
|
+
|
|
53
|
+
return queue
|
|
54
|
+
|
|
55
|
+
async def unsubscribe(self, queue: BroadcasterQueue[T]) -> None:
|
|
56
|
+
"""Unsubscribe a queue from broadcasts."""
|
|
57
|
+
queue_id = queue.broadcaster_id
|
|
58
|
+
async with self._lock:
|
|
59
|
+
if queue_id in self.listeners:
|
|
60
|
+
del self.listeners[queue_id]
|
|
61
|
+
|
|
62
|
+
async def broadcast(self, message: T) -> None:
|
|
63
|
+
"""Broadcast a message to all subscribers."""
|
|
64
|
+
async with self._lock:
|
|
65
|
+
current_listeners = list(self.listeners.values())
|
|
66
|
+
|
|
67
|
+
# Send to all listeners without holding the lock
|
|
68
|
+
for queue in current_listeners:
|
|
69
|
+
try:
|
|
70
|
+
queue.put_nowait(message)
|
|
71
|
+
except asyncio.QueueFull:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def new_broadcaster(name: str) -> Broadcaster[Any]:
|
|
76
|
+
"""Get or create a singleton broadcaster with the given name."""
|
|
77
|
+
return Broadcaster(name)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Listener using asyncio for fully async-aware notifications, cleaned up using a
|
|
3
|
+
SmallRunner (threading.Thread) abstraction for non-blocking execution of callbacks.
|
|
4
|
+
|
|
5
|
+
This implementation uses asyncio throughout for consistent async behavior.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import threading
|
|
10
|
+
from typing import TypeVar, Generic, Callable, Optional, Any
|
|
11
|
+
|
|
12
|
+
from core.broadcaster import Broadcaster
|
|
13
|
+
from core.runner import go_func
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Listener(Generic[T]):
|
|
19
|
+
"""A listener that receives broadcasts and calls notification functions."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, broadcaster: Broadcaster[T]):
|
|
22
|
+
"""Initialize listener."""
|
|
23
|
+
self.broadcaster: Broadcaster[T] = broadcaster
|
|
24
|
+
self._stop_event = None
|
|
25
|
+
self._listening = False
|
|
26
|
+
self._active_notifications = 0
|
|
27
|
+
self._notification_lock = threading.Lock()
|
|
28
|
+
self._completion_event = threading.Event()
|
|
29
|
+
|
|
30
|
+
def listen(
|
|
31
|
+
self,
|
|
32
|
+
stop_event: Optional[threading.Event] = None,
|
|
33
|
+
ready_event: Optional[threading.Event] = None,
|
|
34
|
+
notify_function: Optional[Callable[[T], Any]] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Starts listening for broadcasts and calls the notify function when received.
|
|
38
|
+
|
|
39
|
+
The core asyncio loop is launched in a separate thread (via SmallRunner).
|
|
40
|
+
"""
|
|
41
|
+
if notify_function is None:
|
|
42
|
+
if ready_event is not None:
|
|
43
|
+
ready_event.set()
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# Create internal stop event if not provided
|
|
47
|
+
if stop_event is None:
|
|
48
|
+
stop_event = threading.Event()
|
|
49
|
+
|
|
50
|
+
self._stop_event = stop_event
|
|
51
|
+
|
|
52
|
+
# Define the main coroutine that handles subscription and message reception
|
|
53
|
+
async def _listen_async():
|
|
54
|
+
ch = await self.broadcaster.subscribe()
|
|
55
|
+
if ready_event is not None:
|
|
56
|
+
ready_event.set()
|
|
57
|
+
|
|
58
|
+
if self._stop_event is None:
|
|
59
|
+
self._stop_event = threading.Event()
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
while not self._stop_event.is_set():
|
|
63
|
+
try:
|
|
64
|
+
msg = await asyncio.wait_for(ch.get(), timeout=0.1)
|
|
65
|
+
|
|
66
|
+
with self._notification_lock:
|
|
67
|
+
self._active_notifications += 1
|
|
68
|
+
self._completion_event.clear()
|
|
69
|
+
|
|
70
|
+
def process_notification():
|
|
71
|
+
"""Executes the user's callback in a separate thread."""
|
|
72
|
+
try:
|
|
73
|
+
notify_function(msg)
|
|
74
|
+
finally:
|
|
75
|
+
with self._notification_lock:
|
|
76
|
+
self._active_notifications -= 1
|
|
77
|
+
if self._active_notifications == 0:
|
|
78
|
+
self._completion_event.set()
|
|
79
|
+
|
|
80
|
+
go_func(process_notification, use_mp=False)
|
|
81
|
+
except asyncio.TimeoutError:
|
|
82
|
+
# Timeout is expected; loop checks stop event and continues
|
|
83
|
+
continue
|
|
84
|
+
except Exception as e:
|
|
85
|
+
print(f"Error receiving message: {e}")
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
finally:
|
|
89
|
+
await self.broadcaster.unsubscribe(ch)
|
|
90
|
+
|
|
91
|
+
go_func(_listen_async, use_mp=False)
|
|
92
|
+
self._listening = True
|
|
93
|
+
|
|
94
|
+
def notify(self, data: T) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Sends notification directly by broadcasting the data in a separate thread.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
async def _notify():
|
|
100
|
+
await self.broadcaster.broadcast(data)
|
|
101
|
+
|
|
102
|
+
go_func(_notify, use_mp=False)
|
|
103
|
+
|
|
104
|
+
def wait_for_notifications_processed(self, timeout: float = 5.0) -> bool:
|
|
105
|
+
"""Wait for all notifications to be processed."""
|
|
106
|
+
return self._completion_event.wait(timeout=timeout)
|
|
107
|
+
|
|
108
|
+
def stop(self):
|
|
109
|
+
"""Stop listening."""
|
|
110
|
+
if self._stop_event:
|
|
111
|
+
self._stop_event.set()
|
|
112
|
+
self._listening = False
|
|
113
|
+
|
|
114
|
+
def is_listening(self) -> bool:
|
|
115
|
+
"""Check if currently listening."""
|
|
116
|
+
return self._listening
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Retryer component for the queuer system.
|
|
3
|
+
|
|
4
|
+
This module provides retry functionality mirroring the Go implementation
|
|
5
|
+
for reliable task execution with various backoff strategies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
import os
|
|
11
|
+
import asyncio
|
|
12
|
+
import inspect
|
|
13
|
+
from typing import Any, Callable, Optional
|
|
14
|
+
|
|
15
|
+
# Add parent directory to path for imports
|
|
16
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
17
|
+
|
|
18
|
+
from model.options_on_error import OnError, RetryBackoff
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Configure logging
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Retryer:
|
|
26
|
+
"""Simple retryer mirroring Go's Retryer implementation."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, function: Callable[..., Any], options: Optional[OnError]):
|
|
29
|
+
"""Initialize the retryer.
|
|
30
|
+
|
|
31
|
+
:param function: The function to execute with retries (can be sync or async)
|
|
32
|
+
:param options: OnError options for retry behavior
|
|
33
|
+
:raises ValueError: If options are invalid
|
|
34
|
+
"""
|
|
35
|
+
if options is None or options.max_retries <= 0 or options.retry_delay < 0:
|
|
36
|
+
raise ValueError("No valid retry options provided")
|
|
37
|
+
|
|
38
|
+
self.function = function
|
|
39
|
+
self.options = options
|
|
40
|
+
self.sleep_duration = options.retry_delay
|
|
41
|
+
self.is_async = inspect.iscoroutinefunction(function)
|
|
42
|
+
|
|
43
|
+
async def retry(self) -> Optional[Exception]:
|
|
44
|
+
"""Attempt to execute the function up to MaxRetries times.
|
|
45
|
+
|
|
46
|
+
The retry behavior is determined by the RetryBackoff option.
|
|
47
|
+
If the function raises an exception, it will retry according to the specified backoff strategy.
|
|
48
|
+
If all retries fail, it returns the last exception encountered.
|
|
49
|
+
|
|
50
|
+
The backoff strategies are:
|
|
51
|
+
- RETRY_BACKOFF_NONE: No backoff, retries immediately.
|
|
52
|
+
- RETRY_BACKOFF_LINEAR: Increases the sleep duration linearly by the initial delay.
|
|
53
|
+
- RETRY_BACKOFF_EXPONENTIAL: Doubles the sleep duration after each retry.
|
|
54
|
+
|
|
55
|
+
:returns: The last exception if all retries fail, otherwise None on success.
|
|
56
|
+
"""
|
|
57
|
+
last_error: Optional[Exception] = None
|
|
58
|
+
|
|
59
|
+
for i in range(self.options.max_retries):
|
|
60
|
+
try:
|
|
61
|
+
if self.is_async:
|
|
62
|
+
await self.function()
|
|
63
|
+
else:
|
|
64
|
+
self.function()
|
|
65
|
+
return None # Success
|
|
66
|
+
except Exception as err:
|
|
67
|
+
last_error = err
|
|
68
|
+
logger.warning(
|
|
69
|
+
f"Retry attempt {i + 1}/{self.options.max_retries} failed: {err}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Sleep between retries (except for the last attempt)
|
|
73
|
+
if i < self.options.max_retries - 1:
|
|
74
|
+
await asyncio.sleep(self.sleep_duration)
|
|
75
|
+
|
|
76
|
+
# Apply backoff strategy
|
|
77
|
+
if self.options.retry_backoff == RetryBackoff.NONE:
|
|
78
|
+
continue
|
|
79
|
+
elif self.options.retry_backoff == RetryBackoff.LINEAR:
|
|
80
|
+
self.sleep_duration += self.options.retry_delay
|
|
81
|
+
continue
|
|
82
|
+
elif self.options.retry_backoff == RetryBackoff.EXPONENTIAL:
|
|
83
|
+
self.sleep_duration *= 2
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
return last_error
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Threadsafe Runner - Go-like async task execution with result channel.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import multiprocessing
|
|
6
|
+
import threading
|
|
7
|
+
import inspect
|
|
8
|
+
import asyncio
|
|
9
|
+
import traceback
|
|
10
|
+
import queue
|
|
11
|
+
from typing import Callable, Any, Optional, Union
|
|
12
|
+
|
|
13
|
+
from helper.logging import get_logger
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Runner(multiprocessing.Process):
|
|
19
|
+
"""
|
|
20
|
+
The core execution environment. Manages the execution of a single task
|
|
21
|
+
in a separate, reliably killable multiprocessing context, and retrieves its result.
|
|
22
|
+
|
|
23
|
+
:param task: The synchronous or asynchronous function to execute.
|
|
24
|
+
:param args: Arguments to pass to the task function.
|
|
25
|
+
:param name_prefix: Prefix for the process name (e.g., "Job-Runner").
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
task: Callable[..., Any],
|
|
31
|
+
*args: Any,
|
|
32
|
+
**kwargs: Any,
|
|
33
|
+
):
|
|
34
|
+
"""
|
|
35
|
+
Initializes the Runner process.
|
|
36
|
+
|
|
37
|
+
:param task: The synchronous or asynchronous function to execute.
|
|
38
|
+
:param args: Arguments to pass to the task function.
|
|
39
|
+
"""
|
|
40
|
+
super().__init__(name=task.__name__, daemon=True)
|
|
41
|
+
self.task = task
|
|
42
|
+
self.args = args
|
|
43
|
+
self.kwargs = kwargs
|
|
44
|
+
self.is_async = inspect.iscoroutinefunction(task)
|
|
45
|
+
self._result_queue: multiprocessing.Queue[Any] = multiprocessing.Queue(1)
|
|
46
|
+
|
|
47
|
+
def go(self):
|
|
48
|
+
"""Starts the Runner process in the background."""
|
|
49
|
+
self.start()
|
|
50
|
+
|
|
51
|
+
def run(self):
|
|
52
|
+
"""
|
|
53
|
+
The main execution method, executed in the child process.
|
|
54
|
+
It executes the assigned task, sends the result to the queue, and exits.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
if self.is_async:
|
|
58
|
+
result = asyncio.run(self.task(*self.args, **self.kwargs))
|
|
59
|
+
else:
|
|
60
|
+
result = self.task(*self.args, **self.kwargs)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
self._result_queue.put(result)
|
|
64
|
+
except BrokenPipeError as e:
|
|
65
|
+
pass
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.debug(traceback.format_exc())
|
|
68
|
+
try:
|
|
69
|
+
self._result_queue.put(e)
|
|
70
|
+
except BrokenPipeError as e:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def get_results(self, timeout: Optional[float] = None) -> Any:
|
|
74
|
+
"""
|
|
75
|
+
Waits for the runner process to complete and returns the result.
|
|
76
|
+
|
|
77
|
+
:param timeout: Time in seconds to wait for the result. If None, waits indefinitely.
|
|
78
|
+
:returns: The result returned by the executed task function.
|
|
79
|
+
:raises TimeoutError: If the result is not available within the specified timeout.
|
|
80
|
+
:raises Exception: If the task execution in the runner process failed.
|
|
81
|
+
"""
|
|
82
|
+
logger.debug(f"Runner.get_results() called for {self.name}, timeout={timeout}")
|
|
83
|
+
try:
|
|
84
|
+
self.join(timeout=timeout)
|
|
85
|
+
|
|
86
|
+
if self.is_alive():
|
|
87
|
+
raise TimeoutError(f"runner timed out after {timeout} seconds.")
|
|
88
|
+
|
|
89
|
+
if self._result_queue.empty():
|
|
90
|
+
if self.exitcode == 0:
|
|
91
|
+
return None
|
|
92
|
+
else:
|
|
93
|
+
raise Exception(f"runner failed with code {self.exitcode}")
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
result = self._result_queue.get_nowait()
|
|
97
|
+
except Exception as e:
|
|
98
|
+
raise
|
|
99
|
+
|
|
100
|
+
if isinstance(result, Exception):
|
|
101
|
+
raise result
|
|
102
|
+
|
|
103
|
+
return result
|
|
104
|
+
except Exception as e:
|
|
105
|
+
raise e
|
|
106
|
+
|
|
107
|
+
def cancel(self) -> bool:
|
|
108
|
+
"""
|
|
109
|
+
Attempts to cancel the running process.
|
|
110
|
+
|
|
111
|
+
:returns: True if the process was successfully terminated, False otherwise.
|
|
112
|
+
:raises: Exception if an error occurs during termination.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
if not self.is_alive():
|
|
116
|
+
logger.debug(f"Runner {self.name}: Already finished")
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
logger.debug(f"Cancelling runner {self.name}")
|
|
120
|
+
self.terminate()
|
|
121
|
+
self.join(timeout=5.0) # Wait up to 5 seconds for clean termination
|
|
122
|
+
result = not self.is_alive()
|
|
123
|
+
return result
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.warning(f"Error cancelling runner {self.name}: {e}")
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class SmallRunner(threading.Thread):
|
|
130
|
+
"""
|
|
131
|
+
A lightweight, thread-based runner suitable for tasks needing
|
|
132
|
+
direct access to the parent process's shared state (like heartbeats).
|
|
133
|
+
|
|
134
|
+
:param task: The synchronous or asynchronous function to execute.
|
|
135
|
+
:param args: Arguments to pass to the task function.
|
|
136
|
+
:param name_prefix: Prefix for the thread name (e.g., "Job-SmallRunner").
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
task: Callable[..., Any],
|
|
142
|
+
*args: Any,
|
|
143
|
+
**kwargs: Any,
|
|
144
|
+
):
|
|
145
|
+
"""
|
|
146
|
+
Initializes the SmallRunner thread.
|
|
147
|
+
|
|
148
|
+
:param task: The synchronous or asynchronous function to execute.
|
|
149
|
+
:param args: Arguments to pass to the task function.
|
|
150
|
+
"""
|
|
151
|
+
super().__init__(name=task.__name__, daemon=True)
|
|
152
|
+
self.task = task
|
|
153
|
+
self.args = args
|
|
154
|
+
self.kwargs = kwargs
|
|
155
|
+
self.is_async = inspect.iscoroutinefunction(task)
|
|
156
|
+
self._result_queue: queue.Queue[Any] = queue.Queue(maxsize=1)
|
|
157
|
+
|
|
158
|
+
def go(self):
|
|
159
|
+
"""Starts the SmallRunner thread in the background."""
|
|
160
|
+
self.start()
|
|
161
|
+
|
|
162
|
+
def run(self):
|
|
163
|
+
"""
|
|
164
|
+
The main execution method for the thread.
|
|
165
|
+
"""
|
|
166
|
+
loop_created = False
|
|
167
|
+
loop = None
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
if self.is_async:
|
|
171
|
+
try:
|
|
172
|
+
loop = asyncio.get_running_loop()
|
|
173
|
+
except RuntimeError:
|
|
174
|
+
loop = asyncio.new_event_loop()
|
|
175
|
+
asyncio.set_event_loop(loop)
|
|
176
|
+
loop_created = True
|
|
177
|
+
|
|
178
|
+
result = loop.run_until_complete(self.task(*self.args, **self.kwargs))
|
|
179
|
+
else:
|
|
180
|
+
result = self.task(*self.args, **self.kwargs)
|
|
181
|
+
|
|
182
|
+
self._result_queue.put(result)
|
|
183
|
+
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.error(f"SmallRunner {self.name} task failed: {e}")
|
|
186
|
+
self._result_queue.put(e)
|
|
187
|
+
finally:
|
|
188
|
+
if loop_created and loop and not loop.is_closed():
|
|
189
|
+
try:
|
|
190
|
+
pending_tasks = asyncio.all_tasks(loop)
|
|
191
|
+
for task in pending_tasks:
|
|
192
|
+
task.cancel()
|
|
193
|
+
if pending_tasks:
|
|
194
|
+
loop.run_until_complete(
|
|
195
|
+
asyncio.gather(*pending_tasks, return_exceptions=True)
|
|
196
|
+
)
|
|
197
|
+
loop.close()
|
|
198
|
+
except Exception as cleanup_error:
|
|
199
|
+
logger.debug(f"error cleaning SmallRunner loop {cleanup_error}")
|
|
200
|
+
|
|
201
|
+
def get_results(self, timeout: Optional[float] = None) -> Any:
|
|
202
|
+
"""
|
|
203
|
+
Waits for the thread to complete and returns the result.
|
|
204
|
+
|
|
205
|
+
:param timeout: Time in seconds to wait for the result. If None, waits indefinitely.
|
|
206
|
+
:returns: The result returned by the executed task function.
|
|
207
|
+
:raises TimeoutError: If the result is not available within the specified timeout.
|
|
208
|
+
:raises Exception: If the task execution in the thread failed.
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
self.join(timeout=timeout)
|
|
212
|
+
|
|
213
|
+
if self.is_alive():
|
|
214
|
+
raise TimeoutError(f"small runner timed out after {timeout} seconds")
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
result = self._result_queue.get(block=False)
|
|
218
|
+
except queue.Empty:
|
|
219
|
+
# If queue is empty and thread is done, return None
|
|
220
|
+
return None
|
|
221
|
+
except Exception as e:
|
|
222
|
+
raise
|
|
223
|
+
|
|
224
|
+
if isinstance(result, Exception):
|
|
225
|
+
raise result
|
|
226
|
+
|
|
227
|
+
return result
|
|
228
|
+
except Exception as e:
|
|
229
|
+
raise e
|
|
230
|
+
|
|
231
|
+
def cancel(self) -> bool:
|
|
232
|
+
"""
|
|
233
|
+
Attempts to cancel the running thread.
|
|
234
|
+
Note: Python threads cannot be forcefully terminated, so this is a no-op
|
|
235
|
+
that provides interface consistency with Runner.
|
|
236
|
+
|
|
237
|
+
:returns: True if the thread is not alive, False if still running.
|
|
238
|
+
"""
|
|
239
|
+
try:
|
|
240
|
+
if not self.is_alive():
|
|
241
|
+
logger.debug(f"SmallRunner {self.name}: Already finished")
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
logger.debug(
|
|
245
|
+
f"SmallRunner {self.name}: Cannot force terminate thread, waiting for natural completion"
|
|
246
|
+
)
|
|
247
|
+
# We can't actually cancel a thread in Python, just check if it's done
|
|
248
|
+
return not self.is_alive()
|
|
249
|
+
|
|
250
|
+
except Exception as e:
|
|
251
|
+
logger.warning(f"Error checking SmallRunner {self.name}: {e}")
|
|
252
|
+
raise e
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def go_func(
|
|
256
|
+
func: Callable[..., Any], use_mp: bool = True, *args: Any, **kwargs: Any
|
|
257
|
+
) -> Union[Runner, SmallRunner]:
|
|
258
|
+
"""
|
|
259
|
+
Go-like async function execution factory. Selects the appropriate runner.
|
|
260
|
+
|
|
261
|
+
:param func: The function to execute.
|
|
262
|
+
:param use_mp: If True (default), uses the multiprocessing Runner (isolated).
|
|
263
|
+
If False, uses the threading SmallRunner (shared state).
|
|
264
|
+
:param args: Positional arguments for the function.
|
|
265
|
+
:param kwargs: Keyword arguments for the function.
|
|
266
|
+
:returns: A running Runner or SmallRunner instance.
|
|
267
|
+
"""
|
|
268
|
+
if use_mp:
|
|
269
|
+
runner = Runner(func, *args, **kwargs)
|
|
270
|
+
else:
|
|
271
|
+
runner = SmallRunner(func, *args, **kwargs)
|
|
272
|
+
|
|
273
|
+
runner.go()
|
|
274
|
+
return runner
|