├── .github
├── dependabot.yaml
├── set_version.py
└── workflows
│ ├── audit.yaml
│ ├── release.yaml
│ └── test.yaml
├── .gitignore
├── .pre-commit-config.yaml
├── CONTRIBUTING.md
├── Cargo.lock
├── Cargo.toml
├── LICENSE
├── README.md
├── bench.py
├── build.rs
├── coverage-report.sh
├── dev-requirements.txt
├── docker-compose.yml
├── docs
├── logo.svg
├── semaphore.png
├── semaphore.svg
├── token_bucket.png
└── token_bucket.svg
├── pyproject.toml
├── rustfmt.toml
├── scripts
├── semaphore.lua
└── token_bucket.lua
├── self_limiters.pyi
├── setup.cfg
├── src
├── errors.rs
├── generated.rs
├── lib.rs
├── semaphore.rs
├── token_bucket.rs
└── utils.rs
└── tests
├── __init__.py
├── conftest.py
├── test_errors.py
├── test_semaphore.py
└── test_token_bucket.py
/.github/dependabot.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | updates:
4 | - package-ecosystem: github-actions
5 | directory: /
6 | target-branch: main
7 | schedule:
8 | interval: daily
9 | reviewers:
10 | - sondrelg
11 |
--------------------------------------------------------------------------------
/.github/set_version.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # Stolen from the pydantic-core repo
3 | import os
4 | import re
5 | import sys
6 | from pathlib import Path
7 |
8 |
9 | def main(cargo_path_env_var='CARGO_PATH', version_env_vars=('VERSION', 'GITHUB_REF')) -> int:
10 | cargo_path = Path(os.getenv(cargo_path_env_var, 'Cargo.toml'))
11 | if not cargo_path.is_file():
12 | print(f'✖ path "{cargo_path}" does not exist')
13 | return 1
14 |
15 | version = None
16 | for var in version_env_vars:
17 | if version_ref := os.getenv(var):
18 | version = re.sub('^refs/tags/v*', '', version_ref.lower())
19 | break
20 | if not version:
21 | print(f'✖ "{version_env_vars}" env variables not found')
22 | return 1
23 |
24 | # convert from python pre-release version to rust pre-release version
25 | # this is the reverse of what's done in lib.rs::_rust_notify
26 | version = version.replace('a', '-alpha').replace('b', '-beta')
27 | print(f'writing version "{version}", to {cargo_path}')
28 |
29 | version_regex = re.compile('^version ?= ?".*"', re.M)
30 | cargo_content = cargo_path.read_text()
31 | if not version_regex.search(cargo_content):
32 | print(f'✖ {version_regex!r} not found in {cargo_path}')
33 | return 1
34 |
35 | new_content = version_regex.sub(f'version = "{version}"', cargo_content)
36 | cargo_path.write_text(new_content)
37 | return 0
38 |
39 |
40 | if __name__ == '__main__':
41 | sys.exit(main())
42 |
--------------------------------------------------------------------------------
/.github/workflows/audit.yaml:
--------------------------------------------------------------------------------
1 | name: security audit
2 | on:
3 | push:
4 | paths:
5 | - '**/Cargo.toml'
6 | - '**/Cargo.lock'
7 | jobs:
8 | security_audit:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 | - uses: actions-rs/audit-check@v1
13 | with:
14 | token: ${{ secrets.GITHUB_TOKEN }}
15 |
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | # Stolen and adapted from https://github.com/pydantic/pydantic-core/blob/main/.github/workflows/ci.yml
2 | name: release
3 |
4 | on:
5 | release:
6 | types: [published, edited]
7 |
8 | jobs:
9 | build:
10 | name: build on ${{ matrix.platform || matrix.os }} (${{ matrix.target }} - ${{ matrix.manylinux || 'auto' }})
11 | strategy:
12 | fail-fast: false
13 | matrix:
14 | os: [ubuntu, macos, windows]
15 | target: [x86_64, aarch64]
16 | manylinux: [auto]
17 | include:
18 | - os: ubuntu
19 | platform: linux
20 | - os: windows
21 | ls: dir
22 | interpreter: 3.7 3.8 3.9 3.10
23 | - os: windows
24 | ls: dir
25 | target: i686
26 | python-architecture: x86
27 | interpreter: 3.7 3.8 3.9 3.10
28 | - os: macos
29 | target: aarch64
30 | interpreter: 3.7 3.8 3.9 3.10
31 | - os: ubuntu
32 | platform: linux
33 | target: i686
34 | # GCC 4.8.5 in manylinux2014 container doesn't support c11 atomic
35 | # we use manylinux_2_24 container for aarch64 and armv7 targets instead,
36 | - os: ubuntu
37 | platform: linux
38 | target: aarch64
39 | container: messense/manylinux_2_24-cross:aarch64
40 | - os: ubuntu
41 | platform: linux
42 | target: armv7
43 | container: messense/manylinux_2_24-cross:armv7
44 | interpreter: 3.7 3.8 3.9 3.10
45 | # musllinux
46 | - os: ubuntu
47 | platform: linux
48 | target: x86_64
49 | manylinux: musllinux_1_1
50 | - os: ubuntu
51 | platform: linux
52 | target: aarch64
53 | manylinux: musllinux_1_1
54 | exclude:
55 | # Windows on arm64 only supports Python 3.11+
56 | - os: windows
57 | target: aarch64
58 |
59 | runs-on: ${{ matrix.os }}-latest
60 | steps:
61 | - uses: actions/checkout@v4
62 | - uses: actions/setup-python@v5
63 | with:
64 | python-version: '3.10'
65 | architecture: ${{ matrix.python-architecture || 'x64' }}
66 |
67 | - name: set package version
68 | run: python .github/set_version.py
69 |
70 | - name: sync Cargo.lock
71 | run: cargo update -p self-limiters
72 | if: "startsWith(github.ref, 'refs/tags/')"
73 |
74 | - run: pip install -U twine 'black>=22.3.0,<23' typing_extensions
75 |
76 | - name: build wheels
77 | uses: messense/maturin-action@v1
78 | with:
79 | target: ${{ matrix.target }}
80 | manylinux: ${{ matrix.manylinux || 'auto' }}
81 | container: ${{ matrix.container }}
82 | args: --release --out dist --interpreter ${{ matrix.interpreter || '3.7 3.8 3.9 3.10' }}
83 | rust-toolchain: stable
84 |
85 | - run: ${{ matrix.ls || 'ls -lh' }} dist/
86 | - run: twine check dist/*
87 | - uses: actions/upload-artifact@v4
88 | with:
89 | name: pypi_files
90 | path: dist
91 |
92 | release:
93 | needs: [build]
94 | runs-on: ubuntu-latest
95 | steps:
96 | - uses: actions/checkout@v4
97 | - uses: actions/setup-python@v5
98 | with:
99 | python-version: '3.10'
100 | - run: pip install -U twine
101 | - name: get dist artifacts
102 | uses: actions/download-artifact@v4
103 | with:
104 | name: pypi_files
105 | path: dist
106 | - run: twine check dist/*
107 | - name: upload to pypi
108 | run: twine upload dist/*
109 | env:
110 | TWINE_USERNAME: __token__
111 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
112 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: test
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - main
8 |
9 | jobs:
10 | pre_commit:
11 | name: "pre-commit"
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: actions/checkout@v4
15 | - uses: actions/setup-python@v5
16 | with:
17 | python-version: "3.11"
18 | - run: pip install pre-commit
19 | - uses: actions/cache@v3
20 | id: pre-commit-cache
21 | with:
22 | path: ~/.cache/pre-commit
23 | key: key-0
24 | - run: pre-commit run --all-files
25 |
26 | python_test:
27 | runs-on: ubuntu-latest
28 | strategy:
29 | fail-fast: false
30 | matrix:
31 | python-version: [ "3.9", "3.10", "3.11.1" ]
32 | services:
33 | redis:
34 | image: redis:6.2.5-alpine
35 | options: >-
36 | --health-cmd "redis-cli ping"
37 | --health-interval 10s
38 | --health-timeout 5s
39 | --health-retries 5
40 | ports:
41 | - '127.0.0.1:6389:6379'
42 | steps:
43 | - uses: actions/checkout@v4
44 | - uses: actions-rs/toolchain@v1
45 | with:
46 | toolchain: stable
47 | override: true
48 | profile: minimal
49 | components: llvm-tools-preview
50 | - uses: taiki-e/install-action@cargo-llvm-cov
51 | - uses: actions/setup-python@v5
52 | id: python-setup
53 | with:
54 | python-version: "${{ matrix.python-version }}"
55 | - uses: actions/cache@v3
56 | id: cache-venv-and-cargo
57 | with:
58 | path: |
59 | .venv
60 | ~/.cargo/bin/
61 | ~/.cargo/registry/index/
62 | ~/.cargo/registry/cache/
63 | ~/.cargo/git/db/
64 | target/
65 | key: ${{ hashFiles('**/dev-requirements.txt') }}-${{ steps.python-setup.outputs.python-version }}-${{ hashFiles('**/Cargo.lock') }}-0
66 | - run: |
67 | python -m venv .venv
68 | source .venv/bin/activate
69 | pip install -U pip setuptools wheel
70 | pip install -r dev-requirements.txt
71 | if: steps.cache-venv-and-cargo.outputs.cache-hit != 'true'
72 | - name: Run tests
73 | run: |
74 | source .venv/bin/activate
75 | source <(cargo llvm-cov show-env --export-prefix)
76 | export CARGO_TARGET_DIR=$CARGO_LLVM_COV_TARGET_DIR
77 | export CARGO_INCREMENTAL=1
78 | cargo llvm-cov clean --workspace
79 | cargo test
80 | maturin develop
81 | coverage run -m pytest tests
82 | coverage xml
83 | cargo llvm-cov report --lcov --output-path coverage.lcov --ignore-filename-regex "_errors|_tests|lib"
84 | - name: Run benchmarks
85 | run: |
86 | source .venv/bin/activate
87 | maturin build --release --locked --out ./ --strip --find-interpreter --no-default-features
88 | pip install ./self_limiters-* --force-reinstall
89 | python bench.py tb --count 30 --iterations 30 --target 0.25
90 | python bench.py s --count 30 --iterations 30 --target 1.5
91 | if: matrix.python-version == '3.11'
92 | - uses: codecov/codecov-action@v3
93 | with:
94 | files: coverage.lcov,coverage.xml
95 | if: matrix.python-version == '3.11'
96 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 |
3 | # Byte-compiled / optimized / DLL files
4 | __pycache__/
5 | .pytest_cache/
6 | *.py[cod]
7 |
8 | # C extensions
9 | *.so
10 |
11 | # Distribution / packaging
12 | .Python
13 | .venv/
14 | env/
15 | bin/
16 | build/
17 | develop-eggs/
18 | dist/
19 | eggs/
20 | lib/
21 | lib64/
22 | parts/
23 | sdist/
24 | var/
25 | include/
26 | man/
27 | venv/
28 | *.egg-info/
29 | .installed.cfg
30 | *.egg
31 |
32 | # Installer logs
33 | pip-log.txt
34 | pip-delete-this-directory.txt
35 | pip-selfcheck.json
36 |
37 | # Unit test / coverage reports
38 | htmlcov/
39 | .tox/
40 | .coverage
41 | .cache
42 | nosetests.xml
43 | coverage.xml
44 |
45 | # Translations
46 | *.mo
47 |
48 | # Mr Developer
49 | .mr.developer.cfg
50 | .project
51 | .pydevproject
52 |
53 | # Rope
54 | .ropeproject
55 |
56 | # Django stuff:
57 | *.log
58 | *.pot
59 |
60 | .DS_Store
61 |
62 | # Sphinx documentation
63 | docs/_build/
64 |
65 | # PyCharm
66 | .idea/
67 |
68 | # VSCode
69 | .vscode/
70 |
71 | # Pyenv
72 | .python-version
73 |
74 | ARGO_LLVM_COV_TARGET_DIR
75 |
76 | .env
77 |
78 | default.profraw
79 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/doublify/pre-commit-rust
3 | rev: v1.0
4 | hooks:
5 | - id: fmt
6 | - id: cargo-check
7 | - id: clippy
8 | - repo: https://github.com/psf/black
9 | rev: 22.12.0
10 | hooks:
11 | - id: black
12 |
13 | - repo: https://github.com/pycqa/isort
14 | rev: 5.11.4
15 | hooks:
16 | - id: isort
17 |
18 | - repo: https://github.com/pre-commit/pre-commit-hooks
19 | rev: v4.4.0
20 | hooks:
21 | - id: check-ast
22 | - id: check-merge-conflict
23 | - id: check-case-conflict
24 | - id: check-docstring-first
25 | - id: check-json
26 | - id: check-yaml
27 | - id: end-of-file-fixer
28 | - id: trailing-whitespace
29 | - id: mixed-line-ending
30 | - id: trailing-whitespace
31 | - id: double-quote-string-fixer
32 |
33 | - repo: https://github.com/pycqa/flake8
34 | rev: 6.0.0
35 | hooks:
36 | - id: flake8
37 | additional_dependencies: [
38 | 'flake8-bugbear',
39 | 'flake8-comprehensions',
40 | 'flake8-mutable',
41 | 'flake8-simplify',
42 | 'flake8-pytest-style',
43 | 'flake8-printf-formatting',
44 | 'flake8-type-checking',
45 | ]
46 |
47 | - repo: https://github.com/sirosen/check-jsonschema
48 | rev: 0.19.2
49 | hooks:
50 | - id: check-github-actions
51 | - id: check-github-workflows
52 |
53 | - repo: https://github.com/asottile/pyupgrade
54 | rev: v3.3.1
55 | hooks:
56 | - id: pyupgrade
57 | args: [ "--py36-plus", "--py37-plus", "--keep-runtime-typing" ]
58 |
59 | - repo: https://github.com/pre-commit/mirrors-mypy
60 | rev: v0.991
61 | hooks:
62 | - id: mypy
63 | additional_dependencies:
64 | - types-redis
65 | - pydantic
66 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Contributions are always welcome, and if you're unsure about something, please don't hesitate
4 | to open an issue.
5 |
6 | The rest of this file is dedicated document development and releases for things
7 | that I'll otherwise forget.
8 |
9 | ## Package version
10 |
11 | The package's version lives in `cargo.toml`, but is set
12 | in the release workflow based on the release tag. Release tags
13 | should therefore always conform to `v{0-9}.{0-9}.{0-9}`
14 |
15 | ## Debugging the Lua scripts
16 |
17 | Assuming you have a redis server running at `:6389` you can debug
18 | a lua script by calling `redis-cli -u redis://127.0.0.1:6389 --ldb --eval src/semaphore/rpushnx.lua x 1`.
19 |
20 | Just type `help` in the debugger for options.
21 |
22 | Another option is to run `MONITOR` in the redis-cli before running the relevant code,
23 | and checking the output of calls made.
24 |
25 | ## Setting up the environment
26 |
27 | 1. Create a venv
28 | 2. `pip install -r requirements-dev.txt`
29 | 2. `pre-commit install`
30 |
31 | And I think that's it!
32 |
33 | ## Running tests
34 |
35 | The tests rely on a Redis instance running on port 6389.
36 | Run `docker compose up -d` to start the dc redis.
37 |
38 | Rust tests are run with `cargo test`, while python tests can be run using `pytest .`.
39 |
40 | ## Coverage
41 |
42 | Since some of our tests are written in Rust, and some are written in Python,
43 | we've modelled our codecov setup after [this](https://github.com/cjermain/rust-python-coverage)
44 | project. The process consists of running both test suites with individual coverage tools, then
45 | patching the coverage data together via codecov.
46 |
47 | To run tests with coverage, you can use the `./coverage-report.sh` script.
48 |
--------------------------------------------------------------------------------
/Cargo.lock:
--------------------------------------------------------------------------------
1 | # This file is automatically @generated by Cargo.
2 | # It is not intended for manual editing.
3 | version = 3
4 |
5 | [[package]]
6 | name = "ahash"
7 | version = "0.7.6"
8 | source = "registry+https://github.com/rust-lang/crates.io-index"
9 | checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47"
10 | dependencies = [
11 | "getrandom 0.2.8",
12 | "once_cell",
13 | "version_check",
14 | ]
15 |
16 | [[package]]
17 | name = "aho-corasick"
18 | version = "0.7.20"
19 | source = "registry+https://github.com/rust-lang/crates.io-index"
20 | checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac"
21 | dependencies = [
22 | "memchr",
23 | ]
24 |
25 | [[package]]
26 | name = "anyhow"
27 | version = "1.0.68"
28 | source = "registry+https://github.com/rust-lang/crates.io-index"
29 | checksum = "2cb2f989d18dd141ab8ae82f64d1a8cdd37e0840f73a406896cf5e99502fab61"
30 |
31 | [[package]]
32 | name = "arc-swap"
33 | version = "1.5.1"
34 | source = "registry+https://github.com/rust-lang/crates.io-index"
35 | checksum = "983cd8b9d4b02a6dc6ffa557262eb5858a27a0038ffffe21a0f133eaa819a164"
36 |
37 | [[package]]
38 | name = "arrayref"
39 | version = "0.3.6"
40 | source = "registry+https://github.com/rust-lang/crates.io-index"
41 | checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544"
42 |
43 | [[package]]
44 | name = "arrayvec"
45 | version = "0.5.2"
46 | source = "registry+https://github.com/rust-lang/crates.io-index"
47 | checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
48 |
49 | [[package]]
50 | name = "async-trait"
51 | version = "0.1.60"
52 | source = "registry+https://github.com/rust-lang/crates.io-index"
53 | checksum = "677d1d8ab452a3936018a687b20e6f7cf5363d713b732b8884001317b0e48aa3"
54 | dependencies = [
55 | "proc-macro2",
56 | "quote",
57 | "syn",
58 | ]
59 |
60 | [[package]]
61 | name = "autocfg"
62 | version = "1.1.0"
63 | source = "registry+https://github.com/rust-lang/crates.io-index"
64 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
65 |
66 | [[package]]
67 | name = "base64"
68 | version = "0.13.1"
69 | source = "registry+https://github.com/rust-lang/crates.io-index"
70 | checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
71 |
72 | [[package]]
73 | name = "bb8"
74 | version = "0.8.0"
75 | source = "registry+https://github.com/rust-lang/crates.io-index"
76 | checksum = "1627eccf3aa91405435ba240be23513eeca466b5dc33866422672264de061582"
77 | dependencies = [
78 | "async-trait",
79 | "futures-channel",
80 | "futures-util",
81 | "parking_lot",
82 | "tokio",
83 | ]
84 |
85 | [[package]]
86 | name = "bb8-redis"
87 | version = "0.12.0"
88 | source = "registry+https://github.com/rust-lang/crates.io-index"
89 | checksum = "8310da711a26fa0326202261abd73a414340f3ec957f63927a055511a01d7fb2"
90 | dependencies = [
91 | "async-trait",
92 | "bb8",
93 | "redis",
94 | ]
95 |
96 | [[package]]
97 | name = "bitflags"
98 | version = "1.3.2"
99 | source = "registry+https://github.com/rust-lang/crates.io-index"
100 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
101 |
102 | [[package]]
103 | name = "blake2b_simd"
104 | version = "0.5.11"
105 | source = "registry+https://github.com/rust-lang/crates.io-index"
106 | checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587"
107 | dependencies = [
108 | "arrayref",
109 | "arrayvec",
110 | "constant_time_eq",
111 | ]
112 |
113 | [[package]]
114 | name = "bstr"
115 | version = "0.2.17"
116 | source = "registry+https://github.com/rust-lang/crates.io-index"
117 | checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223"
118 | dependencies = [
119 | "lazy_static",
120 | "memchr",
121 | "regex-automata",
122 | ]
123 |
124 | [[package]]
125 | name = "byteorder"
126 | version = "1.4.3"
127 | source = "registry+https://github.com/rust-lang/crates.io-index"
128 | checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
129 |
130 | [[package]]
131 | name = "bytes"
132 | version = "1.3.0"
133 | source = "registry+https://github.com/rust-lang/crates.io-index"
134 | checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c"
135 |
136 | [[package]]
137 | name = "camino"
138 | version = "1.1.1"
139 | source = "registry+https://github.com/rust-lang/crates.io-index"
140 | checksum = "88ad0e1e3e88dd237a156ab9f571021b8a158caa0ae44b1968a241efb5144c1e"
141 | dependencies = [
142 | "serde",
143 | ]
144 |
145 | [[package]]
146 | name = "cargo-llvm-cov"
147 | version = "0.5.3"
148 | source = "registry+https://github.com/rust-lang/crates.io-index"
149 | checksum = "04d16ff8d380c58bb0affa6c4ef578473b95ea20a4248e5325a0be852622ea0d"
150 | dependencies = [
151 | "anyhow",
152 | "camino",
153 | "cargo_metadata",
154 | "duct",
155 | "fs-err",
156 | "glob",
157 | "home",
158 | "is-terminal",
159 | "is_executable",
160 | "lcov2cobertura",
161 | "lexopt",
162 | "opener",
163 | "regex",
164 | "rustc-demangle",
165 | "serde",
166 | "serde_json",
167 | "shell-escape",
168 | "target-spec",
169 | "tempfile",
170 | "termcolor",
171 | "walkdir",
172 | ]
173 |
174 | [[package]]
175 | name = "cargo-platform"
176 | version = "0.1.2"
177 | source = "registry+https://github.com/rust-lang/crates.io-index"
178 | checksum = "cbdb825da8a5df079a43676dbe042702f1707b1109f713a01420fbb4cc71fa27"
179 | dependencies = [
180 | "serde",
181 | ]
182 |
183 | [[package]]
184 | name = "cargo_metadata"
185 | version = "0.14.2"
186 | source = "registry+https://github.com/rust-lang/crates.io-index"
187 | checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa"
188 | dependencies = [
189 | "camino",
190 | "cargo-platform",
191 | "semver",
192 | "serde",
193 | "serde_json",
194 | ]
195 |
196 | [[package]]
197 | name = "cc"
198 | version = "1.0.78"
199 | source = "registry+https://github.com/rust-lang/crates.io-index"
200 | checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d"
201 |
202 | [[package]]
203 | name = "cfg-expr"
204 | version = "0.12.0"
205 | source = "registry+https://github.com/rust-lang/crates.io-index"
206 | checksum = "0bbc13bf6290a6b202cc3efb36f7ec2b739a80634215630c8053a313edf6abef"
207 | dependencies = [
208 | "smallvec",
209 | "target-lexicon",
210 | ]
211 |
212 | [[package]]
213 | name = "cfg-if"
214 | version = "1.0.0"
215 | source = "registry+https://github.com/rust-lang/crates.io-index"
216 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
217 |
218 | [[package]]
219 | name = "clippy"
220 | version = "0.0.302"
221 | source = "registry+https://github.com/rust-lang/crates.io-index"
222 | checksum = "d911ee15579a3f50880d8c1d59ef6e79f9533127a3bd342462f5d584f5e8c294"
223 | dependencies = [
224 | "term",
225 | ]
226 |
227 | [[package]]
228 | name = "combine"
229 | version = "4.6.6"
230 | source = "registry+https://github.com/rust-lang/crates.io-index"
231 | checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4"
232 | dependencies = [
233 | "bytes",
234 | "futures-core",
235 | "memchr",
236 | "pin-project-lite",
237 | "tokio",
238 | "tokio-util",
239 | ]
240 |
241 | [[package]]
242 | name = "constant_time_eq"
243 | version = "0.1.5"
244 | source = "registry+https://github.com/rust-lang/crates.io-index"
245 | checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
246 |
247 | [[package]]
248 | name = "crossbeam-utils"
249 | version = "0.8.14"
250 | source = "registry+https://github.com/rust-lang/crates.io-index"
251 | checksum = "4fb766fa798726286dbbb842f174001dab8abc7b627a1dd86e0b7222a95d929f"
252 | dependencies = [
253 | "cfg-if",
254 | ]
255 |
256 | [[package]]
257 | name = "dirs"
258 | version = "1.0.5"
259 | source = "registry+https://github.com/rust-lang/crates.io-index"
260 | checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901"
261 | dependencies = [
262 | "libc",
263 | "redox_users",
264 | "winapi",
265 | ]
266 |
267 | [[package]]
268 | name = "duct"
269 | version = "0.13.6"
270 | source = "registry+https://github.com/rust-lang/crates.io-index"
271 | checksum = "37ae3fc31835f74c2a7ceda3aeede378b0ae2e74c8f1c36559fcc9ae2a4e7d3e"
272 | dependencies = [
273 | "libc",
274 | "once_cell",
275 | "os_pipe",
276 | "shared_child",
277 | ]
278 |
279 | [[package]]
280 | name = "errno"
281 | version = "0.2.8"
282 | source = "registry+https://github.com/rust-lang/crates.io-index"
283 | checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1"
284 | dependencies = [
285 | "errno-dragonfly",
286 | "libc",
287 | "winapi",
288 | ]
289 |
290 | [[package]]
291 | name = "errno-dragonfly"
292 | version = "0.1.2"
293 | source = "registry+https://github.com/rust-lang/crates.io-index"
294 | checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf"
295 | dependencies = [
296 | "cc",
297 | "libc",
298 | ]
299 |
300 | [[package]]
301 | name = "fastrand"
302 | version = "1.8.0"
303 | source = "registry+https://github.com/rust-lang/crates.io-index"
304 | checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499"
305 | dependencies = [
306 | "instant",
307 | ]
308 |
309 | [[package]]
310 | name = "form_urlencoded"
311 | version = "1.1.0"
312 | source = "registry+https://github.com/rust-lang/crates.io-index"
313 | checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8"
314 | dependencies = [
315 | "percent-encoding",
316 | ]
317 |
318 | [[package]]
319 | name = "fs-err"
320 | version = "2.9.0"
321 | source = "registry+https://github.com/rust-lang/crates.io-index"
322 | checksum = "0845fa252299212f0389d64ba26f34fa32cfe41588355f21ed507c59a0f64541"
323 |
324 | [[package]]
325 | name = "futures"
326 | version = "0.3.25"
327 | source = "registry+https://github.com/rust-lang/crates.io-index"
328 | checksum = "38390104763dc37a5145a53c29c63c1290b5d316d6086ec32c293f6736051bb0"
329 | dependencies = [
330 | "futures-channel",
331 | "futures-core",
332 | "futures-executor",
333 | "futures-io",
334 | "futures-sink",
335 | "futures-task",
336 | "futures-util",
337 | ]
338 |
339 | [[package]]
340 | name = "futures-channel"
341 | version = "0.3.25"
342 | source = "registry+https://github.com/rust-lang/crates.io-index"
343 | checksum = "52ba265a92256105f45b719605a571ffe2d1f0fea3807304b522c1d778f79eed"
344 | dependencies = [
345 | "futures-core",
346 | "futures-sink",
347 | ]
348 |
349 | [[package]]
350 | name = "futures-core"
351 | version = "0.3.25"
352 | source = "registry+https://github.com/rust-lang/crates.io-index"
353 | checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac"
354 |
355 | [[package]]
356 | name = "futures-executor"
357 | version = "0.3.25"
358 | source = "registry+https://github.com/rust-lang/crates.io-index"
359 | checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2"
360 | dependencies = [
361 | "futures-core",
362 | "futures-task",
363 | "futures-util",
364 | ]
365 |
366 | [[package]]
367 | name = "futures-io"
368 | version = "0.3.25"
369 | source = "registry+https://github.com/rust-lang/crates.io-index"
370 | checksum = "00f5fb52a06bdcadeb54e8d3671f8888a39697dcb0b81b23b55174030427f4eb"
371 |
372 | [[package]]
373 | name = "futures-macro"
374 | version = "0.3.25"
375 | source = "registry+https://github.com/rust-lang/crates.io-index"
376 | checksum = "bdfb8ce053d86b91919aad980c220b1fb8401a9394410e1c289ed7e66b61835d"
377 | dependencies = [
378 | "proc-macro2",
379 | "quote",
380 | "syn",
381 | ]
382 |
383 | [[package]]
384 | name = "futures-sink"
385 | version = "0.3.25"
386 | source = "registry+https://github.com/rust-lang/crates.io-index"
387 | checksum = "39c15cf1a4aa79df40f1bb462fb39676d0ad9e366c2a33b590d7c66f4f81fcf9"
388 |
389 | [[package]]
390 | name = "futures-task"
391 | version = "0.3.25"
392 | source = "registry+https://github.com/rust-lang/crates.io-index"
393 | checksum = "2ffb393ac5d9a6eaa9d3fdf37ae2776656b706e200c8e16b1bdb227f5198e6ea"
394 |
395 | [[package]]
396 | name = "futures-util"
397 | version = "0.3.25"
398 | source = "registry+https://github.com/rust-lang/crates.io-index"
399 | checksum = "197676987abd2f9cadff84926f410af1c183608d36641465df73ae8211dc65d6"
400 | dependencies = [
401 | "futures-channel",
402 | "futures-core",
403 | "futures-io",
404 | "futures-macro",
405 | "futures-sink",
406 | "futures-task",
407 | "memchr",
408 | "pin-project-lite",
409 | "pin-utils",
410 | "slab",
411 | ]
412 |
413 | [[package]]
414 | name = "getrandom"
415 | version = "0.1.16"
416 | source = "registry+https://github.com/rust-lang/crates.io-index"
417 | checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
418 | dependencies = [
419 | "cfg-if",
420 | "libc",
421 | "wasi 0.9.0+wasi-snapshot-preview1",
422 | ]
423 |
424 | [[package]]
425 | name = "getrandom"
426 | version = "0.2.8"
427 | source = "registry+https://github.com/rust-lang/crates.io-index"
428 | checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31"
429 | dependencies = [
430 | "cfg-if",
431 | "libc",
432 | "wasi 0.11.0+wasi-snapshot-preview1",
433 | ]
434 |
435 | [[package]]
436 | name = "glob"
437 | version = "0.3.0"
438 | source = "registry+https://github.com/rust-lang/crates.io-index"
439 | checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
440 |
441 | [[package]]
442 | name = "guppy-workspace-hack"
443 | version = "0.1.0"
444 | source = "registry+https://github.com/rust-lang/crates.io-index"
445 | checksum = "92620684d99f750bae383ecb3be3748142d6095760afd5cbcf2261e9a279d780"
446 |
447 | [[package]]
448 | name = "hermit-abi"
449 | version = "0.2.6"
450 | source = "registry+https://github.com/rust-lang/crates.io-index"
451 | checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7"
452 | dependencies = [
453 | "libc",
454 | ]
455 |
456 | [[package]]
457 | name = "home"
458 | version = "0.5.4"
459 | source = "registry+https://github.com/rust-lang/crates.io-index"
460 | checksum = "747309b4b440c06d57b0b25f2aee03ee9b5e5397d288c60e21fc709bb98a7408"
461 | dependencies = [
462 | "winapi",
463 | ]
464 |
465 | [[package]]
466 | name = "idna"
467 | version = "0.3.0"
468 | source = "registry+https://github.com/rust-lang/crates.io-index"
469 | checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6"
470 | dependencies = [
471 | "unicode-bidi",
472 | "unicode-normalization",
473 | ]
474 |
475 | [[package]]
476 | name = "indoc"
477 | version = "1.0.8"
478 | source = "registry+https://github.com/rust-lang/crates.io-index"
479 | checksum = "da2d6f23ffea9d7e76c53eee25dfb67bcd8fde7f1198b0855350698c9f07c780"
480 |
481 | [[package]]
482 | name = "instant"
483 | version = "0.1.12"
484 | source = "registry+https://github.com/rust-lang/crates.io-index"
485 | checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
486 | dependencies = [
487 | "cfg-if",
488 | ]
489 |
490 | [[package]]
491 | name = "io-lifetimes"
492 | version = "1.0.3"
493 | source = "registry+https://github.com/rust-lang/crates.io-index"
494 | checksum = "46112a93252b123d31a119a8d1a1ac19deac4fac6e0e8b0df58f0d4e5870e63c"
495 | dependencies = [
496 | "libc",
497 | "windows-sys",
498 | ]
499 |
500 | [[package]]
501 | name = "is-terminal"
502 | version = "0.4.2"
503 | source = "registry+https://github.com/rust-lang/crates.io-index"
504 | checksum = "28dfb6c8100ccc63462345b67d1bbc3679177c75ee4bf59bf29c8b1d110b8189"
505 | dependencies = [
506 | "hermit-abi",
507 | "io-lifetimes",
508 | "rustix",
509 | "windows-sys",
510 | ]
511 |
512 | [[package]]
513 | name = "is_executable"
514 | version = "1.0.1"
515 | source = "registry+https://github.com/rust-lang/crates.io-index"
516 | checksum = "fa9acdc6d67b75e626ad644734e8bc6df893d9cd2a834129065d3dd6158ea9c8"
517 | dependencies = [
518 | "winapi",
519 | ]
520 |
521 | [[package]]
522 | name = "itoa"
523 | version = "1.0.5"
524 | source = "registry+https://github.com/rust-lang/crates.io-index"
525 | checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440"
526 |
527 | [[package]]
528 | name = "lazy_static"
529 | version = "1.4.0"
530 | source = "registry+https://github.com/rust-lang/crates.io-index"
531 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
532 |
533 | [[package]]
534 | name = "lcov2cobertura"
535 | version = "1.0.1"
536 | source = "registry+https://github.com/rust-lang/crates.io-index"
537 | checksum = "cf88eb39b4cae8a9c79b5052613fa2489e7bd0fb2a4e46c9d6c908b4c2de162d"
538 | dependencies = [
539 | "anyhow",
540 | "quick-xml",
541 | "regex",
542 | "rustc-demangle",
543 | ]
544 |
545 | [[package]]
546 | name = "lexopt"
547 | version = "0.2.1"
548 | source = "registry+https://github.com/rust-lang/crates.io-index"
549 | checksum = "478ee9e62aaeaf5b140bd4138753d1f109765488581444218d3ddda43234f3e8"
550 |
551 | [[package]]
552 | name = "libc"
553 | version = "0.2.139"
554 | source = "registry+https://github.com/rust-lang/crates.io-index"
555 | checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79"
556 |
557 | [[package]]
558 | name = "linux-raw-sys"
559 | version = "0.1.4"
560 | source = "registry+https://github.com/rust-lang/crates.io-index"
561 | checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4"
562 |
563 | [[package]]
564 | name = "lock_api"
565 | version = "0.4.9"
566 | source = "registry+https://github.com/rust-lang/crates.io-index"
567 | checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df"
568 | dependencies = [
569 | "autocfg",
570 | "scopeguard",
571 | ]
572 |
573 | [[package]]
574 | name = "log"
575 | version = "0.4.17"
576 | source = "registry+https://github.com/rust-lang/crates.io-index"
577 | checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e"
578 | dependencies = [
579 | "cfg-if",
580 | ]
581 |
582 | [[package]]
583 | name = "memchr"
584 | version = "2.5.0"
585 | source = "registry+https://github.com/rust-lang/crates.io-index"
586 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
587 |
588 | [[package]]
589 | name = "memoffset"
590 | version = "0.6.5"
591 | source = "registry+https://github.com/rust-lang/crates.io-index"
592 | checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
593 | dependencies = [
594 | "autocfg",
595 | ]
596 |
597 | [[package]]
598 | name = "mio"
599 | version = "0.8.5"
600 | source = "registry+https://github.com/rust-lang/crates.io-index"
601 | checksum = "e5d732bc30207a6423068df043e3d02e0735b155ad7ce1a6f76fe2baa5b158de"
602 | dependencies = [
603 | "libc",
604 | "log",
605 | "wasi 0.11.0+wasi-snapshot-preview1",
606 | "windows-sys",
607 | ]
608 |
609 | [[package]]
610 | name = "num_cpus"
611 | version = "1.15.0"
612 | source = "registry+https://github.com/rust-lang/crates.io-index"
613 | checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b"
614 | dependencies = [
615 | "hermit-abi",
616 | "libc",
617 | ]
618 |
619 | [[package]]
620 | name = "once_cell"
621 | version = "1.16.0"
622 | source = "registry+https://github.com/rust-lang/crates.io-index"
623 | checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860"
624 |
625 | [[package]]
626 | name = "opener"
627 | version = "0.5.0"
628 | source = "registry+https://github.com/rust-lang/crates.io-index"
629 | checksum = "4ea3ebcd72a54701f56345f16785a6d3ac2df7e986d273eb4395c0b01db17952"
630 | dependencies = [
631 | "bstr",
632 | "winapi",
633 | ]
634 |
635 | [[package]]
636 | name = "os_pipe"
637 | version = "1.1.2"
638 | source = "registry+https://github.com/rust-lang/crates.io-index"
639 | checksum = "c6a252f1f8c11e84b3ab59d7a488e48e4478a93937e027076638c49536204639"
640 | dependencies = [
641 | "libc",
642 | "windows-sys",
643 | ]
644 |
645 | [[package]]
646 | name = "parking_lot"
647 | version = "0.12.1"
648 | source = "registry+https://github.com/rust-lang/crates.io-index"
649 | checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
650 | dependencies = [
651 | "lock_api",
652 | "parking_lot_core",
653 | ]
654 |
655 | [[package]]
656 | name = "parking_lot_core"
657 | version = "0.9.5"
658 | source = "registry+https://github.com/rust-lang/crates.io-index"
659 | checksum = "7ff9f3fef3968a3ec5945535ed654cb38ff72d7495a25619e2247fb15a2ed9ba"
660 | dependencies = [
661 | "cfg-if",
662 | "libc",
663 | "redox_syscall 0.2.16",
664 | "smallvec",
665 | "windows-sys",
666 | ]
667 |
668 | [[package]]
669 | name = "percent-encoding"
670 | version = "2.2.0"
671 | source = "registry+https://github.com/rust-lang/crates.io-index"
672 | checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e"
673 |
674 | [[package]]
675 | name = "pin-project-lite"
676 | version = "0.2.9"
677 | source = "registry+https://github.com/rust-lang/crates.io-index"
678 | checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116"
679 |
680 | [[package]]
681 | name = "pin-utils"
682 | version = "0.1.0"
683 | source = "registry+https://github.com/rust-lang/crates.io-index"
684 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
685 |
686 | [[package]]
687 | name = "proc-macro2"
688 | version = "1.0.49"
689 | source = "registry+https://github.com/rust-lang/crates.io-index"
690 | checksum = "57a8eca9f9c4ffde41714334dee777596264c7825420f521abc92b5b5deb63a5"
691 | dependencies = [
692 | "unicode-ident",
693 | ]
694 |
695 | [[package]]
696 | name = "pyo3"
697 | version = "0.17.3"
698 | source = "registry+https://github.com/rust-lang/crates.io-index"
699 | checksum = "268be0c73583c183f2b14052337465768c07726936a260f480f0857cb95ba543"
700 | dependencies = [
701 | "cfg-if",
702 | "indoc",
703 | "libc",
704 | "memoffset",
705 | "parking_lot",
706 | "pyo3-build-config",
707 | "pyo3-ffi",
708 | "pyo3-macros",
709 | "unindent",
710 | ]
711 |
712 | [[package]]
713 | name = "pyo3-asyncio"
714 | version = "0.17.0"
715 | source = "registry+https://github.com/rust-lang/crates.io-index"
716 | checksum = "1febe3946b26194628f00526929ee6f8559f9e807f811257e94d4c456103be0e"
717 | dependencies = [
718 | "futures",
719 | "once_cell",
720 | "pin-project-lite",
721 | "pyo3",
722 | "tokio",
723 | ]
724 |
725 | [[package]]
726 | name = "pyo3-build-config"
727 | version = "0.17.3"
728 | source = "registry+https://github.com/rust-lang/crates.io-index"
729 | checksum = "28fcd1e73f06ec85bf3280c48c67e731d8290ad3d730f8be9dc07946923005c8"
730 | dependencies = [
731 | "once_cell",
732 | "target-lexicon",
733 | ]
734 |
735 | [[package]]
736 | name = "pyo3-ffi"
737 | version = "0.17.3"
738 | source = "registry+https://github.com/rust-lang/crates.io-index"
739 | checksum = "0f6cb136e222e49115b3c51c32792886defbfb0adead26a688142b346a0b9ffc"
740 | dependencies = [
741 | "libc",
742 | "pyo3-build-config",
743 | ]
744 |
745 | [[package]]
746 | name = "pyo3-log"
747 | version = "0.7.0"
748 | source = "registry+https://github.com/rust-lang/crates.io-index"
749 | checksum = "e5695ccff5060c13ca1751cf8c857a12da9b0bf0378cb071c5e0326f7c7e4c1b"
750 | dependencies = [
751 | "arc-swap",
752 | "log",
753 | "pyo3",
754 | ]
755 |
756 | [[package]]
757 | name = "pyo3-macros"
758 | version = "0.17.3"
759 | source = "registry+https://github.com/rust-lang/crates.io-index"
760 | checksum = "94144a1266e236b1c932682136dc35a9dee8d3589728f68130c7c3861ef96b28"
761 | dependencies = [
762 | "proc-macro2",
763 | "pyo3-macros-backend",
764 | "quote",
765 | "syn",
766 | ]
767 |
768 | [[package]]
769 | name = "pyo3-macros-backend"
770 | version = "0.17.3"
771 | source = "registry+https://github.com/rust-lang/crates.io-index"
772 | checksum = "c8df9be978a2d2f0cdebabb03206ed73b11314701a5bfe71b0d753b81997777f"
773 | dependencies = [
774 | "proc-macro2",
775 | "quote",
776 | "syn",
777 | ]
778 |
779 | [[package]]
780 | name = "quick-xml"
781 | version = "0.26.0"
782 | source = "registry+https://github.com/rust-lang/crates.io-index"
783 | checksum = "7f50b1c63b38611e7d4d7f68b82d3ad0cc71a2ad2e7f61fc10f1328d917c93cd"
784 | dependencies = [
785 | "memchr",
786 | ]
787 |
788 | [[package]]
789 | name = "quote"
790 | version = "1.0.23"
791 | source = "registry+https://github.com/rust-lang/crates.io-index"
792 | checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b"
793 | dependencies = [
794 | "proc-macro2",
795 | ]
796 |
797 | [[package]]
798 | name = "redis"
799 | version = "0.22.1"
800 | source = "registry+https://github.com/rust-lang/crates.io-index"
801 | checksum = "513b3649f1a111c17954296e4a3b9eecb108b766c803e2b99f179ebe27005985"
802 | dependencies = [
803 | "ahash",
804 | "async-trait",
805 | "bytes",
806 | "combine",
807 | "futures-util",
808 | "itoa",
809 | "percent-encoding",
810 | "pin-project-lite",
811 | "ryu",
812 | "sha1_smol",
813 | "tokio",
814 | "tokio-util",
815 | "url",
816 | ]
817 |
818 | [[package]]
819 | name = "redox_syscall"
820 | version = "0.1.57"
821 | source = "registry+https://github.com/rust-lang/crates.io-index"
822 | checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce"
823 |
824 | [[package]]
825 | name = "redox_syscall"
826 | version = "0.2.16"
827 | source = "registry+https://github.com/rust-lang/crates.io-index"
828 | checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
829 | dependencies = [
830 | "bitflags",
831 | ]
832 |
833 | [[package]]
834 | name = "redox_users"
835 | version = "0.3.5"
836 | source = "registry+https://github.com/rust-lang/crates.io-index"
837 | checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d"
838 | dependencies = [
839 | "getrandom 0.1.16",
840 | "redox_syscall 0.1.57",
841 | "rust-argon2",
842 | ]
843 |
844 | [[package]]
845 | name = "regex"
846 | version = "1.7.0"
847 | source = "registry+https://github.com/rust-lang/crates.io-index"
848 | checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a"
849 | dependencies = [
850 | "aho-corasick",
851 | "memchr",
852 | "regex-syntax",
853 | ]
854 |
855 | [[package]]
856 | name = "regex-automata"
857 | version = "0.1.10"
858 | source = "registry+https://github.com/rust-lang/crates.io-index"
859 | checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
860 |
861 | [[package]]
862 | name = "regex-syntax"
863 | version = "0.6.28"
864 | source = "registry+https://github.com/rust-lang/crates.io-index"
865 | checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848"
866 |
867 | [[package]]
868 | name = "remove_dir_all"
869 | version = "0.5.3"
870 | source = "registry+https://github.com/rust-lang/crates.io-index"
871 | checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
872 | dependencies = [
873 | "winapi",
874 | ]
875 |
876 | [[package]]
877 | name = "rust-argon2"
878 | version = "0.8.3"
879 | source = "registry+https://github.com/rust-lang/crates.io-index"
880 | checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb"
881 | dependencies = [
882 | "base64",
883 | "blake2b_simd",
884 | "constant_time_eq",
885 | "crossbeam-utils",
886 | ]
887 |
888 | [[package]]
889 | name = "rustc-demangle"
890 | version = "0.1.21"
891 | source = "registry+https://github.com/rust-lang/crates.io-index"
892 | checksum = "7ef03e0a2b150c7a90d01faf6254c9c48a41e95fb2a8c2ac1c6f0d2b9aefc342"
893 |
894 | [[package]]
895 | name = "rustix"
896 | version = "0.36.6"
897 | source = "registry+https://github.com/rust-lang/crates.io-index"
898 | checksum = "4feacf7db682c6c329c4ede12649cd36ecab0f3be5b7d74e6a20304725db4549"
899 | dependencies = [
900 | "bitflags",
901 | "errno",
902 | "io-lifetimes",
903 | "libc",
904 | "linux-raw-sys",
905 | "windows-sys",
906 | ]
907 |
908 | [[package]]
909 | name = "ryu"
910 | version = "1.0.12"
911 | source = "registry+https://github.com/rust-lang/crates.io-index"
912 | checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde"
913 |
914 | [[package]]
915 | name = "same-file"
916 | version = "1.0.6"
917 | source = "registry+https://github.com/rust-lang/crates.io-index"
918 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
919 | dependencies = [
920 | "winapi-util",
921 | ]
922 |
923 | [[package]]
924 | name = "scopeguard"
925 | version = "1.1.0"
926 | source = "registry+https://github.com/rust-lang/crates.io-index"
927 | checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
928 |
929 | [[package]]
930 | name = "self-limiters"
931 | version = "0.0.0"
932 | dependencies = [
933 | "bb8-redis",
934 | "cargo-llvm-cov",
935 | "clippy",
936 | "log",
937 | "pyo3",
938 | "pyo3-asyncio",
939 | "pyo3-log",
940 | "redis",
941 | "tokio",
942 | ]
943 |
944 | [[package]]
945 | name = "semver"
946 | version = "1.0.16"
947 | source = "registry+https://github.com/rust-lang/crates.io-index"
948 | checksum = "58bc9567378fc7690d6b2addae4e60ac2eeea07becb2c64b9f218b53865cba2a"
949 | dependencies = [
950 | "serde",
951 | ]
952 |
953 | [[package]]
954 | name = "serde"
955 | version = "1.0.152"
956 | source = "registry+https://github.com/rust-lang/crates.io-index"
957 | checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb"
958 | dependencies = [
959 | "serde_derive",
960 | ]
961 |
962 | [[package]]
963 | name = "serde_derive"
964 | version = "1.0.152"
965 | source = "registry+https://github.com/rust-lang/crates.io-index"
966 | checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e"
967 | dependencies = [
968 | "proc-macro2",
969 | "quote",
970 | "syn",
971 | ]
972 |
973 | [[package]]
974 | name = "serde_json"
975 | version = "1.0.91"
976 | source = "registry+https://github.com/rust-lang/crates.io-index"
977 | checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883"
978 | dependencies = [
979 | "itoa",
980 | "ryu",
981 | "serde",
982 | ]
983 |
984 | [[package]]
985 | name = "sha1_smol"
986 | version = "1.0.0"
987 | source = "registry+https://github.com/rust-lang/crates.io-index"
988 | checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012"
989 |
990 | [[package]]
991 | name = "shared_child"
992 | version = "1.0.0"
993 | source = "registry+https://github.com/rust-lang/crates.io-index"
994 | checksum = "b0d94659ad3c2137fef23ae75b03d5241d633f8acded53d672decfa0e6e0caef"
995 | dependencies = [
996 | "libc",
997 | "winapi",
998 | ]
999 |
1000 | [[package]]
1001 | name = "shell-escape"
1002 | version = "0.1.5"
1003 | source = "registry+https://github.com/rust-lang/crates.io-index"
1004 | checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f"
1005 |
1006 | [[package]]
1007 | name = "signal-hook-registry"
1008 | version = "1.4.0"
1009 | source = "registry+https://github.com/rust-lang/crates.io-index"
1010 | checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0"
1011 | dependencies = [
1012 | "libc",
1013 | ]
1014 |
1015 | [[package]]
1016 | name = "slab"
1017 | version = "0.4.7"
1018 | source = "registry+https://github.com/rust-lang/crates.io-index"
1019 | checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef"
1020 | dependencies = [
1021 | "autocfg",
1022 | ]
1023 |
1024 | [[package]]
1025 | name = "smallvec"
1026 | version = "1.10.0"
1027 | source = "registry+https://github.com/rust-lang/crates.io-index"
1028 | checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
1029 |
1030 | [[package]]
1031 | name = "socket2"
1032 | version = "0.4.7"
1033 | source = "registry+https://github.com/rust-lang/crates.io-index"
1034 | checksum = "02e2d2db9033d13a1567121ddd7a095ee144db4e1ca1b1bda3419bc0da294ebd"
1035 | dependencies = [
1036 | "libc",
1037 | "winapi",
1038 | ]
1039 |
1040 | [[package]]
1041 | name = "syn"
1042 | version = "1.0.107"
1043 | source = "registry+https://github.com/rust-lang/crates.io-index"
1044 | checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5"
1045 | dependencies = [
1046 | "proc-macro2",
1047 | "quote",
1048 | "unicode-ident",
1049 | ]
1050 |
1051 | [[package]]
1052 | name = "target-lexicon"
1053 | version = "0.12.5"
1054 | source = "registry+https://github.com/rust-lang/crates.io-index"
1055 | checksum = "9410d0f6853b1d94f0e519fb95df60f29d2c1eff2d921ffdf01a4c8a3b54f12d"
1056 |
1057 | [[package]]
1058 | name = "target-spec"
1059 | version = "1.2.2"
1060 | source = "registry+https://github.com/rust-lang/crates.io-index"
1061 | checksum = "6a4b9859e2d5bf61d17ccdf2659396d69b207d956f2cb60e09df319394a8ccd4"
1062 | dependencies = [
1063 | "cfg-expr",
1064 | "guppy-workspace-hack",
1065 | "target-lexicon",
1066 | ]
1067 |
1068 | [[package]]
1069 | name = "tempfile"
1070 | version = "3.3.0"
1071 | source = "registry+https://github.com/rust-lang/crates.io-index"
1072 | checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4"
1073 | dependencies = [
1074 | "cfg-if",
1075 | "fastrand",
1076 | "libc",
1077 | "redox_syscall 0.2.16",
1078 | "remove_dir_all",
1079 | "winapi",
1080 | ]
1081 |
1082 | [[package]]
1083 | name = "term"
1084 | version = "0.5.2"
1085 | source = "registry+https://github.com/rust-lang/crates.io-index"
1086 | checksum = "edd106a334b7657c10b7c540a0106114feadeb4dc314513e97df481d5d966f42"
1087 | dependencies = [
1088 | "byteorder",
1089 | "dirs",
1090 | "winapi",
1091 | ]
1092 |
1093 | [[package]]
1094 | name = "termcolor"
1095 | version = "1.1.3"
1096 | source = "registry+https://github.com/rust-lang/crates.io-index"
1097 | checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755"
1098 | dependencies = [
1099 | "winapi-util",
1100 | ]
1101 |
1102 | [[package]]
1103 | name = "tinyvec"
1104 | version = "1.6.0"
1105 | source = "registry+https://github.com/rust-lang/crates.io-index"
1106 | checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
1107 | dependencies = [
1108 | "tinyvec_macros",
1109 | ]
1110 |
1111 | [[package]]
1112 | name = "tinyvec_macros"
1113 | version = "0.1.0"
1114 | source = "registry+https://github.com/rust-lang/crates.io-index"
1115 | checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
1116 |
1117 | [[package]]
1118 | name = "tokio"
1119 | version = "1.23.0"
1120 | source = "registry+https://github.com/rust-lang/crates.io-index"
1121 | checksum = "eab6d665857cc6ca78d6e80303a02cea7a7851e85dfbd77cbdc09bd129f1ef46"
1122 | dependencies = [
1123 | "autocfg",
1124 | "bytes",
1125 | "libc",
1126 | "memchr",
1127 | "mio",
1128 | "num_cpus",
1129 | "parking_lot",
1130 | "pin-project-lite",
1131 | "signal-hook-registry",
1132 | "socket2",
1133 | "tokio-macros",
1134 | "windows-sys",
1135 | ]
1136 |
1137 | [[package]]
1138 | name = "tokio-macros"
1139 | version = "1.8.2"
1140 | source = "registry+https://github.com/rust-lang/crates.io-index"
1141 | checksum = "d266c00fde287f55d3f1c3e96c500c362a2b8c695076ec180f27918820bc6df8"
1142 | dependencies = [
1143 | "proc-macro2",
1144 | "quote",
1145 | "syn",
1146 | ]
1147 |
1148 | [[package]]
1149 | name = "tokio-util"
1150 | version = "0.7.4"
1151 | source = "registry+https://github.com/rust-lang/crates.io-index"
1152 | checksum = "0bb2e075f03b3d66d8d8785356224ba688d2906a371015e225beeb65ca92c740"
1153 | dependencies = [
1154 | "bytes",
1155 | "futures-core",
1156 | "futures-sink",
1157 | "pin-project-lite",
1158 | "tokio",
1159 | "tracing",
1160 | ]
1161 |
1162 | [[package]]
1163 | name = "tracing"
1164 | version = "0.1.37"
1165 | source = "registry+https://github.com/rust-lang/crates.io-index"
1166 | checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8"
1167 | dependencies = [
1168 | "cfg-if",
1169 | "pin-project-lite",
1170 | "tracing-core",
1171 | ]
1172 |
1173 | [[package]]
1174 | name = "tracing-core"
1175 | version = "0.1.30"
1176 | source = "registry+https://github.com/rust-lang/crates.io-index"
1177 | checksum = "24eb03ba0eab1fd845050058ce5e616558e8f8d8fca633e6b163fe25c797213a"
1178 | dependencies = [
1179 | "once_cell",
1180 | ]
1181 |
1182 | [[package]]
1183 | name = "unicode-bidi"
1184 | version = "0.3.8"
1185 | source = "registry+https://github.com/rust-lang/crates.io-index"
1186 | checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992"
1187 |
1188 | [[package]]
1189 | name = "unicode-ident"
1190 | version = "1.0.6"
1191 | source = "registry+https://github.com/rust-lang/crates.io-index"
1192 | checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc"
1193 |
1194 | [[package]]
1195 | name = "unicode-normalization"
1196 | version = "0.1.22"
1197 | source = "registry+https://github.com/rust-lang/crates.io-index"
1198 | checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
1199 | dependencies = [
1200 | "tinyvec",
1201 | ]
1202 |
1203 | [[package]]
1204 | name = "unindent"
1205 | version = "0.1.11"
1206 | source = "registry+https://github.com/rust-lang/crates.io-index"
1207 | checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c"
1208 |
1209 | [[package]]
1210 | name = "url"
1211 | version = "2.3.1"
1212 | source = "registry+https://github.com/rust-lang/crates.io-index"
1213 | checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643"
1214 | dependencies = [
1215 | "form_urlencoded",
1216 | "idna",
1217 | "percent-encoding",
1218 | ]
1219 |
1220 | [[package]]
1221 | name = "version_check"
1222 | version = "0.9.4"
1223 | source = "registry+https://github.com/rust-lang/crates.io-index"
1224 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
1225 |
1226 | [[package]]
1227 | name = "walkdir"
1228 | version = "2.3.2"
1229 | source = "registry+https://github.com/rust-lang/crates.io-index"
1230 | checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56"
1231 | dependencies = [
1232 | "same-file",
1233 | "winapi",
1234 | "winapi-util",
1235 | ]
1236 |
1237 | [[package]]
1238 | name = "wasi"
1239 | version = "0.9.0+wasi-snapshot-preview1"
1240 | source = "registry+https://github.com/rust-lang/crates.io-index"
1241 | checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
1242 |
1243 | [[package]]
1244 | name = "wasi"
1245 | version = "0.11.0+wasi-snapshot-preview1"
1246 | source = "registry+https://github.com/rust-lang/crates.io-index"
1247 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
1248 |
1249 | [[package]]
1250 | name = "winapi"
1251 | version = "0.3.9"
1252 | source = "registry+https://github.com/rust-lang/crates.io-index"
1253 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
1254 | dependencies = [
1255 | "winapi-i686-pc-windows-gnu",
1256 | "winapi-x86_64-pc-windows-gnu",
1257 | ]
1258 |
1259 | [[package]]
1260 | name = "winapi-i686-pc-windows-gnu"
1261 | version = "0.4.0"
1262 | source = "registry+https://github.com/rust-lang/crates.io-index"
1263 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
1264 |
1265 | [[package]]
1266 | name = "winapi-util"
1267 | version = "0.1.5"
1268 | source = "registry+https://github.com/rust-lang/crates.io-index"
1269 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
1270 | dependencies = [
1271 | "winapi",
1272 | ]
1273 |
1274 | [[package]]
1275 | name = "winapi-x86_64-pc-windows-gnu"
1276 | version = "0.4.0"
1277 | source = "registry+https://github.com/rust-lang/crates.io-index"
1278 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
1279 |
1280 | [[package]]
1281 | name = "windows-sys"
1282 | version = "0.42.0"
1283 | source = "registry+https://github.com/rust-lang/crates.io-index"
1284 | checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7"
1285 | dependencies = [
1286 | "windows_aarch64_gnullvm",
1287 | "windows_aarch64_msvc",
1288 | "windows_i686_gnu",
1289 | "windows_i686_msvc",
1290 | "windows_x86_64_gnu",
1291 | "windows_x86_64_gnullvm",
1292 | "windows_x86_64_msvc",
1293 | ]
1294 |
1295 | [[package]]
1296 | name = "windows_aarch64_gnullvm"
1297 | version = "0.42.0"
1298 | source = "registry+https://github.com/rust-lang/crates.io-index"
1299 | checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e"
1300 |
1301 | [[package]]
1302 | name = "windows_aarch64_msvc"
1303 | version = "0.42.0"
1304 | source = "registry+https://github.com/rust-lang/crates.io-index"
1305 | checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4"
1306 |
1307 | [[package]]
1308 | name = "windows_i686_gnu"
1309 | version = "0.42.0"
1310 | source = "registry+https://github.com/rust-lang/crates.io-index"
1311 | checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7"
1312 |
1313 | [[package]]
1314 | name = "windows_i686_msvc"
1315 | version = "0.42.0"
1316 | source = "registry+https://github.com/rust-lang/crates.io-index"
1317 | checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246"
1318 |
1319 | [[package]]
1320 | name = "windows_x86_64_gnu"
1321 | version = "0.42.0"
1322 | source = "registry+https://github.com/rust-lang/crates.io-index"
1323 | checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed"
1324 |
1325 | [[package]]
1326 | name = "windows_x86_64_gnullvm"
1327 | version = "0.42.0"
1328 | source = "registry+https://github.com/rust-lang/crates.io-index"
1329 | checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028"
1330 |
1331 | [[package]]
1332 | name = "windows_x86_64_msvc"
1333 | version = "0.42.0"
1334 | source = "registry+https://github.com/rust-lang/crates.io-index"
1335 | checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5"
1336 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "self-limiters"
3 | version = "0.0.0" # This is set on release in the release workflow
4 | edition = "2021"
5 | include = ["/src", "pyproject.toml"]
6 |
7 | [lib]
8 | name = "self_limiters"
9 | crate-type = ["cdylib"]
10 |
11 | [dependencies]
12 | pyo3 = { version = " >=0.17.2", features = ["extension-module", "abi3-py38"] }
13 | pyo3-log = ">=0.7.0"
14 | log = ">=0.4.17"
15 | pyo3-asyncio = { version = ">=0.17.0", features = ["tokio-runtime"] }
16 | tokio = {version=">=1.20.1", default-features=false}
17 | redis = { version=">=0.21.5", default-features=false, features = ["ahash", "script"] }
18 | bb8-redis = "0.12.0"
19 |
20 | [dev-dependencies]
21 | cargo-llvm-cov = { version = ">=0.4.1" }
22 | clippy = { version = ">=0.0.302" }
23 |
24 | [profile.release]
25 | opt-level = "s"
26 | overflow-checks = true
27 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 4-Clause License
2 |
3 | Copyright (c) 2022, Sondre Lillebø Gundersen
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | 3. All advertising materials mentioning features or use of this software must
17 | display the following acknowledgement:
18 | This product includes software developed by [project].
19 |
20 | 4. Neither the name of the copyright holder nor the names of its
21 | contributors may be used to endorse or promote products derived from
22 | this software without specific prior written permission.
23 |
24 | THIS SOFTWARE IS PROVIDED BY COPYRIGHT HOLDER "AS IS" AND ANY EXPRESS OR
25 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
26 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
27 | EVENT SHALL COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
28 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
29 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
30 | OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
31 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
32 | OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
33 | ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
34 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | > Project no longer maintained
7 | >
8 | > This was a fun experiment, but since the library is i/o-bound, the long-term maintenance burden of a Rust Python plugin did not seem worth it. Instead I decided to re-implement this in Python.
9 | >
10 | > See https://github.com/otovo/redis-rate-limiters for the python version :)
11 |
12 |
13 | # Self limiters
14 |
15 | A library for regulating traffic with respect to **concurrency** or **time**.
16 |
17 | It implements a [semaphore](https://en.wikipedia.org/wiki/Semaphore_(programming)) to be used when you need to
18 | limit the number of *concurrent* requests to an API (or other resources). For example if you can at most
19 | send 5 requests at the same time.
20 |
21 | It also implements the [token bucket algorithm](https://en.wikipedia.org/wiki/Token_bucket) which can be used
22 | to limit the number of requests made in a given time interval. For example if you're restricted to
23 | sending, at most, 10 requests per second.
24 |
25 | Both limiters are async, FIFO, and distributed using Redis. This means the limiters can be
26 | used across several distributed processes. If that isn't your use-case, you should probably
27 | look for another library which does this in memory.
28 |
29 | The package was written with rate-limiting in mind, but the semaphore and token bucket
30 | implementations can be used for anything.
31 |
32 | # Installation
33 |
34 | ```
35 | pip install self-limiters
36 | ```
37 |
38 | # Usage
39 |
40 | Both implementations are written as async context managers.
41 |
42 | ### Semaphore
43 |
44 | The `Semaphore` can be used like this:
45 |
46 | ```python
47 | from self_limiters import Semaphore
48 |
49 |
50 | # 5 requests at the time
51 | async with Semaphore(name="", capacity=5, max_sleep=60, redis_url=""):
52 | client.get(...)
53 | ```
54 |
55 | The `name` given is what determines whether your processes will use the same limiter or not.
56 |
57 | The semaphore implementation is largely a wrapper around the [`blpop`](https://redis.io/commands/blpop/)
58 | redis command. We use it to wait for the semaphore to be freed up, in a non-blocking way.
59 |
60 | If you specify a non-zero `max_sleep`, a `MaxSleepExceededError` will be raised if `blpop` waits for longer than that specified value.
61 |
62 | ### Token bucket
63 |
64 | The `TokenBucket` context manager is used the same way, like this:
65 |
66 | ```python
67 | from self_limiters import TokenBucket
68 |
69 |
70 | # 1 requests per minute
71 | async with TokenBucket(
72 | name="",
73 | capacity=1,
74 | refill_amount=1,
75 | refill_frequency=60,
76 | max_sleep=600,
77 | redis_url=""
78 | ):
79 | client.get(...)
80 | ```
81 |
82 | The limiter first estimates when there will be capacity in the bucket - i.e., when it's this instances turn to go,
83 | then async sleeps until then.
84 |
85 | If `max_sleep` is set and the estimated sleep time exceeds this, a `MaxSleepExceededError`
86 | is raised immediately.
87 |
88 | ### As a decorator
89 |
90 | The package doesn't ship any decorators, but if you would
91 | like to limit the rate at which a whole function is run,
92 | you can create your own, like this:
93 |
94 | ```python
95 | from self_limiters import Semaphore
96 |
97 |
98 | # Define a decorator function
99 | def limit(name, capacity):
100 | def middle(f):
101 | async def inner(*args, **kwargs):
102 | async with Semaphore(
103 | name=name,
104 | capacity=capacity,
105 | redis_url="redis://127.0.0.1:6389"
106 | ):
107 | return await f(*args, **kwargs)
108 | return inner
109 | return middle
110 |
111 |
112 | # Then pass the relevant limiter arguments like this
113 | @limit(name="foo", capacity=5)
114 | def fetch_foo(id: UUID) -> Foo:
115 | ```
116 |
117 | # Implementation and general flow
118 |
119 | The library is written in Rust (for fun) and more importantly, relies on
120 | [Lua](http://www.lua.org/about.html) scripts and
121 | [pipelining](https://docs.rs/redis/0.22.0/redis/struct.Pipeline.html) to
122 | improve the performance of each implementation.
123 |
124 | Redis lets users upload and execute Lua scripts on the server directly, meaning we can write
125 | e.g., the entire token bucket logic in Lua. Using Lua scripts presents a couple of nice benefits:
126 |
127 | - Since they are executed on the redis instance, we can make 1 request to redis
128 | where we would otherwise have to make 3 or 4. The time saved by reducing
129 | the number of requests can be significant.
130 |
131 | - Redis is single-threaded and guarantees atomic execution of scripts, meaning
132 | we don't have to worry about data races. As a consequence, our implementations
133 | become FIFO out of the box. Without atomic execution we'd need distributed locks to prevent race conditions, which would
134 | be very expensive.
135 |
136 | So Lua scripts help make our implementation faster and simpler.
137 |
138 | This is the rough flow of execution, for each implementation:
139 |
140 | ### The semaphore implementation
141 |
142 | 1. Run a [lua script](https://github.com/snok/self-limiters/blob/main/scripts/semaphore.lua)
143 | to create a list data structure in redis, as the foundation of the semaphore.
144 |
145 | This script is idempotent, and skipped if it has already been created.
146 |
147 | 2. Run [`BLPOP`](https://redis.io/commands/blpop/) to non-blockingly wait until the semaphore has capacity,
148 | and pop from the list when it does.
149 |
150 | 3. Then run a [pipelined command](https://github.com/snok/self-limiters/blob/main/src/semaphore.rs#L78:L83)
151 | to release the semaphore by adding back the capacity borrowed.
152 |
153 | So in total we make 3 calls to redis, which are all non-blocking.
154 |
155 | ### The token bucket implementation
156 |
157 | The token bucket implementation is even simpler. The steps are:
158 |
159 | 1. Run a [lua script](https://github.com/snok/self-limiters/blob/main/scripts/token_bucket.lua)
160 | to estimate and return a wake-up time.
161 |
162 | 2. Sleep until the given timestamp.
163 |
164 | We make one call, then sleep. Both are non-blocking.
165 |
166 | ---------
167 |
168 | So in summary, almost all of the time is spent waiting for async i/o, meaning the limiters' impact on an
169 | application event-loop should be close to completely negligible.
170 |
171 | ## Benchmarks
172 |
173 | We run benchmarks in CI with Github actions. For a normal `ubuntu-latest` runner, we see runtimes for both limiters:
174 |
175 | When creating 100 instances of each implementation and calling them at the same time, the average runtimes are:
176 |
177 | - Semaphore implementation: ~0.6ms per instance
178 | - Token bucket implementation: ~0.03ms per instance
179 |
180 | Take a look at the [benchmarking script](https://github.com/snok/self-limiters/blob/main/src/bench.py) if you want
181 | to run your own tests!
182 |
183 | # Implementation reference
184 |
185 | ## The semaphore implementation
186 |
187 | The semaphore implementation is useful when you need to limit a process
188 | to `n` concurrent actions. For example if you have several web servers, and
189 | you're interacting with an API that will only tolerate a certain amount of
190 | concurrent requests before locking you out.
191 |
192 | The flow can be broken down as follows:
193 |
194 |
195 |
196 | The initial [lua script](https://github.com/snok/self-limiters/blob/main/scripts/semaphore.lua)
197 | first checks if the redis list we will build the semaphore on exists or not.
198 | It does this by calling [`SETNX`](https://redis.io/commands/setnx/) on the key of the queue plus a postfix
199 | (if the `name` specified in the class instantiation is "my-queue", then the queue name will be
200 | `__self-limiters:my-queue` and setnx will be called for `__self-limiters:my-queue-exists`). If the returned
201 | value is 1 it means the queue we will use for our semaphore does not exist yet and needs to be created.
202 |
203 | It might strike you as weird to maintain a separate value, just to indicate whether a list exists,
204 | when we could just check the list itself. It would be nice if we could use
205 | [`EXISTS`](https://redis.io/commands/exists/) on the list directly, but unfortunately a list is considered
206 | not to exist when all elements are popped (i.e., when a semaphore is fully acquired), so I don't see
207 | another way of doing this. Contributions are very welcome if you do!
208 |
209 | Then if the queue needs to be created we call [`RPUSH`](https://redis.io/commands/rpush/) with the number of arguments
210 | equal to the `capacity` value used when initializing the semaphore instance. For a semaphore with
211 | a capacity of 5, we call `RPUSH 1 1 1 1 1`, where the values are completely arbitrary.
212 |
213 | Once the list/queue has been created, we [`BLPOP`](https://redis.io/commands/blpop/) to block until it's
214 | our turn. `BLPOP` is FIFO by default. We also make sure to specify the `max_sleep` based on the initialized
215 | semaphore instance setting. If nothing was passed we allow sleeping forever.
216 |
217 | On `__aexit__` we run three commands in a pipelined query. We [`RPUSH`](https://redis.io/commands/rpush/) a `1`
218 | back into the queue to "release" the semaphore, and set an expiry on the queue and the string value we called
219 | `SETNX` on.
220 |
221 | The expires are a half measure for dealing with dropped capacity. If a node holding the semaphore dies,
222 | the capacity might never be returned. If, however, there is no one using the semaphore for the duration of the
223 | expiry value, all values will be cleared, and the semaphore will be recreated at full capacity next time it's used.
224 | The expiry is 30 seconds at the time of writing, but could be made configurable.
225 |
226 | ### The token bucket implementation
227 |
228 | The token bucket implementation is useful when you need to limit a process by
229 | a time interval. For example, to 1 request per minute, or 50 requests every 10 seconds.
230 |
231 | The implementation is forward-looking. It works out the time there *would have been*
232 | capacity in the bucket for a given client and returns that time. From there we can
233 | asynchronously sleep until it's time to perform our rate limited action.
234 |
235 | The flow can be broken down as follows:
236 |
237 |
238 |
239 | Call the [schedule Lua script](https://github.com/snok/self-limiters/blob/main/scripts/token_bucket.lua)
240 | which first [`GET`](https://redis.io/commands/get/)s the *state* of the bucket.
241 |
242 | The bucket state contains the last time slot scheduled and the number of tokens left for that time slot.
243 | With a capacity of 1, having a `tokens_left_for_slot` variable makes no sense, but if there's
244 | capacity of 2 or more, it is possible that we will need to schedule multiple clients to the
245 | same time slot.
246 |
247 | The script then works out whether to decrement the `tokens_left_for_slot` value, or to
248 | increment the time slot value wrt. the frequency variable.
249 |
250 | Finally, we store the bucket state again using [`SETEX`](https://redis.io/commands/setex/).
251 | This allows us to store the state and set expiry at the same time. The default expiry
252 | is 30 at the time of writing, but could be made configurable.
253 |
254 | One thing to note, is that this would not work if it wasn't for the fact that redis is single threaded,
255 | so Lua scripts on Redis are FIFO. Without this we would need locks and a lot more logic.
256 |
257 | Then we just sleep!
258 |
259 | # Contributing
260 |
261 | Please do! Feedback on the implementation, issues, and PRs are all welcome. See [`CONTRIBUTING.md`](https://github.com/snok/self-limiters/blob/main/CONTRIBUTING.md) for more details.
262 |
263 | Please also consider starring the repo to raise visibility.
264 |
--------------------------------------------------------------------------------
/bench.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | import asyncio
4 | import logging
5 | from datetime import datetime, timedelta
6 | from functools import partial
7 | from statistics import median
8 | from typing import Optional, Union
9 | from uuid import uuid4
10 |
11 | import typer
12 | from self_limiters import Semaphore, TokenBucket
13 |
14 | FORMAT = '[%(asctime)s] %(message)s'
15 | logging.basicConfig(format=FORMAT, level=logging.DEBUG)
16 |
17 |
18 | async def run(t: Union[Semaphore, TokenBucket], sleep: float):
19 | """Run context manager."""
20 | async with t:
21 | await asyncio.sleep(sleep)
22 |
23 |
24 | async def run_n(*, n: int, t: Union[Semaphore, TokenBucket], sleep: float):
25 | """Call run `n` times for the same limiter."""
26 | tasks = [asyncio.create_task(run(t, sleep)) for _ in range(n)]
27 | start = datetime.now()
28 | await asyncio.gather(*tasks)
29 | return datetime.now() - start
30 |
31 |
32 | async def run_iterations(t: partial, sleep: float, count: int, iterations: int):
33 | """Call run_n for `iterations` permutations of a limiter."""
34 | return [await run_n(n=count, t=t(name=uuid4().hex[:6]), sleep=sleep) for _ in range(iterations)]
35 |
36 |
37 | def main(
38 | type: str,
39 | count: int = 10,
40 | iterations: int = 10,
41 | target: Optional[float] = None,
42 | capacity: int = 1,
43 | max_sleep: float = 0.0,
44 | redis_url: str = 'redis://127.0.0.1:6389',
45 | sleep: float = 0.0,
46 | ):
47 | """
48 | Runs a simple benchmark using the library limiters.
49 |
50 | Can be run like:
51 |
52 | python bench.py s \
53 | --count 100 \
54 | --iterations 12 \
55 | --target 4
56 |
57 | :param type: Which of the limiters to use. Semaphore or TokenBucket.
58 | :param count: How many context managers to run for a single limiter.
59 | :param iterations: How many limiters to run.
60 | :param target: What maximum time to allow, in ms.
61 | :param capacity: The limiter capacity.
62 | :param max_sleep: The limiter max sleep.
63 | :param redis_url: Redis connection string.
64 | :param sleep: How long to sleep before exiting context manager closure.
65 | :return: Nothing.
66 | """
67 | t: partial
68 | if type.startswith('s'):
69 | typer.echo('Testing semaphore...')
70 | t = partial(Semaphore, capacity=capacity, max_sleep=max_sleep, redis_url=redis_url, connection_pool_size=30)
71 | offset = 0.0
72 | elif type.startswith('t'):
73 | typer.echo('Testing token bucket...')
74 | t = partial(
75 | TokenBucket,
76 | refill_frequency=0.01,
77 | refill_amount=1,
78 | capacity=capacity,
79 | max_sleep=max_sleep,
80 | redis_url=redis_url,
81 | connection_pool_size=30,
82 | )
83 | offset = 0.01 * count
84 | else:
85 | typer.echo(f'type must be \'semaphore\' or \'token_bucket\', not {type}', color=True, err=True)
86 | return exit(1)
87 |
88 | times: list[timedelta] = asyncio.run(run_iterations(t, sleep, count, iterations))
89 |
90 | seconds = [t.seconds + t.microseconds / 1_000_000 - offset for t in times]
91 |
92 | # discard first iterations
93 | n = len(seconds) // 10
94 | print(f'Discarding {n} first samples')
95 | seconds = seconds[n:]
96 |
97 | avg = sum(seconds) / iterations / count * 1000
98 | med = median(seconds) / count * 1000
99 | print(f'Average was {avg :.2f}ms per run')
100 | print(f'Median was {med:.2f}ms per run')
101 |
102 | if target:
103 | assert med <= target, f'Median time of {med}ms was not above target of {target}ms'
104 | print(f'Median time was below target of {target}ms')
105 |
106 |
107 | if __name__ == '__main__':
108 | typer.run(main)
109 |
--------------------------------------------------------------------------------
/build.rs:
--------------------------------------------------------------------------------
1 | use std::fs;
2 |
3 | fn read_script(filename: &str) -> String {
4 | fs::read_to_string(format!("./scripts/{}.lua", filename)).ok().unwrap()
5 | }
6 |
7 | fn main() -> Result<(), Box> {
8 | let semaphore_script_contents = read_script("semaphore");
9 | let token_bucket_script_contents = read_script("token_bucket");
10 |
11 | let mut file_content = "\
12 | /// This file is generated with a build script.
13 | ///
14 | /// Do not make changes to this file. Instead edit the Lua scripts directly.
15 |
16 | "
17 | .to_string();
18 | file_content += &format!(
19 | "pub const SEMAPHORE_SCRIPT: &str = \"\\\n{}\";\n",
20 | semaphore_script_contents
21 | );
22 | file_content += &format!(
23 | "pub const TOKEN_BUCKET_SCRIPT: &str = \"\\\n{}\";\n",
24 | token_bucket_script_contents
25 | );
26 |
27 | fs::write("src/generated.rs", file_content).unwrap();
28 | Ok(())
29 | }
30 |
--------------------------------------------------------------------------------
/coverage-report.sh:
--------------------------------------------------------------------------------
1 | #!/bin/zsh
2 |
3 | # Before running this, you'll need to run `rustup component add llvm-tools-preview`
4 |
5 | set -euo pipefail
6 |
7 | cargo llvm-cov show-env --export-prefix
8 | export RUSTFLAGS=" -C instrument-coverage --cfg coverage --cfg trybuild_no_target"
9 | export LLVM_PROFILE_FILE="$PWD/target/self-limiters-%m.profraw"
10 | export CARGO_INCREMENTAL="0"
11 | export CARGO_LLVM_COV_TARGET_DIR="$PWD/target"
12 |
13 | source <(cargo llvm-cov show-env --export-prefix)
14 |
15 | export CARGO_TARGET_DIR=$CARGO_LLVM_COV_TARGET_DIR
16 | export CARGO_INCREMENTAL=1
17 | cargo llvm-cov clean --workspace
18 | cargo test
19 | source "$VIRTUAL_ENV"/bin/activate
20 | maturin develop
21 | coverage run -m pytest tests
22 |
23 | cargo llvm-cov report --ignore-filename-regex "errors|_tests|lib"
24 |
--------------------------------------------------------------------------------
/dev-requirements.txt:
--------------------------------------------------------------------------------
1 | pytest
2 | pytest-randomly
3 | pytest-asyncio
4 | pre-commit
5 | coverage
6 | maturin[zig]
7 | uvloop
8 | coverage
9 | redis
10 | types-redis
11 | typer
12 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | redis:
5 | container_name: 'sl-redis'
6 | image: redis:alpine
7 | command: redis-server --appendonly yes
8 | ports:
9 | - '127.0.0.1:6389:6379'
10 |
--------------------------------------------------------------------------------
/docs/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/docs/semaphore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/snok/self-limiters/28092ab71d71c1686826c02c14b464b2fcee367e/docs/semaphore.png
--------------------------------------------------------------------------------
/docs/semaphore.svg:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/docs/token_bucket.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/snok/self-limiters/28092ab71d71c1686826c02c14b464b2fcee367e/docs/token_bucket.png
--------------------------------------------------------------------------------
/docs/token_bucket.svg:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "self-limiters"
3 | description = "Distributed async rate limiters, using Redis"
4 | authors = [{ name = "Sondre Lillebø Gundersen", email = "sondrelg@live.no" }]
5 | readme = "README.md"
6 | requires-python = ">=3.9"
7 | license = { file = "LICENSE", type = 'BSD-3' }
8 | homepage = "https://github.com/sondrelg/self-limiters"
9 | repository = "https://github.com/sondrelg/self-limiters"
10 | keywords = [
11 | "distributed",
12 | "async",
13 | "rate-limit",
14 | "rate",
15 | "limit",
16 | "limiting",
17 | "redis",
18 | "rust",
19 | "semaphore",
20 | "token",
21 | "leaky",
22 | "bucket",
23 | "tokenbucket",
24 | ]
25 | dependencies = []
26 | classifiers = [
27 | "Development Status :: 4 - Beta",
28 | "Programming Language :: Rust",
29 | 'Programming Language :: Python',
30 | 'Programming Language :: Python :: 3.9',
31 | 'Programming Language :: Python :: 3.10',
32 | 'Programming Language :: Python :: 3.11',
33 | "Programming Language :: Python :: Implementation :: CPython",
34 | "Programming Language :: Python :: Implementation :: PyPy",
35 | 'License :: OSI Approved :: BSD License',
36 | 'Intended Audience :: Developers',
37 | ]
38 |
39 | [tool.maturin]
40 | bindings = "pyo3"
41 | strip = true
42 |
43 | [project.urls]
44 | releases = "https://github.com/sondrelg/self-limiters/releases"
45 |
46 | [build-system]
47 | requires = ["maturin>=0.13,<0.14"]
48 | build-backend = "maturin"
49 |
50 | [tool.black]
51 | line-length = 120
52 | skip-string-normalization = true
53 | quiet = true
54 | preview = true
55 | target-version = ["py38"]
56 |
57 | [tool.isort]
58 | profile = "black"
59 | line_length = 120
60 |
--------------------------------------------------------------------------------
/rustfmt.toml:
--------------------------------------------------------------------------------
1 | max_width = 120
2 |
--------------------------------------------------------------------------------
/scripts/semaphore.lua:
--------------------------------------------------------------------------------
1 | --- Script called from the Semaphore implementation.
2 | ---
3 | --- Lua scripts are run atomically by default, and since redis
4 | --- is single threaded, there are no race conditions to worry about.
5 | ---
6 | --- The script checks if a list exists for the Semaphore, and
7 | --- creates one of length `capacity` if it doesn't.
8 | ---
9 | --- keys:
10 | --- * key: The key to use for the list
11 | --- * existskey: The key to use for the string we use to check if the lists exists
12 | ---
13 | --- args:
14 | --- * capacity: The capacity of the semaphore (i.e., the length of the list)
15 | ---
16 | --- returns:
17 | --- * 1 if created, else 0 (but the return value isn't used; only useful for debugging)
18 |
19 | redis.replicate_commands()
20 |
21 | -- Init config variables
22 | local key = tostring(KEYS[1])
23 | local existskey = tostring(KEYS[2])
24 | local capacity = tonumber(ARGV[1])
25 |
26 | -- Check if list exists
27 | -- Note, we cannot use EXISTS or LLEN below, as we need
28 | -- to know if a list exists, but has capacity zero.
29 | local does_not_exist = redis.call('SETNX', string.format(existskey, key), 1)
30 |
31 | -- Create the list if none exists
32 | if does_not_exist == 1 then
33 | -- Add '1' as an argument equal to the capacity of the semaphore
34 | -- If capacity is 5 here, we generate `{RPUSH, 1, 1, 1, 1, 1}`.
35 | local args = { 'RPUSH', key }
36 | for _ = 1, capacity do
37 | table.insert(args, 1)
38 | end
39 | redis.call(unpack(args))
40 | return true
41 | end
42 | return false
43 |
--------------------------------------------------------------------------------
/scripts/token_bucket.lua:
--------------------------------------------------------------------------------
1 | --- Script called from the Semaphore implementation.
2 | --- partially modelled after https://github.com/Tinche/aiosteady
3 | ---
4 | --- Lua scripts are run atomically by default, and since redis
5 | --- is single threaded, there are no race conditions to worry about.
6 | ---
7 | --- This script does three things, in order:
8 | --- 1. Retrieves token bucket state, which means the last slot assigned,
9 | --- and how many tokens are left to be assigned for that slot
10 | --- 2. Works out whether we need to move to the next slot, or consume another
11 | --- token from the current one.
12 | --- 3. Saves the token bucket state and returns the slot.
13 | ---
14 | --- The token bucket implementation is forward looking, so we're really just handing
15 | --- out the next time there would be tokens in the bucket, and letting the client
16 | --- sleep until then. This would be terrible in a sync application, but for an async
17 | --- python task runner or web-server, it's actually very handy. There is the issue
18 | --- of processes sleeping for an unreasonably long time, but there is a max-sleep
19 | --- setting in both implementations to offset this.
20 | ---
21 | --- keys:
22 | --- * key: The key name to use for the semaphore
23 | ---
24 | --- args:
25 | --- * capacity: The max capacity of the bucket
26 | --- * refill_rate: How often tokens are added to the bucket, (NOTE) in *milliseconds*
27 | --- The rate is in milliseconds since we cannot use floats for the `now` variable.
28 | --- This deviates from the rest of the package code, where the rate is specified in seconds.
29 | --- * refill_amount: How many tokens are added at each interval
30 | ---
31 | --- returns:
32 | --- * The assigned slot, as a millisecond timestamp
33 |
34 | redis.replicate_commands()
35 |
36 | -- Init config variables
37 | local data_key = KEYS[1]
38 | local capacity = tonumber(ARGV[1])
39 | local refill_rate = tonumber(ARGV[2])
40 | local refill_amount = tonumber(ARGV[3])
41 |
42 | -- Get current time (ms timestamp)
43 | local redis_time = redis.call('TIME') -- Array of [seconds, microseconds]
44 | local now = tonumber(redis_time[1]) * 1000 + (tonumber(redis_time[2]) / 1000)
45 |
46 | -- Instantiate default bucket values
47 | -- These are used if no state is retrieved below; i.e., they
48 | -- are the values we use for creating a new bucket.
49 | local tokens = refill_amount
50 | local slot = now + refill_rate
51 |
52 | -- Retrieve (possibly) stored state
53 | local data = redis.call('GET', data_key)
54 |
55 | if data ~= false then
56 | for a, b in string.gmatch(data, '(%S+) (%S+)') do
57 | slot = tonumber(a)
58 | tokens = tonumber(b)
59 | end
60 |
61 | -- Quickly validate our state
62 |
63 | -- If the slot is in the past, we need to increment the slot
64 | -- value, and add tokens to the bucket equal to the slots skipped
65 | if slot < now + 20 then
66 | tokens = tokens + (slot - now) / refill_rate
67 | slot = slot + refill_rate
68 |
69 | -- If we skipped 3 slots, but the capacity is 1,
70 | -- trim the tokens left.
71 | if tokens > capacity then
72 | tokens = capacity
73 | end
74 | end
75 |
76 | -- If the current slot has no more tokens to assign,
77 | -- move to the next slot.
78 | if tokens <= 0 then
79 | slot = slot + refill_rate
80 | tokens = refill_amount
81 | end
82 | end
83 |
84 | -- Consume a token
85 | tokens = tokens - 1
86 |
87 | -- Save state and set expiry
88 | redis.call('SETEX', data_key, 30, string.format('%d %d', slot, tokens))
89 |
90 | return slot
91 |
--------------------------------------------------------------------------------
/self_limiters.pyi:
--------------------------------------------------------------------------------
1 | from types import TracebackType
2 | from typing import Optional
3 |
4 | class TokenBucket:
5 | def __init__(
6 | self,
7 | name: str,
8 | capacity: int,
9 | refill_frequency: float,
10 | refill_amount: int,
11 | redis_url: Optional[str] = None, # will be set as "redis://127.0.0.1:6379" if None
12 | max_sleep: Optional[float] = None, # will be set to 0.0 if None. In seconds.
13 | connection_pool_size: Optional[int] = None, # Will be set to 15 if None
14 | ) -> None: ...
15 |
16 | capacity: int
17 | name: str
18 | refill_frequency: float
19 | refill_amount: int
20 |
21 | async def __aenter__(self) -> None: ...
22 | async def __aexit__(
23 | self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
24 | ) -> None: ...
25 |
26 | class Semaphore:
27 | def __init__(
28 | self,
29 | name: str,
30 | capacity: int,
31 | max_sleep: Optional[float] = None, # Set to 0.0 when None is passed. In seconds.
32 | expiry: Optional[int] = None, # Set to 30 when None is passed. In seconds.
33 | redis_url: Optional[str] = None, # will be set as "redis://127.0.0.1:6379" if None
34 | connection_pool_size: Optional[int] = None, # Will be set to 15 if None
35 | ) -> None: ...
36 |
37 | capacity: int
38 | name: str
39 | max_sleep: float
40 | expiry: int
41 |
42 | async def __aenter__(self) -> None: ...
43 | async def __aexit__(
44 | self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
45 | ) -> None: ...
46 |
47 | __all__: list[str]
48 |
49 | class RedisError(Exception):
50 | """
51 | Raised when the downstream redis library raises any exception.
52 | """
53 |
54 | pass
55 |
56 | class MaxSleepExceededError(Exception):
57 | """
58 | Raised when we've slept for longer than the `max_sleep` specified limit.
59 | """
60 |
61 | pass
62 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [tool:pytest]
2 | asyncio_mode = auto
3 | testpaths = tests
4 |
5 | [flake8]
6 | max-line-length = 120
7 | enable-extensions=TC,TC2
8 | ignore = PT006
9 | per-file-ignores =
10 | .github/*:T201
11 |
--------------------------------------------------------------------------------
/src/errors.rs:
--------------------------------------------------------------------------------
1 | extern crate redis;
2 |
3 | use std::io::Error;
4 | use std::sync::mpsc::{RecvError, SendError};
5 | use std::time::SystemTimeError;
6 |
7 | use bb8_redis::bb8::RunError;
8 | use pyo3::create_exception;
9 | use pyo3::exceptions::{PyException, PyRuntimeError};
10 | use pyo3::prelude::*;
11 | use redis::RedisError as RedisLibError;
12 |
13 | // Raised when redis::RedisError is raised by the redis crate.
14 | create_exception!(self_limiters, RedisError, PyException);
15 |
16 | // Raised when we've slept for too long. Useful for catching forever-growing queues.
17 | create_exception!(self_limiters, MaxSleepExceededError, PyException);
18 |
19 | /// Enum containing all handled errors.
20 | /// This enables us to use the `?` operator on function calls to utilities
21 | /// that raise any of the mapped errors below, to automatically raise the
22 | /// appropriate mapped Python error.
23 | #[derive(Debug)]
24 | pub(crate) enum SLError {
25 | MaxSleepExceeded(String),
26 | Redis(String),
27 | RuntimeError(String),
28 | }
29 |
30 | // Map relevant error types to appropriate Python exceptions
31 | impl From for PyErr {
32 | fn from(e: SLError) -> Self {
33 | match e {
34 | SLError::MaxSleepExceeded(e) => MaxSleepExceededError::new_err(e),
35 | SLError::Redis(e) => RedisError::new_err(e),
36 | SLError::RuntimeError(e) => PyRuntimeError::new_err(e),
37 | }
38 | }
39 | }
40 |
41 | // redis::RedisError could be raised any time we perform a call to redis
42 | impl From for SLError {
43 | fn from(e: RedisLibError) -> Self {
44 | Self::Redis(e.to_string())
45 | }
46 | }
47 |
48 | // SendError could be raised when we pass data to a channel
49 | impl From> for SLError {
50 | fn from(e: SendError) -> Self {
51 | Self::RuntimeError(e.to_string())
52 | }
53 | }
54 |
55 | // RecvError could be raised when we read data from a channel
56 | impl From for SLError {
57 | fn from(e: RecvError) -> Self {
58 | Self::RuntimeError(e.to_string())
59 | }
60 | }
61 |
62 | // std::io::Error could be raised when we read our Lua scripts
63 | impl From for SLError {
64 | fn from(e: Error) -> Self {
65 | Self::RuntimeError(e.to_string())
66 | }
67 | }
68 |
69 | // SystemTimeError could be raised when calling SystemTime.now()
70 | impl From for SLError {
71 | fn from(e: SystemTimeError) -> Self {
72 | Self::RuntimeError(e.to_string())
73 | }
74 | }
75 |
76 | // RunError could happen when creating a connection pool
77 | impl From> for SLError {
78 | fn from(e: RunError) -> Self {
79 | Self::RuntimeError(e.to_string())
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/generated.rs:
--------------------------------------------------------------------------------
1 | /// This file is generated with a build script.
2 | ///
3 | /// Do not make changes to this file. Instead edit the Lua scripts directly.
4 |
5 | pub const SEMAPHORE_SCRIPT: &str = "\
6 | --- Script called from the Semaphore implementation.
7 | ---
8 | --- Lua scripts are run atomically by default, and since redis
9 | --- is single threaded, there are no race conditions to worry about.
10 | ---
11 | --- The script checks if a list exists for the Semaphore, and
12 | --- creates one of length `capacity` if it doesn't.
13 | ---
14 | --- keys:
15 | --- * key: The key to use for the list
16 | --- * existskey: The key to use for the string we use to check if the lists exists
17 | ---
18 | --- args:
19 | --- * capacity: The capacity of the semaphore (i.e., the length of the list)
20 | ---
21 | --- returns:
22 | --- * 1 if created, else 0 (but the return value isn't used; only useful for debugging)
23 |
24 | redis.replicate_commands()
25 |
26 | -- Init config variables
27 | local key = tostring(KEYS[1])
28 | local existskey = tostring(KEYS[2])
29 | local capacity = tonumber(ARGV[1])
30 |
31 | -- Check if list exists
32 | -- Note, we cannot use EXISTS or LLEN below, as we need
33 | -- to know if a list exists, but has capacity zero.
34 | local does_not_exist = redis.call('SETNX', string.format(existskey, key), 1)
35 |
36 | -- Create the list if none exists
37 | if does_not_exist == 1 then
38 | -- Add '1' as an argument equal to the capacity of the semaphore
39 | -- If capacity is 5 here, we generate `{RPUSH, 1, 1, 1, 1, 1}`.
40 | local args = { 'RPUSH', key }
41 | for _ = 1, capacity do
42 | table.insert(args, 1)
43 | end
44 | redis.call(unpack(args))
45 | return true
46 | end
47 | return false
48 | ";
49 | pub const TOKEN_BUCKET_SCRIPT: &str = "\
50 | --- Script called from the Semaphore implementation.
51 | --- partially modelled after https://github.com/Tinche/aiosteady
52 | ---
53 | --- Lua scripts are run atomically by default, and since redis
54 | --- is single threaded, there are no race conditions to worry about.
55 | ---
56 | --- This script does three things, in order:
57 | --- 1. Retrieves token bucket state, which means the last slot assigned,
58 | --- and how many tokens are left to be assigned for that slot
59 | --- 2. Works out whether we need to move to the next slot, or consume another
60 | --- token from the current one.
61 | --- 3. Saves the token bucket state and returns the slot.
62 | ---
63 | --- The token bucket implementation is forward looking, so we're really just handing
64 | --- out the next time there would be tokens in the bucket, and letting the client
65 | --- sleep until then. This would be terrible in a sync application, but for an async
66 | --- python task runner or web-server, it's actually very handy. There is the issue
67 | --- of processes sleeping for an unreasonably long time, but there is a max-sleep
68 | --- setting in both implementations to offset this.
69 | ---
70 | --- keys:
71 | --- * key: The key name to use for the semaphore
72 | ---
73 | --- args:
74 | --- * capacity: The max capacity of the bucket
75 | --- * refill_rate: How often tokens are added to the bucket, (NOTE) in *milliseconds*
76 | --- The rate is in milliseconds since we cannot use floats for the `now` variable.
77 | --- This deviates from the rest of the package code, where the rate is specified in seconds.
78 | --- * refill_amount: How many tokens are added at each interval
79 | ---
80 | --- returns:
81 | --- * The assigned slot, as a millisecond timestamp
82 |
83 | redis.replicate_commands()
84 |
85 | -- Init config variables
86 | local data_key = KEYS[1]
87 | local capacity = tonumber(ARGV[1])
88 | local refill_rate = tonumber(ARGV[2])
89 | local refill_amount = tonumber(ARGV[3])
90 |
91 | -- Get current time (ms timestamp)
92 | local redis_time = redis.call('TIME') -- Array of [seconds, microseconds]
93 | local now = tonumber(redis_time[1]) * 1000 + (tonumber(redis_time[2]) / 1000)
94 |
95 | -- Instantiate default bucket values
96 | -- These are used if no state is retrieved below; i.e., they
97 | -- are the values we use for creating a new bucket.
98 | local tokens = refill_amount
99 | local slot = now + refill_rate
100 |
101 | -- Retrieve (possibly) stored state
102 | local data = redis.call('GET', data_key)
103 |
104 | if data ~= false then
105 | for a, b in string.gmatch(data, '(%S+) (%S+)') do
106 | slot = tonumber(a)
107 | tokens = tonumber(b)
108 | end
109 |
110 | -- Quickly validate our state
111 |
112 | -- If the slot is in the past, we need to increment the slot
113 | -- value, and add tokens to the bucket equal to the slots skipped
114 | if slot < now + 20 then
115 | tokens = tokens + (slot - now) / refill_rate
116 | slot = slot + refill_rate
117 |
118 | -- If we skipped 3 slots, but the capacity is 1,
119 | -- trim the tokens left.
120 | if tokens > capacity then
121 | tokens = capacity
122 | end
123 | end
124 |
125 | -- If the current slot has no more tokens to assign,
126 | -- move to the next slot.
127 | if tokens <= 0 then
128 | slot = slot + refill_rate
129 | tokens = refill_amount
130 | end
131 | end
132 |
133 | -- Consume a token
134 | tokens = tokens - 1
135 |
136 | -- Save state and set expiry
137 | redis.call('SETEX', data_key, 30, string.format('%d %d', slot, tokens))
138 |
139 | return slot
140 | ";
141 |
--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
1 | extern crate core;
2 |
3 | use pyo3::prelude::*;
4 |
5 | use token_bucket::TokenBucket;
6 |
7 | use crate::errors::{MaxSleepExceededError, RedisError};
8 | use crate::semaphore::Semaphore;
9 |
10 | mod errors;
11 | mod generated;
12 | mod semaphore;
13 | mod token_bucket;
14 | mod utils;
15 |
16 | #[pymodule]
17 | fn self_limiters(py: Python<'_>, m: &PyModule) -> PyResult<()> {
18 | pyo3_log::init();
19 | m.add("MaxSleepExceededError", py.get_type::())?;
20 | m.add("RedisError", py.get_type::())?;
21 | m.add_class::()?;
22 | m.add_class::()?;
23 | Ok(())
24 | }
25 |
26 | #[cfg(test)]
27 | mod tests {
28 | use std::time::Duration;
29 |
30 | use crate::utils::*;
31 |
32 | #[tokio::test]
33 | async fn test_now_millis() -> SLResult<()> {
34 | let now = now_millis()?;
35 | tokio::time::sleep(Duration::from_millis(30)).await;
36 | assert!(now + 30 <= now_millis()?);
37 | assert!(now + 33 >= now_millis()?);
38 | Ok(())
39 | }
40 |
41 | #[test]
42 | fn test_create_connection_manager() {
43 | // Make sure these normal URLs pass parsing
44 | for good_url in &[
45 | "redis://127.0.0.1",
46 | "redis://username:@127.0.0.1",
47 | "redis://username:password@127.0.0.1",
48 | "redis://:password@127.0.0.1",
49 | "redis+unix:///127.0.0.1",
50 | "unix:///127.0.0.1",
51 | ] {
52 | for port_postfix in &[":6379", ":1234", ""] {
53 | create_connection_manager(Some(&format!("{}{}", good_url, port_postfix))).unwrap();
54 | }
55 | }
56 |
57 | // None is also allowed, and we will try to connect to the default address
58 | create_connection_manager(None).unwrap();
59 |
60 | // Make sure these bad URLs fail
61 | for bad_url in &["", "1", "127.0.0.1:6379", "test://127.0.0.1:6379"] {
62 | if create_connection_manager(Some(bad_url)).is_ok() {
63 | panic!("Should fail")
64 | }
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/semaphore.rs:
--------------------------------------------------------------------------------
1 | use bb8_redis::bb8::Pool;
2 | use bb8_redis::RedisConnectionManager;
3 | use log::{debug, info};
4 | use pyo3::prelude::*;
5 | use pyo3::types::PyTuple;
6 | use pyo3_asyncio::tokio::future_into_py;
7 | use redis::{AsyncCommands, Script};
8 |
9 | use crate::errors::SLError;
10 | use crate::generated::SEMAPHORE_SCRIPT;
11 | use crate::utils::{create_connection_manager, create_connection_pool, now_millis, SLResult, REDIS_KEY_PREFIX};
12 |
13 | struct ThreadState {
14 | open_connection_pool: Pool,
15 | return_connection_pool: Pool,
16 | name: String,
17 | expiry: usize,
18 | capacity: u32,
19 | max_sleep: f32,
20 | }
21 |
22 | impl ThreadState {
23 | fn from(slf: &Semaphore) -> Self {
24 | Self {
25 | open_connection_pool: slf.open_connection_pool.clone(),
26 | return_connection_pool: slf.return_connection_pool.clone(),
27 | name: slf.name.clone(),
28 | expiry: slf.expiry,
29 | capacity: slf.capacity,
30 | max_sleep: slf.max_sleep,
31 | }
32 | }
33 |
34 | /// Key (re)use in Lua scripts to determine if Semaphore exists or not
35 | fn exists_key(&self) -> String {
36 | format!("{}-exists", self.name)
37 | }
38 | }
39 |
40 | async fn create_and_acquire_semaphore(ts: ThreadState) -> SLResult<()> {
41 | // Connect to redis
42 | let mut connection = ts.open_connection_pool.get().await?;
43 |
44 | // Define queue if it doesn't already exist
45 | if Script::new(SEMAPHORE_SCRIPT)
46 | .key(&ts.name)
47 | .key(&ts.exists_key())
48 | .arg(ts.capacity)
49 | .invoke_async(&mut *connection)
50 | .await?
51 | {
52 | info!("Created new semaphore queue with a capacity of {}", &ts.capacity);
53 | } else {
54 | debug!("Skipped creating new semaphore queue, since one exists already")
55 | }
56 |
57 | // Wait for our turn - this waits non-blockingly until we're free to proceed
58 | let start = now_millis()?;
59 | connection.blpop(&ts.name, ts.max_sleep as usize).await?;
60 |
61 | // Raise an exception if we waited too long
62 | if ts.max_sleep > 0.0 && (now_millis()? - start) > (ts.max_sleep * 1000.0) as u64 {
63 | return Err(SLError::MaxSleepExceeded(
64 | "Max sleep exceeded waiting for Semaphore".to_string(),
65 | ));
66 | };
67 |
68 | debug!("Acquired semaphore");
69 | Ok(())
70 | }
71 |
72 | async fn release_semaphore(ts: ThreadState) -> SLResult<()> {
73 | // Connect to redis
74 | let mut connection = ts.return_connection_pool.get().await?;
75 |
76 | // Push capacity back to the semaphore
77 | // We don't care about this being atomic
78 | redis::pipe()
79 | .lpush(&ts.name, 1)
80 | .expire(&ts.name, ts.expiry)
81 | .expire(&ts.exists_key(), ts.expiry)
82 | .query_async(&mut *connection)
83 | .await?;
84 |
85 | debug!("Released semaphore");
86 | Ok(())
87 | }
88 |
89 | /// Async context manager useful for controlling client traffic
90 | /// in situations where you need to limit traffic to `n` requests concurrently.
91 | /// For example, when you can only have 2 active requests simultaneously.
92 | #[pyclass(frozen)]
93 | #[pyo3(name = "Semaphore")]
94 | #[pyo3(module = "self_limiters")]
95 | pub(crate) struct Semaphore {
96 | #[pyo3(get)]
97 | name: String,
98 | #[pyo3(get)]
99 | capacity: u32,
100 | #[pyo3(get)]
101 | max_sleep: f32,
102 | #[pyo3(get)]
103 | expiry: usize,
104 | open_connection_pool: Pool,
105 | return_connection_pool: Pool,
106 | }
107 |
108 | #[pymethods]
109 | impl Semaphore {
110 | /// Create a new class instance.
111 | #[new]
112 | fn new(
113 | name: String,
114 | capacity: u32,
115 | max_sleep: Option,
116 | expiry: Option,
117 | redis_url: Option<&str>,
118 | connection_pool_size: Option,
119 | ) -> PyResult {
120 | debug!("Creating new Semaphore instance");
121 |
122 | // Create redis connection manager
123 | let open_manager = create_connection_manager(redis_url)?;
124 | let return_manager = create_connection_manager(redis_url)?;
125 |
126 | // Create connection pool
127 | let open_pool = create_connection_pool(open_manager, connection_pool_size.unwrap_or(15))?;
128 | let return_pool = create_connection_pool(return_manager, connection_pool_size.unwrap_or(15))?;
129 |
130 | Ok(Self {
131 | capacity,
132 | name: format!("{}{}", REDIS_KEY_PREFIX, name),
133 | max_sleep: max_sleep.unwrap_or(0.0),
134 | expiry: expiry.unwrap_or(30),
135 | open_connection_pool: open_pool,
136 | return_connection_pool: return_pool,
137 | })
138 | }
139 |
140 | fn __aenter__<'p>(&self, py: Python<'p>) -> PyResult<&'p PyAny> {
141 | let ts = ThreadState::from(self);
142 | future_into_py(py, async { Ok(create_and_acquire_semaphore(ts).await?) })
143 | }
144 |
145 | #[args(_a = "*")]
146 | fn __aexit__<'p>(&self, py: Python<'p>, _a: &'p PyTuple) -> PyResult<&'p PyAny> {
147 | let ts = ThreadState::from(self);
148 | future_into_py(py, async { Ok(release_semaphore(ts).await?) })
149 | }
150 |
151 | fn __repr__(&self) -> String {
152 | format!("Semaphore instance for queue {}", &self.name)
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/src/token_bucket.rs:
--------------------------------------------------------------------------------
1 | use std::time::Duration;
2 |
3 | use bb8_redis::bb8::Pool;
4 | use bb8_redis::RedisConnectionManager;
5 | use log::debug;
6 | use pyo3::exceptions::PyValueError;
7 | use pyo3::prelude::*;
8 | use pyo3::types::PyTuple;
9 | use pyo3::{PyAny, PyResult, Python};
10 | use pyo3_asyncio::tokio::future_into_py;
11 | use redis::Script;
12 |
13 | use crate::errors::SLError;
14 | use crate::generated::TOKEN_BUCKET_SCRIPT;
15 | use crate::utils::{create_connection_manager, create_connection_pool, now_millis, SLResult, REDIS_KEY_PREFIX};
16 |
17 | struct ThreadState {
18 | capacity: u32,
19 | frequency: f32,
20 | amount: u32,
21 | max_sleep: f32,
22 | connection_pool: Pool,
23 | name: String,
24 | }
25 |
26 | impl ThreadState {
27 | fn from(slf: &TokenBucket) -> Self {
28 | Self {
29 | capacity: slf.capacity,
30 | frequency: slf.refill_frequency,
31 | amount: slf.refill_amount,
32 | max_sleep: slf.max_sleep,
33 | connection_pool: slf.connection_pool.clone(),
34 | name: slf.name.clone(),
35 | }
36 | }
37 | }
38 |
39 | async fn schedule_and_sleep(ts: ThreadState) -> SLResult<()> {
40 | // Connect to redis
41 | let mut connection = ts.connection_pool.get().await?;
42 |
43 | // Retrieve slot
44 | let slot: u64 = Script::new(TOKEN_BUCKET_SCRIPT)
45 | .key(&ts.name)
46 | .arg(ts.capacity)
47 | .arg(ts.frequency * 1000.0) // in ms
48 | .arg(ts.amount)
49 | .invoke_async(&mut *connection)
50 | .await?;
51 |
52 | let now = now_millis()?;
53 | let sleep_duration = {
54 | // This might happen at very low refill frequencies.
55 | // Current handling isn't robust enough to ensure
56 | // exactly uniform traffic when this happens. Might be
57 | // something worth looking at more in the future, if needed.
58 | if slot <= now {
59 | Duration::from_millis(0)
60 | } else {
61 | Duration::from_millis(slot - now)
62 | }
63 | };
64 |
65 | if ts.max_sleep > 0.0 && sleep_duration > Duration::from_secs_f32(ts.max_sleep) {
66 | return Err(SLError::MaxSleepExceeded(format!(
67 | "Received wake up time in {} seconds, which is \
68 | greater or equal to the specified max sleep of {} seconds",
69 | sleep_duration.as_secs(),
70 | ts.max_sleep
71 | )));
72 | }
73 |
74 | debug!("Retrieved slot. Sleeping for {}.", sleep_duration.as_secs_f32());
75 | tokio::time::sleep(sleep_duration).await;
76 |
77 | Ok(())
78 | }
79 |
80 | /// Async context manager useful for controlling client traffic
81 | /// in situations where you need to limit traffic to `n` requests per `m` unit of time.
82 | /// For example, when you can only send 1 request per minute.
83 | #[pyclass(frozen)]
84 | #[pyo3(name = "TokenBucket")]
85 | #[pyo3(module = "self_limiters")]
86 | pub(crate) struct TokenBucket {
87 | #[pyo3(get)]
88 | capacity: u32,
89 | #[pyo3(get)]
90 | refill_frequency: f32,
91 | #[pyo3(get)]
92 | refill_amount: u32,
93 | #[pyo3(get)]
94 | name: String,
95 | max_sleep: f32,
96 | connection_pool: Pool,
97 | }
98 |
99 | #[pymethods]
100 | impl TokenBucket {
101 | /// Create a new class instance.
102 | #[new]
103 | fn new(
104 | name: String,
105 | capacity: u32,
106 | refill_frequency: f32,
107 | refill_amount: u32,
108 | redis_url: Option<&str>,
109 | max_sleep: Option,
110 | connection_pool_size: Option,
111 | ) -> PyResult {
112 | debug!("Creating new TokenBucket instance");
113 |
114 | if refill_frequency <= 0.0 {
115 | return Err(PyValueError::new_err("Refill frequency must be greater than 0"));
116 | }
117 | // Create redis connection manager
118 | let manager = create_connection_manager(redis_url)?;
119 |
120 | // Create connection pool
121 | let pool = create_connection_pool(manager, connection_pool_size.unwrap_or(30))?;
122 |
123 | Ok(Self {
124 | capacity,
125 | refill_amount,
126 | refill_frequency,
127 | max_sleep: max_sleep.unwrap_or(0.0),
128 | name: format!("{}{}", REDIS_KEY_PREFIX, name),
129 | connection_pool: pool,
130 | })
131 | }
132 |
133 | /// Spawn a scheduler thread to schedule wake-up times for nodes,
134 | /// and let the main thread wait for assignment of wake-up time
135 | /// then sleep until ready.
136 | fn __aenter__<'p>(&self, py: Python<'p>) -> PyResult<&'p PyAny> {
137 | let ts = ThreadState::from(self);
138 | future_into_py(py, async { Ok(schedule_and_sleep(ts).await?) })
139 | }
140 |
141 | /// Do nothing on aexit.
142 | #[args(_a = "*")]
143 | fn __aexit__<'p>(&self, py: Python<'p>, _a: &'p PyTuple) -> PyResult<&'p PyAny> {
144 | future_into_py(py, async { Ok(()) })
145 | }
146 |
147 | fn __repr__(&self) -> String {
148 | format!("Token bucket instance for queue {}", &self.name)
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/src/utils.rs:
--------------------------------------------------------------------------------
1 | use std::time::{SystemTime, UNIX_EPOCH};
2 |
3 | use bb8_redis::bb8::Pool;
4 | use bb8_redis::RedisConnectionManager;
5 | use log::info;
6 | use redis::parse_redis_url;
7 |
8 | use crate::errors::SLError;
9 |
10 | pub(crate) type SLResult = Result;
11 | pub(crate) const REDIS_DEFAULT_URL: &str = "redis://127.0.0.1:6379";
12 | pub(crate) const REDIS_KEY_PREFIX: &str = "__self-limiters:";
13 |
14 | pub(crate) fn now_millis() -> SLResult {
15 | // Beware: This will overflow in 500 thousand years
16 | Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64)
17 | }
18 |
19 | pub(crate) fn create_connection_manager(redis_url: Option<&str>) -> SLResult {
20 | match parse_redis_url(redis_url.unwrap_or(REDIS_DEFAULT_URL)) {
21 | Some(url) => match RedisConnectionManager::new(url) {
22 | Ok(manager) => Ok(manager),
23 | Err(e) => Err(SLError::Redis(format!(
24 | "Failed to open redis connection manager: {}",
25 | e
26 | ))),
27 | },
28 | None => Err(SLError::Redis(String::from("Failed to parse redis url"))),
29 | }
30 | }
31 |
32 | pub(crate) fn create_connection_pool(
33 | manager: RedisConnectionManager,
34 | max_size: u32,
35 | ) -> SLResult> {
36 | let future = async move { Pool::builder().max_size(max_size).build(manager).await.unwrap() };
37 | let res = tokio::runtime::Builder::new_current_thread()
38 | .enable_all()
39 | .build()
40 | .unwrap()
41 | .block_on(future);
42 | info!("Created connection pool of max {} connections", max_size);
43 | Ok(res)
44 | }
45 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/snok/self-limiters/28092ab71d71c1686826c02c14b464b2fcee367e/tests/__init__.py
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | from functools import partial
4 | from pathlib import Path
5 | from typing import TYPE_CHECKING
6 | from uuid import uuid4
7 |
8 | from self_limiters import Semaphore, TokenBucket
9 |
10 | if TYPE_CHECKING:
11 | from datetime import timedelta
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 | REPO_ROOT = Path(__file__).parent.parent
16 |
17 |
18 | def semaphore_factory(**kwargs) -> partial:
19 | """
20 | Provide an almost initialized semaphore with defaults.
21 |
22 | This makes it easy to init semaphores with slightly different configurations in tests.
23 | """
24 |
25 | defaults = {'name': uuid4().hex[:6], 'capacity': 1, 'redis_url': 'redis://127.0.0.1:6389'}
26 | return partial(Semaphore, **{**defaults, **kwargs})
27 |
28 |
29 | def tokenbucket_factory(**kwargs) -> partial:
30 | """
31 | Provide an almost initialized token bucket with defaults.
32 |
33 | This makes it easy to init token buckets with slightly different configurations in tests.
34 | """
35 |
36 | defaults = {
37 | 'name': uuid4().hex[:6],
38 | 'capacity': 1,
39 | 'refill_frequency': 1.0,
40 | 'refill_amount': 1,
41 | 'redis_url': 'redis://127.0.0.1:6389',
42 | }
43 | return partial(TokenBucket, **{**defaults, **kwargs})
44 |
45 |
46 | def delta_to_seconds(t: 'timedelta') -> float:
47 | return t.seconds + t.microseconds / 1_000_000
48 |
49 |
50 | async def run(pt: partial, duration: float) -> None:
51 | async with pt():
52 | logger.info(f'Sleeping {duration}')
53 | await asyncio.sleep(duration)
54 |
--------------------------------------------------------------------------------
/tests/test_errors.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | from uuid import uuid4
4 |
5 | import pytest
6 | from redis.asyncio.client import Redis
7 | from self_limiters import RedisError
8 |
9 | from .conftest import run, semaphore_factory, tokenbucket_factory
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 |
14 | @pytest.mark.parametrize('limiter', [semaphore_factory(redis_url='test'), tokenbucket_factory(redis_url='test')])
15 | async def test_redis_error_on_bad_connection_string(limiter):
16 | """
17 | Redis errors should propagate as self_limiters.RedisError.
18 | """
19 | with pytest.raises(RedisError):
20 | await run(limiter, 0)
21 |
22 |
23 | async def test_redis_error():
24 | """
25 | Trigger the equivalent of a runtime error in Redis,
26 | to ensure that these types of redis errors are also
27 | propagated correctly.
28 | """
29 | name = f'error-test-{uuid4()}'
30 | queue_name = f'__self-limiters:{name}'
31 |
32 | async def corrupt_queue():
33 | # Wait 0.1 seconds then destroy the queue
34 | await asyncio.sleep(0.3)
35 | r = Redis.from_url('redis://127.0.0.1:6389')
36 | await r.delete(queue_name)
37 | await r.set(queue_name, 'test')
38 |
39 | tasks = [asyncio.create_task(corrupt_queue())]
40 | tasks += [asyncio.create_task(run(semaphore_factory(name=name), 0.1)) for _ in range(10)]
41 |
42 | with pytest.raises(RedisError):
43 | await asyncio.gather(*tasks)
44 |
--------------------------------------------------------------------------------
/tests/test_semaphore.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | import re
4 | from datetime import datetime
5 | from uuid import uuid4
6 |
7 | import pytest
8 | from redis.asyncio.client import Monitor, Redis
9 | from self_limiters import MaxSleepExceededError, Semaphore
10 |
11 | from .conftest import delta_to_seconds, run, semaphore_factory
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 |
16 | @pytest.mark.parametrize(
17 | 'n, capacity, sleep, timeout',
18 | [
19 | (10, 1, 0.1, 1),
20 | (10, 2, 0.1, 0.5),
21 | (10, 10, 0.1, 0.1),
22 | (5, 1, 0.1, 0.5),
23 | ],
24 | )
25 | async def test_semaphore_runtimes(n, capacity, sleep, timeout):
26 | """
27 | Make sure that the runtime of multiple Semaphore instances conform to our expectations.
28 |
29 | The runtime should never fall below the expected lower bound. If we run 6 instances for
30 | a Semaphore with a capacity of 5, where each instance sleeps 1 second, then it should
31 | always take 1 >= seconds to run those.
32 | """
33 | name = f'runtimes-{uuid4()}'
34 | tasks = [
35 | asyncio.create_task(run(semaphore_factory(name=name, capacity=capacity), duration=sleep)) for _ in range(n)
36 | ]
37 |
38 | before = datetime.now()
39 | await asyncio.gather(*tasks)
40 | assert timeout <= delta_to_seconds(datetime.now() - before)
41 |
42 |
43 | async def test_sleep_is_non_blocking():
44 | async def _sleep(duration: float) -> None:
45 | await asyncio.sleep(duration)
46 |
47 | tasks = [
48 | # Create task for semaphore to sleep 1 second
49 | asyncio.create_task(run(semaphore_factory(), 0)),
50 | # And create another task to normal asyncio sleep for 1 second
51 | asyncio.create_task(_sleep(1)),
52 | ]
53 |
54 | # Both tasks should complete in ~1 second if thing are working correctly
55 | await asyncio.wait_for(asyncio.gather(*tasks), 1.05)
56 |
57 |
58 | def test_class_attributes():
59 | """Check attributes are readable, but immutable."""
60 | semaphore = Semaphore(name='test', capacity=1)
61 |
62 | assert semaphore.name == '__self-limiters:test'
63 | assert semaphore.capacity == 1
64 | assert semaphore.max_sleep == 0
65 |
66 | with pytest.raises(AttributeError, match="attribute 'name' of 'self_limiters.Semaphore' objects is not writable"):
67 | semaphore.name = 'test2'
68 |
69 |
70 | def test_repr():
71 | semaphore = Semaphore(name='test', capacity=1)
72 | assert re.match(r'Semaphore instance for queue __self-limiters:test', str(semaphore)) # noqa: W605
73 |
74 |
75 | @pytest.mark.parametrize(
76 | 'config,e',
77 | [
78 | ({'name': ''}, None),
79 | ({'name': None}, TypeError),
80 | ({'name': 1}, TypeError),
81 | ({'name': True}, TypeError),
82 | ({'capacity': 2}, None),
83 | ({'capacity': 2.2}, TypeError),
84 | ({'capacity': None}, TypeError),
85 | ({'capacity': 'test'}, TypeError),
86 | ({'redis_url': 'redis://a.b'}, None),
87 | ({'redis_url': 1}, TypeError),
88 | ({'redis_url': True}, TypeError),
89 | ({'max_sleep': 20}, None),
90 | ({'max_sleep': 0}, None),
91 | ({'max_sleep': 'test'}, TypeError),
92 | ({'max_sleep': None}, None),
93 | ],
94 | )
95 | def test_init_types(config, e):
96 | if e:
97 | with pytest.raises(e):
98 | semaphore_factory(**config)()
99 | else:
100 | semaphore_factory(**config)()
101 |
102 |
103 | @pytest.mark.filterwarnings('ignore::RuntimeWarning')
104 | async def test_max_sleep():
105 | name = uuid4().hex[:6]
106 | with pytest.raises(MaxSleepExceededError, match='Max sleep exceeded waiting for Semaphore'):
107 | await asyncio.gather(
108 | *[asyncio.create_task(run(semaphore_factory(name=name, max_sleep=1), 1)) for _ in range(3)]
109 | )
110 |
111 |
112 | async def test_redis_instructions():
113 | r = Redis.from_url('redis://127.0.0.1:6389')
114 | name = uuid4().hex
115 |
116 | m: Monitor
117 | async with r.monitor() as m:
118 | await m.connect()
119 | await run(semaphore_factory(name=name, expiry=1), 0)
120 |
121 | # We expect the eval to generate 7 calls
122 | commands = [
123 | # EVALSHA
124 | str(await m.connection.read_response()),
125 | # SETNX
126 | str(await m.connection.read_response()),
127 | # RPUSH
128 | str(await m.connection.read_response()),
129 | # BLPOP
130 | str(await m.connection.read_response()),
131 | # LPUSH
132 | str(await m.connection.read_response()),
133 | # EXPIRE
134 | str(await m.connection.read_response()),
135 | # EXPIRE
136 | str(await m.connection.read_response()),
137 | ]
138 |
139 | # Make sure there are no other commands generated
140 | with pytest.raises(asyncio.TimeoutError):
141 | # This will time out if there are no other commands
142 | await asyncio.wait_for(timeout=1, fut=m.connection.read_response())
143 |
144 | # Make sure each command conforms to our expectations
145 | assert 'EVALSHA' in commands[0]
146 | assert 'SETNX' in commands[1]
147 | assert f'__self-limiters:{name}-exists' in commands[1]
148 | assert 'RPUSH' in commands[2]
149 | assert f'__self-limiters:{name}' in commands[2]
150 | assert 'BLPOP' in commands[3]
151 | assert 'LPUSH' in commands[4]
152 | assert 'EXPIRE' in commands[5]
153 | assert f'__self-limiters:{name}' in commands[5]
154 | assert 'EXPIRE' in commands[6]
155 | assert f'__self-limiters:{name}-exists' in commands[6]
156 |
--------------------------------------------------------------------------------
/tests/test_token_bucket.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | import re
4 | from datetime import datetime
5 | from uuid import uuid4
6 |
7 | import pytest
8 | from self_limiters import MaxSleepExceededError
9 |
10 | from .conftest import delta_to_seconds, run, tokenbucket_factory
11 |
12 | logger = logging.getLogger(__name__)
13 |
14 |
15 | @pytest.mark.parametrize(
16 | 'n, frequency, timeout',
17 | [
18 | (10, 0.1, 1),
19 | (10, 0.01, 0.1),
20 | (2, 1, 2),
21 | ],
22 | )
23 | async def test_token_bucket_runtimes(n, frequency, timeout):
24 | # Ensure n tasks never complete in less than n/(refill_frequency * refill_amount)
25 | name = f'runtimes-{uuid4()}'
26 | tasks = [
27 | asyncio.create_task(run(tokenbucket_factory(name=name, capacity=1, refill_frequency=frequency), duration=0))
28 | for _ in range(n)
29 | ]
30 |
31 | before = datetime.now()
32 | await asyncio.gather(*tasks)
33 | assert timeout <= delta_to_seconds(datetime.now() - before)
34 |
35 |
36 | async def test_sleep_is_non_blocking():
37 | async def _sleep(duration: float) -> None:
38 | await asyncio.sleep(duration)
39 |
40 | tasks = [
41 | # Create task for token bucket to sleep 1 second
42 | # And create other tasks to normal asyncio sleep for 1 second
43 | asyncio.create_task(_sleep(1)),
44 | asyncio.create_task(run(tokenbucket_factory(), 0)),
45 | asyncio.create_task(_sleep(1)),
46 | asyncio.create_task(run(tokenbucket_factory(), 0)),
47 | ]
48 |
49 | # Both tasks should complete in ~1 second if thing are working correctly
50 | await asyncio.wait_for(timeout=1.1, fut=asyncio.gather(*tasks))
51 |
52 |
53 | def test_class_attributes():
54 | """
55 | Check attributes are accessible, and check defaults.
56 | """
57 | tb = tokenbucket_factory(name='test', capacity=1)()
58 | assert tb.name
59 | assert tb.capacity == 1
60 | assert tb.refill_frequency == 1.0
61 | assert tb.refill_amount == 1
62 |
63 | with pytest.raises(
64 | AttributeError, match="attribute 'refill_amount' of 'self_limiters.TokenBucket' objects is not writable"
65 | ):
66 | tb.refill_amount = 'test2'
67 |
68 |
69 | def test_repr():
70 | tb = tokenbucket_factory(name='test', capacity=1)()
71 | assert re.match(r'Token bucket instance for queue __self-limiters:test', str(tb)) # noqa: W605
72 |
73 |
74 | @pytest.mark.parametrize(
75 | 'config,e',
76 | [
77 | ({'name': ''}, None),
78 | ({'name': None}, TypeError),
79 | ({'name': 1}, TypeError),
80 | ({'name': True}, TypeError),
81 | ({'capacity': 2}, None),
82 | ({'capacity': 2.2}, TypeError),
83 | ({'capacity': -1}, OverflowError),
84 | ({'capacity': None}, TypeError),
85 | ({'capacity': 'test'}, TypeError),
86 | ({'refill_frequency': 2.2}, None),
87 | ({'refill_frequency': 'test'}, TypeError),
88 | ({'refill_frequency': None}, TypeError),
89 | ({'refill_frequency': -1}, ValueError),
90 | ({'refill_amount': 1}, None),
91 | ({'refill_amount': -1}, OverflowError),
92 | ({'refill_amount': 'test'}, TypeError),
93 | ({'refill_amount': None}, TypeError),
94 | ({'redis_url': 'redis://a.b'}, None),
95 | ({'redis_url': 1}, TypeError),
96 | ({'redis_url': True}, TypeError),
97 | ({'max_sleep': 20}, None),
98 | ({'max_sleep': 0}, None),
99 | ({'max_sleep': 'test'}, TypeError),
100 | ({'max_sleep': None}, None),
101 | ],
102 | )
103 | def test_init_types(config, e):
104 | if e:
105 | with pytest.raises(e):
106 | tokenbucket_factory(**config)()
107 | else:
108 | tokenbucket_factory(**config)()
109 |
110 |
111 | async def test_max_sleep():
112 | name = uuid4().hex[:6]
113 | e = 'Received wake up time in [0-9] seconds, which is greater or equal to the specified max sleep of 1 seconds'
114 | with pytest.raises(MaxSleepExceededError, match=e):
115 | await asyncio.gather(
116 | *[asyncio.create_task(run(tokenbucket_factory(name=name, max_sleep=1), 0)) for _ in range(10)]
117 | )
118 |
--------------------------------------------------------------------------------