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.
- {redis_allocator-0.5.2/redis_allocator.egg-info → redis_allocator-0.6.1}/PKG-INFO +53 -3
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/README.md +50 -0
- redis_allocator-0.6.1/pyproject.toml +26 -0
- redis_allocator-0.6.1/redis_allocator/_version.py +1 -0
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator/allocator.py +145 -41
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator/lock.py +0 -14
- {redis_allocator-0.5.2 → redis_allocator-0.6.1/redis_allocator.egg-info}/PKG-INFO +53 -3
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator.egg-info/SOURCES.txt +9 -0
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator.egg-info/requires.txt +2 -2
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/setup.py +1 -1
- redis_allocator-0.6.1/tests/_bughunt_helpers.py +333 -0
- redis_allocator-0.6.1/tests/_perf_real_redis.py +115 -0
- redis_allocator-0.6.1/tests/_profile_fakeredis.py +109 -0
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/tests/conftest.py +43 -0
- redis_allocator-0.6.1/tests/stress_pool_corruption.py +285 -0
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/tests/test_allocator.py +1 -1
- redis_allocator-0.6.1/tests/test_allocator_benchmark.py +127 -0
- redis_allocator-0.6.1/tests/test_allocator_bughunt.py +475 -0
- redis_allocator-0.6.1/tests/test_allocator_concurrency.py +176 -0
- redis_allocator-0.6.1/tests/test_allocator_forensics.py +213 -0
- redis_allocator-0.6.1/tests/test_allocator_fuzz.py +124 -0
- redis_allocator-0.5.2/pyproject.toml +0 -11
- redis_allocator-0.5.2/redis_allocator/_version.py +0 -1
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/LICENSE +0 -0
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator/__init__.py +0 -0
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator/task_queue.py +0 -0
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator.egg-info/dependency_links.txt +0 -0
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/redis_allocator.egg-info/top_level.txt +0 -0
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/setup.cfg +0 -0
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/tests/__init__.py +0 -0
- {redis_allocator-0.5.2 → redis_allocator-0.6.1}/tests/test_lock.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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) .. "
|
|
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 == "
|
|
673
|
-
assert(next == "" or next == "
|
|
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
|
-
|
|
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("
|
|
729
|
-
|
|
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("
|
|
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 ~= "
|
|
753
|
-
assert(next ~= "
|
|
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("
|
|
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 == "
|
|
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 == "
|
|
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
|
-
--
|
|
800
|
-
--
|
|
801
|
-
|
|
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 == "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
899
|
-
|
|
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
|
-
|
|
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
|
|
1181
|
-
|
|
1182
|
-
1.
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
|
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
|