pythonboot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pythonboot/__init__.py +41 -0
- pythonboot/aop/__init__.py +11 -0
- pythonboot/aop/decorators.py +75 -0
- pythonboot/aop/joinpoint.py +33 -0
- pythonboot/aop/pointcut.py +81 -0
- pythonboot/aop/proxy.py +196 -0
- pythonboot/beans/__init__.py +7 -0
- pythonboot/beans/conditions.py +108 -0
- pythonboot/beans/definitions.py +108 -0
- pythonboot/beans/lifecycle.py +82 -0
- pythonboot/beans/scanner.py +164 -0
- pythonboot/boot/__init__.py +5 -0
- pythonboot/boot/autoconfiguration.py +120 -0
- pythonboot/boot/task_configuration.py +38 -0
- pythonboot/config/__init__.py +6 -0
- pythonboot/config/logging.py +90 -0
- pythonboot/config/properties.py +148 -0
- pythonboot/context/__init__.py +6 -0
- pythonboot/context/container.py +427 -0
- pythonboot/context/injection.py +60 -0
- pythonboot/exceptions/__init__.py +40 -0
- pythonboot/task/__init__.py +7 -0
- pythonboot/task/executor.py +241 -0
- pythonboot/transaction/__init__.py +24 -0
- pythonboot/transaction/decorators.py +48 -0
- pythonboot/transaction/definition.py +90 -0
- pythonboot/transaction/exceptions.py +31 -0
- pythonboot/transaction/interceptor.py +71 -0
- pythonboot/transaction/manager.py +114 -0
- pythonboot/util/__init__.py +5 -0
- pythonboot/util/context_util.py +59 -0
- pythonboot-0.1.0.dist-info/METADATA +502 -0
- pythonboot-0.1.0.dist-info/RECORD +34 -0
- pythonboot-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Application-scoped task executors with Spring-style pool admission."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import logging
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections import deque
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from concurrent.futures import Future
|
|
10
|
+
from contextvars import Context
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from functools import partial
|
|
13
|
+
from threading import Condition, Thread, current_thread
|
|
14
|
+
from time import monotonic
|
|
15
|
+
from typing import Any, ParamSpec, TypeVar
|
|
16
|
+
|
|
17
|
+
P = ParamSpec("P")
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
TaskDecorator = Callable[[Callable[[], Any]], Callable[[], Any]]
|
|
20
|
+
_logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TaskRejectedError(RuntimeError):
|
|
24
|
+
"""The executor is shut down or its pool and queue are saturated."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RejectionPolicy(StrEnum):
|
|
28
|
+
ABORT = "abort"
|
|
29
|
+
CALLER_RUNS = "caller_runs"
|
|
30
|
+
DISCARD = "discard"
|
|
31
|
+
DISCARD_OLDEST = "discard_oldest"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TaskExecutor(ABC):
|
|
35
|
+
"""Submit synchronous work for execution outside the calling thread."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def submit(self, task: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs) -> Future[T]:
|
|
39
|
+
"""Return a future containing the task result or exception."""
|
|
40
|
+
|
|
41
|
+
def execute(self, task: Callable[P, Any], /, *args: P.args, **kwargs: P.kwargs) -> None:
|
|
42
|
+
"""Submit fire-and-forget work and log unhandled task failures."""
|
|
43
|
+
future = self.submit(task, *args, **kwargs)
|
|
44
|
+
|
|
45
|
+
def report(completed: Future[Any]) -> None:
|
|
46
|
+
if not completed.cancelled() and (error := completed.exception()) is not None:
|
|
47
|
+
_logger.error("Task execution failed", exc_info=(type(error), error, error.__traceback__))
|
|
48
|
+
|
|
49
|
+
future.add_done_callback(report)
|
|
50
|
+
|
|
51
|
+
async def asubmit(self, task: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs) -> T:
|
|
52
|
+
"""Await synchronous work without blocking the event loop."""
|
|
53
|
+
return await asyncio.wrap_future(self.submit(task, *args, **kwargs))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ThreadPoolTaskExecutor(TaskExecutor):
|
|
57
|
+
"""Grow core workers, enqueue work, then grow to max workers before rejecting.
|
|
58
|
+
|
|
59
|
+
A queue capacity of None is unbounded; zero allows direct handoff only.
|
|
60
|
+
Threads are created on demand. Extra workers expire after keep_alive_seconds.
|
|
61
|
+
Running tasks cannot be forcibly interrupted during cancellation or shutdown.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self, *, core_pool_size: int = 8, max_pool_size: int = 32,
|
|
66
|
+
queue_capacity: int | None = 256, keep_alive_seconds: float = 60,
|
|
67
|
+
thread_name_prefix: str = "pythonboot-task-",
|
|
68
|
+
allow_core_thread_timeout: bool = False,
|
|
69
|
+
rejection_policy: RejectionPolicy | str = RejectionPolicy.ABORT,
|
|
70
|
+
wait_for_tasks_to_complete_on_shutdown: bool = True,
|
|
71
|
+
await_termination_seconds: float | None = None,
|
|
72
|
+
task_decorator: TaskDecorator | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
for name, value in (("core_pool_size", core_pool_size), ("max_pool_size", max_pool_size)):
|
|
75
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
76
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
77
|
+
if max_pool_size < core_pool_size:
|
|
78
|
+
raise ValueError("max_pool_size must be at least core_pool_size")
|
|
79
|
+
if queue_capacity is not None and (
|
|
80
|
+
isinstance(queue_capacity, bool) or not isinstance(queue_capacity, int) or queue_capacity < 0
|
|
81
|
+
):
|
|
82
|
+
raise ValueError("queue_capacity must be a non-negative integer or None")
|
|
83
|
+
if not 0 < keep_alive_seconds < float("inf"):
|
|
84
|
+
raise ValueError("keep_alive_seconds must be positive and finite")
|
|
85
|
+
if await_termination_seconds is not None and not 0 <= await_termination_seconds < float("inf"):
|
|
86
|
+
raise ValueError("await_termination_seconds must be non-negative and finite or None")
|
|
87
|
+
if not isinstance(thread_name_prefix, str) or not thread_name_prefix:
|
|
88
|
+
raise ValueError("thread_name_prefix must be a non-empty string")
|
|
89
|
+
if task_decorator is not None and not callable(task_decorator):
|
|
90
|
+
raise TypeError("task_decorator must be callable")
|
|
91
|
+
self.core_pool_size = core_pool_size
|
|
92
|
+
self.max_pool_size = max_pool_size
|
|
93
|
+
self.queue_capacity = queue_capacity
|
|
94
|
+
self.keep_alive_seconds = keep_alive_seconds
|
|
95
|
+
self.thread_name_prefix = thread_name_prefix
|
|
96
|
+
self.allow_core_thread_timeout = allow_core_thread_timeout
|
|
97
|
+
self.rejection_policy = RejectionPolicy(rejection_policy)
|
|
98
|
+
self.wait_for_tasks_to_complete_on_shutdown = wait_for_tasks_to_complete_on_shutdown
|
|
99
|
+
self.await_termination_seconds = await_termination_seconds
|
|
100
|
+
self.task_decorator = task_decorator
|
|
101
|
+
self._condition = Condition()
|
|
102
|
+
self._queue: deque[tuple[Future[Any], Callable[[], Any]]] = deque()
|
|
103
|
+
self._threads: set[Thread] = set()
|
|
104
|
+
self._idle = 0
|
|
105
|
+
self._sequence = 0
|
|
106
|
+
self._shutdown = False
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def pool_size(self) -> int:
|
|
110
|
+
with self._condition:
|
|
111
|
+
return len(self._threads)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def queue_size(self) -> int:
|
|
115
|
+
with self._condition:
|
|
116
|
+
return len(self._queue)
|
|
117
|
+
|
|
118
|
+
def submit(self, task: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs) -> Future[T]:
|
|
119
|
+
if not callable(task) or inspect.iscoroutinefunction(task) or inspect.iscoroutinefunction(getattr(task, "__call__", None)):
|
|
120
|
+
raise TypeError("Thread pool tasks must be synchronous callables")
|
|
121
|
+
callback = partial(task, *args, **kwargs)
|
|
122
|
+
if self.task_decorator is not None:
|
|
123
|
+
callback = self.task_decorator(callback)
|
|
124
|
+
if not callable(callback):
|
|
125
|
+
raise TypeError("task_decorator must return a callable")
|
|
126
|
+
future: Future[T] = Future()
|
|
127
|
+
work = (future, callback)
|
|
128
|
+
discarded: Future[Any] | None = None
|
|
129
|
+
with self._condition:
|
|
130
|
+
if self._shutdown:
|
|
131
|
+
raise TaskRejectedError("Task executor has been shut down")
|
|
132
|
+
if len(self._threads) < self.core_pool_size:
|
|
133
|
+
self._start_worker(work)
|
|
134
|
+
return future
|
|
135
|
+
available = (
|
|
136
|
+
self.queue_capacity is None
|
|
137
|
+
or self.queue_capacity > 0 and len(self._queue) < self.queue_capacity
|
|
138
|
+
or self.queue_capacity == 0 and len(self._queue) < self._idle
|
|
139
|
+
)
|
|
140
|
+
if available:
|
|
141
|
+
self._queue.append(work)
|
|
142
|
+
self._condition.notify()
|
|
143
|
+
return future
|
|
144
|
+
if len(self._threads) < self.max_pool_size:
|
|
145
|
+
self._start_worker(work)
|
|
146
|
+
return future
|
|
147
|
+
if self.rejection_policy == RejectionPolicy.ABORT:
|
|
148
|
+
raise TaskRejectedError("Task executor pool and queue are saturated")
|
|
149
|
+
if self.rejection_policy == RejectionPolicy.DISCARD_OLDEST and self._queue:
|
|
150
|
+
discarded, _ = self._queue.popleft()
|
|
151
|
+
self._queue.append(work)
|
|
152
|
+
self._condition.notify()
|
|
153
|
+
if discarded is not None:
|
|
154
|
+
discarded.cancel()
|
|
155
|
+
elif self.rejection_policy == RejectionPolicy.CALLER_RUNS:
|
|
156
|
+
self._run(work)
|
|
157
|
+
else:
|
|
158
|
+
future.cancel()
|
|
159
|
+
return future
|
|
160
|
+
|
|
161
|
+
def _start_worker(self, work: tuple[Future[Any], Callable[[], Any]]) -> None:
|
|
162
|
+
self._sequence += 1
|
|
163
|
+
thread = Thread(
|
|
164
|
+
target=Context().run, args=(self._worker, work),
|
|
165
|
+
name=f"{self.thread_name_prefix}{self._sequence}", daemon=False,
|
|
166
|
+
)
|
|
167
|
+
self._threads.add(thread)
|
|
168
|
+
try:
|
|
169
|
+
thread.start()
|
|
170
|
+
except BaseException:
|
|
171
|
+
self._threads.remove(thread)
|
|
172
|
+
raise
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _run(work: tuple[Future[Any], Callable[[], Any]]) -> None:
|
|
176
|
+
future, callback = work
|
|
177
|
+
if not future.set_running_or_notify_cancel():
|
|
178
|
+
return
|
|
179
|
+
try:
|
|
180
|
+
result = callback()
|
|
181
|
+
if inspect.isawaitable(result):
|
|
182
|
+
if inspect.iscoroutine(result):
|
|
183
|
+
result.close()
|
|
184
|
+
raise TypeError("Thread pool tasks must not return awaitables")
|
|
185
|
+
except BaseException as error:
|
|
186
|
+
future.set_exception(error)
|
|
187
|
+
else:
|
|
188
|
+
future.set_result(result)
|
|
189
|
+
|
|
190
|
+
def _worker(self, work: tuple[Future[Any], Callable[[], Any]]) -> None:
|
|
191
|
+
try:
|
|
192
|
+
while True:
|
|
193
|
+
self._run(work)
|
|
194
|
+
with self._condition:
|
|
195
|
+
deadline = monotonic() + self.keep_alive_seconds
|
|
196
|
+
while not self._queue:
|
|
197
|
+
if self._shutdown:
|
|
198
|
+
self._threads.remove(current_thread())
|
|
199
|
+
return
|
|
200
|
+
may_expire = self.allow_core_thread_timeout or len(self._threads) > self.core_pool_size
|
|
201
|
+
remaining = deadline - monotonic() if may_expire else None
|
|
202
|
+
if remaining is not None and remaining <= 0:
|
|
203
|
+
self._threads.remove(current_thread())
|
|
204
|
+
return
|
|
205
|
+
self._idle += 1
|
|
206
|
+
try:
|
|
207
|
+
self._condition.wait(remaining)
|
|
208
|
+
finally:
|
|
209
|
+
self._idle -= 1
|
|
210
|
+
work = self._queue.popleft()
|
|
211
|
+
finally:
|
|
212
|
+
with self._condition:
|
|
213
|
+
self._threads.discard(current_thread())
|
|
214
|
+
self._condition.notify_all()
|
|
215
|
+
|
|
216
|
+
def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None:
|
|
217
|
+
"""Reject new submissions, optionally cancel queued work, and join workers."""
|
|
218
|
+
with self._condition:
|
|
219
|
+
self._shutdown = True
|
|
220
|
+
pending = tuple(self._queue) if cancel_futures else ()
|
|
221
|
+
if cancel_futures:
|
|
222
|
+
self._queue.clear()
|
|
223
|
+
threads = tuple(self._threads)
|
|
224
|
+
self._condition.notify_all()
|
|
225
|
+
for future, _ in pending:
|
|
226
|
+
future.cancel()
|
|
227
|
+
if wait:
|
|
228
|
+
deadline = (
|
|
229
|
+
monotonic() + self.await_termination_seconds
|
|
230
|
+
if self.await_termination_seconds is not None else None
|
|
231
|
+
)
|
|
232
|
+
for thread in threads:
|
|
233
|
+
if thread is not current_thread():
|
|
234
|
+
thread.join(None if deadline is None else max(0, deadline - monotonic()))
|
|
235
|
+
|
|
236
|
+
async def close(self) -> None:
|
|
237
|
+
"""Release workers through the container's asynchronous destruction hook."""
|
|
238
|
+
await asyncio.to_thread(
|
|
239
|
+
self.shutdown, self.wait_for_tasks_to_complete_on_shutdown,
|
|
240
|
+
cancel_futures=not self.wait_for_tasks_to_complete_on_shutdown,
|
|
241
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Public resource-independent transaction contracts and declarations."""
|
|
2
|
+
|
|
3
|
+
from pythonboot.transaction.decorators import get_transaction_attribute, transactional
|
|
4
|
+
from pythonboot.transaction.definition import Isolation, Propagation, TransactionAttribute, TransactionDefinition
|
|
5
|
+
from pythonboot.transaction.exceptions import (
|
|
6
|
+
IllegalTransactionStateError, InvalidIsolationLevelError,
|
|
7
|
+
NestedTransactionNotSupportedError, NoTransactionError,
|
|
8
|
+
TransactionError, TransactionTimedOutError, UnexpectedRollbackError,
|
|
9
|
+
)
|
|
10
|
+
from pythonboot.transaction.manager import (
|
|
11
|
+
AsyncPlatformTransactionManager, AsyncSavepointManager,
|
|
12
|
+
PlatformTransactionManager, SavepointManager, TransactionStatus,
|
|
13
|
+
)
|
|
14
|
+
from pythonboot.transaction.interceptor import TransactionInterceptor
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Propagation", "Isolation", "TransactionDefinition", "TransactionAttribute",
|
|
18
|
+
"TransactionStatus", "PlatformTransactionManager", "AsyncPlatformTransactionManager",
|
|
19
|
+
"SavepointManager", "AsyncSavepointManager", "transactional", "get_transaction_attribute",
|
|
20
|
+
"TransactionError", "NoTransactionError", "IllegalTransactionStateError",
|
|
21
|
+
"InvalidIsolationLevelError", "TransactionTimedOutError",
|
|
22
|
+
"NestedTransactionNotSupportedError", "UnexpectedRollbackError",
|
|
23
|
+
"TransactionInterceptor",
|
|
24
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Declarative transaction metadata compatible with annotation pointcuts."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable, TypeVar
|
|
4
|
+
|
|
5
|
+
from pythonboot.aop.pointcut import MethodAnnotation
|
|
6
|
+
from pythonboot.transaction.definition import Isolation, Propagation, TransactionAttribute
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
F = TypeVar("F", bound=Callable)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Transactional(MethodAnnotation):
|
|
13
|
+
"""Mark a method for a resource-specific transaction interceptor."""
|
|
14
|
+
|
|
15
|
+
def __call__(
|
|
16
|
+
self,
|
|
17
|
+
method: F | None = None,
|
|
18
|
+
*,
|
|
19
|
+
propagation: Propagation = Propagation.REQUIRED,
|
|
20
|
+
isolation: Isolation = Isolation.DEFAULT,
|
|
21
|
+
timeout: int = -1,
|
|
22
|
+
read_only: bool = False,
|
|
23
|
+
name: str | None = None,
|
|
24
|
+
rollback_for: tuple[type[BaseException], ...] = (Exception,),
|
|
25
|
+
no_rollback_for: tuple[type[BaseException], ...] = (),
|
|
26
|
+
transaction_manager: str | None = None,
|
|
27
|
+
) -> F | Callable[[F], F]:
|
|
28
|
+
"""Support both @transactional and @transactional(...), without wrapping."""
|
|
29
|
+
attribute = TransactionAttribute(
|
|
30
|
+
propagation=propagation, isolation=isolation, timeout=timeout,
|
|
31
|
+
read_only=read_only, name=name, rollback_for=rollback_for,
|
|
32
|
+
no_rollback_for=no_rollback_for, transaction_manager=transaction_manager,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def decorate(target: F) -> F:
|
|
36
|
+
super(Transactional, self).__call__(target)
|
|
37
|
+
target.__pythonboot_transaction__ = attribute
|
|
38
|
+
return target
|
|
39
|
+
|
|
40
|
+
return decorate(method) if method is not None else decorate
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
transactional = Transactional("pythonboot.transactional")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_transaction_attribute(method: Callable) -> TransactionAttribute | None:
|
|
47
|
+
"""Read transaction metadata from a function or bound instance method."""
|
|
48
|
+
return getattr(method, "__pythonboot_transaction__", None)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Resource-independent transaction definitions and rollback rules."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Propagation(IntEnum):
|
|
8
|
+
"""Spring-compatible transaction propagation policies."""
|
|
9
|
+
|
|
10
|
+
REQUIRED = 0
|
|
11
|
+
SUPPORTS = 1
|
|
12
|
+
MANDATORY = 2
|
|
13
|
+
REQUIRES_NEW = 3
|
|
14
|
+
NOT_SUPPORTED = 4
|
|
15
|
+
NEVER = 5
|
|
16
|
+
NESTED = 6
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Isolation(IntEnum):
|
|
20
|
+
"""Standard transaction isolation levels; DEFAULT uses the resource default."""
|
|
21
|
+
|
|
22
|
+
DEFAULT = -1
|
|
23
|
+
READ_UNCOMMITTED = 1
|
|
24
|
+
READ_COMMITTED = 2
|
|
25
|
+
REPEATABLE_READ = 4
|
|
26
|
+
SERIALIZABLE = 8
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class TransactionDefinition:
|
|
31
|
+
"""Describe a transaction without creating or binding resource connections."""
|
|
32
|
+
|
|
33
|
+
propagation: Propagation = Propagation.REQUIRED
|
|
34
|
+
isolation: Isolation = Isolation.DEFAULT
|
|
35
|
+
timeout: int = -1
|
|
36
|
+
read_only: bool = False
|
|
37
|
+
name: str | None = None
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if not isinstance(self.propagation, Propagation):
|
|
41
|
+
raise TypeError("propagation must be a Propagation value")
|
|
42
|
+
if not isinstance(self.isolation, Isolation):
|
|
43
|
+
raise TypeError("isolation must be an Isolation value")
|
|
44
|
+
if isinstance(self.timeout, bool) or not isinstance(self.timeout, int) or self.timeout < -1:
|
|
45
|
+
raise ValueError("timeout must be -1 or a non-negative number of seconds")
|
|
46
|
+
if not isinstance(self.read_only, bool):
|
|
47
|
+
raise TypeError("read_only must be a boolean")
|
|
48
|
+
if self.name is not None and (not isinstance(self.name, str) or not self.name.strip()):
|
|
49
|
+
raise ValueError("name must be a non-empty string or None")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class TransactionAttribute(TransactionDefinition):
|
|
54
|
+
"""Add rollback rules and an optional transaction manager bean name."""
|
|
55
|
+
|
|
56
|
+
rollback_for: tuple[type[BaseException], ...] = (Exception,)
|
|
57
|
+
no_rollback_for: tuple[type[BaseException], ...] = ()
|
|
58
|
+
transaction_manager: str | None = None
|
|
59
|
+
|
|
60
|
+
def __post_init__(self) -> None:
|
|
61
|
+
super().__post_init__()
|
|
62
|
+
for label in ("rollback_for", "no_rollback_for"):
|
|
63
|
+
rules = getattr(self, label)
|
|
64
|
+
if not isinstance(rules, (tuple, list)) or any(
|
|
65
|
+
not isinstance(rule, type) or not issubclass(rule, BaseException)
|
|
66
|
+
for rule in rules
|
|
67
|
+
):
|
|
68
|
+
raise TypeError(f"{label} must be a sequence of exception classes")
|
|
69
|
+
object.__setattr__(self, label, tuple(dict.fromkeys(rules)))
|
|
70
|
+
if self.transaction_manager is not None and (
|
|
71
|
+
not isinstance(self.transaction_manager, str) or not self.transaction_manager.strip()
|
|
72
|
+
):
|
|
73
|
+
raise ValueError("transaction_manager must be a non-empty bean name or None")
|
|
74
|
+
|
|
75
|
+
def rollback_on(self, error: BaseException) -> bool:
|
|
76
|
+
"""Use the closest exception rule; exclusions win ties; fatal errors roll back."""
|
|
77
|
+
if not isinstance(error, BaseException):
|
|
78
|
+
raise TypeError("error must be an exception instance")
|
|
79
|
+
if not isinstance(error, Exception):
|
|
80
|
+
return True
|
|
81
|
+
hierarchy = type(error).__mro__
|
|
82
|
+
rollback_depth = min(
|
|
83
|
+
(hierarchy.index(rule) for rule in self.rollback_for if rule in hierarchy),
|
|
84
|
+
default=float("inf"),
|
|
85
|
+
)
|
|
86
|
+
exclusion_depth = min(
|
|
87
|
+
(hierarchy.index(rule) for rule in self.no_rollback_for if rule in hierarchy),
|
|
88
|
+
default=float("inf"),
|
|
89
|
+
)
|
|
90
|
+
return rollback_depth < exclusion_depth
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Resource-independent transaction exceptions."""
|
|
2
|
+
|
|
3
|
+
from pythonboot.exceptions import PythonBootError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TransactionError(PythonBootError):
|
|
7
|
+
"""Base class for transaction failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NoTransactionError(TransactionError):
|
|
11
|
+
"""A required existing transaction is missing."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IllegalTransactionStateError(TransactionError):
|
|
15
|
+
"""Invalid transaction participation, status ownership, or completion state."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvalidIsolationLevelError(TransactionError):
|
|
19
|
+
"""The requested isolation level cannot be applied by the resource."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TransactionTimedOutError(TransactionError):
|
|
23
|
+
"""A transaction exceeded its configured timeout."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NestedTransactionNotSupportedError(TransactionError):
|
|
27
|
+
"""The resource does not support nested transactions or savepoints."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UnexpectedRollbackError(TransactionError):
|
|
31
|
+
"""Commit was requested for a transaction already marked rollback-only."""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Application-scoped around advice for declarative transactions."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
import inspect
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from pythonboot.aop.decorators import around
|
|
8
|
+
from pythonboot.aop.joinpoint import ProceedingJoinPoint
|
|
9
|
+
from pythonboot.transaction.decorators import get_transaction_attribute, transactional
|
|
10
|
+
from pythonboot.transaction.definition import TransactionAttribute
|
|
11
|
+
from pythonboot.transaction.exceptions import IllegalTransactionStateError
|
|
12
|
+
from pythonboot.transaction.manager import AsyncPlatformTransactionManager, PlatformTransactionManager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TransactionInterceptor:
|
|
16
|
+
"""Resolve managers from the owning container and complete transaction scopes."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
resolver: Callable[[type, str | None], Any],
|
|
21
|
+
async_resolver: Callable[[type, str | None], Any] | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._resolver = resolver
|
|
24
|
+
self._async_resolver = async_resolver
|
|
25
|
+
|
|
26
|
+
@around(transactional)
|
|
27
|
+
def invoke(self, join_point: ProceedingJoinPoint) -> Any:
|
|
28
|
+
"""Dispatch using the original method's coroutine type."""
|
|
29
|
+
attribute = get_transaction_attribute(join_point.method)
|
|
30
|
+
if attribute is None:
|
|
31
|
+
raise IllegalTransactionStateError("Transaction advice requires transaction metadata")
|
|
32
|
+
if attribute.name is None:
|
|
33
|
+
attribute = replace(attribute, name=join_point.qualified_name)
|
|
34
|
+
if inspect.iscoroutinefunction(join_point.method):
|
|
35
|
+
return self._invoke_async(join_point, attribute)
|
|
36
|
+
return self._invoke_sync(join_point, attribute)
|
|
37
|
+
|
|
38
|
+
def _invoke_sync(self, join_point: ProceedingJoinPoint, attribute: TransactionAttribute) -> Any:
|
|
39
|
+
manager = self._resolver(PlatformTransactionManager, attribute.transaction_manager)
|
|
40
|
+
if not isinstance(manager, PlatformTransactionManager):
|
|
41
|
+
raise IllegalTransactionStateError("Synchronous transaction methods require PlatformTransactionManager")
|
|
42
|
+
status = manager.get_transaction(attribute)
|
|
43
|
+
try:
|
|
44
|
+
result = join_point.proceed()
|
|
45
|
+
except BaseException as error:
|
|
46
|
+
if attribute.rollback_on(error):
|
|
47
|
+
manager.rollback(status)
|
|
48
|
+
else:
|
|
49
|
+
manager.commit(status)
|
|
50
|
+
raise
|
|
51
|
+
manager.commit(status)
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
async def _invoke_async(self, join_point: ProceedingJoinPoint, attribute: TransactionAttribute) -> Any:
|
|
55
|
+
if self._async_resolver is None:
|
|
56
|
+
manager = self._resolver(AsyncPlatformTransactionManager, attribute.transaction_manager)
|
|
57
|
+
else:
|
|
58
|
+
manager = await self._async_resolver(AsyncPlatformTransactionManager, attribute.transaction_manager)
|
|
59
|
+
if not isinstance(manager, AsyncPlatformTransactionManager):
|
|
60
|
+
raise IllegalTransactionStateError("Asynchronous transaction methods require AsyncPlatformTransactionManager")
|
|
61
|
+
status = await manager.get_transaction(attribute)
|
|
62
|
+
try:
|
|
63
|
+
result = await join_point.proceed()
|
|
64
|
+
except BaseException as error:
|
|
65
|
+
if attribute.rollback_on(error):
|
|
66
|
+
await manager.rollback(status)
|
|
67
|
+
else:
|
|
68
|
+
await manager.commit(status)
|
|
69
|
+
raise
|
|
70
|
+
await manager.commit(status)
|
|
71
|
+
return result
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Contracts for synchronous and asynchronous resource transaction managers."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pythonboot.transaction.definition import TransactionDefinition
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SavepointManager(ABC):
|
|
10
|
+
"""Optional synchronous savepoint capability for nested transactions."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def create_savepoint(self) -> Any:
|
|
14
|
+
"""Create and return an opaque resource savepoint."""
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def rollback_to_savepoint(self, savepoint: Any) -> None:
|
|
18
|
+
"""Roll back to the given savepoint without completing the transaction."""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def release_savepoint(self, savepoint: Any) -> None:
|
|
22
|
+
"""Release the given savepoint."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AsyncSavepointManager(ABC):
|
|
26
|
+
"""Optional asynchronous savepoint capability for nested transactions."""
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
async def create_savepoint(self) -> Any:
|
|
30
|
+
"""Create and return an opaque resource savepoint."""
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def rollback_to_savepoint(self, savepoint: Any) -> None:
|
|
34
|
+
"""Roll back to the given savepoint without completing the transaction."""
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def release_savepoint(self, savepoint: Any) -> None:
|
|
38
|
+
"""Release the given savepoint."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TransactionStatus(ABC):
|
|
42
|
+
"""Expose transaction participation, rollback-only state, and completion."""
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def is_new_transaction(self) -> bool:
|
|
47
|
+
"""Whether this scope owns a newly created physical transaction."""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def has_transaction(self) -> bool:
|
|
52
|
+
"""Whether this scope participates in a physical transaction."""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def has_savepoint(self) -> bool:
|
|
57
|
+
"""Whether this scope uses a savepoint in an existing transaction."""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def is_rollback_only(self) -> bool:
|
|
62
|
+
"""Whether the underlying transaction must be rolled back."""
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def set_rollback_only(self) -> None:
|
|
66
|
+
"""Mark the transaction for rollback even if the business method returns."""
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def is_completed(self) -> bool:
|
|
71
|
+
"""Whether this scope has already been committed or rolled back."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PlatformTransactionManager(ABC):
|
|
75
|
+
"""Synchronous transaction manager implemented by resource-specific starters.
|
|
76
|
+
|
|
77
|
+
Managers implement propagation and isolation, reject unsupported options,
|
|
78
|
+
and validate status ownership and completion. Completion must release owned
|
|
79
|
+
resources and restore suspended contexts, including on failure. Participating
|
|
80
|
+
rollback marks the shared transaction rollback-only; an owner attempting to
|
|
81
|
+
commit that transaction must raise UnexpectedRollbackError.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
def get_transaction(self, definition: TransactionDefinition | None = None) -> TransactionStatus:
|
|
86
|
+
"""Create, join, suspend, or reject a transaction according to its definition."""
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
def commit(self, status: TransactionStatus) -> None:
|
|
90
|
+
"""Complete a scope; only the owning scope commits the physical transaction."""
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def rollback(self, status: TransactionStatus) -> None:
|
|
94
|
+
"""Roll back an owned transaction or mark a participating transaction."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AsyncPlatformTransactionManager(ABC):
|
|
98
|
+
"""Asynchronous manager with the same completion contract as the sync manager.
|
|
99
|
+
|
|
100
|
+
Implementations must clean up resources on cancellation and prevent sharing
|
|
101
|
+
a non-concurrent resource transaction across independent asyncio tasks.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
@abstractmethod
|
|
105
|
+
async def get_transaction(self, definition: TransactionDefinition | None = None) -> TransactionStatus:
|
|
106
|
+
"""Create, join, suspend, or reject a transaction according to its definition."""
|
|
107
|
+
|
|
108
|
+
@abstractmethod
|
|
109
|
+
async def commit(self, status: TransactionStatus) -> None:
|
|
110
|
+
"""Complete a scope; only the owning scope commits the physical transaction."""
|
|
111
|
+
|
|
112
|
+
@abstractmethod
|
|
113
|
+
async def rollback(self, status: TransactionStatus) -> None:
|
|
114
|
+
"""Roll back an owned transaction or mark a participating transaction."""
|