redis-allocator 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ """RedisAllocator package for distributed memory allocation using Redis.
2
+
3
+ This package provides efficient, Redis-based distributed memory allocation
4
+ services that simulate traditional memory allocation mechanisms in a
5
+ distributed environment.
6
+ """
7
+
8
+ from redis_allocator.lock import (RedisLock, RedisLockPool, LockStatus,
9
+ BaseLock, BaseLockPool, ThreadLock, ThreadLockPool)
10
+ from redis_allocator.task_queue import TaskExecutePolicy, RedisTask, RedisTaskQueue
11
+ from redis_allocator.allocator import RedisAllocator
12
+
13
+
14
+ __version__ = '0.0.1'
15
+
16
+ __all__ = [
17
+ 'RedisLock',
18
+ 'RedisLockPool',
19
+ 'LockStatus',
20
+ 'BaseLock',
21
+ 'BaseLockPool',
22
+ 'ThreadLock',
23
+ 'ThreadLockPool',
24
+ 'TaskExecutePolicy',
25
+ 'RedisTask',
26
+ 'RedisTaskQueue',
27
+ 'RedisAllocator',
28
+ ]
@@ -0,0 +1,601 @@
1
+ """Redis-based distributed memory allocation system.
2
+
3
+ This module provides the core functionality of the RedisAllocator system,
4
+ allowing for distributed memory allocation with support for garbage collection,
5
+ thread health checking, and priority-based allocation mechanisms.
6
+ """
7
+ import logging
8
+ import weakref
9
+ from abc import ABC, abstractmethod
10
+ from functools import cached_property
11
+ from threading import current_thread
12
+ from typing import (Optional, TypeVar, Generic,
13
+ Sequence, Iterable)
14
+ from redis import StrictRedis as Redis
15
+ from .lock import RedisLockPool, Timeout
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
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
+ class RedisThreadHealthCheckPool(RedisLockPool):
44
+ """A class that provides a simple interface for managing the health status of a thread.
45
+
46
+ This class enables tracking the health status of threads in a distributed environment
47
+ using Redis locks.
48
+ """
49
+
50
+ def __init__(self, redis: Redis, identity: str, timeout: int):
51
+ """Initialize a RedisThreadHealthCheckPool instance.
52
+
53
+ Args:
54
+ redis: The Redis client used for interacting with Redis.
55
+ identity: The identity prefix for the health checker.
56
+ timeout: The timeout for health checks in seconds.
57
+ tasks: A list of thread identifiers to track.
58
+ """
59
+ super().__init__(redis, identity, "thread-health-check-pool")
60
+ self.timeout = timeout
61
+ self.initialize()
62
+
63
+ @property
64
+ def current_thread_id(self) -> str:
65
+ """Get the current thread ID.
66
+
67
+ Returns:
68
+ The current thread ID as a string.
69
+ """
70
+ return str(current_thread().ident)
71
+
72
+ def initialize(self):
73
+ """Initialize the health status."""
74
+ self.update()
75
+ self.extend([self.current_thread_id])
76
+
77
+ def update(self): # pylint: disable=arguments-differ
78
+ """Update the health status."""
79
+ super().update(self.current_thread_id, timeout=self.timeout)
80
+
81
+ def finalize(self):
82
+ """Finalize the health status."""
83
+ self.shrink([self.current_thread_id])
84
+ self.unlock(self.current_thread_id)
85
+
86
+
87
+ class RedisAllocatorObject(Generic[U]):
88
+ """Represents an object allocated through RedisAllocator.
89
+
90
+ This class provides an interface for working with allocated objects
91
+ including locking and unlocking mechanisms for thread-safe operations.
92
+
93
+ """
94
+ _allocator: 'RedisAllocator' # Reference to the allocator that created this object
95
+ key: str # Redis key for this allocated object
96
+ params: Optional[dict] # Parameters associated with this object
97
+ obj: Optional[U] # The actual object being allocated
98
+
99
+ def __init__(self, allocator: 'RedisAllocator', key: str, obj: Optional[U] = None, params: Optional[dict] = None):
100
+ """Initialize a RedisAllocatorObject instance.
101
+
102
+ Args:
103
+ allocator: The RedisAllocator that created this object
104
+ key: The Redis key for this allocated object
105
+ obj: The actual object being allocated
106
+ params: Additional parameters passed by local program
107
+ """
108
+ self._allocator = allocator
109
+ self.key = key
110
+ self.obj = obj
111
+ self.params = params
112
+ if self.obj is not None:
113
+ self.obj.set_config(key, params)
114
+
115
+ def update(self, timeout: Timeout = 120):
116
+ """Lock this object for exclusive access.
117
+
118
+ Args:
119
+ timeout: How long the lock should be valid (in seconds)
120
+ """
121
+ if timeout > 0:
122
+ self._allocator.update(self.key, timeout=timeout)
123
+ else:
124
+ self._allocator.free(self)
125
+
126
+ def close(self):
127
+ """Kill the object."""
128
+ if self.obj is not None:
129
+ self.obj.close()
130
+
131
+ def __del__(self):
132
+ """Delete the object."""
133
+ self.close()
134
+
135
+
136
+ 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.
146
+ """
147
+
148
+ def __init__(self, redis: Redis, prefix: str, suffix='allocator', eps=1e-6,
149
+ shared=False):
150
+ """Initialize a RedisAllocator instance.
151
+
152
+ 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
158
+ """
159
+ super().__init__(redis, prefix, suffix=suffix, eps=eps)
160
+ 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()
163
+
164
+ def object_key(self, key: str, obj: U):
165
+ """Get the key for an object."""
166
+ if not self.shared:
167
+ return key
168
+ return f'{key}:{obj}'
169
+
170
+ def _pool_pointer_str(self, head: bool = True):
171
+ """Get the Redis key for the head or tail pointer of the allocation pool.
172
+
173
+ Args:
174
+ head: If True, get the head pointer key; otherwise, get the tail pointer key
175
+
176
+ Returns:
177
+ String representation of the Redis key for the pointer
178
+ """
179
+ pointer_type = 'head' if head else 'tail'
180
+ return f'{self._pool_str()}|{pointer_type}'
181
+
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
+ @property
191
+ 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
201
+ """
202
+ return f'''
203
+ {super()._lua_required_string}
204
+ local function pool_pointer_str(head)
205
+ local pointer_type = 'head'
206
+ if not head then
207
+ pointer_type = 'tail'
208
+ end
209
+ return '{self._pool_str()}|' .. pointer_type
210
+ end
211
+ local function gc_cursor_str()
212
+ return '{self._pool_str()}|gc_cursor'
213
+ end
214
+ local poolItemsKey = pool_str()
215
+ local headKey = pool_pointer_str(true)
216
+ local tailKey = pool_pointer_str(false)
217
+ local function push_to_tail(itemName)
218
+ local tail = redis.call("GET", tailKey)
219
+ if not tail then
220
+ tail = ""
221
+ end
222
+ redis.call("HSET", poolItemsKey, itemName, tostring(tail) .. "||")
223
+ if tail == "" then
224
+ redis.call("SET", headKey, itemName)
225
+ else
226
+ local tailVal = redis.call("HGET", poolItemsKey, tail)
227
+ local prev, _ = string.match(tailVal, "(.*)||(.*)")
228
+ redis.call("HSET", poolItemsKey, tail, tostring(prev) .. "||" .. tostring(itemName))
229
+ end
230
+ redis.call("SET", tailKey, itemName)
231
+ end
232
+ local function pop_from_head()
233
+ local head = redis.call("GET", headKey)
234
+ if head == nil or head == "" then
235
+ return nil
236
+ end
237
+ 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")
244
+ end
245
+ local _, next = string.match(headVal, "(.*)||(.*)")
246
+ if next == "" then
247
+ redis.call("SET", headKey, "")
248
+ redis.call("SET", tailKey, "")
249
+ else
250
+ local nextVal = redis.call("HGET", poolItemsKey, next)
251
+ local prev, _ = string.match(nextVal, "(.*)||(.*)")
252
+ redis.call("HSET", poolItemsKey, next, tostring("") .. "||" .. tostring(prev))
253
+ redis.call("SET", headKey, next)
254
+ end
255
+ return head
256
+ 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))
266
+ 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))
275
+ end
276
+ else
277
+ redis.call("SET", tailKey, prev or "")
278
+ end
279
+ end
280
+ redis.call("HDEL", poolItemsKey, itemName)
281
+ 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
295
+ else
296
+ push_to_tail(itemName)
297
+ end
298
+ end
299
+ '''
300
+
301
+ @cached_property
302
+ def _extend_script(self):
303
+ """Cached Redis script for extending the allocation pool."""
304
+ return self.redis.register_script(self._extend_lua_string)
305
+
306
+ def extend(self, keys: Optional[Sequence[str]] = None):
307
+ """Add new resources to the allocation pool.
308
+
309
+ Args:
310
+ keys: Sequence of resource identifiers to add to the pool
311
+ """
312
+ 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
+ '''
327
+
328
+ @cached_property
329
+ def _shrink_script(self):
330
+ """Cached Redis script for shrinking the allocation pool."""
331
+ return self.redis.register_script(self._shrink_lua_string)
332
+
333
+ def shrink(self, keys: Optional[Sequence[str]] = None):
334
+ """Remove resources from the allocation pool.
335
+
336
+ Args:
337
+ keys: Sequence of resource identifiers to remove from the pool
338
+ """
339
+ if keys is not None and len(keys) > 0:
340
+ self._shrink_script(args=keys)
341
+
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
354
+ end
355
+ local allItems = redis.call("HKEYS", poolItemsKey)
356
+ for _, itemName in ipairs(allItems) do
357
+ if not wantSet[itemName] then
358
+ delete_item(itemName)
359
+ else
360
+ wantSet[itemName] = nil
361
+ end
362
+ end
363
+ for k, v in pairs(wantSet) do
364
+ if v then
365
+ push_to_tail(k)
366
+ end
367
+ end
368
+ '''
369
+
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):
376
+ """Completely replace the resources in the allocation pool.
377
+
378
+ Args:
379
+ keys: Sequence of resource identifiers to assign to the pool,
380
+ replacing any existing resources
381
+ """
382
+ if keys is not None and len(keys) > 0:
383
+ self._assign_lua_script(args=keys)
384
+ else:
385
+ self.clear()
386
+
387
+ def keys(self) -> Iterable[str]:
388
+ """Get all resource identifiers in the allocation pool.
389
+
390
+ Returns:
391
+ Iterable of resource identifiers in the pool
392
+ """
393
+ return self.redis.hkeys(self._pool_str())
394
+
395
+ def __contains__(self, key):
396
+ """Check if a resource identifier is in the allocation pool.
397
+
398
+ Args:
399
+ key: Resource identifier to check
400
+
401
+ Returns:
402
+ True if the resource is in the pool, False otherwise
403
+ """
404
+ return self.redis.hexists(self._pool_str(), key)
405
+
406
+ @property
407
+ def _cache_str(self):
408
+ """Get the Redis key for the allocator's cache.
409
+
410
+ Returns:
411
+ String representation of the Redis key for the cache
412
+ """
413
+ return f'{self.prefix}|{self.suffix}-cache'
414
+
415
+ def clear(self):
416
+ """Clear all resources from the allocation pool and cache."""
417
+ super().clear()
418
+ self.redis.delete(self._cache_str)
419
+
420
+ def _soft_bind_name(self, name: str) -> str:
421
+ """Get the Redis key for a soft binding.
422
+
423
+ Args:
424
+ name: Name of the soft binding
425
+
426
+ Returns:
427
+ String representation of the Redis key for the soft binding
428
+ """
429
+ return f"{self._cache_str}:bind:{name}"
430
+
431
+ def update_soft_bind(self, name: str, key: str):
432
+ """Update a soft binding between a name and a resource.
433
+
434
+ Args:
435
+ name: Name to bind
436
+ key: Resource identifier to bind to the name
437
+ """
438
+ self.update(self._soft_bind_name(name), key, timeout=self.soft_bind_timeout)
439
+
440
+ def unbind_soft_bind(self, name: str):
441
+ """Remove a soft binding.
442
+
443
+ Args:
444
+ name: Name of the soft binding to remove
445
+ """
446
+ self.unlock(self._soft_bind_name(name))
447
+
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.
455
+ """
456
+ return f'''
457
+ {self._lua_required_string}
458
+ local shared = {1 if self.shared else 0}
459
+ 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")
470
+ end
471
+ end
472
+ end
473
+ end
474
+ return itemName
475
+ '''
476
+
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)
481
+
482
+ def malloc_key(self, timeout: Timeout = 120) -> Optional[str]:
483
+ """Allocate a resource key from the pool.
484
+
485
+ Args:
486
+ timeout: How long the allocation should be valid (in seconds)
487
+
488
+ Returns:
489
+ Resource identifier if allocation was successful, None otherwise
490
+ """
491
+ return self._malloc_script(keys=[self._to_seconds(timeout)])
492
+
493
+ def malloc(self, timeout: Timeout = 120, obj: Optional[U] = None, params: Optional[dict] = None) -> Optional[RedisAllocatorObject[U]]:
494
+ """Allocate a resource from the pool and wrap it in a RedisAllocatorObject.
495
+
496
+ 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
+
501
+ Returns:
502
+ RedisAllocatorObject wrapping the allocated resource if successful, None otherwise
503
+ """
504
+ key = self.malloc_key(timeout)
505
+ if key is None:
506
+ return None
507
+ return RedisAllocatorObject(self, key, obj, params)
508
+
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.
515
+ """
516
+ return f'''
517
+ {self._lua_required_string}
518
+ for _, k in ipairs(KEYS) do
519
+ redis.call('DEL', key_str(k))
520
+ push_to_tail(k)
521
+ end
522
+ '''
523
+
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):
530
+ """Free allocated resources.
531
+
532
+ Args:
533
+ *keys: Resource identifiers to free
534
+ """
535
+ self._free_script(keys, args=keys)
536
+
537
+ def free(self, obj: RedisAllocatorObject[U]):
538
+ """Free an allocated object."""
539
+ self.free_keys(obj.key)
540
+
541
+ @cached_property
542
+ def _gc_script(self):
543
+ """Cached Redis script for garbage collection."""
544
+ return self.redis.register_script(self._gc_lua_script)
545
+
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.
556
+ """
557
+ return f'''
558
+ {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"
564
+ end
565
+ local scanResult = redis.call("HSCAN", poolItemsKey, oldCursor, "COUNT", n)
566
+ local newCursor = scanResult[1]
567
+ local kvList = scanResult[2]
568
+ local tail = redis.call("GET", tailKey)
569
+ if not tail then
570
+ tail = ""
571
+ end
572
+ for i = 1, #kvList, 2 do
573
+ local itemName = kvList[i]
574
+ 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
587
+ end
588
+ '''
589
+
590
+ def gc(self, count: int = 10):
591
+ """Perform garbage collection on the allocation pool.
592
+
593
+ This method scans through the pool and ensures consistency between
594
+ the allocation metadata and the actual locks.
595
+
596
+ Args:
597
+ count: Number of items to check in this garbage collection pass
598
+ """
599
+ # Ensure count is positive
600
+ assert count > 0, "count should be positive"
601
+ self._gc_script(args=[count])