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.
Files changed (46) hide show
  1. {redis_allocator-0.6.0/redis_allocator.egg-info → redis_allocator-0.7.0}/PKG-INFO +122 -10
  2. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/README.md +117 -5
  3. redis_allocator-0.7.0/pyproject.toml +27 -0
  4. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator/__init__.py +2 -0
  5. redis_allocator-0.7.0/redis_allocator/_version.py +1 -0
  6. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator/allocator.py +166 -34
  7. redis_allocator-0.7.0/redis_allocator/diagnostics/__init__.py +30 -0
  8. redis_allocator-0.7.0/redis_allocator/diagnostics/cli.py +52 -0
  9. redis_allocator-0.7.0/redis_allocator/diagnostics/collector.py +300 -0
  10. redis_allocator-0.7.0/redis_allocator/diagnostics/dashboard.py +93 -0
  11. redis_allocator-0.7.0/redis_allocator/diagnostics/invariants.py +192 -0
  12. redis_allocator-0.7.0/redis_allocator/diagnostics/models.py +128 -0
  13. redis_allocator-0.7.0/redis_allocator/diagnostics/static/dashboard.html +403 -0
  14. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator/task_queue.py +18 -10
  15. {redis_allocator-0.6.0 → redis_allocator-0.7.0/redis_allocator.egg-info}/PKG-INFO +122 -10
  16. redis_allocator-0.7.0/redis_allocator.egg-info/SOURCES.txt +41 -0
  17. redis_allocator-0.7.0/redis_allocator.egg-info/entry_points.txt +2 -0
  18. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator.egg-info/requires.txt +4 -4
  19. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/setup.py +10 -2
  20. redis_allocator-0.7.0/tests/_bughunt_helpers.py +333 -0
  21. redis_allocator-0.7.0/tests/_perf_real_redis.py +195 -0
  22. redis_allocator-0.7.0/tests/_profile_fakeredis.py +109 -0
  23. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/tests/conftest.py +43 -0
  24. redis_allocator-0.7.0/tests/stress_pool_corruption.py +285 -0
  25. redis_allocator-0.7.0/tests/test_allocator_benchmark.py +194 -0
  26. redis_allocator-0.7.0/tests/test_allocator_bughunt.py +513 -0
  27. redis_allocator-0.7.0/tests/test_allocator_concurrency.py +176 -0
  28. redis_allocator-0.7.0/tests/test_allocator_forensics.py +213 -0
  29. redis_allocator-0.7.0/tests/test_allocator_fuzz.py +124 -0
  30. redis_allocator-0.7.0/tests/test_diagnostics_cli.py +31 -0
  31. redis_allocator-0.7.0/tests/test_diagnostics_collector.py +76 -0
  32. redis_allocator-0.7.0/tests/test_diagnostics_dashboard.py +58 -0
  33. redis_allocator-0.7.0/tests/test_diagnostics_invariants.py +96 -0
  34. redis_allocator-0.7.0/tests/test_diagnostics_models.py +37 -0
  35. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/tests/test_task_queue.py +57 -11
  36. redis_allocator-0.6.0/pyproject.toml +0 -11
  37. redis_allocator-0.6.0/redis_allocator/_version.py +0 -1
  38. redis_allocator-0.6.0/redis_allocator.egg-info/SOURCES.txt +0 -19
  39. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/LICENSE +0 -0
  40. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator/lock.py +0 -0
  41. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator.egg-info/dependency_links.txt +0 -0
  42. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/redis_allocator.egg-info/top_level.txt +0 -0
  43. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/setup.cfg +0 -0
  44. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/tests/__init__.py +0 -0
  45. {redis_allocator-0.6.0 → redis_allocator-0.7.0}/tests/test_allocator.py +0 -0
  46. {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.6.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.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
28
- Requires-Dist: sphinx>=7.0.0; extra == "docs"
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.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
- Requires-Dist: sphinx>=7.0.0; extra == "dev"
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
- try:
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
- finally:
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, # Optional timeout in seconds
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
- try:
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
- finally:
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, # Optional timeout in seconds
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'