py-redis-semaphore 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. py_redis_semaphore-0.1.1/.editorconfig +25 -0
  2. py_redis_semaphore-0.1.1/.github/ISSUE_TEMPLATE/bug_report.yml +89 -0
  3. py_redis_semaphore-0.1.1/.github/ISSUE_TEMPLATE/feature_request.yml +56 -0
  4. py_redis_semaphore-0.1.1/.github/dependabot.yml +19 -0
  5. py_redis_semaphore-0.1.1/.github/pull_request_template.md +32 -0
  6. py_redis_semaphore-0.1.1/.github/workflows/ci.yml +65 -0
  7. py_redis_semaphore-0.1.1/.github/workflows/release.yml +59 -0
  8. py_redis_semaphore-0.1.1/.gitignore +17 -0
  9. py_redis_semaphore-0.1.1/.pre-commit-config.yaml +24 -0
  10. py_redis_semaphore-0.1.1/.python-version +1 -0
  11. py_redis_semaphore-0.1.1/ARCHITECTURE.md +258 -0
  12. py_redis_semaphore-0.1.1/CHANGELOG.md +59 -0
  13. py_redis_semaphore-0.1.1/CLAUDE.md +105 -0
  14. py_redis_semaphore-0.1.1/CONTRIBUTING.md +116 -0
  15. py_redis_semaphore-0.1.1/LICENSE +21 -0
  16. py_redis_semaphore-0.1.1/Makefile +60 -0
  17. py_redis_semaphore-0.1.1/PKG-INFO +530 -0
  18. py_redis_semaphore-0.1.1/README.md +497 -0
  19. py_redis_semaphore-0.1.1/SECURITY.md +41 -0
  20. py_redis_semaphore-0.1.1/codecov.yml +15 -0
  21. py_redis_semaphore-0.1.1/docker/redis/Dockerfile +5 -0
  22. py_redis_semaphore-0.1.1/docker/sentinel/docker-compose.yml +16 -0
  23. py_redis_semaphore-0.1.1/docker/sentinel/sentinel.conf +7 -0
  24. py_redis_semaphore-0.1.1/examples/basic_usage.py +35 -0
  25. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/.env.example +29 -0
  26. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/.gitignore +19 -0
  27. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/Dockerfile +25 -0
  28. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/README.md +207 -0
  29. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/app.py +8 -0
  30. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/client_model_overrides.example.json +9 -0
  31. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/client_model_overrides.example.yaml +6 -0
  32. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/__init__.py +5 -0
  33. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/__init__.py +1 -0
  34. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/dependencies.py +16 -0
  35. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/proxy_common.py +193 -0
  36. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/request_logging.py +115 -0
  37. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/routes/__init__.py +1 -0
  38. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/routes/chat.py +252 -0
  39. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/routes/embeddings.py +113 -0
  40. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/routes/health.py +51 -0
  41. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/routes/proxy.py +70 -0
  42. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/client_model_overrides.py +76 -0
  43. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/config.py +65 -0
  44. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/core/__init__.py +8 -0
  45. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/core/semaphore_pool.py +106 -0
  46. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/infrastructure/__init__.py +23 -0
  47. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/infrastructure/redis_manager.py +55 -0
  48. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/infrastructure/upstream.py +61 -0
  49. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/logging_setup.py +84 -0
  50. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/main.py +132 -0
  51. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/metrics.py +21 -0
  52. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/responses.py +55 -0
  53. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/pyproject.toml +44 -0
  54. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/tests/USAGE.md +52 -0
  55. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/tests/mock_upstream.py +163 -0
  56. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/tests/test_client.py +142 -0
  57. py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/uv.lock +403 -0
  58. py_redis_semaphore-0.1.1/examples/multiprocess_simulation.py +58 -0
  59. py_redis_semaphore-0.1.1/pyproject.toml +130 -0
  60. py_redis_semaphore-0.1.1/src/redis_semaphore/__init__.py +125 -0
  61. py_redis_semaphore-0.1.1/src/redis_semaphore/base.py +129 -0
  62. py_redis_semaphore-0.1.1/src/redis_semaphore/connection.py +91 -0
  63. py_redis_semaphore-0.1.1/src/redis_semaphore/errors.py +141 -0
  64. py_redis_semaphore-0.1.1/src/redis_semaphore/heartbeat.py +187 -0
  65. py_redis_semaphore-0.1.1/src/redis_semaphore/logger.py +41 -0
  66. py_redis_semaphore-0.1.1/src/redis_semaphore/lua_scripts.py +482 -0
  67. py_redis_semaphore-0.1.1/src/redis_semaphore/metrics.py +150 -0
  68. py_redis_semaphore-0.1.1/src/redis_semaphore/py.typed +0 -0
  69. py_redis_semaphore-0.1.1/src/redis_semaphore/semaphore.py +1121 -0
  70. py_redis_semaphore-0.1.1/src/redis_semaphore/types.py +134 -0
  71. py_redis_semaphore-0.1.1/tests/__init__.py +1 -0
  72. py_redis_semaphore-0.1.1/tests/conftest.py +31 -0
  73. py_redis_semaphore-0.1.1/tests/test_async_heartbeat.py +106 -0
  74. py_redis_semaphore-0.1.1/tests/test_async_mutex.py +58 -0
  75. py_redis_semaphore-0.1.1/tests/test_async_semaphore.py +220 -0
  76. py_redis_semaphore-0.1.1/tests/test_backend_errors.py +111 -0
  77. py_redis_semaphore-0.1.1/tests/test_cleanup.py +18 -0
  78. py_redis_semaphore-0.1.1/tests/test_connection.py +96 -0
  79. py_redis_semaphore-0.1.1/tests/test_errors.py +101 -0
  80. py_redis_semaphore-0.1.1/tests/test_heartbeat.py +112 -0
  81. py_redis_semaphore-0.1.1/tests/test_heartbeat_extra.py +273 -0
  82. py_redis_semaphore-0.1.1/tests/test_logger.py +112 -0
  83. py_redis_semaphore-0.1.1/tests/test_lua_scripts.py +162 -0
  84. py_redis_semaphore-0.1.1/tests/test_metrics.py +288 -0
  85. py_redis_semaphore-0.1.1/tests/test_modes.py +93 -0
  86. py_redis_semaphore-0.1.1/tests/test_mutex.py +81 -0
  87. py_redis_semaphore-0.1.1/tests/test_script_client_adapter.py +90 -0
  88. py_redis_semaphore-0.1.1/tests/test_semaphore.py +577 -0
  89. py_redis_semaphore-0.1.1/tests/test_sentinel.py +39 -0
  90. py_redis_semaphore-0.1.1/tests/test_status.py +94 -0
  91. py_redis_semaphore-0.1.1/tests/test_time.py +28 -0
  92. py_redis_semaphore-0.1.1/tests/test_types.py +65 -0
  93. py_redis_semaphore-0.1.1/uv.lock +680 -0
@@ -0,0 +1,25 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ insert_final_newline = true
7
+ trim_trailing_whitespace = true
8
+ indent_style = space
9
+ indent_size = 4
10
+
11
+ [*.py]
12
+ indent_size = 4
13
+ max_line_length = 100
14
+
15
+ [*.{yml,yaml}]
16
+ indent_size = 2
17
+
18
+ [*.{json,toml}]
19
+ indent_size = 2
20
+
21
+ [*.md]
22
+ trim_trailing_whitespace = false
23
+
24
+ [Makefile]
25
+ indent_style = tab
@@ -0,0 +1,89 @@
1
+ name: Bug Report
2
+ description: Report a bug or unexpected behavior
3
+ labels: ["bug", "triage"]
4
+
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: |
9
+ Thanks for taking the time to report a bug!
10
+
11
+ - type: textarea
12
+ id: description
13
+ attributes:
14
+ label: Bug Description
15
+ description: A clear and concise description of what the bug is.
16
+ placeholder: What happened?
17
+ validations:
18
+ required: true
19
+
20
+ - type: textarea
21
+ id: reproduction
22
+ attributes:
23
+ label: Steps to Reproduce
24
+ description: Steps to reproduce the behavior.
25
+ placeholder: |
26
+ 1. Create Semaphore with config...
27
+ 2. Call acquire()...
28
+ 3. See error...
29
+ validations:
30
+ required: true
31
+
32
+ - type: textarea
33
+ id: expected
34
+ attributes:
35
+ label: Expected Behavior
36
+ description: What you expected to happen.
37
+ validations:
38
+ required: true
39
+
40
+ - type: textarea
41
+ id: code
42
+ attributes:
43
+ label: Code Example
44
+ description: Minimal code to reproduce the issue.
45
+ render: python
46
+
47
+ - type: input
48
+ id: version
49
+ attributes:
50
+ label: Library Version
51
+ placeholder: "0.1.0"
52
+ validations:
53
+ required: true
54
+
55
+ - type: input
56
+ id: python-version
57
+ attributes:
58
+ label: Python Version
59
+ placeholder: "3.12"
60
+ validations:
61
+ required: true
62
+
63
+ - type: input
64
+ id: redis-version
65
+ attributes:
66
+ label: Redis Version
67
+ placeholder: "7.2"
68
+
69
+ - type: dropdown
70
+ id: api-type
71
+ attributes:
72
+ label: API Type
73
+ options:
74
+ - Sync
75
+ - Async
76
+ - Both
77
+
78
+ - type: textarea
79
+ id: logs
80
+ attributes:
81
+ label: Logs / Traceback
82
+ description: Any relevant logs or error traceback.
83
+ render: shell
84
+
85
+ - type: textarea
86
+ id: additional
87
+ attributes:
88
+ label: Additional Context
89
+ description: Any other context about the problem.
@@ -0,0 +1,56 @@
1
+ name: Feature Request
2
+ description: Suggest a new feature or improvement
3
+ labels: ["enhancement"]
4
+
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: |
9
+ Thanks for suggesting a feature!
10
+
11
+ - type: textarea
12
+ id: problem
13
+ attributes:
14
+ label: Problem Statement
15
+ description: What problem does this feature solve?
16
+ placeholder: I'm always frustrated when...
17
+ validations:
18
+ required: true
19
+
20
+ - type: textarea
21
+ id: solution
22
+ attributes:
23
+ label: Proposed Solution
24
+ description: Describe the solution you'd like.
25
+ validations:
26
+ required: true
27
+
28
+ - type: textarea
29
+ id: alternatives
30
+ attributes:
31
+ label: Alternatives Considered
32
+ description: Any alternative solutions or features you've considered.
33
+
34
+ - type: textarea
35
+ id: code
36
+ attributes:
37
+ label: Example Usage
38
+ description: How would this feature be used?
39
+ render: python
40
+
41
+ - type: dropdown
42
+ id: scope
43
+ attributes:
44
+ label: Scope
45
+ options:
46
+ - New feature
47
+ - Enhancement to existing feature
48
+ - Performance improvement
49
+ - Documentation
50
+ - Other
51
+
52
+ - type: textarea
53
+ id: additional
54
+ attributes:
55
+ label: Additional Context
56
+ description: Any other context, screenshots, or references.
@@ -0,0 +1,19 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "pip"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ groups:
8
+ python-dependencies:
9
+ patterns:
10
+ - "*"
11
+ commit-message:
12
+ prefix: "deps"
13
+
14
+ - package-ecosystem: "github-actions"
15
+ directory: "/"
16
+ schedule:
17
+ interval: "weekly"
18
+ commit-message:
19
+ prefix: "ci"
@@ -0,0 +1,32 @@
1
+ ## Description
2
+
3
+ <!-- Brief description of what this PR does -->
4
+
5
+ ## Type of Change
6
+
7
+ - [ ] Bug fix (non-breaking change that fixes an issue)
8
+ - [ ] New feature (non-breaking change that adds functionality)
9
+ - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
10
+ - [ ] Documentation update
11
+ - [ ] Refactoring (no functional changes)
12
+
13
+ ## Related Issues
14
+
15
+ <!-- Link to related issues: Fixes #123, Closes #456 -->
16
+
17
+ ## Checklist
18
+
19
+ - [ ] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guidelines
20
+ - [ ] My code follows the project's code style (ran `make ruff-fix`)
21
+ - [ ] I have added tests that prove my fix/feature works
22
+ - [ ] All new and existing tests pass (`make test`)
23
+ - [ ] Type checking passes (`make typecheck`)
24
+ - [ ] I have updated documentation if needed
25
+
26
+ ## Testing
27
+
28
+ <!-- How was this tested? -->
29
+
30
+ ## Screenshots / Logs
31
+
32
+ <!-- If applicable, add screenshots or logs -->
@@ -0,0 +1,65 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, dev]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v6
14
+ - uses: astral-sh/setup-uv@v7
15
+
16
+ - name: Install dependencies
17
+ run: uv sync
18
+
19
+ - name: Ruff check
20
+ run: uv run ruff check .
21
+
22
+ - name: Ruff format check
23
+ run: uv run ruff format --check .
24
+
25
+ - name: Type check
26
+ run: uv run mypy src
27
+
28
+ test:
29
+ runs-on: ubuntu-latest
30
+ needs: lint
31
+ strategy:
32
+ matrix:
33
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
34
+
35
+ services:
36
+ redis:
37
+ image: redis:7
38
+ ports:
39
+ - 6379:6379
40
+ options: >-
41
+ --health-cmd "redis-cli ping"
42
+ --health-interval 10s
43
+ --health-timeout 5s
44
+ --health-retries 5
45
+
46
+ steps:
47
+ - uses: actions/checkout@v6
48
+ - uses: astral-sh/setup-uv@v7
49
+
50
+ - name: Set up Python ${{ matrix.python-version }}
51
+ run: uv python install ${{ matrix.python-version }}
52
+
53
+ - name: Install dependencies
54
+ run: uv sync --python ${{ matrix.python-version }}
55
+
56
+ - name: Run tests with coverage
57
+ run: uv run pytest --cov --cov-report=xml --cov-report=term-missing --cov-fail-under=80 -v
58
+
59
+ - name: Upload coverage
60
+ if: matrix.python-version == '3.12'
61
+ uses: codecov/codecov-action@v5
62
+ with:
63
+ files: coverage.xml
64
+ token: ${{ secrets.CODECOV_TOKEN }}
65
+ fail_ci_if_error: false
@@ -0,0 +1,59 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v6
13
+
14
+ - uses: astral-sh/setup-uv@v7
15
+
16
+ - name: Build package
17
+ run: uv build
18
+
19
+ - name: Upload artifacts
20
+ uses: actions/upload-artifact@v6
21
+ with:
22
+ name: dist
23
+ path: dist/
24
+
25
+ publish-pypi:
26
+ needs: build
27
+ runs-on: ubuntu-latest
28
+ environment: pypi
29
+ permissions:
30
+ id-token: write # Required for trusted publishing
31
+
32
+ steps:
33
+ - uses: actions/download-artifact@v7
34
+ with:
35
+ name: dist
36
+ path: dist/
37
+
38
+ - name: Publish to PyPI
39
+ uses: pypa/gh-action-pypi-publish@release/v1
40
+
41
+ github-release:
42
+ needs: build
43
+ runs-on: ubuntu-latest
44
+ permissions:
45
+ contents: write
46
+
47
+ steps:
48
+ - uses: actions/checkout@v6
49
+
50
+ - uses: actions/download-artifact@v7
51
+ with:
52
+ name: dist
53
+ path: dist/
54
+
55
+ - name: Create GitHub Release
56
+ uses: softprops/action-gh-release@v2
57
+ with:
58
+ files: dist/*
59
+ generate_release_notes: true
@@ -0,0 +1,17 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+ .coverage
9
+ .mypy_cache/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .claude/
13
+
14
+ # Virtual environments
15
+ .venv
16
+
17
+ habr_article.md
@@ -0,0 +1,24 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.8.6
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix]
7
+ - id: ruff-format
8
+
9
+ - repo: https://github.com/pre-commit/mirrors-mypy
10
+ rev: v1.14.1
11
+ hooks:
12
+ - id: mypy
13
+ additional_dependencies: [redis>=5.0.0, types-redis]
14
+ args: [--ignore-missing-imports]
15
+ files: ^src/
16
+
17
+ - repo: https://github.com/pre-commit/pre-commit-hooks
18
+ rev: v5.0.0
19
+ hooks:
20
+ - id: trailing-whitespace
21
+ - id: end-of-file-fixer
22
+ - id: check-yaml
23
+ - id: check-added-large-files
24
+ - id: check-merge-conflict
@@ -0,0 +1 @@
1
+ 3.10
@@ -0,0 +1,258 @@
1
+ # How the semaphore works
2
+
3
+ A step-by-step description of the algorithm, in words and examples, without diagrams.
4
+
5
+ Scenario: there is a shared Redis and several applications that need to limit
6
+ concurrent access to a resource.
7
+
8
+ ---
9
+
10
+ ## 1) What is stored in Redis
11
+
12
+ For a semaphore named `payments`, two keys are created (the `namespace` prefix
13
+ defaults to `semaphore`):
14
+
15
+ 1) `semaphore:payments:owners` (ZSET)
16
+ - member: the unique identifier of an owner (process/instance)
17
+ - score: the expiration time of that owner (milliseconds)
18
+
19
+ 2) `semaphore:payments:fencing` (STRING)
20
+ - an integer that grows via INCR
21
+ - this is the fencing token
22
+
23
+ ---
24
+
25
+ ## 2) What acquire does (obtain a slot)
26
+
27
+ Suppose `limit=2` and there are 3 processes: A, B, C.
28
+
29
+ ### What happens in Redis
30
+
31
+ 1) Expired owners are removed (ZREMRANGEBYSCORE).
32
+ 2) If the current identifier is already an owner:
33
+ - its TTL is updated
34
+ - the fencing token is incremented
35
+ - success is returned
36
+ 3) The current number of owners is counted (ZCARD).
37
+ 4) If there are fewer owners than `limit`:
38
+ - a new owner is added to the ZSET with a fresh expires_at
39
+ - the fencing token is incremented
40
+ - success is returned
41
+ 5) If there are already `limit` owners:
42
+ - "busy" is returned
43
+
44
+ ### Blocking vs non-blocking
45
+
46
+ - `blocking=True` (default) — a wait loop stepping at `retry_interval`
47
+ until a slot is acquired or `acquire_timeout` elapses.
48
+ - `acquire_timeout=None` — wait forever.
49
+ - `blocking=False` — a single attempt and an immediate `busy` response, no waiting.
50
+
51
+ Note: `with Semaphore(...)` always uses `blocking=True` and will wait for a
52
+ slot; if `acquire_timeout` is set, an `AcquireTimeoutError` is raised when it
53
+ elapses.
54
+
55
+ ### Example
56
+
57
+ - A calls acquire -> gets a slot (token=1)
58
+ - B calls acquire -> gets a slot (token=2)
59
+ - C calls acquire:
60
+ - if blocking=False -> immediately "busy"
61
+ - if blocking=True -> waits and retries
62
+
63
+ ---
64
+
65
+ ## 3) How waiting works (blocking)
66
+
67
+ There are two wait strategies, selected via `acquire_mode`:
68
+
69
+ ### BLPOP (default)
70
+
71
+ Uses Redis `BLPOP` for a blocking wait:
72
+
73
+ ```
74
+ try acquire
75
+ if busy:
76
+ BLPOP semaphore:{name}:queue timeout
77
+ try again
78
+ ```
79
+
80
+ On `release()`, a signal is pushed to the queue via `LPUSH`, which wakes a
81
+ waiting client.
82
+
83
+ **Advantages of BLPOP:**
84
+ - Minimal load on Redis (no constant polling)
85
+ - Instant wake-up when a slot is freed
86
+ - The queue is not stored explicitly — it is a wake-up signal, not a strict FIFO
87
+
88
+ **Fallback:**
89
+
90
+ If the signal is lost (a race condition), a retry happens after `blpop_timeout`
91
+ seconds — this protects against waiting forever.
92
+
93
+ ### Additional key for BLPOP
94
+
95
+ When using BLPOP, a third key is created:
96
+
97
+ 3) `semaphore:payments:queue` (LIST)
98
+ - used to notify waiters via BLPOP/LPUSH
99
+ - this is not a real wait queue: the elements are just wake-up signals
100
+
101
+ ### POLLING
102
+
103
+ Waiting is a local loop inside the process:
104
+
105
+ ```
106
+ try acquire
107
+ if busy:
108
+ sleep(retry_interval)
109
+ try again
110
+ ```
111
+
112
+ **With exponential backoff:**
113
+
114
+ If `retry_interval_max` is set, the interval grows exponentially:
115
+ ```
116
+ attempt 1: sleep(0.1)
117
+ attempt 2: sleep(0.2)
118
+ attempt 3: sleep(0.4)
119
+ ...
120
+ attempt N: sleep(min(calculated, retry_interval_max))
121
+ ```
122
+
123
+ **With jitter:**
124
+
125
+ If `retry_jitter` is set (e.g. 0.1), a random amount of up to 10% of the current
126
+ interval is added. This helps avoid a thundering herd, when many clients wake up
127
+ at the same time.
128
+
129
+ ---
130
+
131
+ **Note:** In DEBUG logs, `waiting=1` is a **local counter** within this process
132
+ only, not the global number of waiters across the whole system.
133
+
134
+ ---
135
+
136
+ ## 4) Release (free a slot)
137
+
138
+ When a process is done, it calls `release()`:
139
+
140
+ 1) The heartbeat is stopped.
141
+ 2) Its identifier is removed from owners in Redis.
142
+ 3) Waiting clients are notified (LPUSH into queue).
143
+ 4) Local state is reset.
144
+
145
+ The notification (step 3) always happens, regardless of `acquire_mode` —
146
+ this lets different clients use different wait strategies.
147
+
148
+ Calling `release()` without a successful `acquire()` raises an error.
149
+
150
+ ---
151
+
152
+ ## 5) Refresh and Heartbeat (extending the TTL)
153
+
154
+ ### Refresh
155
+
156
+ `refresh()` extends the owner's TTL:
157
+ 1) Redis checks that the identifier still exists and has not expired.
158
+ 2) If everything is fine -> it extends the TTL.
159
+ 3) If it expired or is missing -> it returns False (lock lost).
160
+
161
+ ### Heartbeat
162
+
163
+ Once a slot is acquired, a background heartbeat starts and periodically calls
164
+ `refresh()` to extend the TTL.
165
+
166
+ If the heartbeat stops working (the process dies, the network drops), the TTL
167
+ expires and the slot frees itself.
168
+
169
+ ---
170
+
171
+ ## 6) Lock lost
172
+
173
+ Scenario:
174
+
175
+ - A process believes it holds a slot.
176
+ - But its TTL expired or the record was removed.
177
+ - refresh returns False.
178
+
179
+ What happens next:
180
+ - If strict_mode=False: a warning is logged, but the code keeps running.
181
+ - If strict_mode=True: a LockLostError is raised immediately.
182
+
183
+ This is useful for critical systems where you must not continue after losing the lock.
184
+
185
+ ---
186
+
187
+ ## 7) Fencing token (protection against races)
188
+
189
+ A fencing token is a number that grows on every successful acquire.
190
+ Even on a repeated acquire by the same owner, the token increases.
191
+
192
+ ### Why this is needed
193
+
194
+ Imagine:
195
+ - Process A obtained token=10 but then stalled and never finished its work.
196
+ - Process B obtained token=11 and continued.
197
+
198
+ If the resource (e.g. a database) only accepts the highest token, then stale
199
+ operations carrying token=10 can be safely ignored.
200
+
201
+ ---
202
+
203
+ ## 8) Example of a full cycle
204
+
205
+ limit=2, processes A, B, C:
206
+
207
+ 1) A acquire -> success, token=1
208
+ 2) B acquire -> success, token=2
209
+ 3) C acquire -> busy, waits
210
+ 4) A release -> a slot frees up
211
+ 5) C acquire -> success, token=3
212
+
213
+ ---
214
+
215
+ ## 9) Time: client-side or server-side
216
+
217
+ By default, client-side time (`time.time()`) is used.
218
+ You can enable `use_server_time=True`, and time will be taken from Redis (`TIME`).
219
+
220
+ Why this is needed:
221
+ - If clocks on different machines drift, server-side time is more reliable.
222
+ - The downside: an extra network RTT.
223
+
224
+ ---
225
+
226
+ ## 10) Sync vs Async
227
+
228
+ The same `Semaphore` object cannot be used in both sync and async modes
229
+ at the same time.
230
+
231
+ Example of incorrect usage:
232
+ ```
233
+ sem.acquire() # sync
234
+ await sem.aacquire() # async -> error
235
+ ```
236
+
237
+ This protects against races inside a single object.
238
+
239
+ ---
240
+
241
+ ## 11) Metrics and logging
242
+
243
+ ### Metrics
244
+
245
+ Metrics are counted **within the process**, not globally.
246
+ If there are many processes, Prometheus should aggregate them itself.
247
+
248
+ ### DEBUG logs
249
+
250
+ Logs like:
251
+ ```
252
+ Semaphore 'payments' usage 2/2
253
+ Semaphore 'payments' full 2/2; waiting=1
254
+ ```
255
+
256
+ - `usage 2/2` — total slots taken in Redis
257
+ - `waiting=1` — local to the current process
258
+
@@ -0,0 +1,59 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.1] - 2026-06-02
11
+
12
+ ### Added
13
+
14
+ - `status()` / `astatus()` returning a `SemaphoreStatus` — observe occupancy,
15
+ availability, ownership, and slot expiry without acquiring (expired owners are
16
+ purged first)
17
+ - `cleanup()` / `acleanup()` to force-remove expired owner entries; returns the
18
+ number of entries removed
19
+ - `AcquireResult.used_slots` — slot occupancy observed atomically by the acquire
20
+ call, for observability without an extra round-trip
21
+ - `SemaphoreConfig.refresh_retry_interval` — retry cadence for the heartbeat
22
+ after a transient connection error (defaults to `min(refresh_interval, 1.0)`)
23
+ - Backend error hierarchy: `BackendError`, `TransientBackendError`,
24
+ `PermanentBackendError`, and `CommandDeniedError`
25
+ - BLPOP wait strategy (now the default) and POLLING with exponential backoff and
26
+ jitter, selectable via `acquire_mode`
27
+
28
+ ### Changed
29
+
30
+ - Heartbeat now tolerates transient Redis connection errors and keeps retrying
31
+ until the lock is refreshed or `lock_timeout` elapses since the last
32
+ successful refresh; only then is the lock treated as lost. Permanent errors
33
+ (e.g. ACL denial → `PermanentBackendError`) escalate immediately
34
+ - `RedisConnectionError` is now a subclass of `TransientBackendError`
35
+
36
+ ### Removed
37
+
38
+ - `RefreshError` (no longer part of the public API)
39
+
40
+ ## [0.1.0] - 2026-01-15
41
+
42
+ ### Added
43
+
44
+ - Initial release
45
+ - `Semaphore` - counting semaphore with configurable limit
46
+ - `Mutex` - exclusive lock (binary semaphore)
47
+ - Sync and async API support via same classes
48
+ - Redis Sentinel support for high availability
49
+ - Automatic heartbeat to maintain lock TTL
50
+ - Fencing tokens for distributed consistency
51
+ - `on_lock_lost` callback for lock loss detection
52
+ - Prometheus metrics (optional)
53
+ - Custom logger support (loguru, structlog compatible)
54
+
55
+ ### Technical
56
+
57
+ - Atomic operations via Lua scripts
58
+ - `py.typed` marker for PEP 561 compliance
59
+ - Python 3.10+ support