redis-allocator 0.6.0__tar.gz → 0.7.0__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.6.0/redis_allocator.egg-info → redis_allocator-0.7.0}/PKG-INFO +122 -10
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/README.md +117 -5
- redis_allocator-0.7.0/pyproject.toml +27 -0
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator/__init__.py +2 -0
- redis_allocator-0.7.0/redis_allocator/_version.py +1 -0
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator/allocator.py +166 -34
- redis_allocator-0.7.0/redis_allocator/diagnostics/__init__.py +30 -0
- redis_allocator-0.7.0/redis_allocator/diagnostics/cli.py +52 -0
- redis_allocator-0.7.0/redis_allocator/diagnostics/collector.py +300 -0
- redis_allocator-0.7.0/redis_allocator/diagnostics/dashboard.py +93 -0
- redis_allocator-0.7.0/redis_allocator/diagnostics/invariants.py +192 -0
- redis_allocator-0.7.0/redis_allocator/diagnostics/models.py +128 -0
- redis_allocator-0.7.0/redis_allocator/diagnostics/static/dashboard.html +403 -0
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator/task_queue.py +18 -10
- {redis_allocator-0.6.0 → redis_allocator-0.7.0/redis_allocator.egg-info}/PKG-INFO +122 -10
- redis_allocator-0.7.0/redis_allocator.egg-info/SOURCES.txt +41 -0
- redis_allocator-0.7.0/redis_allocator.egg-info/entry_points.txt +2 -0
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator.egg-info/requires.txt +4 -4
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/setup.py +10 -2
- redis_allocator-0.7.0/tests/_bughunt_helpers.py +333 -0
- redis_allocator-0.7.0/tests/_perf_real_redis.py +195 -0
- redis_allocator-0.7.0/tests/_profile_fakeredis.py +109 -0
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/tests/conftest.py +43 -0
- redis_allocator-0.7.0/tests/stress_pool_corruption.py +285 -0
- redis_allocator-0.7.0/tests/test_allocator_benchmark.py +194 -0
- redis_allocator-0.7.0/tests/test_allocator_bughunt.py +513 -0
- redis_allocator-0.7.0/tests/test_allocator_concurrency.py +176 -0
- redis_allocator-0.7.0/tests/test_allocator_forensics.py +213 -0
- redis_allocator-0.7.0/tests/test_allocator_fuzz.py +124 -0
- redis_allocator-0.7.0/tests/test_diagnostics_cli.py +31 -0
- redis_allocator-0.7.0/tests/test_diagnostics_collector.py +76 -0
- redis_allocator-0.7.0/tests/test_diagnostics_dashboard.py +58 -0
- redis_allocator-0.7.0/tests/test_diagnostics_invariants.py +96 -0
- redis_allocator-0.7.0/tests/test_diagnostics_models.py +37 -0
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/tests/test_task_queue.py +57 -11
- redis_allocator-0.6.0/pyproject.toml +0 -11
- redis_allocator-0.6.0/redis_allocator/_version.py +0 -1
- redis_allocator-0.6.0/redis_allocator.egg-info/SOURCES.txt +0 -19
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/LICENSE +0 -0
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator/lock.py +0 -0
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator.egg-info/dependency_links.txt +0 -0
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator.egg-info/top_level.txt +0 -0
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/setup.cfg +0 -0
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/tests/__init__.py +0 -0
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/tests/test_allocator.py +0 -0
- {redis_allocator-0.6.0 → redis_allocator-0.7.0}/tests/test_lock.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redis-allocator
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
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,11 +21,11 @@ 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
|
|
28
|
-
Requires-Dist: sphinx
|
|
28
|
+
Requires-Dist: sphinx<9.0.0,>=7.0.0; extra == "docs"
|
|
29
29
|
Requires-Dist: sphinx-rtd-theme>=1.3.0; extra == "docs"
|
|
30
30
|
Requires-Dist: sphinx-git>=11.0.0; extra == "docs"
|
|
31
31
|
Requires-Dist: sphinxcontrib-mermaid>=0.7.1; extra == "docs"
|
|
@@ -33,10 +33,10 @@ 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
|
-
Requires-Dist: sphinx
|
|
39
|
+
Requires-Dist: sphinx<9.0.0,>=7.0.0; extra == "dev"
|
|
40
40
|
Requires-Dist: sphinx-rtd-theme>=1.3.0; extra == "dev"
|
|
41
41
|
Requires-Dist: sphinx-git>=11.0.0; extra == "dev"
|
|
42
42
|
Requires-Dist: sphinxcontrib-mermaid>=0.7.1; extra == "dev"
|
|
@@ -91,6 +91,17 @@ 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
|
+
- **`malloc()` and `malloc_key()` return `None` when no resource is available.** Check the return value before using it; an empty pool is not represented as a wrapper with `key=None`.
|
|
101
|
+
- **Prefer `with allocator.malloc(...) as allocation:` or `allocation.release()` for object allocations.** `release()` closes the wrapped object and returns the key to the pool. Plain `close()` only closes the wrapped object and leaves allocation ownership unchanged for callers that manage `free()` manually.
|
|
102
|
+
- **`assign()` / `shrink()` / `clear()` are not namespace scrubbers.** `assign()` and `shrink()` remove pool membership but intentionally leave lock keys for removed members; `clear()` deletes the pool hash/head/tail only. This avoids write amplification and wildcard scans, but an evicted permanent lock becomes a permanent orphan. To recover a known orphan, call `free_keys(key)`, reintroduce that same key with `extend([key])` or `assign([... key ...])`, or delete `<prefix>|<suffix>:<key>` directly. Reintroducing unrelated keys, `clear()`, and `gc()` do not remove orphan locks outside the pool.
|
|
103
|
+
- **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.
|
|
104
|
+
|
|
94
105
|
## Documentation
|
|
95
106
|
|
|
96
107
|
For complete documentation, please visit our [official documentation site](https://invoker-bot.github.io/RedisAllocator-python/).
|
|
@@ -218,15 +229,13 @@ if key:
|
|
|
218
229
|
# Allocate a resource with object (returns a RedisAllocatorObject)
|
|
219
230
|
allocated_obj = allocator.malloc(timeout=120)
|
|
220
231
|
if allocated_obj:
|
|
221
|
-
|
|
232
|
+
with allocated_obj:
|
|
222
233
|
# The key is available as a property
|
|
223
234
|
print(f"Allocated resource: {allocated_obj.key}")
|
|
224
235
|
|
|
225
236
|
# Update the resource's lock timeout
|
|
226
237
|
allocated_obj.update(timeout=60)
|
|
227
|
-
|
|
228
|
-
# Free the resource when done
|
|
229
|
-
allocator.free(allocated_obj)
|
|
238
|
+
# Leaving the with block calls allocated_obj.release().
|
|
230
239
|
|
|
231
240
|
# Using soft binding (associates a name with a resource)
|
|
232
241
|
allocator.update_soft_bind("worker-1", "resource-1")
|
|
@@ -452,7 +461,7 @@ result = task_queue.query(
|
|
|
452
461
|
id="task-123",
|
|
453
462
|
name="example-task",
|
|
454
463
|
params={"input": "data"},
|
|
455
|
-
timeout=300, #
|
|
464
|
+
timeout=300, # End-to-end wait timeout in seconds for remote execution
|
|
456
465
|
policy=TaskExecutePolicy.Auto, # Execution policy
|
|
457
466
|
once=False # Whether to delete the result after getting it
|
|
458
467
|
)
|
|
@@ -487,6 +496,106 @@ 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 after allocation can leave a lock behind. If the resource is still present in pool metadata, the lock either expires naturally or is reconciled by GC; if the resource is later evicted by `assign()` / `shrink()` / `clear()`, the lock becomes an orphan outside GC scope and expires only by TTL unless it is permanent.
|
|
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
|
+
#### Atomic Operation Boundaries
|
|
506
|
+
|
|
507
|
+
Allocator state transitions are written as single Redis Lua scripts. Redis runs each script atomically on the server event loop, so concurrent processes cannot observe a half-updated free-list node, lock key, or soft-binding update. This guarantee depends on using one Redis instance; Redis Cluster is out of scope because the allocator mutates multiple related keys in one script.
|
|
508
|
+
|
|
509
|
+
| Operation | Atomic boundary | Notes |
|
|
510
|
+
|---|---|---|
|
|
511
|
+
| `malloc_key()` | One Lua EVAL | Checks soft binding, pops/rotates the free-list head, marks the item allocated, creates the lock key, and refreshes binding state together. |
|
|
512
|
+
| `free_keys()` | One Lua EVAL | Deletes lock keys and returns still-present pool members to the free-list tail together. Unknown or evicted keys are no-ops. |
|
|
513
|
+
| `extend()` / `shrink()` / `assign()` | One Lua EVAL per call | Batch updates are atomic as a batch. `assign()` and `shrink()` intentionally preserve lock keys for evicted members; `extend()` / `assign()` delete a residual lock only when that same key is newly added to the pool. |
|
|
514
|
+
| `clear()` | One Lua EVAL | Deletes the pool hash plus head/tail pointers only; it does not scan or delete per-resource lock keys. |
|
|
515
|
+
| `gc()` | One Lua EVAL per pass | Incrementally reconciles up to the requested scan count and saves the scan cursor. |
|
|
516
|
+
| `malloc()` / `RedisAllocatorObject.release()` | Wrapper around atomic calls | `malloc()` wraps `malloc_key()` after the atomic allocation; `release()` wraps `free_keys()` after closing the local object. |
|
|
517
|
+
|
|
518
|
+
Multi-process coordination is provided by those atomic scripts plus lock TTLs and GC. A process killed after allocation may leave a lock behind; pool-member locks either expire naturally or are reconciled by GC. Orphan locks whose key is no longer in the pool are outside GC scope. Permanent locks (`timeout <= 0`) are never reclaimed automatically and must be released explicitly with `free_keys(key)`, by reintroducing that same key with `extend([key])` or `assign([... key ...])`, or by deleting `<prefix>|<suffix>:<key>` directly.
|
|
519
|
+
|
|
520
|
+
### Operation Complexity
|
|
521
|
+
|
|
522
|
+
The hot path is designed to avoid scanning the whole pool. Use `malloc_key()` / `free_keys()` for high-frequency allocation, use `extend()` for incremental pool refreshes, and reserve `assign()` for lower-frequency wholesale replacement.
|
|
523
|
+
|
|
524
|
+
| Operation | Time complexity | Space / Redis writes | Recommended use |
|
|
525
|
+
|---|---:|---|---|
|
|
526
|
+
| `malloc_key()` | Amortized `O(1)`; worst-case `O(k)` when skipping `k` expired or locked head entries | Constant extra metadata writes | Hot path allocation. |
|
|
527
|
+
| `free_keys(n)` | `O(n)` | Constant writes per released key | Hot path release; single-key free is `O(1)`. |
|
|
528
|
+
| `extend(n)` | `O(n)`, independent of existing pool size | Constant writes per supplied key | Incremental pool additions or expiry refresh. |
|
|
529
|
+
| `shrink(n)` | `O(n)` | Constant writes per supplied key | Remove known resources. |
|
|
530
|
+
| `assign(n)` | `O(current_pool_size + n)` | Rewrites membership for removed/new keys | Low-frequency full replacement. |
|
|
531
|
+
| `gc(count)` | Approximately `O(count)` per pass | Bounded by scanned entries | Periodic incremental repair/reclaim. |
|
|
532
|
+
|
|
533
|
+
`k` in `malloc_key()` is the number of stale head entries cleaned during that allocation. Those entries are removed or marked allocated as they are skipped, so the cost is paid once and amortizes over later calls.
|
|
534
|
+
|
|
535
|
+
## Testing
|
|
536
|
+
|
|
537
|
+
The default unit suite is fast (~7s) and self-contained — `pytest` runs it without any external services:
|
|
538
|
+
|
|
539
|
+
```bash
|
|
540
|
+
pip install -e ".[dev]"
|
|
541
|
+
pytest # ~180 tests, fakeredis only, ~7s
|
|
542
|
+
flake8 redis_allocator tests --max-line-length=150
|
|
543
|
+
tox # py310 / py311 / flake8, matches CI
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
Several test categories are **opt-in via pytest markers** (excluded from the default run by `pyproject.toml`):
|
|
547
|
+
|
|
548
|
+
```bash
|
|
549
|
+
pytest -m concurrency # multi-thread fakeredis race tests (~25s)
|
|
550
|
+
pytest -m fuzz # randomised single-thread sequences
|
|
551
|
+
pytest -m benchmark # operation-complexity assertions for allocator commands
|
|
552
|
+
pytest -m "concurrency or fuzz or benchmark" # all of the above
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
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:
|
|
556
|
+
|
|
557
|
+
```bash
|
|
558
|
+
docker run --rm -d --name allocator-stress -p 6399:6379 redis:7-alpine
|
|
559
|
+
python tests/stress_pool_corruption.py # 60s default; STRESS_DURATION/STRESS_WORKERS env vars
|
|
560
|
+
docker rm -f allocator-stress
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
Performance contracts are validated on both backends. The fakeredis `pytest -m benchmark` suite enforces scaling ratios across pool sizes from 10 to 5000 keys for `malloc_key() + free_keys()`, batched `free_keys(n)`, `extend(n)`, `shrink(n)`, and `gc(count)`. It covers `assign(n)` across 10 to 1000 keys because fakeredis/lupa is unreliable with 5000+ Lua ARGV on Windows; the real Redis script covers `assign(n)` through 5000. The benchmark asserts operation cost follows the documented complexity table instead of silently regressing to full-pool scans. 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`.
|
|
564
|
+
|
|
565
|
+
A standalone Python script `tests/_perf_real_redis.py` reproduces the same operation-complexity benchmark against a real Redis container for cross-checking and exits non-zero when a contract fails.
|
|
566
|
+
|
|
567
|
+
## Live Diagnostics
|
|
568
|
+
|
|
569
|
+
`redis-allocator` includes an observer-only diagnostics API and local dashboard for inspecting live allocator state under multi-process load.
|
|
570
|
+
|
|
571
|
+
Python API:
|
|
572
|
+
|
|
573
|
+
```python
|
|
574
|
+
from redis import Redis
|
|
575
|
+
from redis_allocator import RedisAllocatorDiagnostics
|
|
576
|
+
|
|
577
|
+
redis = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
|
|
578
|
+
diagnostics = RedisAllocatorDiagnostics(redis, prefix="myapp", suffix="allocator")
|
|
579
|
+
snapshot = diagnostics.snapshot()
|
|
580
|
+
print(snapshot.to_json(indent=2))
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
One-shot JSON:
|
|
584
|
+
|
|
585
|
+
```bash
|
|
586
|
+
redis-allocator-diagnose --redis-url redis://localhost:6379/0 --prefix myapp --suffix allocator --json
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
Live dashboard:
|
|
590
|
+
|
|
591
|
+
```bash
|
|
592
|
+
redis-allocator-diagnose --redis-url redis://localhost:6379/0 --prefix myapp --suffix allocator --interval 1 --port 8765
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
The diagnostics collector reads pool structure and per-pool-key lock state atomically with one Lua script. Advisory metrics such as Redis memory, commandstats, slowlog, soft bindings, and orphan locks are sampled separately. V1 never mutates allocator state and does not repair corruption. Orphan-lock samples are reporting only: clean a known orphan with `free_keys(key)`, by reintroducing the same key through `extend()` / `assign()`, or with a precise Redis `DEL`; `clear()` and `gc()` do not repair orphan locks outside the pool.
|
|
596
|
+
|
|
597
|
+
Use `sample_limit` to bound returned examples and `scan_limit` to cap advisory keyspace scans during dashboard refreshes. When a scan is capped, the JSON marks that section as `truncated`.
|
|
598
|
+
|
|
490
599
|
## Roadmap
|
|
491
600
|
|
|
492
601
|
* **Core Focus & Recently Completed:**
|
|
@@ -497,6 +606,9 @@ The RedisAllocator maintains resources in a doubly-linked list structure stored
|
|
|
497
606
|
* [x] Basic Garbage Collection & Health Checking Foundation
|
|
498
607
|
* [x] Documentation Improvements (Shared Mode, Soft Binding, Lua Clarity)
|
|
499
608
|
* [x] Foundational Unit Tests & Allocation Mode Coverage
|
|
609
|
+
* [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).
|
|
610
|
+
* [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.
|
|
611
|
+
* [x] **`O(1)` performance contract** for `malloc` / `free` / `extend` validated on both real Redis and patched fakeredis.
|
|
500
612
|
|
|
501
613
|
* **Current Development Priorities (Focusing on Proxy Pool Needs):**
|
|
502
614
|
* [ ] **Resource Prioritization:** Implement priority-based allocation in `RedisAllocator`, likely using Redis Sorted Sets (`ZSET`) for the free pool. *(New - High Priority)*
|
|
@@ -36,6 +36,17 @@ 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
|
+
- **`malloc()` and `malloc_key()` return `None` when no resource is available.** Check the return value before using it; an empty pool is not represented as a wrapper with `key=None`.
|
|
46
|
+
- **Prefer `with allocator.malloc(...) as allocation:` or `allocation.release()` for object allocations.** `release()` closes the wrapped object and returns the key to the pool. Plain `close()` only closes the wrapped object and leaves allocation ownership unchanged for callers that manage `free()` manually.
|
|
47
|
+
- **`assign()` / `shrink()` / `clear()` are not namespace scrubbers.** `assign()` and `shrink()` remove pool membership but intentionally leave lock keys for removed members; `clear()` deletes the pool hash/head/tail only. This avoids write amplification and wildcard scans, but an evicted permanent lock becomes a permanent orphan. To recover a known orphan, call `free_keys(key)`, reintroduce that same key with `extend([key])` or `assign([... key ...])`, or delete `<prefix>|<suffix>:<key>` directly. Reintroducing unrelated keys, `clear()`, and `gc()` do not remove orphan locks outside the pool.
|
|
48
|
+
- **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.
|
|
49
|
+
|
|
39
50
|
## Documentation
|
|
40
51
|
|
|
41
52
|
For complete documentation, please visit our [official documentation site](https://invoker-bot.github.io/RedisAllocator-python/).
|
|
@@ -163,15 +174,13 @@ if key:
|
|
|
163
174
|
# Allocate a resource with object (returns a RedisAllocatorObject)
|
|
164
175
|
allocated_obj = allocator.malloc(timeout=120)
|
|
165
176
|
if allocated_obj:
|
|
166
|
-
|
|
177
|
+
with allocated_obj:
|
|
167
178
|
# The key is available as a property
|
|
168
179
|
print(f"Allocated resource: {allocated_obj.key}")
|
|
169
180
|
|
|
170
181
|
# Update the resource's lock timeout
|
|
171
182
|
allocated_obj.update(timeout=60)
|
|
172
|
-
|
|
173
|
-
# Free the resource when done
|
|
174
|
-
allocator.free(allocated_obj)
|
|
183
|
+
# Leaving the with block calls allocated_obj.release().
|
|
175
184
|
|
|
176
185
|
# Using soft binding (associates a name with a resource)
|
|
177
186
|
allocator.update_soft_bind("worker-1", "resource-1")
|
|
@@ -397,7 +406,7 @@ result = task_queue.query(
|
|
|
397
406
|
id="task-123",
|
|
398
407
|
name="example-task",
|
|
399
408
|
params={"input": "data"},
|
|
400
|
-
timeout=300, #
|
|
409
|
+
timeout=300, # End-to-end wait timeout in seconds for remote execution
|
|
401
410
|
policy=TaskExecutePolicy.Auto, # Execution policy
|
|
402
411
|
once=False # Whether to delete the result after getting it
|
|
403
412
|
)
|
|
@@ -432,6 +441,106 @@ 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 after allocation can leave a lock behind. If the resource is still present in pool metadata, the lock either expires naturally or is reconciled by GC; if the resource is later evicted by `assign()` / `shrink()` / `clear()`, the lock becomes an orphan outside GC scope and expires only by TTL unless it is permanent.
|
|
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
|
+
#### Atomic Operation Boundaries
|
|
451
|
+
|
|
452
|
+
Allocator state transitions are written as single Redis Lua scripts. Redis runs each script atomically on the server event loop, so concurrent processes cannot observe a half-updated free-list node, lock key, or soft-binding update. This guarantee depends on using one Redis instance; Redis Cluster is out of scope because the allocator mutates multiple related keys in one script.
|
|
453
|
+
|
|
454
|
+
| Operation | Atomic boundary | Notes |
|
|
455
|
+
|---|---|---|
|
|
456
|
+
| `malloc_key()` | One Lua EVAL | Checks soft binding, pops/rotates the free-list head, marks the item allocated, creates the lock key, and refreshes binding state together. |
|
|
457
|
+
| `free_keys()` | One Lua EVAL | Deletes lock keys and returns still-present pool members to the free-list tail together. Unknown or evicted keys are no-ops. |
|
|
458
|
+
| `extend()` / `shrink()` / `assign()` | One Lua EVAL per call | Batch updates are atomic as a batch. `assign()` and `shrink()` intentionally preserve lock keys for evicted members; `extend()` / `assign()` delete a residual lock only when that same key is newly added to the pool. |
|
|
459
|
+
| `clear()` | One Lua EVAL | Deletes the pool hash plus head/tail pointers only; it does not scan or delete per-resource lock keys. |
|
|
460
|
+
| `gc()` | One Lua EVAL per pass | Incrementally reconciles up to the requested scan count and saves the scan cursor. |
|
|
461
|
+
| `malloc()` / `RedisAllocatorObject.release()` | Wrapper around atomic calls | `malloc()` wraps `malloc_key()` after the atomic allocation; `release()` wraps `free_keys()` after closing the local object. |
|
|
462
|
+
|
|
463
|
+
Multi-process coordination is provided by those atomic scripts plus lock TTLs and GC. A process killed after allocation may leave a lock behind; pool-member locks either expire naturally or are reconciled by GC. Orphan locks whose key is no longer in the pool are outside GC scope. Permanent locks (`timeout <= 0`) are never reclaimed automatically and must be released explicitly with `free_keys(key)`, by reintroducing that same key with `extend([key])` or `assign([... key ...])`, or by deleting `<prefix>|<suffix>:<key>` directly.
|
|
464
|
+
|
|
465
|
+
### Operation Complexity
|
|
466
|
+
|
|
467
|
+
The hot path is designed to avoid scanning the whole pool. Use `malloc_key()` / `free_keys()` for high-frequency allocation, use `extend()` for incremental pool refreshes, and reserve `assign()` for lower-frequency wholesale replacement.
|
|
468
|
+
|
|
469
|
+
| Operation | Time complexity | Space / Redis writes | Recommended use |
|
|
470
|
+
|---|---:|---|---|
|
|
471
|
+
| `malloc_key()` | Amortized `O(1)`; worst-case `O(k)` when skipping `k` expired or locked head entries | Constant extra metadata writes | Hot path allocation. |
|
|
472
|
+
| `free_keys(n)` | `O(n)` | Constant writes per released key | Hot path release; single-key free is `O(1)`. |
|
|
473
|
+
| `extend(n)` | `O(n)`, independent of existing pool size | Constant writes per supplied key | Incremental pool additions or expiry refresh. |
|
|
474
|
+
| `shrink(n)` | `O(n)` | Constant writes per supplied key | Remove known resources. |
|
|
475
|
+
| `assign(n)` | `O(current_pool_size + n)` | Rewrites membership for removed/new keys | Low-frequency full replacement. |
|
|
476
|
+
| `gc(count)` | Approximately `O(count)` per pass | Bounded by scanned entries | Periodic incremental repair/reclaim. |
|
|
477
|
+
|
|
478
|
+
`k` in `malloc_key()` is the number of stale head entries cleaned during that allocation. Those entries are removed or marked allocated as they are skipped, so the cost is paid once and amortizes over later calls.
|
|
479
|
+
|
|
480
|
+
## Testing
|
|
481
|
+
|
|
482
|
+
The default unit suite is fast (~7s) and self-contained — `pytest` runs it without any external services:
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
pip install -e ".[dev]"
|
|
486
|
+
pytest # ~180 tests, fakeredis only, ~7s
|
|
487
|
+
flake8 redis_allocator tests --max-line-length=150
|
|
488
|
+
tox # py310 / py311 / flake8, matches CI
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Several test categories are **opt-in via pytest markers** (excluded from the default run by `pyproject.toml`):
|
|
492
|
+
|
|
493
|
+
```bash
|
|
494
|
+
pytest -m concurrency # multi-thread fakeredis race tests (~25s)
|
|
495
|
+
pytest -m fuzz # randomised single-thread sequences
|
|
496
|
+
pytest -m benchmark # operation-complexity assertions for allocator commands
|
|
497
|
+
pytest -m "concurrency or fuzz or benchmark" # all of the above
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
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:
|
|
501
|
+
|
|
502
|
+
```bash
|
|
503
|
+
docker run --rm -d --name allocator-stress -p 6399:6379 redis:7-alpine
|
|
504
|
+
python tests/stress_pool_corruption.py # 60s default; STRESS_DURATION/STRESS_WORKERS env vars
|
|
505
|
+
docker rm -f allocator-stress
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Performance contracts are validated on both backends. The fakeredis `pytest -m benchmark` suite enforces scaling ratios across pool sizes from 10 to 5000 keys for `malloc_key() + free_keys()`, batched `free_keys(n)`, `extend(n)`, `shrink(n)`, and `gc(count)`. It covers `assign(n)` across 10 to 1000 keys because fakeredis/lupa is unreliable with 5000+ Lua ARGV on Windows; the real Redis script covers `assign(n)` through 5000. The benchmark asserts operation cost follows the documented complexity table instead of silently regressing to full-pool scans. 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`.
|
|
509
|
+
|
|
510
|
+
A standalone Python script `tests/_perf_real_redis.py` reproduces the same operation-complexity benchmark against a real Redis container for cross-checking and exits non-zero when a contract fails.
|
|
511
|
+
|
|
512
|
+
## Live Diagnostics
|
|
513
|
+
|
|
514
|
+
`redis-allocator` includes an observer-only diagnostics API and local dashboard for inspecting live allocator state under multi-process load.
|
|
515
|
+
|
|
516
|
+
Python API:
|
|
517
|
+
|
|
518
|
+
```python
|
|
519
|
+
from redis import Redis
|
|
520
|
+
from redis_allocator import RedisAllocatorDiagnostics
|
|
521
|
+
|
|
522
|
+
redis = Redis.from_url("redis://localhost:6379/0", decode_responses=True)
|
|
523
|
+
diagnostics = RedisAllocatorDiagnostics(redis, prefix="myapp", suffix="allocator")
|
|
524
|
+
snapshot = diagnostics.snapshot()
|
|
525
|
+
print(snapshot.to_json(indent=2))
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
One-shot JSON:
|
|
529
|
+
|
|
530
|
+
```bash
|
|
531
|
+
redis-allocator-diagnose --redis-url redis://localhost:6379/0 --prefix myapp --suffix allocator --json
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Live dashboard:
|
|
535
|
+
|
|
536
|
+
```bash
|
|
537
|
+
redis-allocator-diagnose --redis-url redis://localhost:6379/0 --prefix myapp --suffix allocator --interval 1 --port 8765
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
The diagnostics collector reads pool structure and per-pool-key lock state atomically with one Lua script. Advisory metrics such as Redis memory, commandstats, slowlog, soft bindings, and orphan locks are sampled separately. V1 never mutates allocator state and does not repair corruption. Orphan-lock samples are reporting only: clean a known orphan with `free_keys(key)`, by reintroducing the same key through `extend()` / `assign()`, or with a precise Redis `DEL`; `clear()` and `gc()` do not repair orphan locks outside the pool.
|
|
541
|
+
|
|
542
|
+
Use `sample_limit` to bound returned examples and `scan_limit` to cap advisory keyspace scans during dashboard refreshes. When a scan is capped, the JSON marks that section as `truncated`.
|
|
543
|
+
|
|
435
544
|
## Roadmap
|
|
436
545
|
|
|
437
546
|
* **Core Focus & Recently Completed:**
|
|
@@ -442,6 +551,9 @@ The RedisAllocator maintains resources in a doubly-linked list structure stored
|
|
|
442
551
|
* [x] Basic Garbage Collection & Health Checking Foundation
|
|
443
552
|
* [x] Documentation Improvements (Shared Mode, Soft Binding, Lua Clarity)
|
|
444
553
|
* [x] Foundational Unit Tests & Allocation Mode Coverage
|
|
554
|
+
* [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).
|
|
555
|
+
* [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.
|
|
556
|
+
* [x] **`O(1)` performance contract** for `malloc` / `free` / `extend` validated on both real Redis and patched fakeredis.
|
|
445
557
|
|
|
446
558
|
* **Current Development Priorities (Focusing on Proxy Pool Needs):**
|
|
447
559
|
* [ ] **Resource Prioritization:** Implement priority-based allocation in `RedisAllocator`, likely using Redis Sorted Sets (`ZSET`) for the free pool. *(New - High Priority)*
|
|
@@ -0,0 +1,27 @@
|
|
|
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.7.0"
|
|
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 benchmark # allocator operation-complexity contracts
|
|
20
|
+
# pytest -m "concurrency or fuzz or benchmark"
|
|
21
|
+
markers = [
|
|
22
|
+
"concurrency: tests that drive the allocator from multiple threads (fakeredis)",
|
|
23
|
+
"fuzz: random-sequence exploratory tests (may exercise lupa edge cases)",
|
|
24
|
+
"stress: long-running stress tests requiring external Redis (Docker)",
|
|
25
|
+
"benchmark: performance assertions (constant-time complexity, etc.)",
|
|
26
|
+
]
|
|
27
|
+
addopts = "-m 'not concurrency and not fuzz and not stress and not benchmark'"
|
|
@@ -9,6 +9,7 @@ from redis_allocator.lock import (RedisLock, RedisLockPool, LockStatus,
|
|
|
9
9
|
BaseLock, BaseLockPool, ThreadLock, ThreadLockPool)
|
|
10
10
|
from redis_allocator.task_queue import TaskExecutePolicy, RedisTask, RedisTaskQueue
|
|
11
11
|
from redis_allocator.allocator import RedisAllocator, RedisAllocatorObject, RedisAllocatorUpdater, RedisAllocatorPolicy, DefaultRedisAllocatorPolicy
|
|
12
|
+
from redis_allocator.diagnostics import RedisAllocatorDiagnostics
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
__version__ = '0.0.1'
|
|
@@ -29,4 +30,5 @@ __all__ = [
|
|
|
29
30
|
'RedisAllocatorUpdater',
|
|
30
31
|
'RedisAllocatorPolicy',
|
|
31
32
|
'DefaultRedisAllocatorPolicy',
|
|
33
|
+
'RedisAllocatorDiagnostics',
|
|
32
34
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.7.0'
|