redis-allocator 0.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_allocator/__init__.py +28 -0
- redis_allocator/allocator.py +601 -0
- redis_allocator/lock.py +682 -0
- redis_allocator/task_queue.py +382 -0
- redis_allocator-0.0.1.dist-info/METADATA +229 -0
- redis_allocator-0.0.1.dist-info/RECORD +14 -0
- redis_allocator-0.0.1.dist-info/WHEEL +5 -0
- redis_allocator-0.0.1.dist-info/licenses/LICENSE +21 -0
- redis_allocator-0.0.1.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +46 -0
- tests/test_allocator.py +525 -0
- tests/test_lock.py +851 -0
- tests/test_task_queue.py +778 -0
redis_allocator/lock.py
ADDED
@@ -0,0 +1,682 @@
|
|
1
|
+
"""Redis-based locking mechanisms for distributed coordination.
|
2
|
+
|
3
|
+
This module provides Redis-based locking classes that enable distributed
|
4
|
+
coordination and ensure data consistency across distributed processes.
|
5
|
+
"""
|
6
|
+
import time
|
7
|
+
import logging
|
8
|
+
import threading
|
9
|
+
from abc import ABC, ABCMeta, abstractmethod
|
10
|
+
from dataclasses import dataclass
|
11
|
+
from enum import IntEnum
|
12
|
+
from collections import defaultdict
|
13
|
+
from functools import cached_property
|
14
|
+
from datetime import timedelta, datetime
|
15
|
+
from typing import Iterable, Optional, Sequence, Tuple, Union, Any
|
16
|
+
from redis import StrictRedis as Redis
|
17
|
+
|
18
|
+
logger = logging.getLogger(__name__)
|
19
|
+
Timeout = Union[float, timedelta, None]
|
20
|
+
|
21
|
+
|
22
|
+
class LockStatus(IntEnum):
|
23
|
+
"""Enumeration representing the status of a Redis lock.
|
24
|
+
|
25
|
+
The LockStatus enum defines the possible states of a Redis lock:
|
26
|
+
|
27
|
+
- FREE: The lock is not being used.
|
28
|
+
- UNAVAILABLE: The lock is being used by another program, or it has been marked as unavailable for a certain period of time.
|
29
|
+
- LOCKED: The lock is being used by the current program.
|
30
|
+
- ERROR: The lock is being used permanently, indicating a potential issue with the program.
|
31
|
+
"""
|
32
|
+
FREE = 0x00
|
33
|
+
UNAVAILABLE = 0x01
|
34
|
+
LOCKED = 0x02
|
35
|
+
ERROR = 0x04
|
36
|
+
|
37
|
+
|
38
|
+
class BaseLock(ABC):
|
39
|
+
"""Abstract base class defining the interface for lock implementations.
|
40
|
+
|
41
|
+
Attributes:
|
42
|
+
eps: Epsilon value for floating point comparison.
|
43
|
+
"""
|
44
|
+
eps: float
|
45
|
+
|
46
|
+
def __init__(self, eps: float = 1e-6):
|
47
|
+
"""Initialize a BaseLock instance.
|
48
|
+
|
49
|
+
Args:
|
50
|
+
eps: Epsilon value for floating point comparison.
|
51
|
+
"""
|
52
|
+
self.eps = eps
|
53
|
+
|
54
|
+
@abstractmethod
|
55
|
+
def key_status(self, key: str, timeout: int = 120) -> LockStatus:
|
56
|
+
"""Get the status of a key.
|
57
|
+
|
58
|
+
Args:
|
59
|
+
key: The key to check the status of.
|
60
|
+
timeout: The lock timeout in seconds.
|
61
|
+
|
62
|
+
Returns:
|
63
|
+
The current status of the key.
|
64
|
+
"""
|
65
|
+
pass
|
66
|
+
|
67
|
+
@abstractmethod
|
68
|
+
def update(self, key: str, value='1', timeout: Timeout = 120):
|
69
|
+
"""Lock a key for a specified duration without checking if the key is already locked.
|
70
|
+
|
71
|
+
Args:
|
72
|
+
key: The key to lock.
|
73
|
+
value: The value to set for the key.
|
74
|
+
timeout: The lock timeout in seconds.
|
75
|
+
"""
|
76
|
+
pass
|
77
|
+
|
78
|
+
@abstractmethod
|
79
|
+
def lock(self, key: str, value: str = '1', timeout: Timeout = 120) -> bool:
|
80
|
+
"""Try to lock a key for a specified duration.
|
81
|
+
|
82
|
+
Args:
|
83
|
+
key: The key to lock.
|
84
|
+
value: The value to set for the key.
|
85
|
+
timeout: The lock timeout in seconds.
|
86
|
+
|
87
|
+
Returns:
|
88
|
+
True if the ownership of the key is successfully acquired, False otherwise.
|
89
|
+
"""
|
90
|
+
pass
|
91
|
+
|
92
|
+
@abstractmethod
|
93
|
+
def is_locked(self, key: str) -> bool:
|
94
|
+
"""Check if a key is locked.
|
95
|
+
|
96
|
+
Args:
|
97
|
+
key: The key to check.
|
98
|
+
|
99
|
+
Returns:
|
100
|
+
True if the key is locked, False otherwise.
|
101
|
+
"""
|
102
|
+
pass
|
103
|
+
|
104
|
+
@abstractmethod
|
105
|
+
def lock_value(self, key: str) -> Optional[str]:
|
106
|
+
"""Get the value of a locked key.
|
107
|
+
|
108
|
+
Args:
|
109
|
+
key: The key to get the value of.
|
110
|
+
|
111
|
+
Returns:
|
112
|
+
The value of the key if the key is locked, None otherwise.
|
113
|
+
"""
|
114
|
+
pass
|
115
|
+
|
116
|
+
@abstractmethod
|
117
|
+
def rlock(self, key: str, value: str = '1', timeout=120) -> bool:
|
118
|
+
"""Try to lock a key for a specified duration.
|
119
|
+
|
120
|
+
When the value is the same as the current value, the function will return True.
|
121
|
+
|
122
|
+
Args:
|
123
|
+
key: The key to lock.
|
124
|
+
value: The value to set for the key.
|
125
|
+
timeout: The lock timeout in seconds.
|
126
|
+
|
127
|
+
Returns:
|
128
|
+
True if the ownership of the key is successfully acquired, False otherwise.
|
129
|
+
"""
|
130
|
+
pass
|
131
|
+
|
132
|
+
@abstractmethod
|
133
|
+
def unlock(self, key: str) -> bool:
|
134
|
+
"""Forcefully release a key without checking if the key is locked.
|
135
|
+
|
136
|
+
Args:
|
137
|
+
key: The key to release.
|
138
|
+
|
139
|
+
Returns:
|
140
|
+
True if the key is successfully released, False if the key is not locked.
|
141
|
+
"""
|
142
|
+
pass
|
143
|
+
|
144
|
+
@abstractmethod
|
145
|
+
def _conditional_setdel(self, op: str, key: str, value: float, set_value: Optional[float] = None,
|
146
|
+
ex: Optional[int] = None, isdel: bool = False) -> bool:
|
147
|
+
"""Conditionally set or del a key's value based on comparison with current value.
|
148
|
+
|
149
|
+
Args:
|
150
|
+
op: Comparison operator ('>', '<', '>=', '<=', '==', '!=').
|
151
|
+
key: The key to set or delete.
|
152
|
+
value: The value to compare with.
|
153
|
+
set_value: The value to set, if None, will use value instead.
|
154
|
+
ex: Optional expiration time in seconds.
|
155
|
+
isdel: Whether to delete the key or set the value if the condition is met.
|
156
|
+
|
157
|
+
Returns:
|
158
|
+
Whether the operation was successful.
|
159
|
+
"""
|
160
|
+
pass
|
161
|
+
|
162
|
+
def setgt(self, key: str, value: float, set_value: Optional[float] = None, ex: Optional[int] = None) -> bool:
|
163
|
+
"""Sets a new value when the comparison value is greater than the current value."""
|
164
|
+
return self._conditional_setdel('>', key, value, set_value, ex, False)
|
165
|
+
|
166
|
+
def setlt(self, key: str, value: float, set_value: Optional[float] = None, ex: Optional[int] = None) -> bool:
|
167
|
+
"""Sets a new value when the comparison value is less than the current value."""
|
168
|
+
return self._conditional_setdel('<', key, value, set_value, ex, False)
|
169
|
+
|
170
|
+
def setge(self, key: str, value: float, set_value: Optional[float] = None, ex: Optional[int] = None) -> bool:
|
171
|
+
"""Sets a new value when the comparison value is greater than or equal to the current value."""
|
172
|
+
return self._conditional_setdel('>=', key, value, set_value, ex, False)
|
173
|
+
|
174
|
+
def setle(self, key: str, value: float, set_value: Optional[float] = None, ex: Optional[int] = None) -> bool:
|
175
|
+
"""Sets a new value when the comparison value is less than or equal to the current value."""
|
176
|
+
return self._conditional_setdel('<=', key, value, set_value, ex, False)
|
177
|
+
|
178
|
+
def seteq(self, key: str, value: float, set_value: Optional[float] = None, ex: Optional[int] = None) -> bool:
|
179
|
+
"""Sets a new value when the comparison value is equal to the current value."""
|
180
|
+
return self._conditional_setdel('==', key, value, set_value, ex, False)
|
181
|
+
|
182
|
+
def setne(self, key: str, value: float, set_value: Optional[float] = None, ex: Optional[int] = None) -> bool:
|
183
|
+
"""Sets a new value when the comparison value is not equal to the current value."""
|
184
|
+
return self._conditional_setdel('!=', key, value, set_value, ex, False)
|
185
|
+
|
186
|
+
def delgt(self, key: str, value: float):
|
187
|
+
"""Deletes a key when the comparison value is greater than the current value."""
|
188
|
+
return self._conditional_setdel('>', key, value, None, None, True)
|
189
|
+
|
190
|
+
def dellt(self, key: str, value: float):
|
191
|
+
"""Deletes a key when the comparison value is less than the current value."""
|
192
|
+
return self._conditional_setdel('<', key, value, None, None, True)
|
193
|
+
|
194
|
+
def delge(self, key: str, value: float):
|
195
|
+
"""Deletes a key when the comparison value is greater than or equal to the current value."""
|
196
|
+
return self._conditional_setdel('>=', key, value, None, None, True)
|
197
|
+
|
198
|
+
def delle(self, key: str, value: float):
|
199
|
+
"""Deletes a key when the comparison value is less than or equal to the current value."""
|
200
|
+
return self._conditional_setdel('<=', key, value, None, None, True)
|
201
|
+
|
202
|
+
def deleq(self, key: str, value: float):
|
203
|
+
"""Deletes a key when the comparison value is equal to the current value."""
|
204
|
+
return self._conditional_setdel('==', key, value, None, None, True)
|
205
|
+
|
206
|
+
def delne(self, key: str, value: float):
|
207
|
+
"""Deletes a key when the comparison value is not equal to the current value."""
|
208
|
+
return self._conditional_setdel('!=', key, value, None, None, True)
|
209
|
+
|
210
|
+
def _to_seconds(self, timeout: Timeout):
|
211
|
+
"""Convert a timeout to seconds."""
|
212
|
+
if timeout is None:
|
213
|
+
timeout = datetime(2099, 1, 1).timestamp()
|
214
|
+
elif isinstance(timeout, timedelta):
|
215
|
+
timeout = timeout.total_seconds()
|
216
|
+
return timeout
|
217
|
+
|
218
|
+
|
219
|
+
class BaseLockPool(BaseLock, metaclass=ABCMeta):
|
220
|
+
"""Abstract base class defining the interface for lock pool implementations.
|
221
|
+
|
222
|
+
A lock pool manages a collection of lock keys as a group, providing methods
|
223
|
+
to track, add, remove, and check lock status of multiple keys.
|
224
|
+
|
225
|
+
Attributes:
|
226
|
+
eps: Epsilon value for floating point comparison.
|
227
|
+
"""
|
228
|
+
|
229
|
+
@abstractmethod
|
230
|
+
def extend(self, keys: Optional[Sequence[str]] = None):
|
231
|
+
"""Extend the pool with the specified keys."""
|
232
|
+
pass
|
233
|
+
|
234
|
+
@abstractmethod
|
235
|
+
def shrink(self, keys: Sequence[str]):
|
236
|
+
"""Shrink the pool by removing the specified keys."""
|
237
|
+
pass
|
238
|
+
|
239
|
+
@abstractmethod
|
240
|
+
def assign(self, keys: Optional[Sequence[str]] = None):
|
241
|
+
"""Assign keys to the pool, replacing any existing keys."""
|
242
|
+
pass
|
243
|
+
|
244
|
+
@abstractmethod
|
245
|
+
def clear(self):
|
246
|
+
"""Empty the pool."""
|
247
|
+
pass
|
248
|
+
|
249
|
+
@abstractmethod
|
250
|
+
def keys(self) -> Iterable[str]:
|
251
|
+
"""Get the keys in the pool."""
|
252
|
+
pass
|
253
|
+
|
254
|
+
@abstractmethod
|
255
|
+
def _get_key_lock_status(self, keys: Iterable[str]) -> Iterable[bool]:
|
256
|
+
"""Get the lock status of the specified keys."""
|
257
|
+
pass
|
258
|
+
|
259
|
+
def values_lock_status(self) -> Iterable[bool]:
|
260
|
+
"""Get the lock status of all keys in the pool."""
|
261
|
+
return self._get_key_lock_status(self.keys())
|
262
|
+
|
263
|
+
def items_locked_status(self) -> Iterable[Tuple[str, bool]]:
|
264
|
+
"""Get (key, lock_status) pairs for all keys in the pool."""
|
265
|
+
all_keys = list(self.keys())
|
266
|
+
return zip(all_keys, self._get_key_lock_status(all_keys))
|
267
|
+
|
268
|
+
def health_check(self) -> Tuple[int, int]:
|
269
|
+
"""Check the health status of the keys in the pool.
|
270
|
+
|
271
|
+
Returns:
|
272
|
+
A tuple of (locked_count, free_count)
|
273
|
+
"""
|
274
|
+
items = list(self.values_lock_status())
|
275
|
+
locked = sum(1 for item in items if item)
|
276
|
+
free = len(items) - locked
|
277
|
+
return locked, free
|
278
|
+
|
279
|
+
def __len__(self):
|
280
|
+
"""Get the number of keys in the pool."""
|
281
|
+
return len(list(self.keys()))
|
282
|
+
|
283
|
+
def __iter__(self):
|
284
|
+
"""Iterate over the keys in the pool."""
|
285
|
+
return iter(self.keys())
|
286
|
+
|
287
|
+
|
288
|
+
class RedisLock(BaseLock):
|
289
|
+
"""Redis-based lock implementation.
|
290
|
+
|
291
|
+
Provides distributed locking capabilities using Redis as the backend storage.
|
292
|
+
|
293
|
+
Attributes:
|
294
|
+
redis: The Redis client instance.
|
295
|
+
prefix: Prefix for Redis keys.
|
296
|
+
suffix: Suffix for Redis keys.
|
297
|
+
eps: Epsilon value for floating point comparison.
|
298
|
+
"""
|
299
|
+
|
300
|
+
redis: Redis
|
301
|
+
prefix: str
|
302
|
+
suffix: str
|
303
|
+
|
304
|
+
def __init__(self, redis: Redis, prefix: str, suffix="lock", eps: float = 1e-6):
|
305
|
+
"""Initialize a RedisLock instance.
|
306
|
+
|
307
|
+
Args:
|
308
|
+
redis: Redis client instance.
|
309
|
+
prefix: Prefix for Redis keys.
|
310
|
+
suffix: Suffix for Redis keys.
|
311
|
+
eps: Epsilon value for floating point comparison.
|
312
|
+
"""
|
313
|
+
assert "'" not in prefix and "'" not in suffix, "Prefix and suffix cannot contain single quotes"
|
314
|
+
assert redis.get_encoder().decode_responses, "Redis must be configured to decode responses"
|
315
|
+
super().__init__(eps=eps)
|
316
|
+
self.redis = redis
|
317
|
+
self.prefix = prefix
|
318
|
+
self.suffix = suffix
|
319
|
+
|
320
|
+
@property
|
321
|
+
def _lua_required_string(self):
|
322
|
+
return f'''
|
323
|
+
local function key_str(key)
|
324
|
+
return '{self.prefix}|{self.suffix}:' .. key
|
325
|
+
end
|
326
|
+
'''
|
327
|
+
|
328
|
+
def _key_str(self, key: str):
|
329
|
+
return f'{self.prefix}|{self.suffix}:{key}'
|
330
|
+
|
331
|
+
def key_status(self, key: str, timeout: int = 120) -> LockStatus:
|
332
|
+
ttl = self.redis.ttl(self._key_str(key))
|
333
|
+
if ttl > timeout: # If TTL is greater than the required expiration time, it means the usage is incorrect
|
334
|
+
return LockStatus.UNAVAILABLE
|
335
|
+
elif ttl >= 0:
|
336
|
+
return LockStatus.LOCKED
|
337
|
+
elif ttl == -1:
|
338
|
+
return LockStatus.ERROR # Permanent lock
|
339
|
+
return LockStatus.FREE
|
340
|
+
|
341
|
+
def update(self, key: str, value='1', timeout: Timeout = 120):
|
342
|
+
self.redis.set(self._key_str(key), value, ex=timeout)
|
343
|
+
|
344
|
+
def lock(self, key: str, value: str = '1', timeout: Timeout = 120) -> bool:
|
345
|
+
key_str = self._key_str(key)
|
346
|
+
return self.redis.set(key_str, value, ex=timeout, nx=True)
|
347
|
+
|
348
|
+
def is_locked(self, key: str) -> bool:
|
349
|
+
return self.redis.exists(self._key_str(key))
|
350
|
+
|
351
|
+
def lock_value(self, key: str) -> Optional[str]:
|
352
|
+
return self.redis.get(self._key_str(key))
|
353
|
+
|
354
|
+
def rlock(self, key: str, value: str = '1', timeout=120) -> bool:
|
355
|
+
key_str = self._key_str(key)
|
356
|
+
old_value = self.redis.set(key_str, value, ex=timeout, nx=True, get=True)
|
357
|
+
return old_value is None or old_value == value
|
358
|
+
|
359
|
+
def unlock(self, key: str) -> bool:
|
360
|
+
return bool(self.redis.delete(self._key_str(key)))
|
361
|
+
|
362
|
+
def _conditional_setdel(self, op: str, key: str, value: float, set_value: Optional[float] = None, ex: Optional[int] = None,
|
363
|
+
isdel: bool = False) -> bool:
|
364
|
+
# Convert None to a valid value for Redis (using -1 to indicate no expiration)
|
365
|
+
key_value = self._key_str(key)
|
366
|
+
ex_value = -1 if ex is None else ex
|
367
|
+
isdel_value = '1' if isdel else '0'
|
368
|
+
if set_value is None:
|
369
|
+
set_value = value
|
370
|
+
return self._conditional_setdel_script[op](keys=[key_value],
|
371
|
+
args=[value, set_value, ex_value, isdel_value])
|
372
|
+
|
373
|
+
@cached_property
|
374
|
+
def _conditional_setdel_script(self):
|
375
|
+
return {op: self.redis.register_script(self._conditional_setdel_lua_script(op, self.eps)) for op in
|
376
|
+
('>', '<', '>=', '<=', '==', '!=')}
|
377
|
+
|
378
|
+
def _conditional_setdel_lua_script(self, op: str, eps: float = 1e-6) -> str:
|
379
|
+
match op:
|
380
|
+
case '>':
|
381
|
+
condition = 'compare_value > current_value'
|
382
|
+
case '<':
|
383
|
+
condition = 'compare_value < current_value'
|
384
|
+
case '>=':
|
385
|
+
condition = f'compare_value >= current_value - {eps}'
|
386
|
+
case '<=':
|
387
|
+
condition = f'compare_value <= current_value + {eps}'
|
388
|
+
case '==':
|
389
|
+
condition = f'abs(compare_value - current_value) < {eps}'
|
390
|
+
case '!=':
|
391
|
+
condition = f'abs(compare_value - current_value) > {eps}'
|
392
|
+
case _:
|
393
|
+
raise ValueError(f"Invalid operator: {op}")
|
394
|
+
return f'''
|
395
|
+
{self._lua_required_string}
|
396
|
+
local abs = math.abs
|
397
|
+
local current_key = KEYS[1]
|
398
|
+
local current_value = tonumber(redis.call('GET', current_key))
|
399
|
+
local compare_value = tonumber(ARGV[1])
|
400
|
+
local new_value = tonumber(ARGV[2])
|
401
|
+
local ex = tonumber(ARGV[3])
|
402
|
+
local isdel = ARGV[4] ~= '0'
|
403
|
+
if current_value == nil or {condition} then
|
404
|
+
if isdel then
|
405
|
+
redis.call('DEL', current_key)
|
406
|
+
else
|
407
|
+
if ex ~= nil and ex > 0 then
|
408
|
+
redis.call('SET', current_key, new_value, 'EX', ex)
|
409
|
+
else
|
410
|
+
redis.call('SET', current_key, new_value)
|
411
|
+
end
|
412
|
+
end
|
413
|
+
return true
|
414
|
+
end
|
415
|
+
return false
|
416
|
+
'''
|
417
|
+
|
418
|
+
def __eq__(self, value: Any) -> bool:
|
419
|
+
if isinstance(value, RedisLock):
|
420
|
+
return self.prefix == value.prefix and self.suffix == value.suffix
|
421
|
+
return False
|
422
|
+
|
423
|
+
def __hash__(self) -> int:
|
424
|
+
return hash((self.prefix, self.suffix))
|
425
|
+
|
426
|
+
|
427
|
+
class RedisLockPool(RedisLock, BaseLockPool):
|
428
|
+
"""Redis-based lock pool implementation.
|
429
|
+
|
430
|
+
Manages a pool of Redis locks, stored as a Redis set.
|
431
|
+
"""
|
432
|
+
|
433
|
+
def __init__(self, redis: Redis, prefix: str, suffix='lock-pool', eps: float = 1e-6):
|
434
|
+
"""Initialize a RedisLockPool instance.
|
435
|
+
|
436
|
+
Args:
|
437
|
+
redis: Redis client instance.
|
438
|
+
prefix: Prefix for Redis keys.
|
439
|
+
suffix: Suffix for Redis keys.
|
440
|
+
eps: Epsilon value for floating point comparison.
|
441
|
+
"""
|
442
|
+
super().__init__(redis, prefix, suffix=suffix, eps=eps)
|
443
|
+
assert redis.get_encoder().decode_responses, "Redis must be configured to decode responses"
|
444
|
+
|
445
|
+
@property
|
446
|
+
def _lua_required_string(self):
|
447
|
+
return f'''
|
448
|
+
{super()._lua_required_string}
|
449
|
+
local function pool_str()
|
450
|
+
return '{self._pool_str()}'
|
451
|
+
end
|
452
|
+
'''
|
453
|
+
|
454
|
+
def _pool_str(self):
|
455
|
+
"""Returns the Redis key for the pool."""
|
456
|
+
return f'{self.prefix}|{self.suffix}|pool'
|
457
|
+
|
458
|
+
def extend(self, keys: Optional[Sequence[str]] = None):
|
459
|
+
"""Extend the pool with the specified keys."""
|
460
|
+
if keys is not None and len(keys) > 0:
|
461
|
+
self.redis.sadd(self._pool_str(), *keys)
|
462
|
+
|
463
|
+
def shrink(self, keys: Sequence[str]):
|
464
|
+
"""Shrink the pool by removing the specified keys."""
|
465
|
+
if keys is not None and len(keys) > 0:
|
466
|
+
self.redis.srem(self._pool_str(), *keys)
|
467
|
+
|
468
|
+
@property
|
469
|
+
def _assign_lua_string(self):
|
470
|
+
return f'''
|
471
|
+
{self._lua_required_string}
|
472
|
+
redis.call('DEL', pool_str())
|
473
|
+
redis.call('SADD', pool_str(), unpack(ARGV))
|
474
|
+
'''
|
475
|
+
|
476
|
+
@cached_property
|
477
|
+
def _assign_lua_script(self):
|
478
|
+
return self.redis.register_script(self._assign_lua_string)
|
479
|
+
|
480
|
+
def assign(self, keys: Optional[Sequence[str]] = None):
|
481
|
+
"""Assign keys to the pool, replacing any existing keys."""
|
482
|
+
if keys is not None and len(keys) > 0:
|
483
|
+
self._assign_lua_script(args=keys)
|
484
|
+
else:
|
485
|
+
self.clear()
|
486
|
+
|
487
|
+
def clear(self):
|
488
|
+
"""Empty the pool."""
|
489
|
+
self.redis.delete(self._pool_str())
|
490
|
+
|
491
|
+
def keys(self) -> Iterable[str]:
|
492
|
+
"""Get the keys in the pool."""
|
493
|
+
return self.redis.smembers(self._pool_str())
|
494
|
+
|
495
|
+
def __contains__(self, key):
|
496
|
+
"""Check if a key is in the pool."""
|
497
|
+
return self.redis.sismember(self._pool_str(), key)
|
498
|
+
|
499
|
+
def _get_key_lock_status(self, keys: Iterable[str]) -> Iterable[bool]:
|
500
|
+
"""Get the lock status of the specified keys."""
|
501
|
+
return map(lambda x: x is not None, self.redis.mget(map(self._key_str, keys)))
|
502
|
+
|
503
|
+
|
504
|
+
@dataclass
|
505
|
+
class LockData:
|
506
|
+
"""Data structure to store lock information.
|
507
|
+
|
508
|
+
Attributes:
|
509
|
+
value: The lock value.
|
510
|
+
expiry: The expiration timestamp.
|
511
|
+
"""
|
512
|
+
value: str
|
513
|
+
expiry: float
|
514
|
+
|
515
|
+
|
516
|
+
class ThreadLock(BaseLock):
|
517
|
+
"""Thread-safe lock implementation for local process use.
|
518
|
+
|
519
|
+
Provides similar functionality to RedisLock but works locally
|
520
|
+
within a single Python process using threading mechanisms.
|
521
|
+
|
522
|
+
Attributes:
|
523
|
+
eps: Epsilon value for floating point comparison.
|
524
|
+
"""
|
525
|
+
|
526
|
+
def __init__(self, eps: float = 1e-6):
|
527
|
+
"""Initialize a ThreadLock instance.
|
528
|
+
|
529
|
+
Args:
|
530
|
+
eps: Epsilon value for floating point comparison.
|
531
|
+
"""
|
532
|
+
super().__init__(eps=eps)
|
533
|
+
self._locks = defaultdict(lambda: LockData(value='1', expiry=0))
|
534
|
+
self._lock = threading.RLock() # Thread lock to protect access to _locks
|
535
|
+
|
536
|
+
def _is_expired(self, key: str) -> bool:
|
537
|
+
"""Check if a lock has expired."""
|
538
|
+
return self._get_ttl(key) <= 0
|
539
|
+
|
540
|
+
def _get_ttl(self, key: str):
|
541
|
+
"""Get the TTL of a lock in seconds."""
|
542
|
+
return self._locks[key].expiry - time.time()
|
543
|
+
|
544
|
+
def key_status(self, key: str, timeout: int = 120) -> LockStatus:
|
545
|
+
"""Get the status of a key."""
|
546
|
+
ttl = self._get_ttl(key)
|
547
|
+
if ttl <= 0:
|
548
|
+
return LockStatus.FREE
|
549
|
+
elif ttl > timeout:
|
550
|
+
return LockStatus.UNAVAILABLE
|
551
|
+
return LockStatus.LOCKED
|
552
|
+
|
553
|
+
def update(self, key: str, value='1', timeout: Timeout = 120):
|
554
|
+
"""Lock a key for a specified duration without checking if already locked."""
|
555
|
+
expiry = time.time() + self._to_seconds(timeout)
|
556
|
+
self._locks[key] = LockData(value=value, expiry=expiry)
|
557
|
+
|
558
|
+
def lock(self, key: str, value: str = '1', timeout: Timeout = 120) -> bool:
|
559
|
+
"""Try to lock a key for a specified duration."""
|
560
|
+
with self._lock:
|
561
|
+
if not self._is_expired(key):
|
562
|
+
return False
|
563
|
+
expiry = time.time() + self._to_seconds(timeout)
|
564
|
+
self._locks[key] = LockData(value=value, expiry=expiry)
|
565
|
+
return True
|
566
|
+
|
567
|
+
def is_locked(self, key: str) -> bool:
|
568
|
+
"""Check if a key is locked."""
|
569
|
+
return not self._is_expired(key)
|
570
|
+
|
571
|
+
def lock_value(self, key: str) -> Optional[str]:
|
572
|
+
"""Get the value of a locked key."""
|
573
|
+
data = self._locks[key]
|
574
|
+
if data.expiry <= time.time():
|
575
|
+
return None
|
576
|
+
return str(data.value)
|
577
|
+
|
578
|
+
def rlock(self, key: str, value: str = '1', timeout=120) -> bool:
|
579
|
+
"""Try to relock a key for a specified duration."""
|
580
|
+
with self._lock:
|
581
|
+
data = self._locks[key]
|
582
|
+
if data.expiry > time.time() and data.value != value:
|
583
|
+
return False
|
584
|
+
expiry = time.time() + self._to_seconds(timeout)
|
585
|
+
self._locks[key] = LockData(value=value, expiry=expiry)
|
586
|
+
return True
|
587
|
+
|
588
|
+
def unlock(self, key: str) -> bool:
|
589
|
+
"""Forcefully release a key."""
|
590
|
+
return self._locks.pop(key, None) is not None
|
591
|
+
|
592
|
+
def _compare_values(self, op: str, compare_value: float, current_value: float) -> bool:
|
593
|
+
"""Compare two values using the specified operator."""
|
594
|
+
match op:
|
595
|
+
case '>':
|
596
|
+
return compare_value > current_value
|
597
|
+
case '<':
|
598
|
+
return compare_value < current_value
|
599
|
+
case '>=':
|
600
|
+
return compare_value >= current_value - self.eps
|
601
|
+
case '<=':
|
602
|
+
return compare_value <= current_value + self.eps
|
603
|
+
case '==':
|
604
|
+
return abs(compare_value - current_value) < self.eps
|
605
|
+
case '!=':
|
606
|
+
return abs(compare_value - current_value) > self.eps
|
607
|
+
case _:
|
608
|
+
raise ValueError(f"Invalid operator: {op}")
|
609
|
+
|
610
|
+
def _conditional_setdel(self, op: str, key: str, value: float, set_value: Optional[float] = None,
|
611
|
+
ex: Optional[int] = None, isdel: bool = False) -> bool:
|
612
|
+
"""Conditionally set or delete a key's value based on comparison."""
|
613
|
+
compare_value = float(value)
|
614
|
+
if set_value is None:
|
615
|
+
set_value = value
|
616
|
+
|
617
|
+
with self._lock:
|
618
|
+
# Get current value if key exists and is not expired
|
619
|
+
current_data = self._locks[key]
|
620
|
+
if current_data.expiry <= time.time():
|
621
|
+
current_value = None
|
622
|
+
else:
|
623
|
+
current_value = float(current_data.value)
|
624
|
+
|
625
|
+
# Condition check
|
626
|
+
if current_value is None or self._compare_values(op, compare_value, current_value):
|
627
|
+
if isdel:
|
628
|
+
# Delete the key
|
629
|
+
self._locks.pop(key, None)
|
630
|
+
else:
|
631
|
+
# Set the key
|
632
|
+
expiry = time.time() + self._to_seconds(ex)
|
633
|
+
self._locks[key] = LockData(value=set_value, expiry=expiry)
|
634
|
+
return True
|
635
|
+
return False
|
636
|
+
|
637
|
+
|
638
|
+
class ThreadLockPool(ThreadLock, BaseLockPool):
|
639
|
+
"""Thread-safe lock pool implementation for local process use.
|
640
|
+
|
641
|
+
Maintains a set of keys as the pool and provides operations to manage them.
|
642
|
+
"""
|
643
|
+
|
644
|
+
def __init__(self, eps: float = 1e-6):
|
645
|
+
"""Initialize a ThreadLockPool instance."""
|
646
|
+
super().__init__(eps=eps)
|
647
|
+
self._locks = defaultdict(lambda: LockData(value='1', expiry=0))
|
648
|
+
self._lock = threading.RLock()
|
649
|
+
self._pool = set()
|
650
|
+
|
651
|
+
def extend(self, keys: Optional[Sequence[str]] = None):
|
652
|
+
"""Extend the pool with the specified keys."""
|
653
|
+
with self._lock:
|
654
|
+
if keys is not None:
|
655
|
+
self._pool.update(keys)
|
656
|
+
|
657
|
+
def shrink(self, keys: Sequence[str]):
|
658
|
+
"""Shrink the pool by removing the specified keys."""
|
659
|
+
with self._lock:
|
660
|
+
self._pool.difference_update(keys)
|
661
|
+
|
662
|
+
def assign(self, keys: Optional[Sequence[str]] = None):
|
663
|
+
"""Assign keys to the pool, replacing any existing keys."""
|
664
|
+
with self._lock:
|
665
|
+
self.clear()
|
666
|
+
self.extend(keys=keys)
|
667
|
+
|
668
|
+
def clear(self):
|
669
|
+
"""Empty the pool."""
|
670
|
+
self._pool.clear()
|
671
|
+
|
672
|
+
def keys(self) -> Iterable[str]:
|
673
|
+
"""Get the keys in the pool."""
|
674
|
+
return self._pool
|
675
|
+
|
676
|
+
def __contains__(self, key):
|
677
|
+
"""Check if a key is in the pool."""
|
678
|
+
return key in self._pool
|
679
|
+
|
680
|
+
def _get_key_lock_status(self, keys: Iterable[str]) -> Iterable[bool]:
|
681
|
+
"""Get the lock status of the specified keys."""
|
682
|
+
return [self.is_locked(key) for key in keys]
|