queueio 0.7.0__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {queueio-0.7.0 → queueio-0.8.0}/.devcontainer/docker-compose.yml +4 -1
- queueio-0.8.0/.github/workflows/build.yml +92 -0
- {queueio-0.7.0 → queueio-0.8.0}/.github/workflows/docs.yml +1 -1
- {queueio-0.7.0 → queueio-0.8.0}/.gitignore +2 -0
- {queueio-0.7.0 → queueio-0.8.0}/CHANGELOG.md +20 -1
- {queueio-0.7.0 → queueio-0.8.0}/PKG-INFO +13 -4
- {queueio-0.7.0 → queueio-0.8.0}/README.md +8 -0
- {queueio-0.7.0 → queueio-0.8.0}/pyproject.toml +11 -6
- {queueio-0.7.0 → queueio-0.8.0}/pyrightconfig.json +1 -1
- {queueio-0.7.0 → queueio-0.8.0}/queueio/__main__.py +1 -1
- {queueio-0.7.0 → queueio-0.8.0}/queueio/broker_test.py +20 -8
- queueio-0.8.0/queueio/conftest.py +98 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/monitor.py +1 -1
- {queueio-0.7.0 → queueio-0.8.0}/queueio/pika/broker.py +11 -0
- queueio-0.8.0/queueio/pika/broker_test.py +38 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/pika/journal_test.py +2 -1
- {queueio-0.7.0 → queueio-0.8.0}/queueio/pika/threadsafe.py +40 -1
- queueio-0.8.0/queueio/pika/threadsafe_test.py +20 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/broker-queries.md +10 -4
- {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/journal-queries.md +1 -1
- {queueio-0.7.0 → queueio-0.8.0}/queueio/queueio.py +5 -4
- {queueio-0.7.0 → queueio-0.8.0}/queueio/queueio_test.py +31 -10
- {queueio-0.7.0 → queueio-0.8.0}/queueio/queuespec.md +4 -4
- {queueio-0.7.0 → queueio-0.8.0}/queueio/queuevar.md +1 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django_test.py +3 -1
- {queueio-0.7.0 → queueio-0.8.0}/queueio/stream.py +2 -1
- {queueio-0.7.0 → queueio-0.8.0}/queueio/stub/broker.py +2 -1
- {queueio-0.7.0 → queueio-0.8.0}/queueio/stub/journal.py +3 -2
- {queueio-0.7.0 → queueio-0.8.0}/queueio/stub/receiver.py +5 -4
- {queueio-0.7.0 → queueio-0.8.0}/queueio/worker.py +3 -2
- queueio-0.7.0/.github/workflows/build.yml +0 -42
- queueio-0.7.0/queueio/conftest.py +0 -32
- queueio-0.7.0/queueio/pika/broker_test.py +0 -18
- queueio-0.7.0/queueio/queue.py +0 -183
- queueio-0.7.0/queueio/queue_test.py +0 -729
- queueio-0.7.0/queueio/select.py +0 -170
- {queueio-0.7.0 → queueio-0.8.0}/.devcontainer/devcontainer.json +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/.github/workflows/publish.yml +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/CNAME +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/LICENSE +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/dill.pyi +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/docs_overrides/assets/images/favicon.svg +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/docs_overrides/assets/images/logo.svg +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/docs_overrides/assets/stylesheets/extra.css +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/docs_overrides/partials/footer.html +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/docs_overrides/partials/javascripts/palette.html +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/logo.svg +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/mkdocs.yml +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/philosophy.md +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/__init__.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/backend.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/broker.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/consumer.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/continuation.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/django/README.md +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/django/__init__.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/django/apps.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/django/djp.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/django/management/__init__.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/django/management/commands/__init__.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/django/management/commands/runserver.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/django/management/commands/runserver_plus.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/django/runserver_test.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/event.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/freethreading_test.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/gather.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/id.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/invocation.md +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/invocation.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/journal.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/journal_test.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/message.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/pause.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/pika/__init__.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/pika/journal.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/pika/receiver.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/README.md +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/__init__.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/journal.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/journal_test.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/psycopg/receiver-queries.md +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/queuespec.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/queuespec_test.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/queuevar.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/receiver.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/registry.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/result.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/routine.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/__init__.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/basic.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/basic_test.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/__init__.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/apps.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/job.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/models.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/settings.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/settings_djp.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/django/urls.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/expanded.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/expanded_test.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/priority.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/priority_test.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/queuevar.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/samples/queuevar_test.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/stub/__init__.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/stub/broker_test.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/stub/journal_test.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/suspension.py +0 -0
- {queueio-0.7.0 → queueio-0.8.0}/queueio/thread.py +0 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
name: Build
|
|
2
|
+
|
|
3
|
+
on: [push, pull_request]
|
|
4
|
+
|
|
5
|
+
jobs:
|
|
6
|
+
build:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
timeout-minutes: 5
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
id-token: write
|
|
12
|
+
services:
|
|
13
|
+
rabbitmq:
|
|
14
|
+
image: rabbitmq
|
|
15
|
+
env:
|
|
16
|
+
RABBITMQ_DEFAULT_USER: guest
|
|
17
|
+
RABBITMQ_DEFAULT_PASS: guest
|
|
18
|
+
RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: "-rabbit permit_deprecated_features #{global_qos=>true}"
|
|
19
|
+
ports:
|
|
20
|
+
- 5672/tcp
|
|
21
|
+
options: >-
|
|
22
|
+
--health-cmd "rabbitmq-diagnostics -q ping"
|
|
23
|
+
--health-interval 10s
|
|
24
|
+
--health-timeout 5s
|
|
25
|
+
--health-retries 5
|
|
26
|
+
rabbitmq_no_global_qos:
|
|
27
|
+
image: rabbitmq
|
|
28
|
+
env:
|
|
29
|
+
RABBITMQ_DEFAULT_USER: guest
|
|
30
|
+
RABBITMQ_DEFAULT_PASS: guest
|
|
31
|
+
RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: "-rabbit permit_deprecated_features #{global_qos=>false}"
|
|
32
|
+
ports:
|
|
33
|
+
- 5672/tcp
|
|
34
|
+
options: >-
|
|
35
|
+
--health-cmd "rabbitmq-diagnostics -q ping"
|
|
36
|
+
--health-interval 10s
|
|
37
|
+
--health-timeout 5s
|
|
38
|
+
--health-retries 5
|
|
39
|
+
lavinmq:
|
|
40
|
+
# 2.10.3 and later starve consumers sharing a channel with global prefetch.
|
|
41
|
+
# https://github.com/cloudamqp/lavinmq/issues/2628
|
|
42
|
+
image: cloudamqp/lavinmq:2.10.2
|
|
43
|
+
ports:
|
|
44
|
+
- 5672/tcp
|
|
45
|
+
options: >-
|
|
46
|
+
--health-cmd "lavinmqctl status"
|
|
47
|
+
--health-interval 10s
|
|
48
|
+
--health-timeout 5s
|
|
49
|
+
--health-retries 5
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
- name: Install uv
|
|
53
|
+
uses: astral-sh/setup-uv@v3
|
|
54
|
+
- name: Set up Python
|
|
55
|
+
run: uv python install 3.15t
|
|
56
|
+
- name: Install dependencies
|
|
57
|
+
run: uv sync --all-groups
|
|
58
|
+
- name: Check formatting
|
|
59
|
+
run: uv run ruff format --check
|
|
60
|
+
- name: Run ruff linting
|
|
61
|
+
run: uv run ruff check
|
|
62
|
+
- name: Run basedpyright
|
|
63
|
+
run: uv run basedpyright
|
|
64
|
+
- name: Run tests
|
|
65
|
+
env:
|
|
66
|
+
QUEUEIO_BROKER: amqp://guest:guest@localhost:${{ job.services.rabbitmq.ports['5672'] }}/
|
|
67
|
+
run: uv run pytest --cov --cov-report=
|
|
68
|
+
timeout-minutes: 2
|
|
69
|
+
- name: Test broker without channel-wide QoS
|
|
70
|
+
env:
|
|
71
|
+
QUEUEIO_BROKER: amqp://guest:guest@localhost:${{ job.services.rabbitmq_no_global_qos.ports['5672'] }}/
|
|
72
|
+
run: uv run pytest -m no_global_qos --cov --cov-append --cov-report=
|
|
73
|
+
- name: Test LavinMQ
|
|
74
|
+
env:
|
|
75
|
+
QUEUEIO_BROKER: amqp://guest:guest@localhost:${{ job.services.lavinmq.ports['5672'] }}/
|
|
76
|
+
run: uv run pytest --cov --cov-append --cov-report=
|
|
77
|
+
timeout-minutes: 2
|
|
78
|
+
- name: Report coverage
|
|
79
|
+
run: |
|
|
80
|
+
uv run coverage report
|
|
81
|
+
uv run coverage xml
|
|
82
|
+
- name: Upload coverage to Codecov
|
|
83
|
+
uses: codecov/codecov-action@v5
|
|
84
|
+
with:
|
|
85
|
+
files: coverage.xml
|
|
86
|
+
disable_search: true
|
|
87
|
+
use_oidc: true
|
|
88
|
+
fail_ci_if_error: true
|
|
89
|
+
- name: Verify built package
|
|
90
|
+
run: |
|
|
91
|
+
uv build
|
|
92
|
+
uvx --from dist/queueio-*.whl queueio --help
|
|
@@ -21,7 +21,7 @@ jobs:
|
|
|
21
21
|
steps:
|
|
22
22
|
- uses: actions/checkout@v4
|
|
23
23
|
- uses: astral-sh/setup-uv@v5
|
|
24
|
-
- run: uv python install 3.
|
|
24
|
+
- run: uv python install 3.15t
|
|
25
25
|
- run: uv sync --group dev
|
|
26
26
|
- run: uv run mkdocs build --strict
|
|
27
27
|
- uses: actions/upload-pages-artifact@v3
|
|
@@ -8,6 +8,24 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com).
|
|
|
8
8
|
[Unreleased]
|
|
9
9
|
------------
|
|
10
10
|
|
|
11
|
+
[0.8.0] - 2026-11-04
|
|
12
|
+
--------------------
|
|
13
|
+
|
|
14
|
+
### Breaking Changes
|
|
15
|
+
|
|
16
|
+
- Python 3.15 or later is now required; Python 3.14 is no longer supported.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Load the monitor UI lazily so other CLI commands avoid importing Textual.
|
|
21
|
+
- Use [noio](https://pypi.org/project/noio/) for internal queues and selection,
|
|
22
|
+
replacing `queueio.queue` and `queueio.select`.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- A failed connection to the broker no longer leaves a thread running
|
|
27
|
+
that prevents the process from exiting.
|
|
28
|
+
|
|
11
29
|
[0.7.0] - 2026-03-09
|
|
12
30
|
--------------------
|
|
13
31
|
|
|
@@ -125,7 +143,8 @@ Thank you to Nick Anderegg for allowing me to use the queueio name for this proj
|
|
|
125
143
|
- The queuespec syntax to `queue run` to consume multiple queues with shared capacity.
|
|
126
144
|
- `queueio monitor` command to monitor activity in the queueio system.
|
|
127
145
|
|
|
128
|
-
[Unreleased]: https://github.com/ryanhiebert/queueio/compare/tag/0.
|
|
146
|
+
[Unreleased]: https://github.com/ryanhiebert/queueio/compare/tag/0.8.0...HEAD
|
|
147
|
+
[0.8.0]: https://github.com/ryanhiebert/queueio/compare/tag/0.7.0...tag/0.8.0
|
|
129
148
|
[0.7.0]: https://github.com/ryanhiebert/queueio/compare/tag/0.6.0...tag/0.7.0
|
|
130
149
|
[0.6.0]: https://github.com/ryanhiebert/queueio/compare/tag/0.5.0...tag/0.6.0
|
|
131
150
|
[0.5.0]: https://github.com/ryanhiebert/queueio/compare/tag/0.4.0...tag/0.5.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: queueio
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Python background queues with an async twist
|
|
5
5
|
Author-email: Ryan Hiebert <ryan@ryanhiebert.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -10,11 +10,12 @@ Classifier: Development Status :: 3 - Alpha
|
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
14
14
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
15
|
Classifier: Topic :: System :: Distributed Computing
|
|
16
|
-
Requires-Python: >=3.
|
|
16
|
+
Requires-Python: >=3.15
|
|
17
17
|
Requires-Dist: dill>=0.3.9
|
|
18
|
+
Requires-Dist: noio>=0.3
|
|
18
19
|
Requires-Dist: pika>=1.3.2
|
|
19
20
|
Requires-Dist: textual>=3.2.0
|
|
20
21
|
Requires-Dist: typer>=0.15.4
|
|
@@ -86,6 +87,14 @@ to allow a project to be deployed in multiple environments.
|
|
|
86
87
|
QUEUEIO_BROKER='amqp://guest:guest@localhost:5672/'
|
|
87
88
|
```
|
|
88
89
|
|
|
90
|
+
QueueIO requires RabbitMQ's global QoS for shared concurrency limits and
|
|
91
|
+
suspending tasks. On RabbitMQ 4.3, enable it in `rabbitmq.conf` and restart
|
|
92
|
+
the broker:
|
|
93
|
+
|
|
94
|
+
```ini
|
|
95
|
+
deprecated_features.permit.global_qos = true
|
|
96
|
+
```
|
|
97
|
+
|
|
89
98
|
Sync the queues to the broker:
|
|
90
99
|
|
|
91
100
|
```python
|
|
@@ -59,6 +59,14 @@ to allow a project to be deployed in multiple environments.
|
|
|
59
59
|
QUEUEIO_BROKER='amqp://guest:guest@localhost:5672/'
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
QueueIO requires RabbitMQ's global QoS for shared concurrency limits and
|
|
63
|
+
suspending tasks. On RabbitMQ 4.3, enable it in `rabbitmq.conf` and restart
|
|
64
|
+
the broker:
|
|
65
|
+
|
|
66
|
+
```ini
|
|
67
|
+
deprecated_features.permit.global_qos = true
|
|
68
|
+
```
|
|
69
|
+
|
|
62
70
|
Sync the queues to the broker:
|
|
63
71
|
|
|
64
72
|
```python
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "queueio"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
description = "Python background queues with an async twist"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -15,13 +15,14 @@ classifiers = [
|
|
|
15
15
|
"Intended Audience :: Developers",
|
|
16
16
|
"License :: OSI Approved :: MIT License",
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
18
|
-
"Programming Language :: Python :: 3.
|
|
18
|
+
"Programming Language :: Python :: 3.15",
|
|
19
19
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
20
|
"Topic :: System :: Distributed Computing",
|
|
21
21
|
]
|
|
22
|
-
requires-python = ">=3.
|
|
22
|
+
requires-python = ">=3.15"
|
|
23
23
|
dependencies = [
|
|
24
24
|
"dill>=0.3.9",
|
|
25
|
+
"noio>=0.3",
|
|
25
26
|
"pika>=1.3.2",
|
|
26
27
|
"textual>=3.2.0",
|
|
27
28
|
"typer>=0.15.4",
|
|
@@ -39,7 +40,7 @@ queueio = "queueio.__main__:app"
|
|
|
39
40
|
|
|
40
41
|
[dependency-groups]
|
|
41
42
|
dev = [
|
|
42
|
-
"basedpyright>=1.
|
|
43
|
+
"basedpyright>=1.40.2",
|
|
43
44
|
"django-extensions",
|
|
44
45
|
"djp",
|
|
45
46
|
"queueio[django]",
|
|
@@ -47,9 +48,9 @@ dev = [
|
|
|
47
48
|
"mkdocs-material>=9.7.1",
|
|
48
49
|
"mkdocs-same-dir>=0.1.3",
|
|
49
50
|
"pytest>=8.4.1",
|
|
50
|
-
"pytest-cov>=
|
|
51
|
+
"pytest-cov>=7.0.0",
|
|
51
52
|
"pytest-timeout>=2.4.0",
|
|
52
|
-
"ruff>=0.
|
|
53
|
+
"ruff>=0.17.0",
|
|
53
54
|
"textual-dev>=1.7.0",
|
|
54
55
|
"types-pika>=1.2.0b1",
|
|
55
56
|
"psycopg>=3.3.3",
|
|
@@ -71,12 +72,16 @@ register = [
|
|
|
71
72
|
broker = "amqp://localhost:5672"
|
|
72
73
|
|
|
73
74
|
[tool.pytest.ini_options]
|
|
75
|
+
markers = ["no_global_qos: requires a broker without channel-wide QoS"]
|
|
76
|
+
addopts = "-m 'not no_global_qos'"
|
|
74
77
|
timeout = 30
|
|
75
78
|
norecursedirs = ["site"]
|
|
76
79
|
|
|
77
80
|
[tool.coverage.run]
|
|
78
81
|
branch = true
|
|
79
82
|
source = ["queueio"]
|
|
83
|
+
patch = ["subprocess"]
|
|
84
|
+
sigterm = true
|
|
80
85
|
|
|
81
86
|
[build-system]
|
|
82
87
|
requires = ["hatchling"]
|
|
@@ -25,6 +25,7 @@ class TestMyBroker(BaseBrokerTest):
|
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
27
|
import threading
|
|
28
|
+
import time
|
|
28
29
|
from functools import wraps
|
|
29
30
|
|
|
30
31
|
import pytest
|
|
@@ -47,6 +48,18 @@ def skip_if_unsupported(feature_attr):
|
|
|
47
48
|
return decorator
|
|
48
49
|
|
|
49
50
|
|
|
51
|
+
def wait_for(predicate, timeout=1.0, settle=0.05):
|
|
52
|
+
"""Poll until predicate() is true, or until the timeout expires.
|
|
53
|
+
|
|
54
|
+
Then wait briefly, so that a receiver exceeding its limit has time to
|
|
55
|
+
deliver the excess message before the test asserts an exact count.
|
|
56
|
+
"""
|
|
57
|
+
deadline = time.monotonic() + timeout
|
|
58
|
+
while not predicate() and time.monotonic() < deadline:
|
|
59
|
+
time.sleep(0.001)
|
|
60
|
+
time.sleep(settle)
|
|
61
|
+
|
|
62
|
+
|
|
50
63
|
class BaseBrokerTest:
|
|
51
64
|
"""Base class with common tests for all broker implementations."""
|
|
52
65
|
|
|
@@ -80,7 +93,7 @@ class BaseBrokerTest:
|
|
|
80
93
|
thread.start()
|
|
81
94
|
|
|
82
95
|
# Wait for receiver to reach prefetch limit or timeout
|
|
83
|
-
|
|
96
|
+
wait_for(lambda: len(received_messages) >= prefetch_limit)
|
|
84
97
|
|
|
85
98
|
# Should have received exactly prefetch_limit messages and be blocked
|
|
86
99
|
assert len(received_messages) == prefetch_limit
|
|
@@ -113,13 +126,13 @@ class BaseBrokerTest:
|
|
|
113
126
|
thread.start()
|
|
114
127
|
|
|
115
128
|
# Wait for initial consumption up to prefetch limit
|
|
116
|
-
|
|
129
|
+
wait_for(lambda: len(received_messages) >= prefetch_limit)
|
|
117
130
|
assert len(received_messages) == prefetch_limit
|
|
118
131
|
assert thread.is_alive() # Should be blocked
|
|
119
132
|
|
|
120
133
|
# Pause first message - should free capacity for third message
|
|
121
134
|
receiver.pause(received_messages[0])
|
|
122
|
-
|
|
135
|
+
wait_for(lambda: len(received_messages) >= 3)
|
|
123
136
|
assert len(received_messages) == 3
|
|
124
137
|
|
|
125
138
|
# Unpause first message - should receive capacity again
|
|
@@ -151,18 +164,18 @@ class BaseBrokerTest:
|
|
|
151
164
|
thread.start()
|
|
152
165
|
|
|
153
166
|
# Initial consumption up to prefetch limit
|
|
154
|
-
|
|
167
|
+
wait_for(lambda: len(received_messages) >= prefetch_limit)
|
|
155
168
|
assert len(received_messages) == prefetch_limit
|
|
156
169
|
assert thread.is_alive() # Should be blocked
|
|
157
170
|
|
|
158
171
|
# Finish first message - should allow consuming third message
|
|
159
172
|
receiver.finish(received_messages[0])
|
|
160
|
-
|
|
173
|
+
wait_for(lambda: len(received_messages) >= 3)
|
|
161
174
|
assert len(received_messages) == 3
|
|
162
175
|
|
|
163
176
|
# Finish second message - should allow consuming fourth message
|
|
164
177
|
receiver.finish(received_messages[1])
|
|
165
|
-
|
|
178
|
+
wait_for(lambda: len(received_messages) >= 4)
|
|
166
179
|
assert len(received_messages) == 4
|
|
167
180
|
|
|
168
181
|
broker.shutdown()
|
|
@@ -200,8 +213,7 @@ class BaseBrokerTest:
|
|
|
200
213
|
thread2.start()
|
|
201
214
|
|
|
202
215
|
# Allow both receivers to receive up to their limits
|
|
203
|
-
|
|
204
|
-
thread2.join(timeout=0.1)
|
|
216
|
+
wait_for(lambda: len(receiver1_messages) + len(receiver2_messages) >= 5)
|
|
205
217
|
|
|
206
218
|
# Both receivers should respect their prefetch limits and be blocked
|
|
207
219
|
total_received = len(receiver1_messages) + len(receiver2_messages)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import faulthandler
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
from contextlib import nullcontext
|
|
5
|
+
from typing import TextIO
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from _pytest.capture import CaptureManager
|
|
9
|
+
|
|
10
|
+
# Grace beyond the per-test timeout for pytest-timeout to fail the test.
|
|
11
|
+
WATCHDOG_MARGIN = 15
|
|
12
|
+
# Inactivity limit when per-test timeouts are disabled.
|
|
13
|
+
WATCHDOG_DEFAULT = 60
|
|
14
|
+
# How long the process may take to exit after pytest finishes.
|
|
15
|
+
WATCHDOG_EXIT = 10
|
|
16
|
+
|
|
17
|
+
_watchdog_file: TextIO | None = None
|
|
18
|
+
_watchdog_limit: float = WATCHDOG_DEFAULT
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _watchdog(seconds: float):
|
|
22
|
+
"""Exit with all thread stacks if no progress is made within ``seconds``.
|
|
23
|
+
|
|
24
|
+
Each call resets the deadline. The timer runs in a C-level thread,
|
|
25
|
+
so it fires even if Python code is stuck.
|
|
26
|
+
"""
|
|
27
|
+
if _watchdog_file is not None:
|
|
28
|
+
faulthandler.dump_traceback_later(seconds, exit=True, file=_watchdog_file)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pytest_configure(config: pytest.Config):
|
|
32
|
+
"""Ensure the test suite always exits, even when nobody is watching.
|
|
33
|
+
|
|
34
|
+
pytest-timeout only covers individual tests. The watchdog also covers
|
|
35
|
+
collection and interpreter exit, and dumps every thread's stack before
|
|
36
|
+
exiting so that the hang can be diagnosed afterwards.
|
|
37
|
+
"""
|
|
38
|
+
global _watchdog_file, _watchdog_limit
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
timeout = float(config.getoption("timeout", None) or config.getini("timeout"))
|
|
42
|
+
except ValueError:
|
|
43
|
+
timeout = 0
|
|
44
|
+
_watchdog_limit = timeout + WATCHDOG_MARGIN if timeout > 0 else WATCHDOG_DEFAULT
|
|
45
|
+
|
|
46
|
+
# Write to the real stderr, not pytest's capture of it.
|
|
47
|
+
capman = config.pluginmanager.getplugin("capturemanager")
|
|
48
|
+
if isinstance(capman, CaptureManager):
|
|
49
|
+
suspended = capman.global_and_fixture_disabled()
|
|
50
|
+
else:
|
|
51
|
+
suspended = nullcontext()
|
|
52
|
+
with suspended:
|
|
53
|
+
_watchdog_file = os.fdopen(os.dup(2), "w")
|
|
54
|
+
_watchdog(_watchdog_limit)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pytest_collectstart():
|
|
58
|
+
_watchdog(_watchdog_limit)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pytest_collectreport():
|
|
62
|
+
_watchdog(_watchdog_limit)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def pytest_runtest_logstart():
|
|
66
|
+
_watchdog(_watchdog_limit)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def pytest_runtest_logreport():
|
|
70
|
+
_watchdog(_watchdog_limit)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def pytest_sessionfinish():
|
|
74
|
+
_watchdog(_watchdog_limit)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def pytest_unconfigure():
|
|
78
|
+
_watchdog(WATCHDOG_EXIT)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.fixture(autouse=True)
|
|
82
|
+
def check_thread_cleanup():
|
|
83
|
+
"""Ensure that threads are not left running."""
|
|
84
|
+
initial_threads = set(threading.enumerate())
|
|
85
|
+
yield
|
|
86
|
+
final_threads = {t for t in threading.enumerate() if t.is_alive()}
|
|
87
|
+
new_threads = final_threads - initial_threads
|
|
88
|
+
|
|
89
|
+
if new_threads:
|
|
90
|
+
thread_info = []
|
|
91
|
+
for thread in new_threads:
|
|
92
|
+
daemon = "daemon" if thread.daemon else "non-daemon"
|
|
93
|
+
thread_info.append(f" - {thread.name} ({daemon})")
|
|
94
|
+
|
|
95
|
+
pytest.fail(
|
|
96
|
+
f"Test left {len(new_threads)} thread(s) running:\n"
|
|
97
|
+
+ "\n".join(thread_info)
|
|
98
|
+
)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from noio import ShutDown
|
|
1
2
|
from textual.app import App
|
|
2
3
|
from textual.app import ComposeResult
|
|
3
4
|
from textual.widgets import DataTable
|
|
@@ -5,7 +6,6 @@ from textual.widgets import Footer
|
|
|
5
6
|
from textual.widgets import Header
|
|
6
7
|
|
|
7
8
|
from .invocation import Invocation
|
|
8
|
-
from .queue import ShutDown
|
|
9
9
|
from .queueio import QueueIO
|
|
10
10
|
from .result import Err
|
|
11
11
|
from .result import Ok
|
|
@@ -19,6 +19,17 @@ class PikaBroker(Broker):
|
|
|
19
19
|
@classmethod
|
|
20
20
|
@contextmanager
|
|
21
21
|
def connect(cls, connection: ThreadsafeConnection) -> Generator[PikaBroker]:
|
|
22
|
+
channel = connection.channel()
|
|
23
|
+
try:
|
|
24
|
+
if not channel.supports_global_qos():
|
|
25
|
+
raise RuntimeError(
|
|
26
|
+
"Could not verify channel-wide QoS support. QueueIO requires "
|
|
27
|
+
"it for concurrency limits and task suspension. "
|
|
28
|
+
"On RabbitMQ, set deprecated_features.permit.global_qos = true in "
|
|
29
|
+
"rabbitmq.conf and restart RabbitMQ."
|
|
30
|
+
)
|
|
31
|
+
finally:
|
|
32
|
+
channel.close()
|
|
22
33
|
broker = cls(connection)
|
|
23
34
|
try:
|
|
24
35
|
yield broker
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from queueio.broker_test import BaseBrokerTest
|
|
4
|
+
from queueio.queueio import QueueIO
|
|
5
|
+
|
|
6
|
+
from . import PikaBackend
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_global_qos_supported():
|
|
10
|
+
with PikaBackend.connect(QueueIO.broker_uri()) as backend, backend.broker():
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.mark.no_global_qos
|
|
15
|
+
def test_global_qos_required():
|
|
16
|
+
with (
|
|
17
|
+
PikaBackend.connect(QueueIO.broker_uri()) as backend,
|
|
18
|
+
pytest.raises(RuntimeError) as caught,
|
|
19
|
+
backend.broker(),
|
|
20
|
+
):
|
|
21
|
+
pytest.fail("Broker opened without channel-wide QoS support")
|
|
22
|
+
|
|
23
|
+
assert "Could not verify channel-wide QoS support" in str(caught.value)
|
|
24
|
+
assert "On RabbitMQ, set" in str(caught.value)
|
|
25
|
+
assert "deprecated_features.permit.global_qos = true" in str(caught.value)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestPikaBroker(BaseBrokerTest):
|
|
29
|
+
supports_multiple_queues = True
|
|
30
|
+
supports_weighted_queue_subscriptions = False
|
|
31
|
+
|
|
32
|
+
@pytest.fixture
|
|
33
|
+
def broker(self):
|
|
34
|
+
with (
|
|
35
|
+
PikaBackend.connect(QueueIO.broker_uri()) as backend,
|
|
36
|
+
backend.broker() as broker,
|
|
37
|
+
):
|
|
38
|
+
yield broker
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
3
|
from queueio.journal_test import BaseJournalTest
|
|
4
|
+
from queueio.queueio import QueueIO
|
|
4
5
|
|
|
5
6
|
from . import PikaBackend
|
|
6
7
|
|
|
@@ -9,7 +10,7 @@ class TestPikaJournal(BaseJournalTest):
|
|
|
9
10
|
@pytest.fixture
|
|
10
11
|
def journal(self):
|
|
11
12
|
with (
|
|
12
|
-
PikaBackend.connect(
|
|
13
|
+
PikaBackend.connect(QueueIO.broker_uri()) as backend,
|
|
13
14
|
backend.journal() as journal,
|
|
14
15
|
):
|
|
15
16
|
yield journal
|
|
@@ -5,6 +5,7 @@ from collections.abc import Mapping
|
|
|
5
5
|
from concurrent.futures import Future
|
|
6
6
|
from contextlib import contextmanager
|
|
7
7
|
from contextlib import suppress
|
|
8
|
+
from queue import Empty
|
|
8
9
|
from queue import Queue
|
|
9
10
|
from queue import ShutDown
|
|
10
11
|
from threading import Event
|
|
@@ -33,7 +34,13 @@ class ThreadsafeConnection:
|
|
|
33
34
|
)
|
|
34
35
|
self.__thread = Thread(target=self.__connection.ioloop.start)
|
|
35
36
|
self.__thread.start()
|
|
36
|
-
|
|
37
|
+
try:
|
|
38
|
+
opened.result()
|
|
39
|
+
except BaseException:
|
|
40
|
+
# Stop the ioloop so a failed connection doesn't leak its thread.
|
|
41
|
+
self.__connection.ioloop.stop()
|
|
42
|
+
self.__thread.join()
|
|
43
|
+
raise
|
|
37
44
|
|
|
38
45
|
def __wait[T](self, fn: Callable[[], T]):
|
|
39
46
|
# TODO: Throw connection exceptions on all waiters
|
|
@@ -95,6 +102,38 @@ class ThreadsafeChannel:
|
|
|
95
102
|
finally:
|
|
96
103
|
self.__pending.remove(future)
|
|
97
104
|
|
|
105
|
+
def supports_global_qos(self) -> bool:
|
|
106
|
+
"""Probe global QoS on a fresh, disposable channel.
|
|
107
|
+
|
|
108
|
+
Global QoS shares a concurrency limit across a receiver's queues.
|
|
109
|
+
Updating that limit lets suspended tasks free capacity for other tasks.
|
|
110
|
+
"""
|
|
111
|
+
queues = []
|
|
112
|
+
try:
|
|
113
|
+
self.qos(prefetch_count=1, global_qos=True)
|
|
114
|
+
for _ in range(2):
|
|
115
|
+
result = self.queue_declare("", exclusive=True, auto_delete=True)
|
|
116
|
+
queue = cast(str, result.method.queue)
|
|
117
|
+
queues.append(queue)
|
|
118
|
+
self.publish("", queue, b"")
|
|
119
|
+
self.consume(queue)
|
|
120
|
+
try:
|
|
121
|
+
self.__messages.get(timeout=5)
|
|
122
|
+
try:
|
|
123
|
+
self.__messages.get(timeout=0.2)
|
|
124
|
+
except Empty:
|
|
125
|
+
pass
|
|
126
|
+
else:
|
|
127
|
+
return False
|
|
128
|
+
self.qos(prefetch_count=2, global_qos=True)
|
|
129
|
+
self.__messages.get(timeout=5)
|
|
130
|
+
except Empty:
|
|
131
|
+
return False
|
|
132
|
+
return True
|
|
133
|
+
finally:
|
|
134
|
+
for queue in queues:
|
|
135
|
+
self.delete(queue)
|
|
136
|
+
|
|
98
137
|
def queue_declare(
|
|
99
138
|
self,
|
|
100
139
|
queue: str,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pika import ConnectionParameters
|
|
5
|
+
from pika.exceptions import AMQPConnectionError
|
|
6
|
+
|
|
7
|
+
from .threadsafe import ThreadsafeConnection
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_failed_connection_stops_ioloop_thread():
|
|
11
|
+
# Find a local port with nothing listening on it.
|
|
12
|
+
with socket.socket() as sock:
|
|
13
|
+
sock.bind(("127.0.0.1", 0))
|
|
14
|
+
port = sock.getsockname()[1]
|
|
15
|
+
|
|
16
|
+
# The autouse check_thread_cleanup fixture fails if the ioloop thread leaks.
|
|
17
|
+
with pytest.raises(AMQPConnectionError):
|
|
18
|
+
ThreadsafeConnection(
|
|
19
|
+
ConnectionParameters(host="127.0.0.1", port=port, connection_attempts=1)
|
|
20
|
+
)
|