aiogram-coordination 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.
- aiogram_coordination/__init__.py +47 -0
- aiogram_coordination/compat.py +72 -0
- aiogram_coordination/errors.py +21 -0
- aiogram_coordination/identity.py +66 -0
- aiogram_coordination/isolation.py +331 -0
- aiogram_coordination/memory.py +127 -0
- aiogram_coordination/protocols.py +55 -0
- aiogram_coordination/py.typed +0 -0
- aiogram_coordination/redis.py +399 -0
- aiogram_coordination/redis_isolation.py +202 -0
- aiogram_coordination/sessions.py +61 -0
- aiogram_coordination/version.py +8 -0
- aiogram_coordination-0.1.0.dist-info/METADATA +123 -0
- aiogram_coordination-0.1.0.dist-info/RECORD +16 -0
- aiogram_coordination-0.1.0.dist-info/WHEEL +4 -0
- aiogram_coordination-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Shared task-scoped coordination without Idle/Nav dependencies."""
|
|
2
|
+
|
|
3
|
+
from .errors import (
|
|
4
|
+
ConfigurationError,
|
|
5
|
+
CoordinationError,
|
|
6
|
+
OwnershipLost,
|
|
7
|
+
PersistenceUnavailable,
|
|
8
|
+
SessionAuthorityUnavailable,
|
|
9
|
+
)
|
|
10
|
+
from .identity import CoordinationKey, coordination_key
|
|
11
|
+
from .isolation import AiogramMemoryCoordinator, TrackedEventIsolation
|
|
12
|
+
from .memory import MemoryCoordinator
|
|
13
|
+
from .protocols import (
|
|
14
|
+
BorrowedFsmCoordinator,
|
|
15
|
+
Coordinator,
|
|
16
|
+
FencedCoordinator,
|
|
17
|
+
IsolationProvider,
|
|
18
|
+
LeaseHandle,
|
|
19
|
+
)
|
|
20
|
+
from .redis import RedisCoordinator
|
|
21
|
+
from .redis_isolation import RedisIsolationProvider, RenewableRedisEventIsolation
|
|
22
|
+
from .sessions import ExternalSession, SessionAuthority, validate_session_transition
|
|
23
|
+
from .version import __version__ as __version__
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"BorrowedFsmCoordinator",
|
|
27
|
+
"AiogramMemoryCoordinator",
|
|
28
|
+
"TrackedEventIsolation",
|
|
29
|
+
"RenewableRedisEventIsolation",
|
|
30
|
+
"RedisIsolationProvider",
|
|
31
|
+
"CoordinationKey",
|
|
32
|
+
"coordination_key",
|
|
33
|
+
"Coordinator",
|
|
34
|
+
"FencedCoordinator",
|
|
35
|
+
"MemoryCoordinator",
|
|
36
|
+
"RedisCoordinator",
|
|
37
|
+
"IsolationProvider",
|
|
38
|
+
"LeaseHandle",
|
|
39
|
+
"ExternalSession",
|
|
40
|
+
"SessionAuthority",
|
|
41
|
+
"validate_session_transition",
|
|
42
|
+
"ConfigurationError",
|
|
43
|
+
"CoordinationError",
|
|
44
|
+
"OwnershipLost",
|
|
45
|
+
"PersistenceUnavailable",
|
|
46
|
+
"SessionAuthorityUnavailable",
|
|
47
|
+
]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Task-preserving timeout scope, including Python 3.10."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncGenerator, Callable
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@asynccontextmanager
|
|
12
|
+
async def bounded(seconds: float) -> AsyncGenerator[None, None]:
|
|
13
|
+
task = asyncio.current_task()
|
|
14
|
+
if task is None:
|
|
15
|
+
raise RuntimeError("Timeout scope requires an asyncio task")
|
|
16
|
+
cancelling = cast(Callable[[], int] | None, getattr(task, "cancelling", None))
|
|
17
|
+
uncancel = cast(Callable[[], int] | None, getattr(task, "uncancel", None))
|
|
18
|
+
baseline = cancelling() if cancelling is not None else 0
|
|
19
|
+
expired = False
|
|
20
|
+
consumed = False
|
|
21
|
+
marker = object()
|
|
22
|
+
legacy_marker = False
|
|
23
|
+
legacy_previous: object = None
|
|
24
|
+
|
|
25
|
+
def expire() -> None:
|
|
26
|
+
nonlocal expired, legacy_marker, legacy_previous
|
|
27
|
+
expired = True
|
|
28
|
+
if cancelling is None:
|
|
29
|
+
legacy_previous = getattr(task, "_cancel_message", None)
|
|
30
|
+
task.cancel(marker)
|
|
31
|
+
if cancelling is None and hasattr(task, "_cancel_message"):
|
|
32
|
+
# CPython 3.10 has no cancellation counter. Pin its message so even
|
|
33
|
+
# an external cancel(None) racing this cancellation remains distinct.
|
|
34
|
+
# Other Task implementations fall back to the delivered marker.
|
|
35
|
+
try:
|
|
36
|
+
setattr(task, "_cancel_message", marker) # noqa: B010 -- guarded 3.10-only attribute
|
|
37
|
+
except (AttributeError, TypeError):
|
|
38
|
+
pass
|
|
39
|
+
else:
|
|
40
|
+
legacy_marker = True
|
|
41
|
+
|
|
42
|
+
def consume() -> int:
|
|
43
|
+
nonlocal consumed
|
|
44
|
+
if uncancel is not None and not consumed:
|
|
45
|
+
consumed = True
|
|
46
|
+
return uncancel()
|
|
47
|
+
return cancelling() if cancelling is not None else 0
|
|
48
|
+
|
|
49
|
+
handle = asyncio.get_running_loop().call_later(seconds, expire)
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
except asyncio.CancelledError as exc:
|
|
53
|
+
if expired:
|
|
54
|
+
remaining = consume()
|
|
55
|
+
owned = (
|
|
56
|
+
remaining <= baseline
|
|
57
|
+
if cancelling is not None
|
|
58
|
+
else exc.args == (marker,)
|
|
59
|
+
and (not legacy_marker or getattr(task, "_cancel_message", None) is marker)
|
|
60
|
+
)
|
|
61
|
+
if owned:
|
|
62
|
+
raise asyncio.TimeoutError from exc
|
|
63
|
+
raise
|
|
64
|
+
finally:
|
|
65
|
+
handle.cancel()
|
|
66
|
+
if expired:
|
|
67
|
+
if legacy_marker and getattr(task, "_cancel_message", None) is marker:
|
|
68
|
+
# Restore an outer timeout marker, never an external cancellation.
|
|
69
|
+
setattr(task, "_cancel_message", legacy_previous) # noqa: B010
|
|
70
|
+
# A body that suppresses CancelledError still must relinquish exactly
|
|
71
|
+
# this timeout's request; external cancellation requests are retained.
|
|
72
|
+
consume()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Public coordination and authority failures; no credentials in messages."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CoordinationError(Exception):
|
|
5
|
+
"""Base shared coordination failure."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(CoordinationError, ValueError):
|
|
9
|
+
"""The requested setup cannot prove safe coordination."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OwnershipLost(CoordinationError):
|
|
13
|
+
"""The protected scope no longer owns its lock or lease."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PersistenceUnavailable(CoordinationError):
|
|
17
|
+
"""The lock's authoritative storage is unavailable."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SessionAuthorityUnavailable(CoordinationError):
|
|
21
|
+
"""Session lookup failed; this is not an unauthorized-session result."""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Lossless full-identity keys shared without importing consuming libraries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Protocol, cast
|
|
7
|
+
|
|
8
|
+
from .errors import ConfigurationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class CoordinationKey:
|
|
13
|
+
namespace: str
|
|
14
|
+
bot_id: int
|
|
15
|
+
chat_id: int
|
|
16
|
+
user_id: int
|
|
17
|
+
tenant_id: str | None = None
|
|
18
|
+
|
|
19
|
+
def __post_init__(self) -> None:
|
|
20
|
+
if (
|
|
21
|
+
not isinstance(cast(object, self.namespace), str)
|
|
22
|
+
or not self.namespace
|
|
23
|
+
or len(self.namespace) > 128
|
|
24
|
+
):
|
|
25
|
+
raise ConfigurationError("Namespace must contain 1 to 128 characters")
|
|
26
|
+
for name in ("bot_id", "chat_id", "user_id"):
|
|
27
|
+
value = getattr(self, name)
|
|
28
|
+
if type(value) is not int or value <= 0:
|
|
29
|
+
raise ConfigurationError(f"{name} must be a positive integer")
|
|
30
|
+
if self.tenant_id is not None and (
|
|
31
|
+
not isinstance(cast(object, self.tenant_id), str)
|
|
32
|
+
or not self.tenant_id
|
|
33
|
+
or len(self.tenant_id) > 128
|
|
34
|
+
):
|
|
35
|
+
raise ConfigurationError("Tenant must be nonempty text when supplied")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Identity(Protocol):
|
|
39
|
+
namespace: str
|
|
40
|
+
bot_id: int
|
|
41
|
+
chat_id: int
|
|
42
|
+
user_id: int
|
|
43
|
+
tenant_id: str | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def coordination_key(key: object) -> CoordinationKey:
|
|
47
|
+
"""Accept validated structural library keys without collapsing any identity."""
|
|
48
|
+
if isinstance(key, CoordinationKey):
|
|
49
|
+
return key
|
|
50
|
+
for field in ("thread_id", "message_thread_id", "business_connection_id", "destiny"):
|
|
51
|
+
value = getattr(key, field, None)
|
|
52
|
+
if value is not None and not (field == "destiny" and value == "default"):
|
|
53
|
+
raise ConfigurationError(
|
|
54
|
+
"Additional FSM identity requires an explicit lossless mapping"
|
|
55
|
+
)
|
|
56
|
+
structural = cast(_Identity, key)
|
|
57
|
+
try:
|
|
58
|
+
return CoordinationKey(
|
|
59
|
+
namespace=structural.namespace,
|
|
60
|
+
bot_id=structural.bot_id,
|
|
61
|
+
chat_id=structural.chat_id,
|
|
62
|
+
user_id=structural.user_id,
|
|
63
|
+
tenant_id=structural.tenant_id,
|
|
64
|
+
)
|
|
65
|
+
except (AttributeError, TypeError) as error:
|
|
66
|
+
raise ConfigurationError("Expected a lossless full conversation identity") from error
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Explicit, verifiable application-owned FSM isolation integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncGenerator, Callable, Hashable
|
|
7
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from aiogram.fsm.storage.base import BaseEventIsolation, StorageKey
|
|
13
|
+
from aiogram.fsm.storage.memory import SimpleEventIsolation
|
|
14
|
+
|
|
15
|
+
from .errors import ConfigurationError, OwnershipLost
|
|
16
|
+
from .identity import coordination_key
|
|
17
|
+
from .memory import MemoryCoordinator
|
|
18
|
+
from .protocols import IsolationProvider, LeaseHandle
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class _MemoryLease:
|
|
23
|
+
key: StorageKey
|
|
24
|
+
lost: asyncio.Event
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def valid(self) -> bool:
|
|
28
|
+
return not self.lost.is_set()
|
|
29
|
+
|
|
30
|
+
def assert_owned(self) -> None:
|
|
31
|
+
if not self.valid:
|
|
32
|
+
raise OwnershipLost("FSM isolation ownership ended")
|
|
33
|
+
|
|
34
|
+
async def wait_lost(self) -> None:
|
|
35
|
+
await self.lost.wait()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _SimpleProvider:
|
|
39
|
+
def __init__(self, isolation: SimpleEventIsolation) -> None:
|
|
40
|
+
self.isolation = isolation
|
|
41
|
+
|
|
42
|
+
@asynccontextmanager
|
|
43
|
+
async def acquire(self, key: StorageKey) -> AsyncGenerator[LeaseHandle, None]:
|
|
44
|
+
async with self.isolation.lock(key=key):
|
|
45
|
+
lease = _MemoryLease(key, asyncio.Event())
|
|
46
|
+
try:
|
|
47
|
+
yield lease
|
|
48
|
+
finally:
|
|
49
|
+
lease.lost.set()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class _Held:
|
|
54
|
+
key: StorageKey
|
|
55
|
+
task: asyncio.Task[object]
|
|
56
|
+
lease: LeaseHandle
|
|
57
|
+
revoked: bool = False
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def valid(self) -> bool:
|
|
61
|
+
return not self.revoked and self.lease.valid
|
|
62
|
+
|
|
63
|
+
def assert_owned(self) -> None:
|
|
64
|
+
if not self.valid:
|
|
65
|
+
raise OwnershipLost("Captured FSM scope was revoked")
|
|
66
|
+
|
|
67
|
+
async def wait_lost(self) -> None:
|
|
68
|
+
if not self.valid:
|
|
69
|
+
return
|
|
70
|
+
await self.lease.wait_lost()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TrackedEventIsolation(BaseEventIsolation):
|
|
74
|
+
"""Wrap application isolation at Dispatcher construction, never at install.
|
|
75
|
+
|
|
76
|
+
Only SimpleEventIsolation has a safe implicit provider. A custom provider
|
|
77
|
+
must explicitly reference the same underlying isolation and return a live,
|
|
78
|
+
key-bound lease; opaque fixed-TTL locks are never assumed to renew.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
isolation: BaseEventIsolation,
|
|
84
|
+
*,
|
|
85
|
+
provider: IsolationProvider | None = None,
|
|
86
|
+
close_delegate: bool = False,
|
|
87
|
+
) -> None:
|
|
88
|
+
if not isinstance(cast(object, isolation), BaseEventIsolation):
|
|
89
|
+
raise ConfigurationError("Expected an application BaseEventIsolation")
|
|
90
|
+
if provider is None:
|
|
91
|
+
if type(isolation) is SimpleEventIsolation:
|
|
92
|
+
provider = _SimpleProvider(isolation)
|
|
93
|
+
elif isinstance(isolation, RenewableRedisEventIsolation):
|
|
94
|
+
provider = isolation
|
|
95
|
+
else:
|
|
96
|
+
raise ConfigurationError("Custom isolation requires an explicit lease provider")
|
|
97
|
+
if (
|
|
98
|
+
not isinstance(cast(object, provider), IsolationProvider)
|
|
99
|
+
or provider.isolation is not isolation
|
|
100
|
+
):
|
|
101
|
+
raise ConfigurationError("Lease provider must describe the exact application isolation")
|
|
102
|
+
if type(close_delegate) is not bool:
|
|
103
|
+
raise ConfigurationError("close_delegate must be boolean")
|
|
104
|
+
self.isolation = isolation
|
|
105
|
+
self.provider = provider
|
|
106
|
+
self.close_delegate = close_delegate
|
|
107
|
+
self._owned: ContextVar[tuple[_Held, ...]] = ContextVar(
|
|
108
|
+
f"tracked_fsm_{id(self)}", default=()
|
|
109
|
+
)
|
|
110
|
+
self._pending = 0
|
|
111
|
+
self._closed = False
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def active_scopes(self) -> int:
|
|
115
|
+
return self._pending
|
|
116
|
+
|
|
117
|
+
def _held(self, key: StorageKey) -> _Held | None:
|
|
118
|
+
task = asyncio.current_task()
|
|
119
|
+
return next(
|
|
120
|
+
(held for held in reversed(self._owned.get()) if held.key == key and held.task is task),
|
|
121
|
+
None,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def owns(self, key: StorageKey) -> bool:
|
|
125
|
+
held = self._held(key)
|
|
126
|
+
return held is not None and not held.revoked and held.lease.valid
|
|
127
|
+
|
|
128
|
+
def proof(self, key: StorageKey) -> LeaseHandle:
|
|
129
|
+
held = self._held(key)
|
|
130
|
+
if held is None:
|
|
131
|
+
raise OwnershipLost("FSM scope is not owned by this task")
|
|
132
|
+
held.assert_owned()
|
|
133
|
+
return held
|
|
134
|
+
|
|
135
|
+
@asynccontextmanager
|
|
136
|
+
async def borrowed(self, key: StorageKey) -> AsyncGenerator[None, None]:
|
|
137
|
+
held = self._held(key)
|
|
138
|
+
if held is None or held.revoked:
|
|
139
|
+
raise OwnershipLost("FSM lock is not owned by this task")
|
|
140
|
+
held.lease.assert_owned()
|
|
141
|
+
yield
|
|
142
|
+
if held.revoked or not held.lease.valid:
|
|
143
|
+
raise OwnershipLost("Borrowed FSM scope was revoked")
|
|
144
|
+
|
|
145
|
+
@asynccontextmanager
|
|
146
|
+
async def _acquire(self, key: StorageKey) -> AsyncGenerator[LeaseHandle, None]:
|
|
147
|
+
self._pending += 1
|
|
148
|
+
try:
|
|
149
|
+
async with self.provider.acquire(key) as lease:
|
|
150
|
+
if self._closed:
|
|
151
|
+
raise ConfigurationError("FSM isolation closed during acquisition")
|
|
152
|
+
yield lease
|
|
153
|
+
finally:
|
|
154
|
+
# Includes waiting, protected work AND provider.__aexit__ release.
|
|
155
|
+
self._pending -= 1
|
|
156
|
+
|
|
157
|
+
@asynccontextmanager
|
|
158
|
+
async def lock(self, key: StorageKey) -> AsyncGenerator[None, None]:
|
|
159
|
+
if self._closed or not isinstance(cast(object, key), StorageKey):
|
|
160
|
+
raise ConfigurationError("Isolation is closed or the FSM key is invalid")
|
|
161
|
+
if self._held(key) is not None:
|
|
162
|
+
async with self.borrowed(key):
|
|
163
|
+
yield
|
|
164
|
+
return
|
|
165
|
+
task = asyncio.current_task()
|
|
166
|
+
if task is None:
|
|
167
|
+
raise ConfigurationError("FSM coordination requires a task")
|
|
168
|
+
async with self._acquire(key) as lease:
|
|
169
|
+
if not isinstance(cast(object, lease), LeaseHandle) or lease.key != key:
|
|
170
|
+
raise ConfigurationError("Isolation provider returned an invalid lease identity")
|
|
171
|
+
lease.assert_owned()
|
|
172
|
+
held = _Held(key, task, lease)
|
|
173
|
+
token = self._owned.set((*self._owned.get(), held))
|
|
174
|
+
marker = object()
|
|
175
|
+
cancelling = cast(Callable[[], int] | None, getattr(task, "cancelling", None))
|
|
176
|
+
uncancel = cast(Callable[[], int] | None, getattr(task, "uncancel", None))
|
|
177
|
+
baseline = cancelling() if cancelling is not None else 0
|
|
178
|
+
requested = consumed = legacy_marker = False
|
|
179
|
+
legacy_previous: object = None
|
|
180
|
+
|
|
181
|
+
async def watch() -> None:
|
|
182
|
+
nonlocal requested, legacy_marker, legacy_previous
|
|
183
|
+
try:
|
|
184
|
+
await lease.wait_lost()
|
|
185
|
+
except asyncio.CancelledError:
|
|
186
|
+
raise
|
|
187
|
+
except Exception:
|
|
188
|
+
# An inability to monitor ownership must revoke permission.
|
|
189
|
+
pass
|
|
190
|
+
held.revoked = True
|
|
191
|
+
if cancelling is None:
|
|
192
|
+
legacy_previous = getattr(task, "_cancel_message", None)
|
|
193
|
+
requested = task.cancel(marker)
|
|
194
|
+
if requested and cancelling is None and hasattr(task, "_cancel_message"):
|
|
195
|
+
try:
|
|
196
|
+
setattr(task, "_cancel_message", marker) # noqa: B010 -- CPython 3.10
|
|
197
|
+
except (AttributeError, TypeError):
|
|
198
|
+
pass
|
|
199
|
+
else:
|
|
200
|
+
legacy_marker = True
|
|
201
|
+
|
|
202
|
+
def consume() -> int:
|
|
203
|
+
nonlocal consumed
|
|
204
|
+
if requested and not consumed and uncancel is not None:
|
|
205
|
+
consumed = True
|
|
206
|
+
return uncancel()
|
|
207
|
+
return cancelling() if cancelling is not None else 0
|
|
208
|
+
|
|
209
|
+
watcher = asyncio.create_task(watch())
|
|
210
|
+
try:
|
|
211
|
+
yield
|
|
212
|
+
if held.revoked or not lease.valid:
|
|
213
|
+
raise OwnershipLost("FSM isolation lease lost")
|
|
214
|
+
except asyncio.CancelledError as error:
|
|
215
|
+
remaining = consume()
|
|
216
|
+
if requested and (
|
|
217
|
+
remaining <= baseline
|
|
218
|
+
if cancelling is not None
|
|
219
|
+
else error.args == (marker,)
|
|
220
|
+
and (not legacy_marker or getattr(task, "_cancel_message", None) is marker)
|
|
221
|
+
):
|
|
222
|
+
raise OwnershipLost("FSM isolation lease lost") from error
|
|
223
|
+
raise
|
|
224
|
+
finally:
|
|
225
|
+
held.revoked = True
|
|
226
|
+
self._owned.reset(token)
|
|
227
|
+
if legacy_marker and getattr(task, "_cancel_message", None) is marker:
|
|
228
|
+
setattr(task, "_cancel_message", legacy_previous) # noqa: B010
|
|
229
|
+
watcher.cancel()
|
|
230
|
+
await asyncio.gather(watcher, return_exceptions=True)
|
|
231
|
+
consume()
|
|
232
|
+
|
|
233
|
+
async def close(self) -> None:
|
|
234
|
+
if self.active_scopes:
|
|
235
|
+
raise ConfigurationError("Drain FSM scopes before closing isolation")
|
|
236
|
+
self._closed = True
|
|
237
|
+
if self.close_delegate:
|
|
238
|
+
await self.isolation.close()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class AiogramMemoryCoordinator(MemoryCoordinator):
|
|
242
|
+
"""Borrow a proven FSM scope; workers acquire the same normalized lock."""
|
|
243
|
+
|
|
244
|
+
configuration_error = ConfigurationError
|
|
245
|
+
ownership_lost_error = OwnershipLost
|
|
246
|
+
|
|
247
|
+
def __init__(self, isolation: object) -> None:
|
|
248
|
+
if type(isolation) is not SimpleEventIsolation and not isinstance(
|
|
249
|
+
isolation, TrackedEventIsolation
|
|
250
|
+
):
|
|
251
|
+
raise self.configuration_error("Expected verified Simple or TrackedEventIsolation")
|
|
252
|
+
super().__init__()
|
|
253
|
+
self.isolation = isolation
|
|
254
|
+
self._fsm_proofs: dict[object, LeaseHandle] = {}
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def active_scopes(self) -> int:
|
|
258
|
+
fsm_active = (
|
|
259
|
+
self.isolation.active_scopes if isinstance(self.isolation, TrackedEventIsolation) else 0
|
|
260
|
+
)
|
|
261
|
+
return max(super().active_scopes, fsm_active)
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def fsm_isolation(self) -> BaseEventIsolation:
|
|
265
|
+
return self.isolation
|
|
266
|
+
|
|
267
|
+
def configure_fsm_isolation(self, isolation: BaseEventIsolation) -> None:
|
|
268
|
+
if isolation is not self.isolation:
|
|
269
|
+
raise self.configuration_error("FSM bridge cannot replace its configured isolation")
|
|
270
|
+
|
|
271
|
+
def borrowed_fsm(self, key: Hashable) -> AbstractAsyncContextManager[None]:
|
|
272
|
+
return self.borrowed(key)
|
|
273
|
+
|
|
274
|
+
def scope_active(self, token: object) -> bool:
|
|
275
|
+
proof = self._fsm_proofs.get(token)
|
|
276
|
+
if proof is not None and not proof.valid:
|
|
277
|
+
self._live_scopes.discard(token)
|
|
278
|
+
return super().scope_active(token)
|
|
279
|
+
|
|
280
|
+
@asynccontextmanager
|
|
281
|
+
async def borrowed(self, key: Hashable) -> AsyncGenerator[None, None]:
|
|
282
|
+
key = self.normalize_key(key)
|
|
283
|
+
normalized = coordination_key(key)
|
|
284
|
+
fsm_key = StorageKey(
|
|
285
|
+
bot_id=normalized.bot_id, chat_id=normalized.chat_id, user_id=normalized.user_id
|
|
286
|
+
)
|
|
287
|
+
if self.owns(key):
|
|
288
|
+
yield
|
|
289
|
+
return
|
|
290
|
+
if isinstance(self.isolation, TrackedEventIsolation):
|
|
291
|
+
proof = self.isolation.proof(fsm_key)
|
|
292
|
+
async with self.isolation.borrowed(fsm_key), self._scope(key):
|
|
293
|
+
scope = self.scope_token(key)
|
|
294
|
+
if scope is None:
|
|
295
|
+
raise self.ownership_lost_error("No borrowed FSM scope was established")
|
|
296
|
+
self._fsm_proofs[scope] = proof
|
|
297
|
+
try:
|
|
298
|
+
yield
|
|
299
|
+
finally:
|
|
300
|
+
self._fsm_proofs.pop(scope, None)
|
|
301
|
+
else:
|
|
302
|
+
# Legacy installer verifies this branch executes inside aiogram FSM.
|
|
303
|
+
async with self._scope(key):
|
|
304
|
+
yield
|
|
305
|
+
|
|
306
|
+
@asynccontextmanager
|
|
307
|
+
async def hold(self, key: Hashable) -> AsyncGenerator[None, None]:
|
|
308
|
+
key = self.normalize_key(key)
|
|
309
|
+
self._check_reentry(key)
|
|
310
|
+
if self.owns(key):
|
|
311
|
+
yield
|
|
312
|
+
return
|
|
313
|
+
normalized = coordination_key(key)
|
|
314
|
+
fsm_key = StorageKey(
|
|
315
|
+
bot_id=normalized.bot_id, chat_id=normalized.chat_id, user_id=normalized.user_id
|
|
316
|
+
)
|
|
317
|
+
self._pending_scopes += 1
|
|
318
|
+
acquired = False
|
|
319
|
+
try:
|
|
320
|
+
async with self.isolation.lock(key=fsm_key):
|
|
321
|
+
self._pending_scopes -= 1
|
|
322
|
+
acquired = True
|
|
323
|
+
async with self.borrowed(key):
|
|
324
|
+
yield
|
|
325
|
+
finally:
|
|
326
|
+
if not acquired:
|
|
327
|
+
self._pending_scopes -= 1
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# Imported last to keep Redis coordination independent of this adapter module.
|
|
331
|
+
from .redis_isolation import RenewableRedisEventIsolation # noqa: E402
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Single-process, task-scoped conversation coordination."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncGenerator, Hashable
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from .errors import OwnershipLost
|
|
12
|
+
from .identity import coordination_key
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class _LockEntry:
|
|
17
|
+
lock: asyncio.Lock
|
|
18
|
+
users: int = 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MemoryCoordinator:
|
|
22
|
+
"""Serialize a whole handler and worker for each conversation.
|
|
23
|
+
|
|
24
|
+
Ownership is checked against the actual asyncio Task. Context copied into a
|
|
25
|
+
child task never grants that task permission to bypass the lock.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
ownership_lost_error = OwnershipLost
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._locks: dict[Hashable, _LockEntry] = {}
|
|
32
|
+
self._owned: ContextVar[tuple[tuple[Hashable, asyncio.Task[object]], ...]] = ContextVar(
|
|
33
|
+
f"coord_owned_{id(self)}", default=()
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
self._scope_tokens: ContextVar[
|
|
37
|
+
tuple[tuple[Hashable, asyncio.Task[object], object], ...]
|
|
38
|
+
] = ContextVar(f"coord_scope_tokens_{id(self)}", default=())
|
|
39
|
+
self._live_scopes: set[object] = set()
|
|
40
|
+
self._scope_keys: dict[object, Hashable] = {}
|
|
41
|
+
self._pending_scopes = 0
|
|
42
|
+
|
|
43
|
+
def normalize_key(self, key: Hashable) -> Hashable:
|
|
44
|
+
if all(
|
|
45
|
+
hasattr(key, field)
|
|
46
|
+
for field in ("namespace", "tenant_id", "bot_id", "chat_id", "user_id")
|
|
47
|
+
):
|
|
48
|
+
return coordination_key(key)
|
|
49
|
+
return key
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def active_scopes(self) -> int:
|
|
53
|
+
return len(self._scope_keys) + self._pending_scopes
|
|
54
|
+
|
|
55
|
+
def scope_token(self, key: Hashable) -> object | None:
|
|
56
|
+
key = self.normalize_key(key)
|
|
57
|
+
task = asyncio.current_task()
|
|
58
|
+
for held, owner, token in reversed(self._scope_tokens.get()):
|
|
59
|
+
if held == key and owner is task and self.scope_active(token):
|
|
60
|
+
return token
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def scope_active(self, token: object) -> bool:
|
|
64
|
+
return token in self._live_scopes
|
|
65
|
+
|
|
66
|
+
def scope_matches(self, key: Hashable, token: object) -> bool:
|
|
67
|
+
"""Validate a delegated capability without granting task lock ownership."""
|
|
68
|
+
return self._scope_keys.get(token) == self.normalize_key(key) and self.scope_active(token)
|
|
69
|
+
|
|
70
|
+
@asynccontextmanager
|
|
71
|
+
async def _scope(self, key: Hashable) -> AsyncGenerator[None, None]:
|
|
72
|
+
key = self.normalize_key(key)
|
|
73
|
+
task = asyncio.current_task()
|
|
74
|
+
if task is None:
|
|
75
|
+
raise RuntimeError("Conversation coordination requires an asyncio task")
|
|
76
|
+
scope = object()
|
|
77
|
+
self._live_scopes.add(scope)
|
|
78
|
+
self._scope_keys[scope] = key
|
|
79
|
+
ownership = self._owned.set((*self._owned.get(), (key, task)))
|
|
80
|
+
tokens = self._scope_tokens.set((*self._scope_tokens.get(), (key, task, scope)))
|
|
81
|
+
try:
|
|
82
|
+
yield
|
|
83
|
+
finally:
|
|
84
|
+
self._live_scopes.discard(scope)
|
|
85
|
+
self._scope_keys.pop(scope, None)
|
|
86
|
+
self._scope_tokens.reset(tokens)
|
|
87
|
+
self._owned.reset(ownership)
|
|
88
|
+
|
|
89
|
+
def owns(self, key: Hashable) -> bool:
|
|
90
|
+
return self.scope_token(key) is not None
|
|
91
|
+
|
|
92
|
+
def _check_reentry(self, key: Hashable) -> None:
|
|
93
|
+
key = self.normalize_key(key)
|
|
94
|
+
task = asyncio.current_task()
|
|
95
|
+
if not self.owns(key) and any(
|
|
96
|
+
held == key and owner is task for held, owner, _ in self._scope_tokens.get()
|
|
97
|
+
):
|
|
98
|
+
raise self.ownership_lost_error("A revoked scope cannot acquire fresh ownership")
|
|
99
|
+
|
|
100
|
+
@asynccontextmanager
|
|
101
|
+
async def hold(self, key: Hashable) -> AsyncGenerator[None, None]:
|
|
102
|
+
key = self.normalize_key(key)
|
|
103
|
+
self._check_reentry(key)
|
|
104
|
+
if self.owns(key):
|
|
105
|
+
yield
|
|
106
|
+
return
|
|
107
|
+
entry = self._locks.setdefault(key, _LockEntry(asyncio.Lock()))
|
|
108
|
+
entry.users += 1
|
|
109
|
+
self._pending_scopes += 1
|
|
110
|
+
acquired = False
|
|
111
|
+
try:
|
|
112
|
+
async with entry.lock:
|
|
113
|
+
self._pending_scopes -= 1
|
|
114
|
+
acquired = True
|
|
115
|
+
async with self._scope(key):
|
|
116
|
+
yield
|
|
117
|
+
finally:
|
|
118
|
+
if not acquired:
|
|
119
|
+
self._pending_scopes -= 1
|
|
120
|
+
entry.users -= 1
|
|
121
|
+
if entry.users == 0:
|
|
122
|
+
self._locks.pop(key, None)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def lock_count(self) -> int:
|
|
126
|
+
"""Number of active or contended locks, useful for health checks."""
|
|
127
|
+
return len(self._locks)
|