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.
Files changed (46) hide show
  1. {queuerpy-0.1.0 → queuerpy-0.3.0}/MANIFEST.in +7 -0
  2. {queuerpy-0.1.0/queuerPy.egg-info → queuerpy-0.3.0}/PKG-INFO +2 -16
  3. {queuerpy-0.1.0 → queuerpy-0.3.0}/_version.py +1 -1
  4. queuerpy-0.3.0/core/__init__.py +19 -0
  5. queuerpy-0.3.0/core/broadcaster.py +77 -0
  6. queuerpy-0.3.0/core/listener.py +116 -0
  7. queuerpy-0.3.0/core/retryer.py +86 -0
  8. queuerpy-0.3.0/core/runner.py +274 -0
  9. queuerpy-0.3.0/core/scheduler.py +77 -0
  10. queuerpy-0.3.0/core/ticker.py +137 -0
  11. queuerpy-0.3.0/database/__init__.py +48 -0
  12. queuerpy-0.3.0/database/db_job.py +670 -0
  13. queuerpy-0.3.0/database/db_listener.py +159 -0
  14. queuerpy-0.3.0/database/db_worker.py +339 -0
  15. queuerpy-0.3.0/helper/__init__.py +69 -0
  16. queuerpy-0.3.0/helper/database.py +489 -0
  17. queuerpy-0.3.0/helper/error.py +39 -0
  18. queuerpy-0.3.0/helper/logging.py +259 -0
  19. queuerpy-0.3.0/helper/sql.py +178 -0
  20. queuerpy-0.3.0/helper/task.py +188 -0
  21. queuerpy-0.3.0/model/batch_job.py +20 -0
  22. queuerpy-0.3.0/model/connection.py +30 -0
  23. queuerpy-0.3.0/model/job.py +233 -0
  24. queuerpy-0.3.0/model/options.py +130 -0
  25. queuerpy-0.3.0/model/options_on_error.py +112 -0
  26. queuerpy-0.3.0/model/task.py +80 -0
  27. queuerpy-0.3.0/model/worker.py +147 -0
  28. {queuerpy-0.1.0 → queuerpy-0.3.0}/pyproject.toml +9 -4
  29. {queuerpy-0.1.0 → queuerpy-0.3.0/queuerPy.egg-info}/PKG-INFO +2 -16
  30. {queuerpy-0.1.0 → queuerpy-0.3.0}/queuerPy.egg-info/SOURCES.txt +27 -0
  31. {queuerpy-0.1.0 → queuerpy-0.3.0}/LICENSE +0 -0
  32. {queuerpy-0.1.0 → queuerpy-0.3.0}/README.md +0 -0
  33. {queuerpy-0.1.0 → queuerpy-0.3.0}/__init__.py +0 -0
  34. {queuerpy-0.1.0 → queuerpy-0.3.0}/queuer.py +0 -0
  35. {queuerpy-0.1.0 → queuerpy-0.3.0}/queuerPy.egg-info/dependency_links.txt +0 -0
  36. {queuerpy-0.1.0 → queuerpy-0.3.0}/queuerPy.egg-info/requires.txt +0 -0
  37. {queuerpy-0.1.0 → queuerpy-0.3.0}/queuerPy.egg-info/top_level.txt +0 -0
  38. {queuerpy-0.1.0 → queuerpy-0.3.0}/queuer_global.py +0 -0
  39. {queuerpy-0.1.0 → queuerpy-0.3.0}/queuer_job.py +0 -0
  40. {queuerpy-0.1.0 → queuerpy-0.3.0}/queuer_listener.py +0 -0
  41. {queuerpy-0.1.0 → queuerpy-0.3.0}/queuer_next_interval.py +0 -0
  42. {queuerpy-0.1.0 → queuerpy-0.3.0}/queuer_task.py +0 -0
  43. {queuerpy-0.1.0 → queuerpy-0.3.0}/setup.cfg +0 -0
  44. {queuerpy-0.1.0 → queuerpy-0.3.0}/sql/job.sql +0 -0
  45. {queuerpy-0.1.0 → queuerpy-0.3.0}/sql/notify.sql +0 -0
  46. {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.1.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: Copyright 2025 Simon Herrmann
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
@@ -1,6 +1,6 @@
1
1
  """Version information for queuerPy package."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
4
4
  __version_info__ = tuple(int(part) for part in __version__.split("."))
5
5
 
6
6
  # For compatibility
@@ -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