redis-allocator 0.5.2__tar.gz → 0.6.1__tar.gz

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.
Files changed (32) hide show
  1. {redis_allocator-0.5.2/redis_allocator.egg-info → redis_allocator-0.6.1}/PKG-INFO +53 -3
  2. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/README.md +50 -0
  3. redis_allocator-0.6.1/pyproject.toml +26 -0
  4. redis_allocator-0.6.1/redis_allocator/_version.py +1 -0
  5. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator/allocator.py +145 -41
  6. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator/lock.py +0 -14
  7. {redis_allocator-0.5.2 → redis_allocator-0.6.1/redis_allocator.egg-info}/PKG-INFO +53 -3
  8. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator.egg-info/SOURCES.txt +9 -0
  9. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator.egg-info/requires.txt +2 -2
  10. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/setup.py +1 -1
  11. redis_allocator-0.6.1/tests/_bughunt_helpers.py +333 -0
  12. redis_allocator-0.6.1/tests/_perf_real_redis.py +115 -0
  13. redis_allocator-0.6.1/tests/_profile_fakeredis.py +109 -0
  14. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/tests/conftest.py +43 -0
  15. redis_allocator-0.6.1/tests/stress_pool_corruption.py +285 -0
  16. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/tests/test_allocator.py +1 -1
  17. redis_allocator-0.6.1/tests/test_allocator_benchmark.py +127 -0
  18. redis_allocator-0.6.1/tests/test_allocator_bughunt.py +475 -0
  19. redis_allocator-0.6.1/tests/test_allocator_concurrency.py +176 -0
  20. redis_allocator-0.6.1/tests/test_allocator_forensics.py +213 -0
  21. redis_allocator-0.6.1/tests/test_allocator_fuzz.py +124 -0
  22. redis_allocator-0.5.2/pyproject.toml +0 -11
  23. redis_allocator-0.5.2/redis_allocator/_version.py +0 -1
  24. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/LICENSE +0 -0
  25. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator/__init__.py +0 -0
  26. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator/task_queue.py +0 -0
  27. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator.egg-info/dependency_links.txt +0 -0
  28. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator.egg-info/top_level.txt +0 -0
  29. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/setup.cfg +0 -0
  30. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/tests/__init__.py +0 -0
  31. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/tests/test_lock.py +0 -0
  32. {redis_allocator-0.5.2 → redis_allocator-0.6.1}/tests/test_task_queue.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redis-allocator
3
- Version: 0.5.2
3
+ Version: 0.6.1
4
4
  Summary: Redis-based resource allocation system.
5
5
  Home-page: https://github.com/invoker-bot/RedisAllocator-python
6
6
  Author: Invoker Bot
@@ -21,7 +21,7 @@ Provides-Extra: test
21
21
  Requires-Dist: pytest>=7.4.3; extra == "test"
22
22
  Requires-Dist: pytest-cov>=4.1.0; extra == "test"
23
23
  Requires-Dist: pytest-mock>=3.12.0; extra == "test"
24
- Requires-Dist: fakeredis[lua]>=2.20.1; extra == "test"
24
+ Requires-Dist: fakeredis[lua]>=2.35.1; extra == "test"
25
25
  Requires-Dist: flake8>=6.1.0; extra == "test"
26
26
  Requires-Dist: freezegun>=1.4.0; extra == "test"
27
27
  Provides-Extra: docs
@@ -33,7 +33,7 @@ Provides-Extra: dev
33
33
  Requires-Dist: pytest>=7.4.3; extra == "dev"
34
34
  Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
35
35
  Requires-Dist: pytest-mock>=3.12.0; extra == "dev"
36
- Requires-Dist: fakeredis[lua]>=2.20.1; extra == "dev"
36
+ Requires-Dist: fakeredis[lua]>=2.35.1; extra == "dev"
37
37
  Requires-Dist: flake8>=6.1.0; extra == "dev"
38
38
  Requires-Dist: freezegun>=1.4.0; extra == "dev"
39
39
  Requires-Dist: sphinx>=7.0.0; extra == "dev"
@@ -91,6 +91,15 @@ RedisAllocator is built around these key ideas:
91
91
  - **Shared Mode**: Configurable allocation modes supporting both exclusive and shared resource usage
92
92
  - **Soft Binding**: Associates named objects with specific resources for consistent allocation
93
93
 
94
+ ## Behavior Contracts (Important for API Users)
95
+
96
+ A few intentional behaviors that may surprise newcomers — knowing them up-front avoids debugging dead-ends:
97
+
98
+ - **`malloc_key(timeout<=0)` creates a permanent (non-expiring) lock.** Useful for resources whose ownership must outlive process restarts; the caller takes responsibility for releasing via `free_keys`. GC never reclaims it (because expiry is also `-1`). Misuse causes resource leaks — only pass `timeout=0` when you mean "I will manage this lock myself."
99
+ - **`cache_timeout=0` (or negative) creates a permanent soft binding.** Sticks until explicitly overwritten by another `update_soft_bind` for the same name, or removed via `unbind_soft_bind`. Symmetric to the lock-timeout contract above.
100
+ - **`assign()` does not delete the lock keys of items it evicts.** A deliberate write-amplification trade-off: locks expire on their own TTL, and if the same key is later re-`extend`-ed into the pool, the new `extend`/`assign` clears any residual lock so the new entry starts clean. If you evict a key and never re-add it, the lock is an orphan — callers who care must `DEL <prefix>|<suffix>:<key>` themselves.
101
+ - **Pool keys are validated at the API boundary.** `extend()` / `assign()` raise `ValueError` for keys that are empty, equal to the literal `"#ALLOCATED"`, contain the `"||"` separator, contain NUL bytes, or are not strings. These shapes break the on-disk encoding and would silently corrupt the linked list.
102
+
94
103
  ## Documentation
95
104
 
96
105
  For complete documentation, please visit our [official documentation site](https://invoker-bot.github.io/RedisAllocator-python/).
@@ -487,6 +496,44 @@ The RedisAllocator maintains resources in a doubly-linked list structure stored
487
496
  - Cleans up inconsistent states between allocations and locks
488
497
  - Soft bindings are implemented as separate locks with their own timeout period
489
498
 
499
+ ### Concurrency Safety
500
+
501
+ Each EVAL is atomic on Redis (single-threaded server, Lua never preempted), so no operation can be observed half-completed. Multi-process coordination is then provided by lock TTL plus the GC pass — a process killed mid-sequence leaves at most an orphaned lock, which expires on its TTL or is reclaimed by GC.
502
+
503
+ The pool linked-list invariants survive arbitrary interleaving of `malloc` / `free` / `extend` / `assign` / `gc` from concurrent processes (validated by both fakeredis multi-thread tests and a Docker-backed stress driver — see Testing below). If you observe persistent invariant violations under load, please file an issue with the operation sequence.
504
+
505
+ ## Testing
506
+
507
+ The default unit suite is fast (~7s) and self-contained — `pytest` runs it without any external services:
508
+
509
+ ```bash
510
+ pip install -e ".[dev]"
511
+ pytest # ~180 tests, fakeredis only, ~7s
512
+ flake8 redis_allocator tests --max-line-length=150
513
+ tox # py310 / py311 / flake8, matches CI
514
+ ```
515
+
516
+ Several test categories are **opt-in via pytest markers** (excluded from the default run by `pyproject.toml`):
517
+
518
+ ```bash
519
+ pytest -m concurrency # multi-thread fakeredis race tests (~25s)
520
+ pytest -m fuzz # randomised single-thread sequences
521
+ pytest -m benchmark # constant-time complexity assertions for malloc/free/extend
522
+ pytest -m "concurrency or fuzz or benchmark" # all of the above
523
+ ```
524
+
525
+ For the strongest validation against real-world conditions there is a Docker-backed stress driver under `tests/stress_pool_corruption.py`. It is a standalone script (not collected by pytest), spawning multiple worker processes that randomly malloc / free / extend / shrink / assign / gc against a real Redis container while a "murderer" process randomly SIGKILLs workers to simulate crashes. An atomic-snapshot watcher checks pool invariants throughout. Typical run:
526
+
527
+ ```bash
528
+ docker run --rm -d --name allocator-stress -p 6399:6379 redis:7-alpine
529
+ python tests/stress_pool_corruption.py # 60s default; STRESS_DURATION/STRESS_WORKERS env vars
530
+ docker rm -f allocator-stress
531
+ ```
532
+
533
+ Performance contracts are validated on both backends. Real Redis 7 confirms hard `O(1)` for `malloc + free` and `extend` per-key cost across pool sizes from 10 to 5000 keys. The fakeredis `pytest -m benchmark` enforces the same threshold; it relies on a temporary monkey-patch in `tests/conftest.py` because of [fakeredis upstream PR #473](https://github.com/cunla/fakeredis-py/pull/473) (merged to master, not yet released). The patch is idempotent and self-removable once fakeredis ships a release containing the fix — see the cleanup checklist embedded in `tests/conftest.py`.
534
+
535
+ A standalone Python script `tests/_perf_real_redis.py` reproduces the benchmark against a real Redis container for cross-checking.
536
+
490
537
  ## Roadmap
491
538
 
492
539
  * **Core Focus & Recently Completed:**
@@ -497,6 +544,9 @@ The RedisAllocator maintains resources in a doubly-linked list structure stored
497
544
  * [x] Basic Garbage Collection & Health Checking Foundation
498
545
  * [x] Documentation Improvements (Shared Mode, Soft Binding, Lua Clarity)
499
546
  * [x] Foundational Unit Tests & Allocation Mode Coverage
547
+ * [x] **Pool hardened against multi-process race corruption** (input validation, head-pointer maintenance across locked/expired skip, zombie-key prevention, residual-lock cleanup on re-add, soft-binding ordering, GC self-healing) — see [BUGHUNT_REPORT.md](BUGHUNT_REPORT.md).
548
+ * [x] **Comprehensive test infrastructure**: bug-hunt regression suite, atomic-snapshot invariant checker, concurrency / fuzz / benchmark / stress markers, Docker-backed stress driver, real-Redis cross-checks.
549
+ * [x] **`O(1)` performance contract** for `malloc` / `free` / `extend` validated on both real Redis and patched fakeredis.
500
550
 
501
551
  * **Current Development Priorities (Focusing on Proxy Pool Needs):**
502
552
  * [ ] **Resource Prioritization:** Implement priority-based allocation in `RedisAllocator`, likely using Redis Sorted Sets (`ZSET`) for the free pool. *(New - High Priority)*
@@ -36,6 +36,15 @@ RedisAllocator is built around these key ideas:
36
36
  - **Shared Mode**: Configurable allocation modes supporting both exclusive and shared resource usage
37
37
  - **Soft Binding**: Associates named objects with specific resources for consistent allocation
38
38
 
39
+ ## Behavior Contracts (Important for API Users)
40
+
41
+ A few intentional behaviors that may surprise newcomers — knowing them up-front avoids debugging dead-ends:
42
+
43
+ - **`malloc_key(timeout<=0)` creates a permanent (non-expiring) lock.** Useful for resources whose ownership must outlive process restarts; the caller takes responsibility for releasing via `free_keys`. GC never reclaims it (because expiry is also `-1`). Misuse causes resource leaks — only pass `timeout=0` when you mean "I will manage this lock myself."
44
+ - **`cache_timeout=0` (or negative) creates a permanent soft binding.** Sticks until explicitly overwritten by another `update_soft_bind` for the same name, or removed via `unbind_soft_bind`. Symmetric to the lock-timeout contract above.
45
+ - **`assign()` does not delete the lock keys of items it evicts.** A deliberate write-amplification trade-off: locks expire on their own TTL, and if the same key is later re-`extend`-ed into the pool, the new `extend`/`assign` clears any residual lock so the new entry starts clean. If you evict a key and never re-add it, the lock is an orphan — callers who care must `DEL <prefix>|<suffix>:<key>` themselves.
46
+ - **Pool keys are validated at the API boundary.** `extend()` / `assign()` raise `ValueError` for keys that are empty, equal to the literal `"#ALLOCATED"`, contain the `"||"` separator, contain NUL bytes, or are not strings. These shapes break the on-disk encoding and would silently corrupt the linked list.
47
+
39
48
  ## Documentation
40
49
 
41
50
  For complete documentation, please visit our [official documentation site](https://invoker-bot.github.io/RedisAllocator-python/).
@@ -432,6 +441,44 @@ The RedisAllocator maintains resources in a doubly-linked list structure stored
432
441
  - Cleans up inconsistent states between allocations and locks
433
442
  - Soft bindings are implemented as separate locks with their own timeout period
434
443
 
444
+ ### Concurrency Safety
445
+
446
+ Each EVAL is atomic on Redis (single-threaded server, Lua never preempted), so no operation can be observed half-completed. Multi-process coordination is then provided by lock TTL plus the GC pass — a process killed mid-sequence leaves at most an orphaned lock, which expires on its TTL or is reclaimed by GC.
447
+
448
+ The pool linked-list invariants survive arbitrary interleaving of `malloc` / `free` / `extend` / `assign` / `gc` from concurrent processes (validated by both fakeredis multi-thread tests and a Docker-backed stress driver — see Testing below). If you observe persistent invariant violations under load, please file an issue with the operation sequence.
449
+
450
+ ## Testing
451
+
452
+ The default unit suite is fast (~7s) and self-contained — `pytest` runs it without any external services:
453
+
454
+ ```bash
455
+ pip install -e ".[dev]"
456
+ pytest # ~180 tests, fakeredis only, ~7s
457
+ flake8 redis_allocator tests --max-line-length=150
458
+ tox # py310 / py311 / flake8, matches CI
459
+ ```
460
+
461
+ Several test categories are **opt-in via pytest markers** (excluded from the default run by `pyproject.toml`):
462
+
463
+ ```bash
464
+ pytest -m concurrency # multi-thread fakeredis race tests (~25s)
465
+ pytest -m fuzz # randomised single-thread sequences
466
+ pytest -m benchmark # constant-time complexity assertions for malloc/free/extend
467
+ pytest -m "concurrency or fuzz or benchmark" # all of the above
468
+ ```
469
+
470
+ For the strongest validation against real-world conditions there is a Docker-backed stress driver under `tests/stress_pool_corruption.py`. It is a standalone script (not collected by pytest), spawning multiple worker processes that randomly malloc / free / extend / shrink / assign / gc against a real Redis container while a "murderer" process randomly SIGKILLs workers to simulate crashes. An atomic-snapshot watcher checks pool invariants throughout. Typical run:
471
+
472
+ ```bash
473
+ docker run --rm -d --name allocator-stress -p 6399:6379 redis:7-alpine
474
+ python tests/stress_pool_corruption.py # 60s default; STRESS_DURATION/STRESS_WORKERS env vars
475
+ docker rm -f allocator-stress
476
+ ```
477
+
478
+ Performance contracts are validated on both backends. Real Redis 7 confirms hard `O(1)` for `malloc + free` and `extend` per-key cost across pool sizes from 10 to 5000 keys. The fakeredis `pytest -m benchmark` enforces the same threshold; it relies on a temporary monkey-patch in `tests/conftest.py` because of [fakeredis upstream PR #473](https://github.com/cunla/fakeredis-py/pull/473) (merged to master, not yet released). The patch is idempotent and self-removable once fakeredis ships a release containing the fix — see the cleanup checklist embedded in `tests/conftest.py`.
479
+
480
+ A standalone Python script `tests/_perf_real_redis.py` reproduces the benchmark against a real Redis container for cross-checking.
481
+
435
482
  ## Roadmap
436
483
 
437
484
  * **Core Focus & Recently Completed:**
@@ -442,6 +489,9 @@ The RedisAllocator maintains resources in a doubly-linked list structure stored
442
489
  * [x] Basic Garbage Collection & Health Checking Foundation
443
490
  * [x] Documentation Improvements (Shared Mode, Soft Binding, Lua Clarity)
444
491
  * [x] Foundational Unit Tests & Allocation Mode Coverage
492
+ * [x] **Pool hardened against multi-process race corruption** (input validation, head-pointer maintenance across locked/expired skip, zombie-key prevention, residual-lock cleanup on re-add, soft-binding ordering, GC self-healing) — see [BUGHUNT_REPORT.md](BUGHUNT_REPORT.md).
493
+ * [x] **Comprehensive test infrastructure**: bug-hunt regression suite, atomic-snapshot invariant checker, concurrency / fuzz / benchmark / stress markers, Docker-backed stress driver, real-Redis cross-checks.
494
+ * [x] **`O(1)` performance contract** for `malloc` / `free` / `extend` validated on both real Redis and patched fakeredis.
445
495
 
446
496
  * **Current Development Priorities (Focusing on Proxy Pool Needs):**
447
497
  * [ ] **Resource Prioritization:** Implement priority-based allocation in `RedisAllocator`, likely using Redis Sorted Sets (`ZSET`) for the free pool. *(New - High Priority)*
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ['setuptools>=42']
3
+ build-backend = 'setuptools.build_meta'
4
+
5
+ [tool.commitizen]
6
+ name = "cz_conventional_commits"
7
+ version = "0.6.1"
8
+ tag_format = "v$version"
9
+
10
+ [tool.commitizen.version_files]
11
+ "redis_allocator/_version.py" = { search = "__version__ = '", replace = "__version__ = '" }
12
+
13
+ [tool.pytest.ini_options]
14
+ # Opt-in markers for tests that are slow, flaky, or need external services.
15
+ # Default `pytest` invocation excludes them; CI runs only the fast unit suite.
16
+ # Run them explicitly with:
17
+ # pytest -m concurrency # multi-thread fakeredis race tests
18
+ # pytest -m fuzz # randomised single-thread sequences
19
+ # pytest -m "concurrency or fuzz"
20
+ markers = [
21
+ "concurrency: tests that drive the allocator from multiple threads (fakeredis)",
22
+ "fuzz: random-sequence exploratory tests (may exercise lupa edge cases)",
23
+ "stress: long-running stress tests requiring external Redis (Docker)",
24
+ "benchmark: performance assertions (constant-time complexity, etc.)",
25
+ ]
26
+ addopts = "-m 'not concurrency and not fuzz and not stress and not benchmark'"
@@ -0,0 +1 @@
1
+ __version__ = '0.6.1'
@@ -17,23 +17,32 @@ Key features:
17
17
  4. Support for an updater to refresh the pool's keys periodically
18
18
  5. Policy-based control of allocation behavior through RedisAllocatorPolicy
19
19
  """
20
+ import random
20
21
  import atexit
21
22
  import logging
22
23
  import weakref
23
24
  import contextlib
24
25
  from abc import ABC, abstractmethod
25
- from typing import Any, Callable
26
+ from typing import Any, Callable, Optional, TypeVar, Generic, Sequence, Iterable
26
27
  from functools import cached_property
27
28
  from threading import current_thread
28
29
  from concurrent.futures import ThreadPoolExecutor
29
- from typing import (Optional, TypeVar, Generic,
30
- Sequence, Iterable)
31
30
  from redis import StrictRedis as Redis
32
31
  from cachetools import cached, TTLCache
33
32
  from .lock import RedisLockPool, Timeout
34
33
 
35
34
  logger = logging.getLogger(__name__)
36
35
 
36
+ #: Sentinel written into the prev/next fields of a pool hash entry to mark
37
+ #: the key as currently allocated. Pool keys equal to this string are
38
+ #: rejected by ``RedisAllocator._validate_keys`` to avoid collision.
39
+ ALLOCATED_SENTINEL = "#ALLOCATED"
40
+
41
+ #: Separator between prev / next / expiry inside a pool hash value
42
+ #: ("prev||next||expiry"). Pool keys containing this substring are rejected
43
+ #: at the API boundary to avoid the encoding being mis-parsed.
44
+ POOL_VALUE_SEP = "||"
45
+
37
46
 
38
47
  class RedisThreadHealthCheckPool(RedisLockPool):
39
48
  """A class that provides a simple interface for managing the health status of a thread.
@@ -412,8 +421,7 @@ class DefaultRedisAllocatorPolicy(RedisAllocatorPolicy[U]):
412
421
 
413
422
  def refresh_pool_all(self, allocator: 'RedisAllocator[U]'):
414
423
  allocator.clear()
415
- for _ in range(len(self.updater.params)):
416
- self.refresh_pool(allocator)
424
+ self.refresh_pool(allocator, n=len(self.updater.params))
417
425
 
418
426
  def malloc(self, allocator: 'RedisAllocator[U]', timeout: Timeout = 120,
419
427
  obj: Optional[U] = None, params: Optional[dict] = None,
@@ -469,7 +477,7 @@ class DefaultRedisAllocatorPolicy(RedisAllocatorPolicy[U]):
469
477
  # If we got here, we acquired the lock, so we can update the pool
470
478
  self.refresh_pool(allocator)
471
479
 
472
- def refresh_pool(self, allocator: 'RedisAllocator[U]'):
480
+ def refresh_pool(self, allocator: 'RedisAllocator[U]', shuffle=True, n=1):
473
481
  """Refresh the allocation pool using the updater.
474
482
 
475
483
  Args:
@@ -478,12 +486,17 @@ class DefaultRedisAllocatorPolicy(RedisAllocatorPolicy[U]):
478
486
  if self.updater is None:
479
487
  return
480
488
 
481
- keys = self.updater()
489
+ keys = []
490
+ for _ in range(n):
491
+ keys.extend(self.updater())
482
492
 
483
493
  if len(keys) == 0:
484
494
  logger.warning("No keys to update to the pool")
485
495
  return
486
496
 
497
+ if shuffle:
498
+ random.shuffle(keys)
499
+
487
500
  # Update the pool based on the number of keys
488
501
  if len(self.updater) == 1:
489
502
  allocator.assign(keys, timeout=self.expiry_duration)
@@ -617,14 +630,14 @@ class RedisAllocator(RedisLockPool, Generic[U]):
617
630
  return "", "", -1
618
631
  end
619
632
  value = tostring(value)
620
- local prev, next, expiry = string.match(value, "(.*)||(.*)||(.*)")
633
+ local prev, next, expiry = string.match(value, "(.*){POOL_VALUE_SEP}(.*){POOL_VALUE_SEP}(.*)")
621
634
  return prev, next, tonumber(expiry)
622
635
  end
623
636
  local function join_pool_value(prev, next, expiry)
624
637
  if expiry == nil then
625
638
  expiry = -1
626
639
  end
627
- return tostring(prev) .. "||" .. tostring(next) .. "||" .. tostring(expiry)
640
+ return tostring(prev) .. "{POOL_VALUE_SEP}" .. tostring(next) .. "{POOL_VALUE_SEP}" .. tostring(expiry)
628
641
  end
629
642
  local function timeout_to_expiry(timeout)
630
643
  if timeout == nil or timeout <= 0 then
@@ -669,8 +682,8 @@ class RedisAllocator(RedisLockPool, Generic[U]):
669
682
  local itemVal = redis.call("HGET", poolItemsKey, itemName)
670
683
  if itemVal then
671
684
  local prev, next, expiry = split_pool_value(itemVal)
672
- assert(prev == "#ALLOCATED" or prev == "", "item should be allocated or free")
673
- assert(next == "" or next == "#ALLOCATED", "item should be the last item in the free list")
685
+ assert(prev == "{ALLOCATED_SENTINEL}" or prev == "", "item should be allocated or free")
686
+ assert(next == "" or next == "{ALLOCATED_SENTINEL}", "item should be the last item in the free list")
674
687
  end
675
688
  -- set the item points to the tail
676
689
  redis.call("HSET", poolItemsKey, itemName, join_pool_value(tail, "", expiry))
@@ -718,16 +731,32 @@ class RedisAllocator(RedisLockPool, Generic[U]):
718
731
  local headVal = redis.call("HGET", poolItemsKey, head)
719
732
  assert(headVal, "head value should not be nil")
720
733
  local headPrev, headNext, headExpiry = split_pool_value(headVal)
734
+ -- Promote headNext to head and stitch its prev to "" so a later
735
+ -- set_item_allocated cannot dereference the removed prev.
736
+ local function _advance_head_after_removal()
737
+ if headNext == "" then
738
+ redis.call("SET", headKey, "")
739
+ redis.call("SET", tailKey, "")
740
+ return
741
+ end
742
+ local nextVal = redis.call("HGET", poolItemsKey, headNext)
743
+ if not nextVal then
744
+ redis.call("SET", headKey, "")
745
+ redis.call("SET", tailKey, "")
746
+ return
747
+ end
748
+ local _, nNext, nExpiry = split_pool_value(nextVal)
749
+ redis.call("HSET", poolItemsKey, headNext, join_pool_value("", nNext, nExpiry))
750
+ redis.call("SET", headKey, headNext)
751
+ end
721
752
  -- Check if the head item has expired or is locked
722
753
  if is_expiry_invalid(headExpiry) then -- the item has expired
723
754
  redis.call("HDEL", poolItemsKey, head)
724
- set_current_head(headNext)
725
- -- set_item_head_nil(headNext)
755
+ _advance_head_after_removal()
726
756
  return pop_from_head()
727
757
  elseif redis.call("EXISTS", key_str(head)) > 0 then -- the item is locked
728
- redis.call("HSET", poolItemsKey, head, join_pool_value("#ALLOCATED", "#ALLOCATED", headExpiry))
729
- set_current_head(headNext)
730
- -- set_item_head_nil(headNext)
758
+ redis.call("HSET", poolItemsKey, head, join_pool_value("{ALLOCATED_SENTINEL}", "{ALLOCATED_SENTINEL}", headExpiry))
759
+ _advance_head_after_removal()
731
760
  return pop_from_head()
732
761
  elseif headNext == "" then -- the item is the last in the free list
733
762
  redis.call("SET", headKey, "")
@@ -739,7 +768,7 @@ class RedisAllocator(RedisLockPool, Generic[U]):
739
768
  redis.call("HSET", poolItemsKey, headNext, join_pool_value("", nextNext, nextExpiry))
740
769
  redis.call("SET", headKey, headNext)
741
770
  end
742
- redis.call("HSET", poolItemsKey, head, join_pool_value("#ALLOCATED", "#ALLOCATED", headExpiry))
771
+ redis.call("HSET", poolItemsKey, head, join_pool_value("{ALLOCATED_SENTINEL}", "{ALLOCATED_SENTINEL}", headExpiry))
743
772
  return head, headExpiry
744
773
  end
745
774
 
@@ -749,8 +778,8 @@ class RedisAllocator(RedisLockPool, Generic[U]):
749
778
  end
750
779
  assert(val, "val should not be nil")
751
780
  local prev, next, expiry = split_pool_value(val)
752
- if prev ~= "#ALLOCATED" then
753
- assert(next ~= "#ALLOCATED", "next item should not be allocated")
781
+ if prev ~= "{ALLOCATED_SENTINEL}" then
782
+ assert(next ~= "{ALLOCATED_SENTINEL}", "next item should not be allocated")
754
783
  if is_expiry_invalid(expiry) then
755
784
  redis.call("HDEL", poolItemsKey, itemName)
756
785
  end
@@ -770,14 +799,14 @@ class RedisAllocator(RedisLockPool, Generic[U]):
770
799
  else
771
800
  redis.call("SET", tailKey, prev)
772
801
  end
773
- redis.call("HSET", poolItemsKey, itemName, join_pool_value("#ALLOCATED", "#ALLOCATED", expiry))
802
+ redis.call("HSET", poolItemsKey, itemName, join_pool_value("{ALLOCATED_SENTINEL}", "{ALLOCATED_SENTINEL}", expiry))
774
803
  -- If we removed the current head, update head pointer
775
804
  -- local savedHead = redis.call("GET", headKey)
776
805
  -- if savedHead == itemName then
777
806
  -- redis.call("SET", headKey, next or "")
778
807
  -- end
779
808
  else
780
- assert(next == "#ALLOCATED", "next item should also be allocated")
809
+ assert(next == "{ALLOCATED_SENTINEL}", "next item should also be allocated")
781
810
  end
782
811
  end
783
812
 
@@ -791,14 +820,16 @@ class RedisAllocator(RedisLockPool, Generic[U]):
791
820
  return
792
821
  end
793
822
  local locked = redis.call("EXISTS", key_str(itemName)) > 0
794
- if prev == "#ALLOCATED" then
823
+ if prev == "{ALLOCATED_SENTINEL}" then
795
824
  if not locked then
796
825
  push_to_tail(itemName, expiry)
797
826
  end
798
827
  else
799
- -- if locked then
800
- -- set_item_allocated(itemName, value)
801
- -- end
828
+ -- Free entry with a residual lock: pull it out of the free
829
+ -- list so callers can't malloc a key that's still locked.
830
+ if locked then
831
+ set_item_allocated(itemName, value)
832
+ end
802
833
  end
803
834
  end
804
835
 
@@ -825,7 +856,7 @@ class RedisAllocator(RedisLockPool, Generic[U]):
825
856
  break -- corrupted pointer
826
857
  end
827
858
  local _prev, nxt, _expiry = split_pool_value(val)
828
- if nxt == "" or nxt == "#ALLOCATED" then
859
+ if nxt == "" or nxt == "{ALLOCATED_SENTINEL}" then
829
860
  break -- reached tail or allocated marker
830
861
  end
831
862
  current = nxt
@@ -866,19 +897,71 @@ class RedisAllocator(RedisLockPool, Generic[U]):
866
897
  local prev, next, _ = split_pool_value(val)
867
898
  val = join_pool_value(prev, next, expiry)
868
899
  redis.call("HSET", poolItemsKey, itemName, val)
869
- else -- refresh the expiry timeout
900
+ else
901
+ -- New key: wipe any residual lock so the entry starts clean.
902
+ redis.call("DEL", key_str(itemName))
870
903
  push_to_tail(itemName, expiry)
871
904
  end
872
905
  end''')
873
906
 
907
+ def _validate_keys(self, keys: Sequence[str]) -> None:
908
+ """Reject pool keys that would corrupt the on-disk encoding.
909
+
910
+ Pool keys are stored as values inside Redis hash entries of the form
911
+ ``"<prev>||<next>||<expiry>"``. Three key shapes break this encoding
912
+ or collide with internal sentinels and must be rejected at the
913
+ Python API boundary, before they reach the Lua scripts:
914
+
915
+ - **Empty string keys** collide with the sentinel used for "no head"
916
+ / "no tail" of an empty list (see ``pop_from_head`` / ``push_to_tail``
917
+ in ``_lua_required_string``). An entry whose name is ``""`` becomes
918
+ indistinguishable from "the list is empty", causing INV-1 violations.
919
+ - **Keys containing the ``||`` separator** are mis-parsed by
920
+ ``split_pool_value``'s greedy ``(.*)||(.*)||(.*)`` regex and cause
921
+ silent linked-list corruption (later operations crash on
922
+ ``next value should not be nil``).
923
+ - **Keys equal to the literal ``"#ALLOCATED"``** collide with the
924
+ allocated-state marker stored in the prev/next fields of the pool
925
+ hash value, breaking INV-3 (no half-allocated state).
926
+
927
+ Args:
928
+ keys: Sequence of user-supplied keys to validate.
929
+
930
+ Raises:
931
+ ValueError: If any key in ``keys`` violates one of the rules above.
932
+ """
933
+ for key in keys:
934
+ if not isinstance(key, str):
935
+ raise ValueError(
936
+ f"pool key must be str, got {type(key).__name__}: {key!r:.80}"
937
+ )
938
+ if key == "":
939
+ raise ValueError("pool key cannot be empty (collides with empty-pointer sentinel)")
940
+ if POOL_VALUE_SEP in key:
941
+ raise ValueError(
942
+ f"pool key cannot contain {POOL_VALUE_SEP!r} separator: {key!r:.80}"
943
+ )
944
+ if key == ALLOCATED_SENTINEL:
945
+ raise ValueError(f"pool key cannot equal {ALLOCATED_SENTINEL!r} (reserved as internal sentinel)")
946
+ if "\x00" in key:
947
+ raise ValueError(
948
+ f"pool key cannot contain NUL byte: {key!r:.80}"
949
+ )
950
+
874
951
  def extend(self, keys: Optional[Sequence[str]] = None, timeout: int = -1):
875
952
  """Add new resources to the allocation pool.
876
953
 
877
954
  Args:
878
- keys: Sequence of resource identifiers to add to the pool
955
+ keys: Sequence of resource identifiers to add to the pool. Each
956
+ key is validated by :meth:`_validate_keys` before being
957
+ inserted; invalid keys cause the entire batch to be rejected.
879
958
  timeout: Optional timeout in seconds for the pool items (-1 means no timeout)
959
+
960
+ Raises:
961
+ ValueError: If any key fails :meth:`_validate_keys`.
880
962
  """
881
963
  if keys is not None and len(keys) > 0:
964
+ self._validate_keys(keys)
882
965
  # Ensure timeout is integer for Lua script
883
966
  int_timeout = timeout if timeout is not None else -1
884
967
  self._extend_script(args=[int_timeout] + list(keys))
@@ -887,16 +970,21 @@ class RedisAllocator(RedisLockPool, Generic[U]):
887
970
  def _shrink_script(self):
888
971
  """Cached Lua script to remove keys from the pool.
889
972
 
890
- Iterates through provided keys (ARGV[1...]).
891
- For each key:
973
+ Iterates through provided keys (ARGV[1...]). Items not present in the
974
+ pool are silently skipped (no-op), so callers can safely ``shrink`` a
975
+ superset of currently-known keys.
976
+
977
+ For each key that does exist:
892
978
  1. Calls set_item_allocated() to remove it from the free list structure.
893
979
  2. Deletes the key entirely from the pool hash using HDEL.
894
980
  """
895
981
  return self.redis.register_script(f'''{self._lua_required_string}
896
982
  for i=1, #ARGV do
897
983
  local itemName = ARGV[i]
898
- set_item_allocated(itemName)
899
- redis.call("HDEL", poolItemsKey, itemName)
984
+ if redis.call("HEXISTS", poolItemsKey, itemName) > 0 then
985
+ set_item_allocated(itemName)
986
+ redis.call("HDEL", poolItemsKey, itemName)
987
+ end
900
988
  end''')
901
989
 
902
990
  def shrink(self, keys: Optional[Sequence[str]] = None):
@@ -942,6 +1030,10 @@ class RedisAllocator(RedisLockPool, Generic[U]):
942
1030
  end
943
1031
  for k, v in pairs(assignSet) do
944
1032
  if v then
1033
+ -- Same rationale as in _extend_script: wipe any residual
1034
+ -- lock for this name before adding it as a fresh free node,
1035
+ -- otherwise we may surface INV-4 (free + locked).
1036
+ redis.call("DEL", key_str(k))
945
1037
  push_to_tail(k, expiry)
946
1038
  end
947
1039
  end
@@ -952,10 +1044,17 @@ class RedisAllocator(RedisLockPool, Generic[U]):
952
1044
 
953
1045
  Args:
954
1046
  keys: Sequence of resource identifiers to assign to the pool,
955
- replacing any existing resources
1047
+ replacing any existing resources. Each key is validated by
1048
+ :meth:`_validate_keys` before being inserted; invalid keys
1049
+ cause the entire call to be rejected (the existing pool is
1050
+ left untouched).
956
1051
  timeout: Optional timeout in seconds for the pool items (-1 means no timeout)
1052
+
1053
+ Raises:
1054
+ ValueError: If any key fails :meth:`_validate_keys`.
957
1055
  """
958
1056
  if keys is not None and len(keys) > 0:
1057
+ self._validate_keys(keys)
959
1058
  self._assign_script(args=[timeout] + list(keys))
960
1059
  else:
961
1060
  self.clear()
@@ -1081,8 +1180,11 @@ class RedisAllocator(RedisLockPool, Generic[U]):
1081
1180
  -- Cached key is valid (either not locked or in shared mode)
1082
1181
  refresh_cache(cachedKey) -- Refresh the cache expiry
1083
1182
  if shared == 0 then
1084
- redis.call("SET", key_str(cachedKey), "1", "EX", timeout)
1183
+ -- Order matters: chain mutation must succeed before
1184
+ -- the lock SET, otherwise an aborted EVAL leaves a
1185
+ -- free entry with a lock.
1085
1186
  set_item_allocated(cachedKey)
1187
+ redis.call("SET", key_str(cachedKey), "1", "EX", timeout)
1086
1188
  end
1087
1189
  return cachedKey
1088
1190
  end
@@ -1177,12 +1279,14 @@ class RedisAllocator(RedisLockPool, Generic[U]):
1177
1279
  def _free_script(self):
1178
1280
  """Cached Lua script to free allocated keys.
1179
1281
 
1180
- Iterates through provided keys (ARGV[2...]).
1181
- For each key:
1182
- 1. Deletes the corresponding lock key (`key_str(k)`) using DEL.
1183
- If the key existed (DEL returns 1), it proceeds.
1184
- 2. Adds the key back to the tail of the free list using `push_to_tail()`
1185
- with the specified expiry (calculated from ARGV[1] timeout).
1282
+ Iterates through ARGV[2...]. For each key:
1283
+
1284
+ 1. ``DEL`` its lock key.
1285
+ 2. If the lock did exist AND the key is still a pool member
1286
+ (``HEXISTS`` guard), push it back to the free list tail.
1287
+
1288
+ The ``HEXISTS`` guard ensures ``free_keys`` cannot re-insert a key
1289
+ that ``assign``/``shrink`` has evicted from the pool.
1186
1290
  """
1187
1291
  return self.redis.register_script(f'''
1188
1292
  {self._lua_required_string}
@@ -1191,7 +1295,7 @@ class RedisAllocator(RedisLockPool, Generic[U]):
1191
1295
  for i=2, #ARGV do
1192
1296
  local k = ARGV[i]
1193
1297
  local deleted = redis.call('DEL', key_str(k))
1194
- if deleted > 0 then -- Only push back to pool if it was actually locked/deleted
1298
+ if deleted > 0 and redis.call('HEXISTS', poolItemsKey, k) > 0 then
1195
1299
  push_to_tail(k, expiry)
1196
1300
  end
1197
1301
  end