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.
- py_redis_semaphore-0.1.1/.editorconfig +25 -0
- py_redis_semaphore-0.1.1/.github/ISSUE_TEMPLATE/bug_report.yml +89 -0
- py_redis_semaphore-0.1.1/.github/ISSUE_TEMPLATE/feature_request.yml +56 -0
- py_redis_semaphore-0.1.1/.github/dependabot.yml +19 -0
- py_redis_semaphore-0.1.1/.github/pull_request_template.md +32 -0
- py_redis_semaphore-0.1.1/.github/workflows/ci.yml +65 -0
- py_redis_semaphore-0.1.1/.github/workflows/release.yml +59 -0
- py_redis_semaphore-0.1.1/.gitignore +17 -0
- py_redis_semaphore-0.1.1/.pre-commit-config.yaml +24 -0
- py_redis_semaphore-0.1.1/.python-version +1 -0
- py_redis_semaphore-0.1.1/ARCHITECTURE.md +258 -0
- py_redis_semaphore-0.1.1/CHANGELOG.md +59 -0
- py_redis_semaphore-0.1.1/CLAUDE.md +105 -0
- py_redis_semaphore-0.1.1/CONTRIBUTING.md +116 -0
- py_redis_semaphore-0.1.1/LICENSE +21 -0
- py_redis_semaphore-0.1.1/Makefile +60 -0
- py_redis_semaphore-0.1.1/PKG-INFO +530 -0
- py_redis_semaphore-0.1.1/README.md +497 -0
- py_redis_semaphore-0.1.1/SECURITY.md +41 -0
- py_redis_semaphore-0.1.1/codecov.yml +15 -0
- py_redis_semaphore-0.1.1/docker/redis/Dockerfile +5 -0
- py_redis_semaphore-0.1.1/docker/sentinel/docker-compose.yml +16 -0
- py_redis_semaphore-0.1.1/docker/sentinel/sentinel.conf +7 -0
- py_redis_semaphore-0.1.1/examples/basic_usage.py +35 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/.env.example +29 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/.gitignore +19 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/Dockerfile +25 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/README.md +207 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/app.py +8 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/client_model_overrides.example.json +9 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/client_model_overrides.example.yaml +6 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/__init__.py +5 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/__init__.py +1 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/dependencies.py +16 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/proxy_common.py +193 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/request_logging.py +115 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/routes/__init__.py +1 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/routes/chat.py +252 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/routes/embeddings.py +113 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/routes/health.py +51 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/api/routes/proxy.py +70 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/client_model_overrides.py +76 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/config.py +65 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/core/__init__.py +8 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/core/semaphore_pool.py +106 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/infrastructure/__init__.py +23 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/infrastructure/redis_manager.py +55 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/infrastructure/upstream.py +61 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/logging_setup.py +84 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/main.py +132 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/metrics.py +21 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/llm_proxy/responses.py +55 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/pyproject.toml +44 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/tests/USAGE.md +52 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/tests/mock_upstream.py +163 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/tests/test_client.py +142 -0
- py_redis_semaphore-0.1.1/examples/fastapi_llm_proxy/uv.lock +403 -0
- py_redis_semaphore-0.1.1/examples/multiprocess_simulation.py +58 -0
- py_redis_semaphore-0.1.1/pyproject.toml +130 -0
- py_redis_semaphore-0.1.1/src/redis_semaphore/__init__.py +125 -0
- py_redis_semaphore-0.1.1/src/redis_semaphore/base.py +129 -0
- py_redis_semaphore-0.1.1/src/redis_semaphore/connection.py +91 -0
- py_redis_semaphore-0.1.1/src/redis_semaphore/errors.py +141 -0
- py_redis_semaphore-0.1.1/src/redis_semaphore/heartbeat.py +187 -0
- py_redis_semaphore-0.1.1/src/redis_semaphore/logger.py +41 -0
- py_redis_semaphore-0.1.1/src/redis_semaphore/lua_scripts.py +482 -0
- py_redis_semaphore-0.1.1/src/redis_semaphore/metrics.py +150 -0
- py_redis_semaphore-0.1.1/src/redis_semaphore/py.typed +0 -0
- py_redis_semaphore-0.1.1/src/redis_semaphore/semaphore.py +1121 -0
- py_redis_semaphore-0.1.1/src/redis_semaphore/types.py +134 -0
- py_redis_semaphore-0.1.1/tests/__init__.py +1 -0
- py_redis_semaphore-0.1.1/tests/conftest.py +31 -0
- py_redis_semaphore-0.1.1/tests/test_async_heartbeat.py +106 -0
- py_redis_semaphore-0.1.1/tests/test_async_mutex.py +58 -0
- py_redis_semaphore-0.1.1/tests/test_async_semaphore.py +220 -0
- py_redis_semaphore-0.1.1/tests/test_backend_errors.py +111 -0
- py_redis_semaphore-0.1.1/tests/test_cleanup.py +18 -0
- py_redis_semaphore-0.1.1/tests/test_connection.py +96 -0
- py_redis_semaphore-0.1.1/tests/test_errors.py +101 -0
- py_redis_semaphore-0.1.1/tests/test_heartbeat.py +112 -0
- py_redis_semaphore-0.1.1/tests/test_heartbeat_extra.py +273 -0
- py_redis_semaphore-0.1.1/tests/test_logger.py +112 -0
- py_redis_semaphore-0.1.1/tests/test_lua_scripts.py +162 -0
- py_redis_semaphore-0.1.1/tests/test_metrics.py +288 -0
- py_redis_semaphore-0.1.1/tests/test_modes.py +93 -0
- py_redis_semaphore-0.1.1/tests/test_mutex.py +81 -0
- py_redis_semaphore-0.1.1/tests/test_script_client_adapter.py +90 -0
- py_redis_semaphore-0.1.1/tests/test_semaphore.py +577 -0
- py_redis_semaphore-0.1.1/tests/test_sentinel.py +39 -0
- py_redis_semaphore-0.1.1/tests/test_status.py +94 -0
- py_redis_semaphore-0.1.1/tests/test_time.py +28 -0
- py_redis_semaphore-0.1.1/tests/test_types.py +65 -0
- 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,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
|