redis-allocator 0.0.1__py3-none-any.whl → 0.3.2__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 +5 -1
- redis_allocator/_version.py +1 -0
- redis_allocator/allocator.py +819 -280
- redis_allocator/lock.py +66 -17
- redis_allocator/task_queue.py +81 -57
- redis_allocator-0.3.2.dist-info/METADATA +529 -0
- redis_allocator-0.3.2.dist-info/RECORD +15 -0
- {redis_allocator-0.0.1.dist-info → redis_allocator-0.3.2.dist-info}/licenses/LICENSE +21 -21
- tests/conftest.py +150 -46
- tests/test_allocator.py +461 -488
- tests/test_lock.py +675 -338
- tests/test_task_queue.py +136 -136
- redis_allocator-0.0.1.dist-info/METADATA +0 -229
- redis_allocator-0.0.1.dist-info/RECORD +0 -14
- {redis_allocator-0.0.1.dist-info → redis_allocator-0.3.2.dist-info}/WHEEL +0 -0
- {redis_allocator-0.0.1.dist-info → redis_allocator-0.3.2.dist-info}/top_level.txt +0 -0
redis_allocator/allocator.py
CHANGED
@@ -1,45 +1,40 @@
|
|
1
1
|
"""Redis-based distributed memory allocation system.
|
2
2
|
|
3
|
-
This module provides the core functionality of the RedisAllocator system,
|
3
|
+
This module provides the core functionality of the RedisAllocator system,
|
4
4
|
allowing for distributed memory allocation with support for garbage collection,
|
5
5
|
thread health checking, and priority-based allocation mechanisms.
|
6
|
+
|
7
|
+
Key features:
|
8
|
+
1. Shared vs non-shared allocation modes:
|
9
|
+
- In shared mode, allocating an item simply removes it from the free list and puts it back to the tail
|
10
|
+
- In non-shared mode, allocation locks the item to prevent others from accessing it
|
11
|
+
2. Garbage collection for stale/unhealthy items:
|
12
|
+
- Items that are locked (unhealthy) but in the free list are removed
|
13
|
+
- Items that are not in the free list but haven't been updated within their timeout are freed
|
14
|
+
3. Soft binding mechanism:
|
15
|
+
- Maps object names to allocated keys for consistent allocation
|
16
|
+
- Prioritizes previously allocated keys when the same named object requests allocation
|
17
|
+
4. Support for an updater to refresh the pool's keys periodically
|
18
|
+
5. Policy-based control of allocation behavior through RedisAllocatorPolicy
|
6
19
|
"""
|
20
|
+
import atexit
|
7
21
|
import logging
|
8
22
|
import weakref
|
23
|
+
import contextlib
|
9
24
|
from abc import ABC, abstractmethod
|
25
|
+
from typing import Any, Callable
|
10
26
|
from functools import cached_property
|
11
27
|
from threading import current_thread
|
28
|
+
from concurrent.futures import ThreadPoolExecutor
|
12
29
|
from typing import (Optional, TypeVar, Generic,
|
13
30
|
Sequence, Iterable)
|
14
31
|
from redis import StrictRedis as Redis
|
32
|
+
from cachetools import cached, TTLCache
|
15
33
|
from .lock import RedisLockPool, Timeout
|
16
34
|
|
17
35
|
logger = logging.getLogger(__name__)
|
18
36
|
|
19
37
|
|
20
|
-
class RedisAllocatableClass(ABC):
|
21
|
-
@abstractmethod
|
22
|
-
def set_config(self, key: str, params: dict):
|
23
|
-
"""Set the configuration for the object.
|
24
|
-
|
25
|
-
Args:
|
26
|
-
key: The key to set the configuration for.
|
27
|
-
params: The parameters to set the configuration for.
|
28
|
-
"""
|
29
|
-
pass
|
30
|
-
|
31
|
-
def close(self):
|
32
|
-
"""close the object."""
|
33
|
-
pass
|
34
|
-
|
35
|
-
def name(self) -> Optional[str]:
|
36
|
-
"""Get the cache name of the object."""
|
37
|
-
return None
|
38
|
-
|
39
|
-
|
40
|
-
U = TypeVar('U', bound=RedisAllocatableClass)
|
41
|
-
|
42
|
-
|
43
38
|
class RedisThreadHealthCheckPool(RedisLockPool):
|
44
39
|
"""A class that provides a simple interface for managing the health status of a thread.
|
45
40
|
|
@@ -84,14 +79,54 @@ class RedisThreadHealthCheckPool(RedisLockPool):
|
|
84
79
|
self.unlock(self.current_thread_id)
|
85
80
|
|
86
81
|
|
82
|
+
class RedisAllocatableClass(ABC):
|
83
|
+
"""A class that can be allocated through RedisAllocator.
|
84
|
+
|
85
|
+
You should inherit from this class and implement the set_config method.
|
86
|
+
"""
|
87
|
+
|
88
|
+
@abstractmethod
|
89
|
+
def set_config(self, key: str, params: dict):
|
90
|
+
"""Set the configuration for the object.
|
91
|
+
|
92
|
+
Args:
|
93
|
+
key: The key to set the configuration for.
|
94
|
+
params: The parameters to set the configuration for.
|
95
|
+
"""
|
96
|
+
pass
|
97
|
+
|
98
|
+
def open(self):
|
99
|
+
"""Open the object."""
|
100
|
+
pass
|
101
|
+
|
102
|
+
def close(self):
|
103
|
+
"""close the object."""
|
104
|
+
pass
|
105
|
+
|
106
|
+
def is_healthy(self):
|
107
|
+
return True
|
108
|
+
|
109
|
+
@property
|
110
|
+
def name(self) -> Optional[str]:
|
111
|
+
"""Get the cache name of the object, if is none no soft binding will be used."""
|
112
|
+
return None
|
113
|
+
|
114
|
+
@property
|
115
|
+
def unique_id(self) -> str:
|
116
|
+
"""Get the unique ID of the object."""
|
117
|
+
return ""
|
118
|
+
|
119
|
+
|
120
|
+
U = TypeVar('U', bound=RedisAllocatableClass)
|
121
|
+
|
122
|
+
|
87
123
|
class RedisAllocatorObject(Generic[U]):
|
88
124
|
"""Represents an object allocated through RedisAllocator.
|
89
125
|
|
90
126
|
This class provides an interface for working with allocated objects
|
91
127
|
including locking and unlocking mechanisms for thread-safe operations.
|
92
|
-
|
93
128
|
"""
|
94
|
-
|
129
|
+
allocator: 'RedisAllocator' # Reference to the allocator that created this object
|
95
130
|
key: str # Redis key for this allocated object
|
96
131
|
params: Optional[dict] # Parameters associated with this object
|
97
132
|
obj: Optional[U] # The actual object being allocated
|
@@ -105,7 +140,7 @@ class RedisAllocatorObject(Generic[U]):
|
|
105
140
|
obj: The actual object being allocated
|
106
141
|
params: Additional parameters passed by local program
|
107
142
|
"""
|
108
|
-
self.
|
143
|
+
self.allocator = allocator
|
109
144
|
self.key = key
|
110
145
|
self.obj = obj
|
111
146
|
self.params = params
|
@@ -119,47 +154,358 @@ class RedisAllocatorObject(Generic[U]):
|
|
119
154
|
timeout: How long the lock should be valid (in seconds)
|
120
155
|
"""
|
121
156
|
if timeout > 0:
|
122
|
-
self.
|
157
|
+
self.allocator.update(self.key, timeout=timeout)
|
123
158
|
else:
|
124
|
-
self.
|
159
|
+
self.allocator.free(self)
|
160
|
+
|
161
|
+
def open(self):
|
162
|
+
"""Open the object."""
|
163
|
+
if self.obj is not None:
|
164
|
+
self.obj.open()
|
125
165
|
|
126
166
|
def close(self):
|
127
167
|
"""Kill the object."""
|
128
168
|
if self.obj is not None:
|
129
169
|
self.obj.close()
|
130
170
|
|
171
|
+
def is_healthy(self) -> bool:
|
172
|
+
"""Check if the object is healthy."""
|
173
|
+
if self.obj is not None:
|
174
|
+
return self.obj.is_healthy()
|
175
|
+
return True
|
176
|
+
|
177
|
+
def set_unhealthy(self, duration: int = 3600):
|
178
|
+
"""Set the object as unhealthy."""
|
179
|
+
if self.obj is not None and self.obj.name is not None:
|
180
|
+
self.allocator.unbind_soft_bind(self.obj.name)
|
181
|
+
self.allocator.update(self.key, timeout=duration)
|
182
|
+
|
183
|
+
def refresh(self, timeout: Timeout = 120):
|
184
|
+
"""Refresh the object."""
|
185
|
+
self.close()
|
186
|
+
new_obj = self.allocator.policy.malloc(self.allocator, timeout=timeout,
|
187
|
+
obj=self.obj, params=self.params)
|
188
|
+
if new_obj is not None:
|
189
|
+
self.obj = new_obj.obj
|
190
|
+
self.key = new_obj.key
|
191
|
+
self.params = new_obj.params
|
192
|
+
|
193
|
+
@property
|
194
|
+
def unique_id(self) -> str:
|
195
|
+
"""Get the unique ID of the object."""
|
196
|
+
if self.obj is None:
|
197
|
+
return self.key
|
198
|
+
return f"{self.key}:{self.obj.unique_id}"
|
199
|
+
|
200
|
+
@property
|
201
|
+
def name(self) -> Optional[str]:
|
202
|
+
"""Get the name of the object."""
|
203
|
+
if self.obj is None:
|
204
|
+
return None
|
205
|
+
return self.obj.name
|
206
|
+
|
131
207
|
def __del__(self):
|
132
208
|
"""Delete the object."""
|
133
209
|
self.close()
|
134
210
|
|
135
211
|
|
212
|
+
class RedisAllocatorUpdater:
|
213
|
+
"""A class that updates the allocator keys."""
|
214
|
+
|
215
|
+
def __init__(self, params: Sequence[Any]):
|
216
|
+
"""Initialize the allocator updater."""
|
217
|
+
assert len(params) > 0, "params should not be empty"
|
218
|
+
self.params = params
|
219
|
+
self.index = 0
|
220
|
+
|
221
|
+
@abstractmethod
|
222
|
+
def fetch(self, param: Any) -> Sequence[str]:
|
223
|
+
"""Fetch the keys from params."""
|
224
|
+
pass
|
225
|
+
|
226
|
+
def __call__(self):
|
227
|
+
"""Update the allocator key."""
|
228
|
+
current_param = self.params[self.index]
|
229
|
+
self.index = (self.index + 1) % len(self.params)
|
230
|
+
keys = self.fetch(current_param)
|
231
|
+
return keys
|
232
|
+
|
233
|
+
def __len__(self):
|
234
|
+
"""Get the length of the allocator updater."""
|
235
|
+
return len(self.params)
|
236
|
+
|
237
|
+
|
238
|
+
class RedisAllocatorPolicy(ABC):
|
239
|
+
"""Abstract base class for Redis allocator policies.
|
240
|
+
|
241
|
+
This class defines the interface for allocation policies that can be used
|
242
|
+
with RedisAllocator to control allocation behavior.
|
243
|
+
"""
|
244
|
+
|
245
|
+
def initialize(self, allocator: 'RedisAllocator'):
|
246
|
+
"""Initialize the policy with an allocator instance.
|
247
|
+
|
248
|
+
Args:
|
249
|
+
allocator: The RedisAllocator instance to use with this policy
|
250
|
+
"""
|
251
|
+
pass
|
252
|
+
|
253
|
+
@abstractmethod
|
254
|
+
def malloc(self, allocator: 'RedisAllocator', timeout: Timeout = 120,
|
255
|
+
obj: Optional[Any] = None, params: Optional[dict] = None,
|
256
|
+
cache_timeout: Timeout = 3600) -> Optional[RedisAllocatorObject]:
|
257
|
+
"""Allocate a resource according to the policy.
|
258
|
+
|
259
|
+
Args:
|
260
|
+
allocator: The RedisAllocator instance
|
261
|
+
timeout: How long the allocation should be valid (in seconds)
|
262
|
+
obj: The object to associate with the allocation
|
263
|
+
params: Additional parameters for the allocation
|
264
|
+
cache_timeout: Timeout for the soft binding cache entry (seconds).
|
265
|
+
Defaults to 3600.
|
266
|
+
|
267
|
+
Returns:
|
268
|
+
RedisAllocatorObject if allocation was successful, None otherwise
|
269
|
+
"""
|
270
|
+
pass
|
271
|
+
|
272
|
+
@abstractmethod
|
273
|
+
def refresh_pool(self, allocator: 'RedisAllocator'):
|
274
|
+
"""Refresh the allocation pool.
|
275
|
+
|
276
|
+
This method is called periodically to update the pool with new resources.
|
277
|
+
|
278
|
+
Args:
|
279
|
+
allocator: The RedisAllocator instance
|
280
|
+
"""
|
281
|
+
pass
|
282
|
+
|
283
|
+
def check_health_once(self, r_obj: RedisAllocatorObject, duration: int = 3600) -> bool:
|
284
|
+
"""Check the health of the object."""
|
285
|
+
with contextlib.closing(r_obj):
|
286
|
+
try:
|
287
|
+
if r_obj.is_healthy():
|
288
|
+
return True
|
289
|
+
else:
|
290
|
+
r_obj.set_unhealthy(duration)
|
291
|
+
return False
|
292
|
+
except Exception as e:
|
293
|
+
logger.error(f"Error checking health of {r_obj.key}: {e}")
|
294
|
+
r_obj.set_unhealthy(duration)
|
295
|
+
raise
|
296
|
+
|
297
|
+
def check_health(self, allocator: 'RedisAllocator', lock_duration: Timeout = 3600, max_threads: int = 8,
|
298
|
+
obj_fn: Optional[Callable[[str], Any]] = None,
|
299
|
+
params_fn: Optional[Callable[[str], dict]] = None) -> tuple[int, int]:
|
300
|
+
"""Check the health of the allocator.
|
301
|
+
|
302
|
+
Args:
|
303
|
+
allocator: The RedisAllocator instance
|
304
|
+
lock_duration: The duration of the lock (in seconds)
|
305
|
+
max_threads: The maximum number of threads to use
|
306
|
+
|
307
|
+
Returns:
|
308
|
+
A tuple containing the number of healthy and unhealthy items in the allocator
|
309
|
+
"""
|
310
|
+
with ThreadPoolExecutor(max_workers=max_threads) as executor:
|
311
|
+
for key in allocator.keys():
|
312
|
+
if params_fn is not None:
|
313
|
+
params = params_fn(key)
|
314
|
+
else:
|
315
|
+
params = None
|
316
|
+
if obj_fn is not None:
|
317
|
+
obj = obj_fn(key)
|
318
|
+
else:
|
319
|
+
obj = None
|
320
|
+
alloc_obj = RedisAllocatorObject(allocator, key, obj, params)
|
321
|
+
executor.submit(self.check_health_once, alloc_obj, lock_duration)
|
322
|
+
executor.shutdown(wait=True)
|
323
|
+
|
324
|
+
|
325
|
+
class DefaultRedisAllocatorPolicy(RedisAllocatorPolicy):
|
326
|
+
"""Default implementation of RedisAllocatorPolicy.
|
327
|
+
|
328
|
+
This policy provides the following features:
|
329
|
+
1. Garbage collection before allocation: Automatically performs garbage collection
|
330
|
+
operations before allocating resources to ensure stale resources are reclaimed.
|
331
|
+
|
332
|
+
2. Soft binding prioritization: Prioritizes allocation of previously bound keys
|
333
|
+
for named objects, creating a consistent mapping between object names and keys.
|
334
|
+
If a soft binding exists but the bound key is no longer in the pool, the binding is
|
335
|
+
ignored and a new key is allocated.
|
336
|
+
|
337
|
+
3. Periodic pool updates: Uses an optional updater to refresh the pool's keys at
|
338
|
+
configurable intervals. Only one process/thread (the one that acquires the update lock)
|
339
|
+
will perform the update.
|
340
|
+
|
341
|
+
4. Configurable expiry times: Allows setting default expiry durations for pool items,
|
342
|
+
ensuring automatic cleanup of stale resources even without explicit garbage collection.
|
343
|
+
|
344
|
+
The policy controls when garbage collection happens, when the pool is refreshed with new keys,
|
345
|
+
and how allocation prioritizes resources.
|
346
|
+
"""
|
347
|
+
|
348
|
+
def __init__(self, gc_count: int = 5, update_interval: int = 300,
|
349
|
+
expiry_duration: int = -1, updater: Optional[RedisAllocatorUpdater] = None):
|
350
|
+
"""Initialize the default allocation policy.
|
351
|
+
|
352
|
+
Args:
|
353
|
+
gc_count: Number of GC operations to perform before allocation
|
354
|
+
update_interval: Interval in seconds between pool updates
|
355
|
+
expiry_duration: Default timeout for pool items (-1 means no timeout)
|
356
|
+
updater: Optional updater for refreshing the pool's keys
|
357
|
+
"""
|
358
|
+
self.gc_count = gc_count
|
359
|
+
self.update_interval: float = update_interval
|
360
|
+
self.expiry_duration: float = expiry_duration
|
361
|
+
self.updater = updater
|
362
|
+
self._allocator: Optional[weakref.ReferenceType['RedisAllocator']] = None
|
363
|
+
self._update_lock_key: Optional[str] = None
|
364
|
+
self.objects: weakref.WeakValueDictionary[str, RedisAllocatorObject] = weakref.WeakValueDictionary()
|
365
|
+
|
366
|
+
def initialize(self, allocator: 'RedisAllocator'):
|
367
|
+
"""Initialize the policy with an allocator instance.
|
368
|
+
|
369
|
+
Args:
|
370
|
+
allocator: The RedisAllocator instance to use with this policy
|
371
|
+
"""
|
372
|
+
self._allocator = weakref.ref(allocator)
|
373
|
+
self._update_lock_key = f"{allocator._pool_str()}|policy_update_lock"
|
374
|
+
atexit.register(lambda: self.finalize(self._allocator()))
|
375
|
+
|
376
|
+
def malloc(self, allocator: 'RedisAllocator', timeout: Timeout = 120,
|
377
|
+
obj: Optional[Any] = None, params: Optional[dict] = None,
|
378
|
+
cache_timeout: Timeout = 3600) -> Optional[RedisAllocatorObject]:
|
379
|
+
"""Allocate a resource according to the policy.
|
380
|
+
|
381
|
+
This implementation:
|
382
|
+
1. Performs GC operations before allocation
|
383
|
+
2. Checks for soft binding based on object name
|
384
|
+
3. Falls back to regular allocation if no soft binding exists
|
385
|
+
|
386
|
+
Args:
|
387
|
+
allocator: The RedisAllocator instance
|
388
|
+
timeout: How long the allocation should be valid (in seconds)
|
389
|
+
obj: The object to associate with the allocation
|
390
|
+
params: Additional parameters for the allocation
|
391
|
+
cache_timeout: Timeout for the soft binding cache entry (seconds).
|
392
|
+
Defaults to 3600.
|
393
|
+
|
394
|
+
Returns:
|
395
|
+
RedisAllocatorObject if allocation was successful, None otherwise
|
396
|
+
"""
|
397
|
+
# Try to refresh the pool if necessary
|
398
|
+
self._try_refresh_pool(allocator)
|
399
|
+
|
400
|
+
# Perform GC operations before allocation
|
401
|
+
allocator.gc(self.gc_count)
|
402
|
+
|
403
|
+
# Fall back to regular allocation
|
404
|
+
# Explicitly call obj.name if obj exists
|
405
|
+
obj_name = obj.name if obj and hasattr(obj, 'name') and callable(
|
406
|
+
obj.name) else (obj.name if obj and hasattr(obj, 'name') else None)
|
407
|
+
key = allocator.malloc_key(timeout, obj_name,
|
408
|
+
cache_timeout=cache_timeout)
|
409
|
+
alloc_obj = RedisAllocatorObject(allocator, key, obj, params)
|
410
|
+
old_obj = self.objects.get(alloc_obj.unique_id, None)
|
411
|
+
if old_obj is not None:
|
412
|
+
old_obj.close()
|
413
|
+
self.objects[alloc_obj.unique_id] = alloc_obj
|
414
|
+
return alloc_obj
|
415
|
+
|
416
|
+
@cached(TTLCache(maxsize=64, ttl=5))
|
417
|
+
def _try_refresh_pool(self, allocator: 'RedisAllocator'):
|
418
|
+
"""Try to refresh the pool if necessary and if we can acquire the lock.
|
419
|
+
|
420
|
+
Args:
|
421
|
+
allocator: The RedisAllocator instance
|
422
|
+
"""
|
423
|
+
if self.updater is None:
|
424
|
+
return
|
425
|
+
if allocator.lock(self._update_lock_key, timeout=self.update_interval):
|
426
|
+
# If we got here, we acquired the lock, so we can update the pool
|
427
|
+
self.refresh_pool(allocator)
|
428
|
+
|
429
|
+
def refresh_pool(self, allocator: 'RedisAllocator'):
|
430
|
+
"""Refresh the allocation pool using the updater.
|
431
|
+
|
432
|
+
Args:
|
433
|
+
allocator: The RedisAllocator instance
|
434
|
+
"""
|
435
|
+
if self.updater is None:
|
436
|
+
return
|
437
|
+
|
438
|
+
keys = self.updater()
|
439
|
+
|
440
|
+
if len(keys) == 0:
|
441
|
+
logger.warning("No keys to update to the pool")
|
442
|
+
return
|
443
|
+
|
444
|
+
# Update the pool based on the number of keys
|
445
|
+
if len(self.updater) == 1:
|
446
|
+
allocator.assign(keys, timeout=self.expiry_duration)
|
447
|
+
else:
|
448
|
+
allocator.extend(keys, timeout=self.expiry_duration)
|
449
|
+
|
450
|
+
def finalize(self, allocator: 'RedisAllocator'):
|
451
|
+
"""Finalize the policy."""
|
452
|
+
for obj in self.objects.values():
|
453
|
+
obj.close()
|
454
|
+
|
455
|
+
|
136
456
|
class RedisAllocator(RedisLockPool, Generic[U]):
|
137
|
-
"""A Redis-based distributed
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
457
|
+
"""A Redis-based distributed allocation system.
|
458
|
+
|
459
|
+
Manages a pool of resource identifiers (keys) using Redis, allowing distributed
|
460
|
+
clients to allocate, free, and manage these resources. It leverages Redis's
|
461
|
+
atomic operations via Lua scripts for safe concurrent access.
|
462
|
+
|
463
|
+
The allocator maintains a doubly-linked list in a Redis hash to track available
|
464
|
+
(free) resources. Allocated resources are tracked using standard Redis keys
|
465
|
+
that act as locks.
|
466
|
+
|
467
|
+
Key Concepts:
|
468
|
+
- Allocation Pool: A set of resource identifiers (keys) managed by the allocator.
|
469
|
+
Stored in a Redis hash (`<prefix>|<suffix>|pool`) representing a doubly-linked list.
|
470
|
+
Head/Tail pointers are stored in separate keys (`<prefix>|<suffix>|pool|head`,
|
471
|
+
`<prefix>|<suffix>|pool|tail`).
|
472
|
+
- Free List: The subset of keys within the pool that are currently available.
|
473
|
+
Represented by the linked list structure within the pool hash.
|
474
|
+
- Allocated State: A key is considered allocated if a corresponding lock key exists
|
475
|
+
(`<prefix>|<suffix>:<key>`).
|
476
|
+
- Shared Mode: If `shared=True`, allocating a key moves it to the tail of the
|
477
|
+
free list but does *not* create a lock key. This allows multiple clients to
|
478
|
+
"allocate" the same key concurrently, effectively using the list as a rotating pool.
|
479
|
+
If `shared=False` (default), allocation creates a lock key, granting exclusive access.
|
480
|
+
- Soft Binding: Allows associating a logical name with an allocated key. If an object
|
481
|
+
provides a `name`, the allocator tries to reuse the previously bound key for that name.
|
482
|
+
Stored in Redis keys like `<prefix>|<suffix>-cache:bind:<name>`.
|
483
|
+
- Garbage Collection (GC): Periodically scans the pool to reconcile the free list
|
484
|
+
with the lock states. Removes expired/locked items from the free list and returns
|
485
|
+
items whose locks have expired back to the free list.
|
486
|
+
- Policies: Uses `RedisAllocatorPolicy` (e.g., `DefaultRedisAllocatorPolicy`)
|
487
|
+
to customize allocation behavior, GC triggering, and pool updates.
|
488
|
+
|
489
|
+
Generic type U should implement `RedisAllocatableClass`.
|
146
490
|
"""
|
147
491
|
|
148
492
|
def __init__(self, redis: Redis, prefix: str, suffix='allocator', eps=1e-6,
|
149
|
-
shared=False):
|
150
|
-
"""
|
151
|
-
|
493
|
+
shared=False, policy: Optional[RedisAllocatorPolicy] = None):
|
494
|
+
"""Initializes the RedisAllocator.
|
495
|
+
|
152
496
|
Args:
|
153
|
-
redis:
|
154
|
-
prefix: Prefix for all Redis keys used by this allocator
|
155
|
-
suffix: Suffix
|
156
|
-
eps: Small
|
157
|
-
shared:
|
497
|
+
redis: StrictRedis client instance (must decode responses).
|
498
|
+
prefix: Prefix for all Redis keys used by this allocator instance.
|
499
|
+
suffix: Suffix to uniquely identify this allocator instance's keys.
|
500
|
+
eps: Small float tolerance for comparisons (used by underlying lock).
|
501
|
+
shared: If True, operates in shared mode (keys are rotated, not locked).
|
502
|
+
If False (default), keys are locked upon allocation.
|
503
|
+
policy: Optional allocation policy. Defaults to `DefaultRedisAllocatorPolicy`.
|
158
504
|
"""
|
159
505
|
super().__init__(redis, prefix, suffix=suffix, eps=eps)
|
160
506
|
self.shared = shared
|
161
|
-
self.
|
162
|
-
self.
|
507
|
+
self.policy = policy or DefaultRedisAllocatorPolicy()
|
508
|
+
self.policy.initialize(self)
|
163
509
|
|
164
510
|
def object_key(self, key: str, obj: U):
|
165
511
|
"""Get the key for an object."""
|
@@ -169,38 +515,44 @@ class RedisAllocator(RedisLockPool, Generic[U]):
|
|
169
515
|
|
170
516
|
def _pool_pointer_str(self, head: bool = True):
|
171
517
|
"""Get the Redis key for the head or tail pointer of the allocation pool.
|
172
|
-
|
518
|
+
|
173
519
|
Args:
|
174
520
|
head: If True, get the head pointer key; otherwise, get the tail pointer key
|
175
|
-
|
521
|
+
|
176
522
|
Returns:
|
177
523
|
String representation of the Redis key for the pointer
|
178
524
|
"""
|
179
525
|
pointer_type = 'head' if head else 'tail'
|
180
526
|
return f'{self._pool_str()}|{pointer_type}'
|
181
527
|
|
182
|
-
def _gc_cursor_str(self):
|
183
|
-
"""Get the Redis key for the garbage collection cursor.
|
184
|
-
|
185
|
-
Returns:
|
186
|
-
String representation of the Redis key for the GC cursor
|
187
|
-
"""
|
188
|
-
return f'{self._pool_str()}|gc_cursor'
|
189
|
-
|
190
528
|
@property
|
191
529
|
def _lua_required_string(self):
|
192
|
-
"""
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
-
|
197
|
-
-
|
198
|
-
-
|
199
|
-
|
200
|
-
-
|
530
|
+
"""Base Lua script providing common functions for pool manipulation.
|
531
|
+
|
532
|
+
Includes functions inherited from RedisLockPool and adds allocator-specific ones:
|
533
|
+
- pool_pointer_str(head: bool): Returns the Redis key for the head/tail pointer.
|
534
|
+
- cache_str(): Returns the Redis key for the allocator's general cache.
|
535
|
+
- soft_bind_name(name: str): Returns the Redis key for a specific soft binding.
|
536
|
+
- split_pool_value(value: str): Parses the 'prev||next||expiry' string stored
|
537
|
+
in the pool hash for a key.
|
538
|
+
- join_pool_value(prev: str, next: str, expiry: int): Creates the value string.
|
539
|
+
- timeout_to_expiry(timeout: int): Converts relative seconds to absolute Unix timestamp.
|
540
|
+
- is_expiry_invalid(expiry: int): Checks if an absolute expiry time is in the past.
|
541
|
+
- is_expired(value: str): Checks if a pool item's expiry is in the past.
|
542
|
+
- push_to_tail(itemName: str, expiry: int): Adds/updates an item at the tail of the free list.
|
543
|
+
- pop_from_head(): Removes and returns the item from the head of the free list,
|
544
|
+
skipping expired or locked items. Returns (nil, -1) if empty.
|
545
|
+
- set_item_allocated(itemName: str): Removes an item from the free list structure.
|
546
|
+
- check_item_health(itemName: str, value: str|nil): Core GC logic for a single item.
|
547
|
+
- If item is marked #ALLOCATED but has no lock -> push to tail (return to free list).
|
548
|
+
- If item is in free list but expired -> remove from pool hash.
|
549
|
+
- If item is in free list but locked -> mark as #ALLOCATED (remove from free list).
|
201
550
|
"""
|
202
551
|
return f'''
|
203
552
|
{super()._lua_required_string}
|
553
|
+
local function time()
|
554
|
+
return tonumber(redis.call("TIME")[1])
|
555
|
+
end
|
204
556
|
local function pool_pointer_str(head)
|
205
557
|
local pointer_type = 'head'
|
206
558
|
if not head then
|
@@ -208,185 +560,265 @@ class RedisAllocator(RedisLockPool, Generic[U]):
|
|
208
560
|
end
|
209
561
|
return '{self._pool_str()}|' .. pointer_type
|
210
562
|
end
|
211
|
-
local function
|
212
|
-
return '{self.
|
563
|
+
local function cache_str()
|
564
|
+
return '{self._cache_str}'
|
565
|
+
end
|
566
|
+
local function soft_bind_name(name)
|
567
|
+
if name == "" or not name then
|
568
|
+
return ""
|
569
|
+
end
|
570
|
+
return cache_str() .. ':bind:' .. name
|
571
|
+
end
|
572
|
+
local function split_pool_value(value)
|
573
|
+
if not value or value == "" then
|
574
|
+
return "", "", -1
|
575
|
+
end
|
576
|
+
value = tostring(value)
|
577
|
+
local prev, next, expiry = string.match(value, "(.*)||(.*)||(.*)")
|
578
|
+
return prev, next, tonumber(expiry)
|
579
|
+
end
|
580
|
+
local function join_pool_value(prev, next, expiry)
|
581
|
+
if expiry == nil then
|
582
|
+
expiry = -1
|
583
|
+
end
|
584
|
+
return tostring(prev) .. "||" .. tostring(next) .. "||" .. tostring(expiry)
|
585
|
+
end
|
586
|
+
local function timeout_to_expiry(timeout)
|
587
|
+
if timeout == nil or timeout <= 0 then
|
588
|
+
return -1
|
589
|
+
end
|
590
|
+
return time() + timeout
|
591
|
+
end
|
592
|
+
local function is_expiry_invalid(expiry)
|
593
|
+
return expiry ~= nil and expiry > 0 and expiry <= time()
|
594
|
+
end
|
595
|
+
local function is_expired(value)
|
596
|
+
local _, _, expiry = split_pool_value(value)
|
597
|
+
return is_expiry_invalid(expiry)
|
213
598
|
end
|
214
599
|
local poolItemsKey = pool_str()
|
215
600
|
local headKey = pool_pointer_str(true)
|
216
601
|
local tailKey = pool_pointer_str(false)
|
217
|
-
local function push_to_tail(itemName)
|
218
|
-
local tail
|
602
|
+
local function push_to_tail(itemName, expiry) -- push the item to the free list
|
603
|
+
local tail = redis.call("GET", tailKey)
|
219
604
|
if not tail then
|
220
605
|
tail = ""
|
221
606
|
end
|
222
|
-
redis.call("HSET", poolItemsKey, itemName,
|
223
|
-
if tail == "" then
|
607
|
+
redis.call("HSET", poolItemsKey, itemName, join_pool_value(tail, "", expiry))
|
608
|
+
if tail == "" then -- the free list is empty chain
|
224
609
|
redis.call("SET", headKey, itemName)
|
225
610
|
else
|
226
611
|
local tailVal = redis.call("HGET", poolItemsKey, tail)
|
227
|
-
local prev,
|
228
|
-
|
612
|
+
local prev, next, expiry = split_pool_value(tailVal)
|
613
|
+
assert(next == "", "tail is not the last item in the free list")
|
614
|
+
redis.call("HSET", poolItemsKey, tail, join_pool_value(prev, itemName, expiry))
|
229
615
|
end
|
230
|
-
redis.call("SET", tailKey, itemName)
|
616
|
+
redis.call("SET", tailKey, itemName) -- set the tail point to the new item
|
231
617
|
end
|
232
|
-
local function pop_from_head()
|
618
|
+
local function pop_from_head() -- pop the item from the free list
|
233
619
|
local head = redis.call("GET", headKey)
|
234
|
-
if head
|
235
|
-
return nil
|
620
|
+
if not head or head == "" then -- the free list is empty
|
621
|
+
return nil, -1
|
236
622
|
end
|
237
623
|
local headVal = redis.call("HGET", poolItemsKey, head)
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
624
|
+
assert(headVal ~= nil, "head should not nil")
|
625
|
+
local headPrev, headNext, headExpiry = split_pool_value(headVal)
|
626
|
+
-- Check if the head item has expired or is locked
|
627
|
+
if is_expiry_invalid(headExpiry) then -- the item has expired
|
628
|
+
redis.call("HDEL", poolItemsKey, head)
|
629
|
+
redis.call("SET", headKey, headNext)
|
630
|
+
return pop_from_head()
|
244
631
|
end
|
245
|
-
|
246
|
-
|
632
|
+
if redis.call("EXISTS", key_str(head)) > 0 then -- the item is locked
|
633
|
+
redis.call("HSET", poolItemsKey, head, join_pool_value("#ALLOCATED", "#ALLOCATED", headExpiry))
|
634
|
+
redis.call("SET", headKey, headNext)
|
635
|
+
return pop_from_head()
|
636
|
+
end
|
637
|
+
local prev, next, expiry = split_pool_value(headVal)
|
638
|
+
if next == "" then -- the item is the last in the free list
|
247
639
|
redis.call("SET", headKey, "")
|
248
640
|
redis.call("SET", tailKey, "")
|
249
641
|
else
|
250
642
|
local nextVal = redis.call("HGET", poolItemsKey, next)
|
251
|
-
local
|
252
|
-
redis.call("HSET", poolItemsKey, next,
|
643
|
+
local nextPrev, nextNext, nextExpiry = split_pool_value(nextVal)
|
644
|
+
redis.call("HSET", poolItemsKey, next, join_pool_value("", nextNext, nextExpiry))
|
253
645
|
redis.call("SET", headKey, next)
|
254
646
|
end
|
255
|
-
|
647
|
+
redis.call("HSET", poolItemsKey, head, join_pool_value("#ALLOCATED", "#ALLOCATED", expiry))
|
648
|
+
return head, headExpiry
|
256
649
|
end
|
257
|
-
local function
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
redis.call("
|
650
|
+
local function set_item_allocated(itemName, val)
|
651
|
+
if not val then
|
652
|
+
val = redis.call("HGET", poolItemsKey, itemName)
|
653
|
+
end
|
654
|
+
if val then
|
655
|
+
local prev, next, expiry = split_pool_value(val)
|
656
|
+
if prev ~= "#ALLOCATED" then
|
657
|
+
if is_expiry_invalid(expiry) then
|
658
|
+
redis.call("HDEL", poolItemsKey, itemName)
|
266
659
|
end
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
redis.call("
|
660
|
+
if prev ~= "" then
|
661
|
+
local prevVal = redis.call("HGET", poolItemsKey, prev)
|
662
|
+
if prevVal then
|
663
|
+
local prevPrev, prevNext, prevExpiry = split_pool_value(prevVal)
|
664
|
+
redis.call("HSET", poolItemsKey, prev, join_pool_value(prevPrev, next, prevExpiry))
|
665
|
+
end
|
666
|
+
else
|
667
|
+
redis.call("SET", headKey, next or "")
|
275
668
|
end
|
276
|
-
|
277
|
-
|
669
|
+
if next ~= "" then
|
670
|
+
local nextVal = redis.call("HGET", poolItemsKey, next)
|
671
|
+
if nextVal then
|
672
|
+
local nextPrev, nextNext, nextExpiry = split_pool_value(nextVal)
|
673
|
+
redis.call("HSET", poolItemsKey, next, join_pool_value(prev, nextNext, nextExpiry))
|
674
|
+
end
|
675
|
+
else
|
676
|
+
redis.call("SET", tailKey, prev or "")
|
677
|
+
end
|
678
|
+
redis.call("HSET", poolItemsKey, itemName, join_pool_value("#ALLOCATED", "#ALLOCATED", expiry))
|
278
679
|
end
|
279
680
|
end
|
280
|
-
redis.call("HDEL", poolItemsKey, itemName)
|
281
681
|
end
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
if
|
294
|
-
|
682
|
+
local function check_item_health(itemName, value)
|
683
|
+
if not value then
|
684
|
+
value = redis.call("HGET", pool_str(), itemName)
|
685
|
+
end
|
686
|
+
assert(value, "value should not be nil")
|
687
|
+
local prev, next, expiry = split_pool_value(value)
|
688
|
+
if is_expiry_invalid(expiry) then -- Check if the item has expired
|
689
|
+
set_item_allocated(itemName)
|
690
|
+
redis.call("HDEL", poolItemsKey, itemName)
|
691
|
+
end
|
692
|
+
local locked = redis.call("EXISTS", key_str(itemName)) > 0
|
693
|
+
if prev == "#ALLOCATED" then
|
694
|
+
if not locked then
|
695
|
+
push_to_tail(itemName, expiry)
|
696
|
+
end
|
295
697
|
else
|
296
|
-
|
698
|
+
if locked then
|
699
|
+
set_item_allocated(itemName)
|
700
|
+
end
|
297
701
|
end
|
298
702
|
end
|
299
703
|
'''
|
300
704
|
|
301
705
|
@cached_property
|
302
706
|
def _extend_script(self):
|
303
|
-
"""Cached
|
304
|
-
|
707
|
+
"""Cached Lua script to add or update keys in the pool.
|
708
|
+
|
709
|
+
Iterates through provided keys (ARGV[2...]).
|
710
|
+
If a key doesn't exist in the pool hash, it's added to the tail of the free list
|
711
|
+
using push_to_tail() with the specified expiry (calculated from ARGV[1] timeout).
|
712
|
+
If a key *does* exist, its expiry time is updated in the pool hash.
|
713
|
+
"""
|
714
|
+
return self.redis.register_script(f'''
|
715
|
+
{self._lua_required_string}
|
716
|
+
local timeout = tonumber(ARGV[1] or -1)
|
717
|
+
local expiry = timeout_to_expiry(timeout)
|
718
|
+
for i=2, #ARGV do
|
719
|
+
local itemName = ARGV[i]
|
720
|
+
local val = redis.call("HGET", poolItemsKey, itemName)
|
721
|
+
if val then -- only refresh the expiry timeout
|
722
|
+
local prev, next, _ = split_pool_value(val)
|
723
|
+
val = join_pool_value(prev, next, expiry)
|
724
|
+
redis.call("HSET", poolItemsKey, itemName, val)
|
725
|
+
else -- refresh the expiry timeout
|
726
|
+
push_to_tail(itemName, expiry)
|
727
|
+
end
|
728
|
+
end''')
|
305
729
|
|
306
|
-
def extend(self, keys: Optional[Sequence[str]] = None):
|
730
|
+
def extend(self, keys: Optional[Sequence[str]] = None, timeout: int = -1):
|
307
731
|
"""Add new resources to the allocation pool.
|
308
|
-
|
732
|
+
|
309
733
|
Args:
|
310
734
|
keys: Sequence of resource identifiers to add to the pool
|
735
|
+
timeout: Optional timeout in seconds for the pool items (-1 means no timeout)
|
311
736
|
"""
|
312
737
|
if keys is not None and len(keys) > 0:
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
def _shrink_lua_string(self):
|
317
|
-
"""LUA script for removing resources from the allocation pool.
|
318
|
-
|
319
|
-
This script removes specified items from the pool by deleting them
|
320
|
-
from the linked list structure.
|
321
|
-
"""
|
322
|
-
return f'''{self._lua_required_string}
|
323
|
-
for _, itemName in ipairs(ARGV) do
|
324
|
-
delete_item(itemName)
|
325
|
-
end
|
326
|
-
'''
|
738
|
+
# Ensure timeout is integer for Lua script
|
739
|
+
int_timeout = timeout if timeout is not None else -1
|
740
|
+
self._extend_script(args=[int_timeout] + list(keys))
|
327
741
|
|
328
742
|
@cached_property
|
329
743
|
def _shrink_script(self):
|
330
|
-
"""Cached
|
331
|
-
|
744
|
+
"""Cached Lua script to remove keys from the pool.
|
745
|
+
|
746
|
+
Iterates through provided keys (ARGV[1...]).
|
747
|
+
For each key:
|
748
|
+
1. Calls set_item_allocated() to remove it from the free list structure.
|
749
|
+
2. Deletes the key entirely from the pool hash using HDEL.
|
750
|
+
"""
|
751
|
+
return self.redis.register_script(f'''{self._lua_required_string}
|
752
|
+
for i=1, #ARGV do
|
753
|
+
local itemName = ARGV[i]
|
754
|
+
set_item_allocated(itemName)
|
755
|
+
redis.call("HDEL", poolItemsKey, itemName)
|
756
|
+
end''')
|
332
757
|
|
333
758
|
def shrink(self, keys: Optional[Sequence[str]] = None):
|
334
759
|
"""Remove resources from the allocation pool.
|
335
|
-
|
760
|
+
|
336
761
|
Args:
|
337
762
|
keys: Sequence of resource identifiers to remove from the pool
|
338
763
|
"""
|
339
764
|
if keys is not None and len(keys) > 0:
|
340
765
|
self._shrink_script(args=keys)
|
341
766
|
|
342
|
-
@
|
343
|
-
def
|
344
|
-
"""
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
767
|
+
@cached_property
|
768
|
+
def _assign_script(self):
|
769
|
+
"""Cached Lua script to set the pool to exactly the given keys.
|
770
|
+
|
771
|
+
1. Builds a Lua set (`assignSet`) of the desired keys (ARGV[2...]).
|
772
|
+
2. Fetches all current keys from the pool hash (HKEYS).
|
773
|
+
3. Iterates through current keys:
|
774
|
+
- If a key is *not* in `assignSet`, it's removed from the pool
|
775
|
+
(set_item_allocated() then HDEL).
|
776
|
+
- If a key *is* in `assignSet`, it's marked as processed by setting
|
777
|
+
`assignSet[key] = nil`.
|
778
|
+
4. Iterates through the remaining keys in `assignSet` (those not already
|
779
|
+
in the pool). These are added to the tail of the free list using
|
780
|
+
push_to_tail() with the specified expiry (from ARGV[1] timeout).
|
781
|
+
"""
|
782
|
+
return self.redis.register_script(f'''{self._lua_required_string}
|
783
|
+
local timeout = tonumber(ARGV[1] or -1)
|
784
|
+
local expiry = timeout_to_expiry(timeout)
|
785
|
+
local assignSet = {{}}
|
786
|
+
for i=2, #ARGV do
|
787
|
+
local k = ARGV[i]
|
788
|
+
assignSet[k] = true
|
354
789
|
end
|
355
790
|
local allItems = redis.call("HKEYS", poolItemsKey)
|
356
791
|
for _, itemName in ipairs(allItems) do
|
357
|
-
if not
|
358
|
-
|
792
|
+
if not assignSet[itemName] then
|
793
|
+
set_item_allocated(itemName)
|
794
|
+
redis.call("HDEL", poolItemsKey, itemName)
|
359
795
|
else
|
360
|
-
|
796
|
+
assignSet[itemName] = nil
|
361
797
|
end
|
362
798
|
end
|
363
|
-
for k, v in pairs(
|
799
|
+
for k, v in pairs(assignSet) do
|
364
800
|
if v then
|
365
|
-
push_to_tail(k)
|
801
|
+
push_to_tail(k, expiry)
|
366
802
|
end
|
367
803
|
end
|
368
|
-
'''
|
804
|
+
''')
|
369
805
|
|
370
|
-
|
371
|
-
def _assign_lua_script(self):
|
372
|
-
"""Cached Redis script for assigning resources to the allocation pool."""
|
373
|
-
return self.redis.register_script(self._assign_lua_string)
|
374
|
-
|
375
|
-
def assign(self, keys: Optional[Sequence[str]] = None):
|
806
|
+
def assign(self, keys: Optional[Sequence[str]] = None, timeout: int = -1):
|
376
807
|
"""Completely replace the resources in the allocation pool.
|
377
|
-
|
808
|
+
|
378
809
|
Args:
|
379
810
|
keys: Sequence of resource identifiers to assign to the pool,
|
380
811
|
replacing any existing resources
|
812
|
+
timeout: Optional timeout in seconds for the pool items (-1 means no timeout)
|
381
813
|
"""
|
382
814
|
if keys is not None and len(keys) > 0:
|
383
|
-
self.
|
815
|
+
self._assign_script(args=[timeout] + list(keys))
|
384
816
|
else:
|
385
817
|
self.clear()
|
386
818
|
|
387
819
|
def keys(self) -> Iterable[str]:
|
388
820
|
"""Get all resource identifiers in the allocation pool.
|
389
|
-
|
821
|
+
|
390
822
|
Returns:
|
391
823
|
Iterable of resource identifiers in the pool
|
392
824
|
"""
|
@@ -394,10 +826,10 @@ class RedisAllocator(RedisLockPool, Generic[U]):
|
|
394
826
|
|
395
827
|
def __contains__(self, key):
|
396
828
|
"""Check if a resource identifier is in the allocation pool.
|
397
|
-
|
829
|
+
|
398
830
|
Args:
|
399
831
|
key: Resource identifier to check
|
400
|
-
|
832
|
+
|
401
833
|
Returns:
|
402
834
|
True if the resource is in the pool, False otherwise
|
403
835
|
"""
|
@@ -406,7 +838,7 @@ class RedisAllocator(RedisLockPool, Generic[U]):
|
|
406
838
|
@property
|
407
839
|
def _cache_str(self):
|
408
840
|
"""Get the Redis key for the allocator's cache.
|
409
|
-
|
841
|
+
|
410
842
|
Returns:
|
411
843
|
String representation of the Redis key for the cache
|
412
844
|
"""
|
@@ -419,180 +851,287 @@ class RedisAllocator(RedisLockPool, Generic[U]):
|
|
419
851
|
|
420
852
|
def _soft_bind_name(self, name: str) -> str:
|
421
853
|
"""Get the Redis key for a soft binding.
|
422
|
-
|
854
|
+
|
423
855
|
Args:
|
424
856
|
name: Name of the soft binding
|
425
|
-
|
857
|
+
|
426
858
|
Returns:
|
427
859
|
String representation of the Redis key for the soft binding
|
428
860
|
"""
|
429
861
|
return f"{self._cache_str}:bind:{name}"
|
430
862
|
|
431
|
-
def update_soft_bind(self, name: str, key: str):
|
863
|
+
def update_soft_bind(self, name: str, key: str, timeout: Timeout = 3600):
|
432
864
|
"""Update a soft binding between a name and a resource.
|
433
|
-
|
865
|
+
|
866
|
+
Soft bindings create a persistent mapping between named objects and allocated keys,
|
867
|
+
allowing the same key to be consistently allocated to the same named object.
|
868
|
+
This is useful for maintaining affinity between objects and their resources.
|
869
|
+
|
434
870
|
Args:
|
435
871
|
name: Name to bind
|
436
872
|
key: Resource identifier to bind to the name
|
437
873
|
"""
|
438
|
-
self.update(self._soft_bind_name(name), key, timeout=
|
874
|
+
self.update(self._soft_bind_name(name), key, timeout=timeout)
|
439
875
|
|
440
876
|
def unbind_soft_bind(self, name: str):
|
441
877
|
"""Remove a soft binding.
|
442
|
-
|
878
|
+
|
879
|
+
This removes the persistent mapping between a named object and its allocated key,
|
880
|
+
allowing the key to be freely allocated to any requestor.
|
881
|
+
|
443
882
|
Args:
|
444
883
|
name: Name of the soft binding to remove
|
445
884
|
"""
|
446
885
|
self.unlock(self._soft_bind_name(name))
|
447
886
|
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
of the linked list and marking it as allocated. If the allocator
|
454
|
-
is not shared, the script also sets a lock on the allocated resource.
|
887
|
+
def get_soft_bind(self, name: str) -> Optional[str]:
|
888
|
+
"""Get the resource identifier bound to a name.
|
889
|
+
|
890
|
+
Args:
|
891
|
+
name: Name of the soft binding
|
455
892
|
"""
|
456
|
-
return
|
893
|
+
return self.redis.get(self._soft_bind_name(name))
|
894
|
+
|
895
|
+
@cached_property
|
896
|
+
def _malloc_script(self):
|
897
|
+
"""Cached Lua script to allocate a key from the pool.
|
898
|
+
|
899
|
+
Input ARGS: timeout, name (for soft binding), soft_bind_timeout
|
900
|
+
|
901
|
+
1. Soft Binding Check (if name provided):
|
902
|
+
- Tries to GET the bound key from the soft bind cache key.
|
903
|
+
- If found and the key is *not* currently locked (checked via EXISTS key_str(cachedKey)),
|
904
|
+
it refreshes the soft bind expiry and returns the cached key.
|
905
|
+
- If found but the key *is* locked, it deletes the stale soft bind entry.
|
906
|
+
2. Pop from Head: Calls `pop_from_head()` to get the next available key
|
907
|
+
from the free list head. This function internally skips expired/locked items.
|
908
|
+
3. Lock/Update (if key found):
|
909
|
+
- If `shared=False`: Sets the lock key (`key_str(itemName)`) with the specified timeout.
|
910
|
+
- If `shared=True`: Calls `push_to_tail()` to put the item back onto the free list immediately.
|
911
|
+
4. Update Soft Bind Cache (if key found and name provided):
|
912
|
+
- Sets the soft bind cache key to the allocated `itemName` with its timeout.
|
913
|
+
5. Returns the allocated `itemName` or nil if the pool was empty.
|
914
|
+
"""
|
915
|
+
return self.redis.register_script(f'''
|
457
916
|
{self._lua_required_string}
|
458
917
|
local shared = {1 if self.shared else 0}
|
918
|
+
|
459
919
|
local timeout = tonumber(ARGV[1])
|
460
|
-
local
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
920
|
+
local name_arg = ARGV[2] -- Original name argument
|
921
|
+
local cacheName = soft_bind_name(name_arg) -- Key for soft binding cache
|
922
|
+
local cacheTimeout = tonumber(ARGV[3]) -- Timeout for the soft binding cache entry
|
923
|
+
local function refresh_cache(cacheKey)
|
924
|
+
-- Only refresh if a valid name and timeout were provided
|
925
|
+
if name_arg ~= "" then
|
926
|
+
if cacheTimeout ~= nil and cacheTimeout > 0 then
|
927
|
+
redis.call("SET", cacheName, cacheKey, "EX", cacheTimeout)
|
928
|
+
else -- If timeout is invalid/zero, set without expiry
|
929
|
+
redis.call("SET", cacheName, cacheKey)
|
930
|
+
end
|
931
|
+
end
|
932
|
+
end
|
933
|
+
-- Check soft binding only if a name was provided
|
934
|
+
if name_arg ~= "" then
|
935
|
+
local cachedKey = redis.call("GET", cacheName)
|
936
|
+
if cachedKey then
|
937
|
+
-- Check if the cached key exists and is currently locked (in non-shared mode)
|
938
|
+
if redis.call("HEXISTS", poolItemsKey, cachedKey) <= 0 or redis.call("EXISTS", key_str(cachedKey)) > 0 then
|
939
|
+
-- Cached key is locked, binding is stale, remove it
|
940
|
+
redis.call("DEL", cacheName)
|
941
|
+
else
|
942
|
+
-- Cached key is valid (either not locked or in shared mode)
|
943
|
+
refresh_cache(cachedKey) -- Refresh the cache expiry
|
944
|
+
if shared == 0 then
|
945
|
+
redis.call("SET", key_str(cachedKey), "1", "EX", timeout)
|
946
|
+
set_item_allocated(cachedKey)
|
470
947
|
end
|
948
|
+
return cachedKey
|
471
949
|
end
|
472
950
|
end
|
473
951
|
end
|
474
|
-
return itemName
|
475
|
-
'''
|
476
952
|
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
953
|
+
-- No valid soft bind found, proceed with normal allocation
|
954
|
+
local itemName, expiry = pop_from_head()
|
955
|
+
if itemName ~= nil then
|
956
|
+
if shared == 0 then
|
957
|
+
-- Non-shared mode: Acquire lock
|
958
|
+
if timeout ~= nil and timeout > 0 then
|
959
|
+
redis.call("SET", key_str(itemName), "1", "EX", timeout)
|
960
|
+
else
|
961
|
+
redis.call("SET", key_str(itemName), "1") -- Set without expiry if timeout <= 0
|
962
|
+
end
|
963
|
+
else
|
964
|
+
-- Shared mode: Just put it back to the tail
|
965
|
+
push_to_tail(itemName, expiry)
|
966
|
+
end
|
967
|
+
end
|
481
968
|
|
482
|
-
|
969
|
+
-- If allocation was successful and a name was provided, update the soft bind cache
|
970
|
+
if itemName then
|
971
|
+
refresh_cache(itemName)
|
972
|
+
end
|
973
|
+
return itemName
|
974
|
+
''')
|
975
|
+
|
976
|
+
def malloc_key(self, timeout: Timeout = 120, name: Optional[str] = None,
|
977
|
+
cache_timeout: Timeout = 3600) -> Optional[str]:
|
483
978
|
"""Allocate a resource key from the pool.
|
484
|
-
|
979
|
+
|
980
|
+
The behavior depends on the allocator's shared mode:
|
981
|
+
- In non-shared mode (default): Locks the allocated key for exclusive access
|
982
|
+
- In shared mode: Simply removes the key from the free list without locking it
|
983
|
+
|
485
984
|
Args:
|
486
|
-
timeout: How long the allocation should be valid (in seconds)
|
487
|
-
|
985
|
+
timeout: How long the allocation lock should be valid (in seconds).
|
986
|
+
name: Optional name to use for soft binding.
|
987
|
+
cache_timeout: Timeout for the soft binding cache entry (seconds).
|
988
|
+
Defaults to 3600. If <= 0, cache entry persists indefinitely.
|
989
|
+
|
488
990
|
Returns:
|
489
991
|
Resource identifier if allocation was successful, None otherwise
|
490
992
|
"""
|
491
|
-
|
993
|
+
if name is None:
|
994
|
+
name = ""
|
995
|
+
# Convert timeout values to integers for Lua
|
996
|
+
lock_timeout_sec = int(self._to_seconds(timeout))
|
997
|
+
cache_timeout_sec = int(self._to_seconds(cache_timeout))
|
998
|
+
# Convert integers to strings for Lua script arguments
|
999
|
+
return self._malloc_script(args=[
|
1000
|
+
lock_timeout_sec,
|
1001
|
+
name,
|
1002
|
+
cache_timeout_sec,
|
1003
|
+
])
|
492
1004
|
|
493
|
-
def malloc(self, timeout: Timeout = 120, obj: Optional[U] = None, params: Optional[dict] = None
|
1005
|
+
def malloc(self, timeout: Timeout = 120, obj: Optional[U] = None, params: Optional[dict] = None,
|
1006
|
+
cache_timeout: Timeout = 3600) -> Optional[RedisAllocatorObject[U]]:
|
494
1007
|
"""Allocate a resource from the pool and wrap it in a RedisAllocatorObject.
|
495
|
-
|
1008
|
+
|
1009
|
+
If a policy is configured, it will be used to control the allocation behavior.
|
1010
|
+
Otherwise, the basic allocation mechanism will be used.
|
1011
|
+
|
496
1012
|
Args:
|
497
|
-
timeout: How long the allocation should be valid (in seconds)
|
498
|
-
obj: The object to wrap in the RedisAllocatorObject
|
499
|
-
|
500
|
-
|
1013
|
+
timeout: How long the allocation lock should be valid (in seconds)
|
1014
|
+
obj: The object to wrap in the RedisAllocatorObject. If it has a `.name`,
|
1015
|
+
soft binding will be attempted.
|
1016
|
+
params: Additional parameters to associate with the allocated object.
|
1017
|
+
cache_timeout: Timeout for the soft binding cache entry (seconds).
|
1018
|
+
Defaults to 3600. Passed to the policy or `malloc_key`.
|
1019
|
+
|
501
1020
|
Returns:
|
502
1021
|
RedisAllocatorObject wrapping the allocated resource if successful, None otherwise
|
503
1022
|
"""
|
504
|
-
|
505
|
-
|
506
|
-
return
|
507
|
-
|
1023
|
+
if self.policy:
|
1024
|
+
# Pass cache_timeout to the policy's malloc method
|
1025
|
+
return self.policy.malloc(
|
1026
|
+
self, timeout, obj, params,
|
1027
|
+
cache_timeout=cache_timeout
|
1028
|
+
)
|
1029
|
+
# No policy, call malloc_key directly
|
1030
|
+
# Explicitly call obj.name if obj exists
|
1031
|
+
name = obj.name if obj and hasattr(obj, 'name') else None
|
1032
|
+
key = self.malloc_key(timeout, name, cache_timeout=cache_timeout)
|
1033
|
+
return RedisAllocatorObject(
|
1034
|
+
self, key, obj, params
|
1035
|
+
)
|
508
1036
|
|
509
|
-
@
|
510
|
-
def
|
511
|
-
"""
|
512
|
-
|
513
|
-
|
514
|
-
|
1037
|
+
@cached_property
|
1038
|
+
def _free_script(self):
|
1039
|
+
"""Cached Lua script to free allocated keys.
|
1040
|
+
|
1041
|
+
Iterates through provided keys (ARGV[2...]).
|
1042
|
+
For each key:
|
1043
|
+
1. Deletes the corresponding lock key (`key_str(k)`) using DEL.
|
1044
|
+
If the key existed (DEL returns 1), it proceeds.
|
1045
|
+
2. Adds the key back to the tail of the free list using `push_to_tail()`
|
1046
|
+
with the specified expiry (calculated from ARGV[1] timeout).
|
515
1047
|
"""
|
516
|
-
return f'''
|
1048
|
+
return self.redis.register_script(f'''
|
517
1049
|
{self._lua_required_string}
|
518
|
-
|
519
|
-
|
520
|
-
|
1050
|
+
local timeout = tonumber(ARGV[1] or -1)
|
1051
|
+
local expiry = timeout_to_expiry(timeout)
|
1052
|
+
for i=2, #ARGV do
|
1053
|
+
local k = ARGV[i]
|
1054
|
+
local deleted = redis.call('DEL', key_str(k))
|
1055
|
+
if deleted > 0 then -- Only push back to pool if it was actually locked/deleted
|
1056
|
+
push_to_tail(k, expiry)
|
1057
|
+
end
|
521
1058
|
end
|
522
|
-
'''
|
1059
|
+
''')
|
523
1060
|
|
524
|
-
|
525
|
-
def _free_script(self):
|
526
|
-
"""Cached Redis script for freeing allocated resources."""
|
527
|
-
return self.redis.register_script(self._free_lua_script)
|
528
|
-
|
529
|
-
def free_keys(self, *keys: str):
|
1061
|
+
def free_keys(self, *keys: str, timeout: int = -1):
|
530
1062
|
"""Free allocated resources.
|
531
|
-
|
1063
|
+
|
532
1064
|
Args:
|
533
1065
|
*keys: Resource identifiers to free
|
1066
|
+
timeout: Optional timeout in seconds for the pool items (-1 means no timeout)
|
534
1067
|
"""
|
535
|
-
|
1068
|
+
if keys:
|
1069
|
+
self._free_script(args=[timeout] + list(keys))
|
1070
|
+
|
1071
|
+
def free(self, obj: RedisAllocatorObject[U], timeout: int = -1):
|
1072
|
+
"""Free an allocated object.
|
536
1073
|
|
537
|
-
|
538
|
-
|
539
|
-
|
1074
|
+
Args:
|
1075
|
+
obj: The allocated object to free
|
1076
|
+
timeout: Optional timeout in seconds for the pool item (-1 means no timeout)
|
1077
|
+
"""
|
1078
|
+
self.free_keys(obj.key, timeout=timeout)
|
1079
|
+
|
1080
|
+
def _gc_cursor_str(self):
|
1081
|
+
"""Get the Redis key for the garbage collection cursor.
|
1082
|
+
|
1083
|
+
Returns:
|
1084
|
+
String representation of the Redis key for the GC cursor
|
1085
|
+
"""
|
1086
|
+
return f'{self._pool_str()}|gc_cursor'
|
540
1087
|
|
541
1088
|
@cached_property
|
542
1089
|
def _gc_script(self):
|
543
|
-
"""Cached
|
544
|
-
return self.redis.register_script(self._gc_lua_script)
|
1090
|
+
"""Cached Lua script for performing garbage collection.
|
545
1091
|
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
1092
|
+
Uses HSCAN to iterate through the pool hash incrementally.
|
1093
|
+
Input ARGS: count (max items to scan per call)
|
1094
|
+
|
1095
|
+
1. Gets the scan cursor from a dedicated key (`_gc_cursor_str()`).
|
1096
|
+
2. Calls HSCAN on the pool hash (`pool_str()`) starting from the cursor,
|
1097
|
+
requesting up to `count` items.
|
1098
|
+
3. Iterates through the key-value pairs returned by HSCAN.
|
1099
|
+
4. For each item, calls `check_item_health()` to reconcile its state
|
1100
|
+
(see `_lua_required_string` documentation).
|
1101
|
+
5. Saves the new cursor returned by HSCAN for the next GC call.
|
556
1102
|
"""
|
557
|
-
return f'''
|
1103
|
+
return self.redis.register_script(f'''
|
558
1104
|
{self._lua_required_string}
|
559
|
-
local
|
560
|
-
local
|
561
|
-
|
562
|
-
|
563
|
-
|
1105
|
+
local cursorKey = '{self._gc_cursor_str()}'
|
1106
|
+
local function get_cursor()
|
1107
|
+
local oldCursor = redis.call("GET", cursorKey)
|
1108
|
+
if not oldCursor or oldCursor == "" then
|
1109
|
+
return "0"
|
1110
|
+
else
|
1111
|
+
return oldCursor
|
1112
|
+
end
|
1113
|
+
end
|
1114
|
+
local function set_cursor(cursor)
|
1115
|
+
redis.call("SET", cursorKey, cursor)
|
564
1116
|
end
|
565
|
-
local
|
1117
|
+
local n = tonumber(ARGV[1])
|
1118
|
+
local scanResult = redis.call("HSCAN", pool_str(), get_cursor(), "COUNT", n)
|
566
1119
|
local newCursor = scanResult[1]
|
567
1120
|
local kvList = scanResult[2]
|
568
|
-
local tail = redis.call("GET", tailKey)
|
569
|
-
if not tail then
|
570
|
-
tail = ""
|
571
|
-
end
|
572
1121
|
for i = 1, #kvList, 2 do
|
573
1122
|
local itemName = kvList[i]
|
574
1123
|
local val = kvList[i + 1]
|
575
|
-
|
576
|
-
local locked = (redis.call("EXISTS", key_str(itemName)) == 1)
|
577
|
-
if not locked then
|
578
|
-
push_to_tail(itemName)
|
579
|
-
end
|
580
|
-
else
|
581
|
-
local locked = (redis.call("EXISTS", key_str(itemName)) == 1)
|
582
|
-
if locked then
|
583
|
-
delete_item(itemName)
|
584
|
-
redis.call("HSET", poolItemsKey, itemName, "#ALLOCATED")
|
585
|
-
end
|
586
|
-
end
|
1124
|
+
check_item_health(itemName, val)
|
587
1125
|
end
|
588
|
-
|
1126
|
+
set_cursor(newCursor)
|
1127
|
+
''')
|
589
1128
|
|
590
1129
|
def gc(self, count: int = 10):
|
591
1130
|
"""Perform garbage collection on the allocation pool.
|
592
|
-
|
1131
|
+
|
593
1132
|
This method scans through the pool and ensures consistency between
|
594
1133
|
the allocation metadata and the actual locks.
|
595
|
-
|
1134
|
+
|
596
1135
|
Args:
|
597
1136
|
count: Number of items to check in this garbage collection pass
|
598
1137
|
"""
|