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.
@@ -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
- _allocator: 'RedisAllocator' # Reference to the allocator that created this object
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._allocator = allocator
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._allocator.update(self.key, timeout=timeout)
157
+ self.allocator.update(self.key, timeout=timeout)
123
158
  else:
124
- self._allocator.free(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 memory allocation system.
138
-
139
- This class implements a memory allocation interface using Redis as the backend store.
140
- It manages a pool of resources that can be allocated, freed, and garbage collected.
141
- The implementation uses a doubly-linked list structure stored in Redis hashes to
142
- track available and allocated resources.
143
-
144
- Generic type U must implement the RedisContextableObject protocol, allowing
145
- allocated objects to be used as context managers.
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
- """Initialize a RedisAllocator instance.
151
-
493
+ shared=False, policy: Optional[RedisAllocatorPolicy] = None):
494
+ """Initializes the RedisAllocator.
495
+
152
496
  Args:
153
- redis: Redis client for communication with Redis server
154
- prefix: Prefix for all Redis keys used by this allocator
155
- suffix: Suffix for Redis keys to uniquely identify this allocator
156
- eps: Small value for floating point comparisons
157
- shared: Whether resources can be shared across multiple consumers
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.soft_bind_timeout = 3600 # Default timeout for soft bindings (1 hour)
162
- self.objects: weakref.WeakValueDictionary[str, RedisAllocatorObject] = weakref.WeakValueDictionary()
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
- """LUA script containing helper functions for Redis operations.
193
-
194
- This script defines various LUA functions for manipulating the doubly-linked list
195
- structure that represents the allocation pool:
196
- - pool_pointer_str: Get Redis keys for head/tail pointers
197
- - gc_cursor_str: Get Redis key for GC cursor
198
- - push_to_tail: Add an item to the tail of the linked list
199
- - pop_from_head: Remove and return the item at the head of the linked list
200
- - delete_item: Remove an item from anywhere in the linked list
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 gc_cursor_str()
212
- return '{self._pool_str()}|gc_cursor'
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 = redis.call("GET", tailKey)
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, tostring(tail) .. "||")
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, _ = string.match(tailVal, "(.*)||(.*)")
228
- redis.call("HSET", poolItemsKey, tail, tostring(prev) .. "||" .. tostring(itemName))
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 == nil or head == "" then
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
- if headVal == nil then
239
- redis.call("SET", headKey, "")
240
- redis.call("SET", tailKey, "")
241
- return nil
242
- else
243
- assert(headVal ~= "#ALLOCATED", "head is allocated")
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
- local _, next = string.match(headVal, "(.*)||(.*)")
246
- if next == "" then
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 prev, _ = string.match(nextVal, "(.*)||(.*)")
252
- redis.call("HSET", poolItemsKey, next, tostring("") .. "||" .. tostring(prev))
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
- return head
647
+ redis.call("HSET", poolItemsKey, head, join_pool_value("#ALLOCATED", "#ALLOCATED", expiry))
648
+ return head, headExpiry
256
649
  end
257
- local function delete_item(itemName)
258
- local val = redis.call("HGET", poolItemsKey, itemName)
259
- if val ~= '#ALLOCATED' then
260
- local prev, next = string.match(val, "(.*)||(.*)")
261
- if prev ~= "" then
262
- local prevVal = redis.call("HGET", poolItemsKey, prev)
263
- if prevVal then
264
- local p, _ = string.match(prevVal, "(.*)||(.*)")
265
- redis.call("HSET", poolItemsKey, prev, tostring(p) .. "||" .. tostring(next))
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
- else
268
- redis.call("SET", headKey, next or "")
269
- end
270
- if next ~= "" then
271
- local nxtVal = redis.call("HGET", poolItemsKey, next)
272
- if nxtVal then
273
- local _, n = string.match(nxtVal, "(.*)||(.*)")
274
- redis.call("HSET", poolItemsKey, next, tostring(prev) .. "||" .. tostring(n))
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
- else
277
- redis.call("SET", tailKey, prev or "")
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
- @property
285
- def _extend_lua_string(self):
286
- """LUA script for extending the allocation pool with new resources.
287
-
288
- This script adds new items to the pool if they don't already exist.
289
- New items are added to the tail of the linked list.
290
- """
291
- return f'''{self._lua_required_string}
292
- for _, itemName in ipairs(ARGV) do
293
- if redis.call("HEXISTS", poolItemsKey, itemName) == 1 then
294
- -- todo: gc check
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
- push_to_tail(itemName)
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 Redis script for extending the allocation pool."""
304
- return self.redis.register_script(self._extend_lua_string)
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
- self._extend_script(args=keys)
314
-
315
- @property
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 Redis script for shrinking the allocation pool."""
331
- return self.redis.register_script(self._shrink_lua_string)
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
- @property
343
- def _assign_lua_string(self):
344
- """LUA script for completely replacing the resources in the allocation pool.
345
-
346
- This script clears the existing pool and replaces it with a new set of resources.
347
- Items not in the new set are removed, and items in the new set but not in the
348
- existing pool are added to the tail of the linked list.
349
- """
350
- return f'''{self._lua_required_string}
351
- local wantSet = {{}}
352
- for _, k in ipairs(ARGV) do
353
- wantSet[k] = true
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 wantSet[itemName] then
358
- delete_item(itemName)
792
+ if not assignSet[itemName] then
793
+ set_item_allocated(itemName)
794
+ redis.call("HDEL", poolItemsKey, itemName)
359
795
  else
360
- wantSet[itemName] = nil
796
+ assignSet[itemName] = nil
361
797
  end
362
798
  end
363
- for k, v in pairs(wantSet) do
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
- @cached_property
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._assign_lua_script(args=keys)
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=self.soft_bind_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
- @property
449
- def _malloc_lua_script(self):
450
- """LUA script for allocating a resource from the pool.
451
-
452
- This script allocates a resource by popping an item from the head
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 f'''
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 itemName = pop_from_head()
461
- if itemName ~= nil then
462
- if redis.call("EXISTS", key_str(itemName)) == 1 then
463
- itemName = nil
464
- else
465
- if not shared then
466
- if timeout ~= nil then
467
- redis.call("SET", key_str(itemName), "1", "EX", timeout)
468
- else
469
- redis.call("SET", key_str(itemName), "1")
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
- @cached_property
478
- def _malloc_script(self):
479
- """Cached Redis script for allocating a resource."""
480
- return self.redis.register_script(self._malloc_lua_script)
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
- def malloc_key(self, timeout: Timeout = 120) -> Optional[str]:
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
- return self._malloc_script(keys=[self._to_seconds(timeout)])
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) -> Optional[RedisAllocatorObject[U]]:
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
- params: Additional parameters to associate with the allocated object
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
- key = self.malloc_key(timeout)
505
- if key is None:
506
- return None
507
- return RedisAllocatorObject(self, key, obj, params)
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
- @property
510
- def _free_lua_script(self):
511
- """LUA script for freeing allocated resources.
512
-
513
- This script frees allocated resources by removing their locks
514
- and pushing them back to the tail of the linked list.
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
- for _, k in ipairs(KEYS) do
519
- redis.call('DEL', key_str(k))
520
- push_to_tail(k)
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
- @cached_property
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
- self._free_script(keys, args=keys)
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
- def free(self, obj: RedisAllocatorObject[U]):
538
- """Free an allocated object."""
539
- self.free_keys(obj.key)
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 Redis script for garbage collection."""
544
- return self.redis.register_script(self._gc_lua_script)
1090
+ """Cached Lua script for performing garbage collection.
545
1091
 
546
- @property
547
- def _gc_lua_script(self):
548
- """LUA script for garbage collection of the allocation pool.
549
-
550
- This script scans through the pool and performs two types of cleanup:
551
- 1. Resources marked as allocated but not actually locked are pushed back
552
- to the available pool
553
- 2. Resources not marked as allocated but actually locked are marked as allocated
554
-
555
- This ensures consistency between the allocation metadata and the actual locks.
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 n = tonumber(ARGV[1])
560
- local cursorKey = gc_cursor_str()
561
- local oldCursor = redis.call("GET", cursorKey)
562
- if not oldCursor or oldCursor == "" then
563
- oldCursor = "0"
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 scanResult = redis.call("HSCAN", poolItemsKey, oldCursor, "COUNT", n)
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
- if val == "#ALLOCATED" then
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
  """