redis-xsync 1.0.1__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.
- redis_xsync/__init__.py +82 -0
- redis_xsync/awaitable/__init__.py +126 -0
- redis_xsync/distributed_lock.py +202 -0
- redis_xsync/rate_limited_pool.py +554 -0
- redis_xsync/rtypes.py +85 -0
- redis_xsync/rxcore.py +254 -0
- redis_xsync-1.0.1.dist-info/METADATA +333 -0
- redis_xsync-1.0.1.dist-info/RECORD +10 -0
- redis_xsync-1.0.1.dist-info/WHEEL +5 -0
- redis_xsync-1.0.1.dist-info/top_level.txt +1 -0
redis_xsync/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
@File : __init__.py
|
|
4
|
+
@Author : Jay
|
|
5
|
+
@Date : 2025/3/16 14:37
|
|
6
|
+
@Desc : Redis client registry and default instance setup
|
|
7
|
+
"""
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
# Set Windows event loop policy to avoid runtime errors on Windows
|
|
11
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
12
|
+
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from typing import Optional, Union
|
|
15
|
+
|
|
16
|
+
from .rxcore import RedisXsync
|
|
17
|
+
|
|
18
|
+
# Registry of Redis instances keyed by label
|
|
19
|
+
_redis_registry: defaultdict[str, RedisXsync] = defaultdict(RedisXsync)
|
|
20
|
+
|
|
21
|
+
# Global default Redis instance
|
|
22
|
+
redisXsync: Optional[RedisXsync] = RedisXsync(host="localhost", port=6379, db=-1)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RedisNotInstantiatedError(Exception):
|
|
26
|
+
"""Raised when trying to access a Redis instance that hasn't been registered."""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_redis(label: str, host: str = None, port: int = None, db: Union[str, int] = None,
|
|
31
|
+
password: Optional[str] = None, decode_responses: bool = None, **kwargs) -> RedisXsync:
|
|
32
|
+
"""Register a Redis instance in the global registry.
|
|
33
|
+
|
|
34
|
+
If no global default exists, the first registered instance becomes the default.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
label (str): Unique identifier for the Redis instance.
|
|
38
|
+
host (str, optional): Redis server host.
|
|
39
|
+
port (int, optional): Redis server port.
|
|
40
|
+
db (int|str, optional): Redis database index.
|
|
41
|
+
password (str, optional): Redis password.
|
|
42
|
+
decode_responses (bool, optional): Whether to decode byte responses.
|
|
43
|
+
**kwargs: Additional Redis configuration parameters.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
RedisXsync: The registered Redis client instance.
|
|
47
|
+
"""
|
|
48
|
+
global redisXsync
|
|
49
|
+
rx = RedisXsync(
|
|
50
|
+
host=host, port=port,
|
|
51
|
+
db=db, password=password,
|
|
52
|
+
decode_responses=decode_responses,
|
|
53
|
+
**kwargs
|
|
54
|
+
)
|
|
55
|
+
# Set the first registered instance as the default if none exists
|
|
56
|
+
if not _redis_registry:
|
|
57
|
+
redisXsync.rxs = rx.rxs
|
|
58
|
+
_redis_registry.setdefault(label, rx)
|
|
59
|
+
return _redis_registry[label]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_redis(label: str = None) -> RedisXsync:
|
|
63
|
+
"""Resolve a registered Redis instance by label.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
label (str, optional): The label of the Redis instance. If None, returns the first registered instance.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
RedisXsync: The resolved Redis client instance.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
RedisNotInstantiatedError: If no instance with the given label is registered.
|
|
73
|
+
"""
|
|
74
|
+
if label is None and _redis_registry:
|
|
75
|
+
return next(iter(_redis_registry.values()))
|
|
76
|
+
r = _redis_registry.get(label)
|
|
77
|
+
if r:
|
|
78
|
+
return r
|
|
79
|
+
raise RedisNotInstantiatedError(f"No Redis instance registered with label '{label}'")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["register_redis", "resolve_redis", "RedisXsync", "redisXsync"]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
@File : __init__.py
|
|
4
|
+
@Author : Jay
|
|
5
|
+
@Date : 2026/3/19 18:01
|
|
6
|
+
@Desc : Utilities to detect synchronous vs asynchronous call context
|
|
7
|
+
and to decorate async functions to fallback to sync implementations.
|
|
8
|
+
"""
|
|
9
|
+
import inspect
|
|
10
|
+
import sys
|
|
11
|
+
from functools import wraps
|
|
12
|
+
from typing import Callable, Any
|
|
13
|
+
|
|
14
|
+
# Flags from code objects that indicate coroutine behavior
|
|
15
|
+
_CO_FROM_COROUTINE = (
|
|
16
|
+
inspect.CO_COROUTINE
|
|
17
|
+
| inspect.CO_ITERABLE_COROUTINE
|
|
18
|
+
| inspect.CO_ASYNC_GENERATOR
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_coroutine_context(level: int = 2) -> bool:
|
|
23
|
+
"""Determine if the current call context is asynchronous (coroutine).
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
level (int): Stack level to inspect (default 2). Increase if called from wrapper functions.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
bool: True if the current frame is part of a coroutine context, False if synchronous.
|
|
30
|
+
"""
|
|
31
|
+
frame = sys._getframe(level)
|
|
32
|
+
f_code = frame.f_code
|
|
33
|
+
|
|
34
|
+
# Check if the code object has coroutine flags
|
|
35
|
+
if f_code.co_flags & _CO_FROM_COROUTINE:
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
# Handle nested "<...>" function wrappers (like decorators)
|
|
39
|
+
if f_code.co_flags & inspect.CO_NESTED and f_code.co_name[0] == '<':
|
|
40
|
+
return is_coroutine_context(level + 2)
|
|
41
|
+
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def awaitable(sync_func: Callable) -> Callable:
|
|
46
|
+
"""Decorator for async functions to use a synchronous fallback when called from sync context.
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
def foo_sync(x, y): ...
|
|
50
|
+
|
|
51
|
+
@awaitable(foo_sync)
|
|
52
|
+
async def foo_async(x, y): ...
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
sync_func (Callable): Synchronous implementation of the async function.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Callable: Decorated async function that uses `sync_func` if called synchronously.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def decorator(async_func: Callable) -> Callable:
|
|
62
|
+
# Signature check to ensure sync/async match
|
|
63
|
+
sig_sync = inspect.signature(sync_func)
|
|
64
|
+
sig_async = inspect.signature(async_func)
|
|
65
|
+
if sig_sync != sig_async:
|
|
66
|
+
raise TypeError(
|
|
67
|
+
f"{sync_func.__name__} and async {async_func.__name__} signatures differ:\n"
|
|
68
|
+
f" sync: {sig_sync}\n"
|
|
69
|
+
f" async: {sig_async}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@wraps(async_func)
|
|
73
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
74
|
+
# If currently inside a coroutine, run async function
|
|
75
|
+
if is_coroutine_context():
|
|
76
|
+
return async_func(*args, **kwargs)
|
|
77
|
+
|
|
78
|
+
# Otherwise, run synchronous version
|
|
79
|
+
result = sync_func(*args, **kwargs)
|
|
80
|
+
if inspect.iscoroutine(result):
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
f"Synchronous function {sync_func.__name__} returned a coroutine object. "
|
|
83
|
+
f"Ensure it is a true synchronous implementation."
|
|
84
|
+
)
|
|
85
|
+
return result
|
|
86
|
+
|
|
87
|
+
# Attach references for introspection
|
|
88
|
+
wrapper._sync_func = sync_func
|
|
89
|
+
wrapper._async_func = async_func
|
|
90
|
+
wrapper._awaitable = True
|
|
91
|
+
return wrapper
|
|
92
|
+
|
|
93
|
+
return decorator
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = ["awaitable", "is_coroutine_context"]
|
|
97
|
+
|
|
98
|
+
if __name__ == '__main__':
|
|
99
|
+
# Demonstration of `awaitable` decorator usage
|
|
100
|
+
class Limited:
|
|
101
|
+
def _ask_available_container_sync(self, quantity):
|
|
102
|
+
"""Synchronous fallback implementation."""
|
|
103
|
+
return "sync-"
|
|
104
|
+
|
|
105
|
+
@awaitable(_ask_available_container_sync)
|
|
106
|
+
async def ask_available_container(self, quantity):
|
|
107
|
+
"""Async implementation."""
|
|
108
|
+
return "async+"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
lt = Limited()
|
|
112
|
+
|
|
113
|
+
# Synchronous call
|
|
114
|
+
res_sync = lt.ask_available_container(1)
|
|
115
|
+
print("Sync result:", res_sync)
|
|
116
|
+
|
|
117
|
+
# Asynchronous call
|
|
118
|
+
import asyncio
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def main():
|
|
122
|
+
res_async = await lt.ask_available_container(2)
|
|
123
|
+
print("Async result:", res_async)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
@File : distributed_lock.py
|
|
4
|
+
@Author : Jay
|
|
5
|
+
@Date : 2026/3/18 17:47
|
|
6
|
+
@Desc : Redis connection pool and distributed lock implementation
|
|
7
|
+
"""
|
|
8
|
+
import asyncio
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
|
|
12
|
+
_ACQUIRE_LUA = """
|
|
13
|
+
local ttl_ms = tonumber(ARGV[#ARGV])
|
|
14
|
+
for i=1,#KEYS do
|
|
15
|
+
if redis.call('exists', KEYS[i]) == 1 then
|
|
16
|
+
return 0
|
|
17
|
+
end
|
|
18
|
+
redis.call('set', KEYS[i], ARGV[i], 'PX', ttl_ms)
|
|
19
|
+
end
|
|
20
|
+
return 1
|
|
21
|
+
"""
|
|
22
|
+
_RELEASE_LUA = """
|
|
23
|
+
for i=1,#KEYS do
|
|
24
|
+
if redis.call('get', KEYS[i]) == ARGV[i] then
|
|
25
|
+
redis.call('del', KEYS[i])
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
return 1
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Distributed lock class, use `aioredlock` for cluster-wide locks
|
|
33
|
+
class AsyncRedisAtomicMultiLock:
|
|
34
|
+
"""Implements a Redis-based atomic multi-key lock with optional blocking behavior."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*keys: str,
|
|
39
|
+
redis_xsync,
|
|
40
|
+
redis_db,
|
|
41
|
+
ttl_ms: int = 5000,
|
|
42
|
+
blocking: bool = False,
|
|
43
|
+
blocking_timeout: int = 5,
|
|
44
|
+
sleep: float = 0.3
|
|
45
|
+
):
|
|
46
|
+
"""Initialize the atomic multi-key lock.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
*keys (str): Redis keys to lock, can pass multiple keys.
|
|
50
|
+
redis_xsync: Redis client factory supporting sync/async context.
|
|
51
|
+
redis_db: Redis database number.
|
|
52
|
+
ttl_ms (int): Lock timeout in milliseconds.
|
|
53
|
+
blocking (bool): If True, wait until lock is acquired when already locked.
|
|
54
|
+
blocking_timeout (int): Maximum blocking wait time in seconds.
|
|
55
|
+
sleep (float): Sleep interval between retries when blocking.
|
|
56
|
+
"""
|
|
57
|
+
self.redis_xsync = redis_xsync
|
|
58
|
+
self.redis_db = redis_db
|
|
59
|
+
self.keys = list(keys)
|
|
60
|
+
self.ttl_ms = int(ttl_ms)
|
|
61
|
+
self.container = list(map(lambda _: uuid.uuid4().hex, keys)) # Unique lock values
|
|
62
|
+
self.acquired = False
|
|
63
|
+
|
|
64
|
+
self.blocking = blocking
|
|
65
|
+
self.blocking_timeout = blocking_timeout
|
|
66
|
+
self.sleep = sleep
|
|
67
|
+
|
|
68
|
+
# <editor-fold desc="Distributed lock: async implementation">
|
|
69
|
+
async def _async_with_client(self, script: str, keys, *args):
|
|
70
|
+
"""Helper to run Lua scripts asynchronously against Redis.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
script (str): Lua script to execute.
|
|
74
|
+
keys (list): List of Redis keys.
|
|
75
|
+
*args: Arguments for the Lua script.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
bool: True if Lua script returned success (1), False otherwise.
|
|
79
|
+
"""
|
|
80
|
+
async with self.redis_xsync(db=self.redis_db) as redis:
|
|
81
|
+
return (await redis.eval(script, len(keys), *keys, *args)) == 1
|
|
82
|
+
|
|
83
|
+
async def async_acquire(self) -> bool:
|
|
84
|
+
"""Asynchronously acquire the lock.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
bool: True if lock acquired successfully, False otherwise.
|
|
88
|
+
"""
|
|
89
|
+
argv = [*self.container, str(self.ttl_ms)]
|
|
90
|
+
|
|
91
|
+
async def try_once():
|
|
92
|
+
return await self._async_with_client(_ACQUIRE_LUA, self.keys, *argv)
|
|
93
|
+
|
|
94
|
+
if not self.blocking:
|
|
95
|
+
self.acquired = await try_once()
|
|
96
|
+
return self.acquired
|
|
97
|
+
|
|
98
|
+
end_time = asyncio.get_event_loop().time() + self.blocking_timeout
|
|
99
|
+
while True:
|
|
100
|
+
if await try_once():
|
|
101
|
+
self.acquired = True
|
|
102
|
+
return True
|
|
103
|
+
if asyncio.get_event_loop().time() > end_time:
|
|
104
|
+
return False
|
|
105
|
+
await asyncio.sleep(self.sleep)
|
|
106
|
+
|
|
107
|
+
async def async_release(self) -> None:
|
|
108
|
+
"""Asynchronously release the lock, only if held by this instance."""
|
|
109
|
+
try:
|
|
110
|
+
await self._async_with_client(_RELEASE_LUA, self.keys, *self.container)
|
|
111
|
+
finally:
|
|
112
|
+
self.acquired = False
|
|
113
|
+
|
|
114
|
+
async def __aenter__(self):
|
|
115
|
+
"""Async context manager entry.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
TimeoutError: If lock cannot be acquired within timeout.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
AsyncRedisAtomicMultiLock: self
|
|
122
|
+
"""
|
|
123
|
+
ok = await self.async_acquire()
|
|
124
|
+
if not ok:
|
|
125
|
+
raise TimeoutError("Failed to acquire lock within timeout")
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
129
|
+
"""Async context manager exit."""
|
|
130
|
+
await self.async_release()
|
|
131
|
+
|
|
132
|
+
# </editor-fold>
|
|
133
|
+
|
|
134
|
+
# <editor-fold desc="Distributed lock: sync implementation">
|
|
135
|
+
def _sync_with_client(self, script: str, keys, *args) -> bool:
|
|
136
|
+
"""Helper to run Lua scripts synchronously against Redis.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
script (str): Lua script to execute.
|
|
140
|
+
keys (list): List of Redis keys.
|
|
141
|
+
*args: Arguments for the Lua script.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
bool: True if Lua script returned success (1), False otherwise.
|
|
145
|
+
"""
|
|
146
|
+
with self.redis_xsync(db=self.redis_db) as redis:
|
|
147
|
+
return int(redis.eval(script, len(keys), *keys, *args)) == 1
|
|
148
|
+
|
|
149
|
+
def sync_acquire(self) -> bool:
|
|
150
|
+
"""Synchronously acquire the lock.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
bool: True if lock acquired successfully, False otherwise.
|
|
154
|
+
"""
|
|
155
|
+
argv = [*self.container, str(self.ttl_ms)]
|
|
156
|
+
|
|
157
|
+
def try_once():
|
|
158
|
+
return self._sync_with_client(_ACQUIRE_LUA, self.keys, *argv)
|
|
159
|
+
|
|
160
|
+
if not self.blocking:
|
|
161
|
+
self.acquired = try_once()
|
|
162
|
+
return self.acquired
|
|
163
|
+
|
|
164
|
+
end_time = time.time() + self.blocking_timeout
|
|
165
|
+
while True:
|
|
166
|
+
if try_once():
|
|
167
|
+
self.acquired = True
|
|
168
|
+
return True
|
|
169
|
+
if time.time() > end_time:
|
|
170
|
+
return False
|
|
171
|
+
time.sleep(self.sleep)
|
|
172
|
+
|
|
173
|
+
def sync_release(self) -> None:
|
|
174
|
+
"""Synchronously release the lock."""
|
|
175
|
+
try:
|
|
176
|
+
self._sync_with_client(_RELEASE_LUA, self.keys, *self.container)
|
|
177
|
+
finally:
|
|
178
|
+
self.acquired = False
|
|
179
|
+
|
|
180
|
+
def __enter__(self):
|
|
181
|
+
"""Sync context manager entry.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
TimeoutError: If lock cannot be acquired within timeout.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
AsyncRedisAtomicMultiLock: self
|
|
188
|
+
"""
|
|
189
|
+
ok = self.sync_acquire()
|
|
190
|
+
if not ok:
|
|
191
|
+
raise TimeoutError(
|
|
192
|
+
f"Failed to acquire lock on keys {self.keys} in {self.blocking_timeout}s"
|
|
193
|
+
)
|
|
194
|
+
return self
|
|
195
|
+
|
|
196
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
197
|
+
"""Sync context manager exit."""
|
|
198
|
+
self.sync_release()
|
|
199
|
+
# </editor-fold>
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
__all__ = ["AsyncRedisAtomicMultiLock"]
|