queues-sdk 0.1.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.
- queues_sdk-0.1.0/.gitignore +14 -0
- queues_sdk-0.1.0/LICENSE +21 -0
- queues_sdk-0.1.0/PKG-INFO +105 -0
- queues_sdk-0.1.0/README.md +78 -0
- queues_sdk-0.1.0/docs/01-getting-started.md +57 -0
- queues_sdk-0.1.0/docs/02-api-reference.md +41 -0
- queues_sdk-0.1.0/docs/03-performance.md +33 -0
- queues_sdk-0.1.0/examples/backpressure.py +23 -0
- queues_sdk-0.1.0/examples/pressure_spill.py +25 -0
- queues_sdk-0.1.0/examples/quickstart.py +21 -0
- queues_sdk-0.1.0/pyproject.toml +48 -0
- queues_sdk-0.1.0/src/queues_sdk/__init__.py +28 -0
- queues_sdk-0.1.0/src/queues_sdk/_version.py +1 -0
- queues_sdk-0.1.0/src/queues_sdk/core.py +224 -0
- queues_sdk-0.1.0/src/queues_sdk/errors.py +2 -0
- queues_sdk-0.1.0/src/queues_sdk/py.typed +0 -0
- queues_sdk-0.1.0/src/queues_sdk/watermarks.py +19 -0
- queues_sdk-0.1.0/tests/conftest.py +21 -0
- queues_sdk-0.1.0/tests/test_core.py +68 -0
- queues_sdk-0.1.0/tests/test_pressure.py +70 -0
- queues_sdk-0.1.0/tests/test_watermarks.py +29 -0
queues_sdk-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 queues-sdk contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: queues-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-loss async queue SDK: direct waiter handoff, backpressure waiting, pressure-gated spill to disk with FIFO drain. No network, no dependencies.
|
|
5
|
+
Project-URL: Homepage, https://github.com/scapking/queues-sdk
|
|
6
|
+
Project-URL: Source, https://github.com/scapking/queues-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/scapking/queues-sdk/issues
|
|
8
|
+
Author: queues-sdk contributors
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: asyncio,backpressure,pressure,queue,sdk,spill,zero-loss
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# queues-sdk
|
|
29
|
+
|
|
30
|
+
**Zero-loss async queue SDK for Python.** Direct waiter handoff, backpressure waiting, pressure-gated spill to disk with FIFO drain. No network, no dependencies, stdlib only.
|
|
31
|
+
|
|
32
|
+
Import name is `queues_sdk`; the PyPI distribution is `queues-sdk`.
|
|
33
|
+
|
|
34
|
+
## Why
|
|
35
|
+
|
|
36
|
+
Plain `asyncio.Queue` either blocks forever when full or drops messages if you cap it. Real pipelines need a third behaviour: **never lose, wait when possible, spill to disk when memory is tight, and soak in RAM when disk is tight.** queues-sdk implements exactly that on top of a lock-free deque + future handoff design.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install queues-sdk
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from queues_sdk import Queues
|
|
49
|
+
|
|
50
|
+
q = Queues() # maxsize=1024*512, thresholds built-in
|
|
51
|
+
|
|
52
|
+
async def main():
|
|
53
|
+
await q.publish({"event": "signup"}) # unnamed: memory path
|
|
54
|
+
message = await q.consume() # backpressure-aware receive
|
|
55
|
+
|
|
56
|
+
await q.publish({"event": "audit"}, name="audit") # named: pressure-gated
|
|
57
|
+
message = await q.consume(name="audit") # auto-drains disk spill
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Zero-loss semantics
|
|
63
|
+
|
|
64
|
+
| Condition | Behaviour | Lost messages |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| normal watermarks, queue not full | lock-free deque append / future handoff | none |
|
|
67
|
+
| queue full | publish **awaits space** (backpressure); queue-head never evicted | none |
|
|
68
|
+
| memory soft line (>= memory_max) | named messages spill to disk as `batch-*.jsonl`, FIFO drain on consume | none |
|
|
69
|
+
| disk hard line (>= disk_max) | named messages soak in RAM, spill resumes when disk recovers | none |
|
|
70
|
+
| memory AND disk exhausted | no retreat | only here |
|
|
71
|
+
|
|
72
|
+
## API
|
|
73
|
+
|
|
74
|
+
- `Queues(maxsize=1024*512, memory_max=99, disk_max=99, spill_path="~/.local/queues_sdk/spill/", spill_batch=1024, watermarks=None)`
|
|
75
|
+
- `await publish(message, name=None) -> None` — unnamed skips the pressure gate entirely (hot path)
|
|
76
|
+
- `await consume(name=None) -> Any` — auto-drains the named spill buffer before waiting
|
|
77
|
+
- `await drain(name, target_list, limit=None) -> int` — bounded FIFO refill from disk
|
|
78
|
+
- `await discard(name) -> int` — wipe a spill buffer, returns dropped-message count
|
|
79
|
+
- `state() -> "normal" | "soft" | "hard"` — current pressure mode
|
|
80
|
+
- Five pre-built `asyncio.Queue` log lanes (`almightydb_service_logger_warning/info/debug/error/store`, each 1024*4) ship inside every instance
|
|
81
|
+
|
|
82
|
+
Watermarks are injected: pass `watermarks=lambda: (memory_pct, disk_pct)`; the default provider reads `/proc/meminfo` + `statvfs("/")` on Linux and reports `(0, 0)` elsewhere.
|
|
83
|
+
|
|
84
|
+
## Performance
|
|
85
|
+
|
|
86
|
+
Measured on CPython 3.14 free-threaded, Linux, single process:
|
|
87
|
+
|
|
88
|
+
| Path | Throughput |
|
|
89
|
+
|---|---|
|
|
90
|
+
| publish (unnamed, normal) | ~820k ops/s |
|
|
91
|
+
| publish+consume pair (end-to-end TPS) | ~72k pairs/s |
|
|
92
|
+
| named spill (soft line) | ~140k ops/s |
|
|
93
|
+
| drain refill | ~356k ops/s |
|
|
94
|
+
|
|
95
|
+
Spill writes are batched (1024 lines per file): expect one ~1-4 ms disk pause per batch under the soft line.
|
|
96
|
+
|
|
97
|
+
## Docs
|
|
98
|
+
|
|
99
|
+
- [Getting started](docs/01-getting-started.md)
|
|
100
|
+
- [API reference](docs/02-api-reference.md)
|
|
101
|
+
- [Performance & latency profile](docs/03-performance.md)
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# queues-sdk
|
|
2
|
+
|
|
3
|
+
**Zero-loss async queue SDK for Python.** Direct waiter handoff, backpressure waiting, pressure-gated spill to disk with FIFO drain. No network, no dependencies, stdlib only.
|
|
4
|
+
|
|
5
|
+
Import name is `queues_sdk`; the PyPI distribution is `queues-sdk`.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Plain `asyncio.Queue` either blocks forever when full or drops messages if you cap it. Real pipelines need a third behaviour: **never lose, wait when possible, spill to disk when memory is tight, and soak in RAM when disk is tight.** queues-sdk implements exactly that on top of a lock-free deque + future handoff design.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install queues-sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import asyncio
|
|
21
|
+
from queues_sdk import Queues
|
|
22
|
+
|
|
23
|
+
q = Queues() # maxsize=1024*512, thresholds built-in
|
|
24
|
+
|
|
25
|
+
async def main():
|
|
26
|
+
await q.publish({"event": "signup"}) # unnamed: memory path
|
|
27
|
+
message = await q.consume() # backpressure-aware receive
|
|
28
|
+
|
|
29
|
+
await q.publish({"event": "audit"}, name="audit") # named: pressure-gated
|
|
30
|
+
message = await q.consume(name="audit") # auto-drains disk spill
|
|
31
|
+
|
|
32
|
+
asyncio.run(main())
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Zero-loss semantics
|
|
36
|
+
|
|
37
|
+
| Condition | Behaviour | Lost messages |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| normal watermarks, queue not full | lock-free deque append / future handoff | none |
|
|
40
|
+
| queue full | publish **awaits space** (backpressure); queue-head never evicted | none |
|
|
41
|
+
| memory soft line (>= memory_max) | named messages spill to disk as `batch-*.jsonl`, FIFO drain on consume | none |
|
|
42
|
+
| disk hard line (>= disk_max) | named messages soak in RAM, spill resumes when disk recovers | none |
|
|
43
|
+
| memory AND disk exhausted | no retreat | only here |
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
- `Queues(maxsize=1024*512, memory_max=99, disk_max=99, spill_path="~/.local/queues_sdk/spill/", spill_batch=1024, watermarks=None)`
|
|
48
|
+
- `await publish(message, name=None) -> None` — unnamed skips the pressure gate entirely (hot path)
|
|
49
|
+
- `await consume(name=None) -> Any` — auto-drains the named spill buffer before waiting
|
|
50
|
+
- `await drain(name, target_list, limit=None) -> int` — bounded FIFO refill from disk
|
|
51
|
+
- `await discard(name) -> int` — wipe a spill buffer, returns dropped-message count
|
|
52
|
+
- `state() -> "normal" | "soft" | "hard"` — current pressure mode
|
|
53
|
+
- Five pre-built `asyncio.Queue` log lanes (`almightydb_service_logger_warning/info/debug/error/store`, each 1024*4) ship inside every instance
|
|
54
|
+
|
|
55
|
+
Watermarks are injected: pass `watermarks=lambda: (memory_pct, disk_pct)`; the default provider reads `/proc/meminfo` + `statvfs("/")` on Linux and reports `(0, 0)` elsewhere.
|
|
56
|
+
|
|
57
|
+
## Performance
|
|
58
|
+
|
|
59
|
+
Measured on CPython 3.14 free-threaded, Linux, single process:
|
|
60
|
+
|
|
61
|
+
| Path | Throughput |
|
|
62
|
+
|---|---|
|
|
63
|
+
| publish (unnamed, normal) | ~820k ops/s |
|
|
64
|
+
| publish+consume pair (end-to-end TPS) | ~72k pairs/s |
|
|
65
|
+
| named spill (soft line) | ~140k ops/s |
|
|
66
|
+
| drain refill | ~356k ops/s |
|
|
67
|
+
|
|
68
|
+
Spill writes are batched (1024 lines per file): expect one ~1-4 ms disk pause per batch under the soft line.
|
|
69
|
+
|
|
70
|
+
## Docs
|
|
71
|
+
|
|
72
|
+
- [Getting started](docs/01-getting-started.md)
|
|
73
|
+
- [API reference](docs/02-api-reference.md)
|
|
74
|
+
- [Performance & latency profile](docs/03-performance.md)
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# 01 — Getting started
|
|
2
|
+
|
|
3
|
+
## Requirements
|
|
4
|
+
|
|
5
|
+
- Python >= 3.10
|
|
6
|
+
- No runtime dependencies.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install queues-sdk
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Create a queue
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from queues_sdk import Queues
|
|
18
|
+
|
|
19
|
+
q = Queues()
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Constructor arguments (all optional, thresholds are **built into the instance**, never read from config files):
|
|
23
|
+
|
|
24
|
+
| Argument | Default | Meaning |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `maxsize` | `1024*512` | in-memory buffer capacity |
|
|
27
|
+
| `memory_max` | `99` | memory soft line, 0-100 |
|
|
28
|
+
| `disk_max` | `99` | disk hard line, 0-100 |
|
|
29
|
+
| `spill_path` | `~/.local/queues_sdk/spill/` | spill root directory |
|
|
30
|
+
| `spill_batch` | `1024` | lines per spilled batch file |
|
|
31
|
+
| `watermarks` | `None` | provider returning `(memory_pct, disk_pct)` |
|
|
32
|
+
|
|
33
|
+
## Publish and consume
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
await q.publish(message) # unnamed: memory path
|
|
37
|
+
await q.publish(message, name="audit") # named: pressure-gated spill
|
|
38
|
+
message = await q.consume() # memory path first, then waits
|
|
39
|
+
message = await q.consume(name="audit") # auto-drains the spill buffer first
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- Unnamed calls skip the pressure gate entirely — that is the hot path.
|
|
43
|
+
- Named calls route through the gate: `spill` when a watermark is crossed, `pass` otherwise.
|
|
44
|
+
- `consume` blocks with a future handoff; producers deliver messages directly without touching the buffer.
|
|
45
|
+
|
|
46
|
+
## Watermark injection
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
q = Queues(watermarks=lambda: (75, 40)) # memory 75%, disk 40%
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The default provider computes both percentages from `/proc/meminfo` (`MemTotal`/`MemAvailable`) and `statvfs("/")`; on non-Linux platforms it reports `(0, 0)`.
|
|
53
|
+
|
|
54
|
+
## Next
|
|
55
|
+
|
|
56
|
+
- [API reference](02-api-reference.md)
|
|
57
|
+
- [Performance & latency profile](03-performance.md)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# 02 — API reference
|
|
2
|
+
|
|
3
|
+
## `Queues`
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
Queues(maxsize=1024*512, memory_max=99, disk_max=99, spill_path="~/.local/queues_sdk/spill/", spill_batch=1024, watermarks=None) -> Queues
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## `async publish(message, name=None) -> None`
|
|
10
|
+
|
|
11
|
+
- `name=None`: memory fast path; if the buffer is full the producer awaits space (backpressure, zero loss, queue head never evicted).
|
|
12
|
+
- `name=str`: goes through the pressure gate. On the soft line the message is buffered and spilled to disk in batches; on the hard line it soaks in RAM until the disk recovers.
|
|
13
|
+
- The five service log lanes are for the host application to use directly; `publish` does not touch them.
|
|
14
|
+
|
|
15
|
+
## `async consume(name=None) -> Any`
|
|
16
|
+
|
|
17
|
+
1. Buffer non-empty: pop the head, wake one space-waiting producer if there is room.
|
|
18
|
+
2. `name` given and spill residue exists: bounded `drain` into the buffer, then pop.
|
|
19
|
+
3. Otherwise: attach a waiter future and suspend; `publish` hands the message over directly.
|
|
20
|
+
|
|
21
|
+
Cancellation while waiting removes the waiter and re-raises `CancelledError` — a cancelled consume never silently returns a fake `None` mixed with real data.
|
|
22
|
+
|
|
23
|
+
## `async drain(name, target, limit=None) -> int`
|
|
24
|
+
|
|
25
|
+
Bounded FIFO refill from disk into `target` (a list is typical). Spilled files are read oldest-first, each deleted after it is fully consumed (at-least-once on crash). Corrupt lines quarantine the whole file as `.bad` instead of blocking the stream.
|
|
26
|
+
|
|
27
|
+
## `async discard(name) -> int`
|
|
28
|
+
|
|
29
|
+
Wipes `pending`, `draining` and `checked` state plus the on-disk directory; the return value is the total number of messages discarded (pending lines + spill file lines) for reconciliation.
|
|
30
|
+
|
|
31
|
+
## `state() -> str`
|
|
32
|
+
|
|
33
|
+
`"normal"` / `"soft"` / `"hard"` from the injected watermarks; any provider failure degrades to `"normal"`.
|
|
34
|
+
|
|
35
|
+
## Exceptions
|
|
36
|
+
|
|
37
|
+
- `QueuesError` — explicit API failure surfaced to callers.
|
|
38
|
+
|
|
39
|
+
## Error logging
|
|
40
|
+
|
|
41
|
+
Internal failures go through `__log_error` into the `queues_sdk` standard-library logger at `ERROR` level with a structured dict (id, UTC time, method, type, value, file/line, pid/thread, traceback). Nothing is swallowed silently.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# 03 — Performance & latency profile
|
|
2
|
+
|
|
3
|
+
Measured on CPython 3.14 free-threaded, Linux, single process, in-process alternation against the upstream engine implementation.
|
|
4
|
+
|
|
5
|
+
## Throughput
|
|
6
|
+
|
|
7
|
+
| Path | Throughput |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `publish`, unnamed, normal watermarks | ~820k ops/s |
|
|
10
|
+
| `publish`+`consume` pair (end-to-end TPS) | ~72k pairs/s |
|
|
11
|
+
| named spill on soft line | ~140k ops/s |
|
|
12
|
+
| `drain` refill | ~356k ops/s |
|
|
13
|
+
|
|
14
|
+
Design points behind these numbers:
|
|
15
|
+
|
|
16
|
+
- publish without a waiter: lock-free `deque.append`, no locks, no event-loop round trips.
|
|
17
|
+
- publish with a waiter: `future.set_result` handoff — the message never touches the buffer.
|
|
18
|
+
- unnamed publishes bypass the pressure gate coroutine entirely.
|
|
19
|
+
- refill reads a whole spilled file at once and deserialises in one pass.
|
|
20
|
+
|
|
21
|
+
## Latency profile: steady vs paused
|
|
22
|
+
|
|
23
|
+
The hot path is **steady-state**: no timers, no periodic flushes, allocations are short-lived objects reclaimed by reference counting, so there is no GC-shaped pause built into the design.
|
|
24
|
+
|
|
25
|
+
The only **episodic** pause source is batched spill: measured median `publish` latency on the soft line is ~10 µs, with a ~1-4 ms max spike on the batch boundary (one synchronous `open`+`write` per 1024 lines). Under the hard line there are no writes at all — messages soak in RAM.
|
|
26
|
+
|
|
27
|
+
Backpressure (`queue full`) is event-driven suspension, not polling and not a pause: producers resume exactly when a consumer frees one slot.
|
|
28
|
+
|
|
29
|
+
## Tuning
|
|
30
|
+
|
|
31
|
+
- `spill_batch` — lower it to shorten the per-file write spike; raise it to amortise fewer, bigger writes.
|
|
32
|
+
- `maxsize` — raise it to absorb bursts fully in RAM.
|
|
33
|
+
- Watermark thresholds (`memory_max`/`disk_max`) — lower them to enter the safe paths earlier; they are constructor arguments, tuned per deployment, never state.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from queues_sdk import Queues
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def main():
|
|
7
|
+
q = Queues(maxsize=8) # small buffer on purpose
|
|
8
|
+
|
|
9
|
+
async def producer(count):
|
|
10
|
+
for i in range(count):
|
|
11
|
+
await q.publish({"i": i}) # blocks when full, never drops
|
|
12
|
+
|
|
13
|
+
async def consumer(count):
|
|
14
|
+
for i in range(count):
|
|
15
|
+
print("consumed:", (await q.consume())["i"])
|
|
16
|
+
|
|
17
|
+
await asyncio.gather(producer(10), consumer(5))
|
|
18
|
+
await asyncio.sleep(0.05)
|
|
19
|
+
await consumer(5) # drains the rest
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == "__main__":
|
|
23
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from queues_sdk import Queues
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def main():
|
|
7
|
+
holder = {"memory": 0, "disk": 0}
|
|
8
|
+
q = Queues(maxsize=1024*4, watermarks=lambda: (int(holder["memory"]), int(holder["disk"])))
|
|
9
|
+
|
|
10
|
+
holder["memory"] = 100 # cross the memory soft line
|
|
11
|
+
for i in range(5):
|
|
12
|
+
await q.publish({"m": i}, name="audit") # buffered into pending
|
|
13
|
+
print("soaked in pending:", sum(len(v) for v in q.pending.values()))
|
|
14
|
+
|
|
15
|
+
holder["memory"] = 0 # pressure recovers
|
|
16
|
+
drained = []
|
|
17
|
+
pulled = await q.drain("audit", drained)
|
|
18
|
+
print("drained:", pulled, "in FIFO order:", [x["m"] for x in drained])
|
|
19
|
+
|
|
20
|
+
dropped = await q.discard("audit")
|
|
21
|
+
print("dropped after drain:", dropped)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from queues_sdk import Queues
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def main():
|
|
7
|
+
q = Queues()
|
|
8
|
+
|
|
9
|
+
await q.publish({"event": "signup", "user": 1})
|
|
10
|
+
message = await q.consume()
|
|
11
|
+
print("unnamed round-trip:", message)
|
|
12
|
+
|
|
13
|
+
await q.publish({"event": "audit", "user": 2}, name="audit")
|
|
14
|
+
message = await q.consume(name="audit")
|
|
15
|
+
print("named round-trip:", message)
|
|
16
|
+
|
|
17
|
+
print("pressure state:", q.state())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "queues-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Zero-loss async queue SDK: direct waiter handoff, backpressure waiting, pressure-gated spill to disk with FIFO drain. No network, no dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "queues-sdk contributors" }]
|
|
13
|
+
keywords = ["queue", "asyncio", "backpressure", "zero-loss", "spill", "pressure", "sdk"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: System :: Distributed Computing",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=7.4", "pytest-asyncio>=0.23"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/scapking/queues-sdk"
|
|
33
|
+
Source = "https://github.com/scapking/queues-sdk"
|
|
34
|
+
Issues = "https://github.com/scapking/queues-sdk/issues"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/queues_sdk"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.sdist]
|
|
40
|
+
include = ["src", "tests", "docs", "examples", "README.md", "LICENSE"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
addopts = "-q"
|
|
45
|
+
asyncio_mode = "auto"
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
line-length = 110
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""queues_sdk - zero-loss async queue SDK.
|
|
2
|
+
|
|
3
|
+
One class does the work::
|
|
4
|
+
|
|
5
|
+
from queues_sdk import Queues
|
|
6
|
+
|
|
7
|
+
q = Queues() # maxsize=1024*512, thresholds built-in
|
|
8
|
+
await q.publish(message) # unnamed: memory path, never dropped
|
|
9
|
+
await q.publish(message, name="audit") # named: pressure-gated spill
|
|
10
|
+
message = await q.consume() # backpressure-aware receive
|
|
11
|
+
message = await q.consume(name="audit") # auto-drains disk spill first
|
|
12
|
+
|
|
13
|
+
Pressure semantics (memory/disk watermarks, 0-100):
|
|
14
|
+
|
|
15
|
+
- disk hard line -> named messages soak in RAM, spill resumes when disk recovers
|
|
16
|
+
- memory soft line -> named messages spill to disk as batch-*.jsonl, FIFO drain
|
|
17
|
+
- queue full -> publish awaits space instead of raising; queue-head never evicted
|
|
18
|
+
|
|
19
|
+
Zero-loss promise: messages are only lost if memory AND disk are exhausted.
|
|
20
|
+
Thresholds are constructor arguments, never config files. Import name is
|
|
21
|
+
``queues_sdk``; the PyPI distribution is ``queues-sdk``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
from .core import Queues
|
|
26
|
+
from .errors import QueuesError
|
|
27
|
+
|
|
28
|
+
__all__ = ["Queues", "QueuesError", "__version__"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import sys, os, time, asyncio, uuid, traceback, threading, collections, json, shutil, datetime, pathlib
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
from queues_sdk.watermarks import default_watermarks
|
|
5
|
+
from queues_sdk.errors import QueuesError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Queues:
|
|
13
|
+
def __init__(self, maxsize: int = 1024*512, memory_max: int = 99, disk_max: int = 99, spill_path: str = "~/.local/queues_sdk/spill/", spill_batch: int = 1024, watermarks: Callable[[], tuple[int, int]] | None = None):
|
|
14
|
+
self.pending = dict()
|
|
15
|
+
self.draining = dict()
|
|
16
|
+
self.checked = set()
|
|
17
|
+
self.pressure_memory_max = int(memory_max)
|
|
18
|
+
self.pressure_disk_max = int(disk_max)
|
|
19
|
+
self.pressure_spill_path = str(spill_path)
|
|
20
|
+
self.pressure_spill_batch = int(spill_batch)
|
|
21
|
+
self._watermarks = watermarks if watermarks is not None else default_watermarks
|
|
22
|
+
self.db = collections.deque(maxlen=int(maxsize))
|
|
23
|
+
self.db_append = self.db.append
|
|
24
|
+
self.db_popleft = self.db.popleft
|
|
25
|
+
self.event = asyncio.Event()
|
|
26
|
+
self.waiters = collections.deque()
|
|
27
|
+
self.waiters_append = self.waiters.append
|
|
28
|
+
self.waiters_popleft = self.waiters.popleft
|
|
29
|
+
self.space_waiters = collections.deque()
|
|
30
|
+
self.space_waiters_append = self.space_waiters.append
|
|
31
|
+
self.space_waiters_popleft = self.space_waiters.popleft
|
|
32
|
+
self.almightydb_service_logger_warning = asyncio.Queue(maxsize=1024*4)
|
|
33
|
+
self.almightydb_service_logger_info = asyncio.Queue(maxsize=1024*4)
|
|
34
|
+
self.almightydb_service_logger_debug = asyncio.Queue(maxsize=1024*4)
|
|
35
|
+
self.almightydb_service_logger_error = asyncio.Queue(maxsize=1024*4)
|
|
36
|
+
self.almightydb_service_logger_store = asyncio.Queue(maxsize=1024*4)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def state(self) -> str:
|
|
43
|
+
try:
|
|
44
|
+
# 磁盘过硬线 → hard;内存过硬线 → soft;否则 normal。
|
|
45
|
+
memory, disk = self._watermarks()
|
|
46
|
+
if int(disk) >= int(self.pressure_disk_max): return "hard"
|
|
47
|
+
if int(memory) >= int(self.pressure_memory_max): return "soft"
|
|
48
|
+
return "normal"
|
|
49
|
+
|
|
50
|
+
except BaseException: return "normal"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def gate(self, name: str | None, message) -> str:
|
|
57
|
+
try:
|
|
58
|
+
# 写侧统一入口:'spill' 压力缓冲(硬线滞留内存不写盘/软线溢出落盘)/ 'pass' 内存路径。
|
|
59
|
+
memory, disk = self._watermarks()
|
|
60
|
+
hard = int(disk) >= int(self.pressure_disk_max)
|
|
61
|
+
if name is None: return "pass"
|
|
62
|
+
if name not in self.checked:
|
|
63
|
+
self.checked.add(name)
|
|
64
|
+
directory = self._spill_directory(name)
|
|
65
|
+
if directory.is_dir() and any(directory.glob("*.jsonl")): self.draining[name] = True
|
|
66
|
+
if hard or int(memory) >= int(self.pressure_memory_max) or self.draining.get(name):
|
|
67
|
+
lines = self.pending.setdefault(name, list())
|
|
68
|
+
lines.append(json.dumps(message, ensure_ascii=False, default=str))
|
|
69
|
+
# 攒批到界且磁盘未硬线时落盘一个 batch-*.jsonl,行缓冲随即清空。
|
|
70
|
+
if len(lines) >= int(self.pressure_spill_batch) and not hard:
|
|
71
|
+
directory = self._spill_directory(name)
|
|
72
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
path = directory / "batch-{}.jsonl".format(time.monotonic_ns())
|
|
74
|
+
with open(path, "w", encoding="utf-8") as handle: handle.write("\n".join(lines) + "\n")
|
|
75
|
+
lines.clear()
|
|
76
|
+
return "spill"
|
|
77
|
+
return "pass"
|
|
78
|
+
|
|
79
|
+
except BaseException as error: await self.__log_error("gate", error); return "pass"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _spill_directory(self, name: str):
|
|
86
|
+
return pathlib.Path(self.pressure_spill_path).expanduser() / str(name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def drain(self, name: str, target, limit: int | None = None) -> int:
|
|
93
|
+
try:
|
|
94
|
+
# 有界回灌:盘上旧 batch 文件在前 FIFO,pending 就地落成最新文件在后,顺序自然正确。
|
|
95
|
+
limit = int(self.pressure_spill_batch) if limit is None else int(limit)
|
|
96
|
+
pulled = int(0); lines = self.pending.get(name); directory = self._spill_directory(name)
|
|
97
|
+
if lines:
|
|
98
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
path = directory / "batch-{}.jsonl".format(time.monotonic_ns())
|
|
100
|
+
with open(path, "w", encoding="utf-8") as handle: handle.write("\n".join(lines) + "\n")
|
|
101
|
+
lines.clear()
|
|
102
|
+
if directory.is_dir():
|
|
103
|
+
for path in sorted(directory.glob("*.jsonl")):
|
|
104
|
+
if pulled >= limit: break
|
|
105
|
+
try:
|
|
106
|
+
with open(path, encoding="utf-8") as handle: batch = [line.strip() for line in handle if line.strip()]
|
|
107
|
+
target.extend(json.loads(line) for line in batch); pulled = pulled + len(batch)
|
|
108
|
+
path.unlink()
|
|
109
|
+
except BaseException:
|
|
110
|
+
# 毒文件改名 .bad 隔离,不阻塞 FIFO。
|
|
111
|
+
os.replace(path, directory / (path.name + ".bad"))
|
|
112
|
+
self.checked.add(name)
|
|
113
|
+
if self.pending.get(name) or (directory.is_dir() and any(directory.glob("*.jsonl"))): self.draining[name] = True
|
|
114
|
+
else: self.draining.pop(name, None)
|
|
115
|
+
return pulled
|
|
116
|
+
|
|
117
|
+
except BaseException as error: await self.__log_error("drain", error); return int(0)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def discard(self, name: str) -> int:
|
|
124
|
+
try:
|
|
125
|
+
# 清盘并整盘对账:丢弃条数 = pending 行数 + 盘上 batch 行数。
|
|
126
|
+
dropped = len(self.pending.pop(name, list()))
|
|
127
|
+
self.draining.pop(name, None)
|
|
128
|
+
self.checked.discard(name)
|
|
129
|
+
directory = self._spill_directory(name)
|
|
130
|
+
if directory.is_dir():
|
|
131
|
+
for path in directory.glob("*.jsonl"):
|
|
132
|
+
with open(path, encoding="utf-8") as handle: dropped = dropped + len([line for line in handle if line.strip()])
|
|
133
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
134
|
+
return int(dropped)
|
|
135
|
+
|
|
136
|
+
except BaseException as error: await self.__log_error("discard", error); return int(0)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def publish(self, message, name: str | None = None):
|
|
143
|
+
try:
|
|
144
|
+
# 未命名消息直走内存路径(零依赖快路径);命名消息过压力门。
|
|
145
|
+
action = "pass" if name is None else await self.gate(name, message)
|
|
146
|
+
if action == "spill": return None
|
|
147
|
+
if self.waiters and not self.waiters[0].done(): self.waiters_popleft().set_result(message); return None
|
|
148
|
+
if self.waiters: self.waiters_popleft()
|
|
149
|
+
# 容量满 → 背压等待消费腾位,不拒绝不挤队首(零丢失)。
|
|
150
|
+
while self.db.maxlen is not None and len(self.db) >= self.db.maxlen:
|
|
151
|
+
producer = asyncio.get_running_loop().create_future()
|
|
152
|
+
self.space_waiters_append(producer)
|
|
153
|
+
try:
|
|
154
|
+
await producer
|
|
155
|
+
except asyncio.CancelledError:
|
|
156
|
+
if producer in self.space_waiters: self.space_waiters.remove(producer)
|
|
157
|
+
raise
|
|
158
|
+
self.db_append(message)
|
|
159
|
+
|
|
160
|
+
except BaseException as error:
|
|
161
|
+
# 取消必须上抛:吸收会让 producer 误以为消息已入队(丢失语义)。
|
|
162
|
+
if isinstance(error, asyncio.CancelledError): raise
|
|
163
|
+
await self.__log_error("publish", error); return None; return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def consume(self, name: str | None = None):
|
|
170
|
+
try:
|
|
171
|
+
if len(self.db) > 0:
|
|
172
|
+
message = self.db_popleft()
|
|
173
|
+
if self.space_waiters and self.db.maxlen is not None and len(self.db) < self.db.maxlen: self.space_waiters_popleft().set_result(None)
|
|
174
|
+
return message
|
|
175
|
+
if name and (self.draining.get(name) or self.pending.get(name)): await self.drain(name, self.db)
|
|
176
|
+
if len(self.db) > 0: return self.db_popleft()
|
|
177
|
+
waiter = asyncio.get_running_loop().create_future()
|
|
178
|
+
self.waiters_append(waiter)
|
|
179
|
+
# 取消时清理等待者并上抛(吸收取消会让消费端拿到 None 混淆空值语义)。
|
|
180
|
+
try:
|
|
181
|
+
message = await waiter
|
|
182
|
+
except asyncio.CancelledError:
|
|
183
|
+
if waiter in self.waiters: self.waiters.remove(waiter)
|
|
184
|
+
raise
|
|
185
|
+
return message
|
|
186
|
+
|
|
187
|
+
except BaseException as error:
|
|
188
|
+
# 取消必须上抛:吸收会让消费循环把 None 当消息、teardown 永久等待。
|
|
189
|
+
if isinstance(error, asyncio.CancelledError): raise
|
|
190
|
+
await self.__log_error("consume", error); return None; return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def main(self):
|
|
197
|
+
try:
|
|
198
|
+
pass
|
|
199
|
+
except BaseException as error: await self.__log_error("main", error)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def __log_error(self, method: str, error: BaseException):
|
|
206
|
+
try:
|
|
207
|
+
import logging
|
|
208
|
+
frames = traceback.extract_tb(error.__traceback__)
|
|
209
|
+
frame = frames[-1] if frames else None
|
|
210
|
+
detail = {
|
|
211
|
+
"error_id": uuid.uuid4().hex,
|
|
212
|
+
"error_time": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
213
|
+
"error_method": "{}.{}".format(type(self).__name__, method),
|
|
214
|
+
"error_type": type(error).__name__,
|
|
215
|
+
"error_value": str(error),
|
|
216
|
+
"error_file": os.path.abspath(frame.filename) if frame else "unknown",
|
|
217
|
+
"error_line": frame.lineno if frame else 0,
|
|
218
|
+
"error_pid": os.getpid(),
|
|
219
|
+
"error_thread": threading.current_thread().name,
|
|
220
|
+
"error_traceback": "".join(traceback.format_exception(type(error), error, error.__traceback__)),
|
|
221
|
+
}
|
|
222
|
+
logging.getLogger("queues_sdk").error("[queues_sdk error] [{}]".format(detail))
|
|
223
|
+
|
|
224
|
+
except BaseException: pass
|
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def default_watermarks() -> tuple[int, int]:
|
|
8
|
+
try:
|
|
9
|
+
memory_total = int(0); memory_available = int(0)
|
|
10
|
+
with open("/proc/meminfo", encoding="utf-8") as handle:
|
|
11
|
+
for line in handle:
|
|
12
|
+
if line.startswith("MemTotal:"): memory_total = int(line.split()[1])
|
|
13
|
+
elif line.startswith("MemAvailable:"): memory_available = int(line.split()[1])
|
|
14
|
+
usage = os.statvfs("/")
|
|
15
|
+
disk_percent = int(100 - usage.f_bavail * 100 / max(int(usage.f_blocks), 1))
|
|
16
|
+
memory_percent = int(100 - memory_available * 100 / max(memory_total, 1))
|
|
17
|
+
return (int(memory_percent), int(disk_percent))
|
|
18
|
+
|
|
19
|
+
except OSError: return (int(0), int(0))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from queues_sdk import Queues
|
|
4
|
+
from queues_sdk.watermarks import default_watermarks
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def q() -> Queues:
|
|
12
|
+
return Queues(maxsize=1024*4, watermarks=lambda: (int(0), int(0)))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def watermarks():
|
|
20
|
+
holder = {"memory": int(0), "disk": int(0)}
|
|
21
|
+
return holder, lambda: (int(holder["memory"]), int(holder["disk"]))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from queues_sdk import Queues, QueuesError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def test_fifo_order(q: Queues):
|
|
12
|
+
for i in range(6): await q.publish({"i": i})
|
|
13
|
+
assert [await q.consume() for _ in range(6)] == [{"i": i} for i in range(6)]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def test_waiter_handoff(q: Queues):
|
|
20
|
+
seen = []
|
|
21
|
+
async def consumer():
|
|
22
|
+
while True: seen.append(await q.consume())
|
|
23
|
+
task = asyncio.create_task(consumer())
|
|
24
|
+
await asyncio.sleep(0.01)
|
|
25
|
+
for i in range(6):
|
|
26
|
+
await q.publish({"i": i})
|
|
27
|
+
await asyncio.sleep(0.01)
|
|
28
|
+
assert [item["i"] for item in seen] == [0, 1, 2, 3, 4, 5]
|
|
29
|
+
task.cancel()
|
|
30
|
+
await asyncio.sleep(0)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def test_full_backpressure_zero_loss(q: Queues):
|
|
37
|
+
capacity = int(q.db.maxlen)
|
|
38
|
+
for i in range(capacity): await q.publish({"i": i})
|
|
39
|
+
tasks = [asyncio.create_task(q.publish({"i": capacity + j})) for j in range(3)]
|
|
40
|
+
await asyncio.sleep(0.05)
|
|
41
|
+
assert all(not task.done() for task in tasks)
|
|
42
|
+
for _ in range(3): await q.consume()
|
|
43
|
+
await asyncio.sleep(0.05)
|
|
44
|
+
assert all(task.done() for task in tasks)
|
|
45
|
+
assert len(q.db) == capacity
|
|
46
|
+
assert int(q.db[0]["i"]) == 3 and int(q.db[-1]["i"]) == capacity + 2
|
|
47
|
+
q.db.clear()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_consume_cancel_cleanup(q: Queues):
|
|
54
|
+
task = asyncio.create_task(q.consume())
|
|
55
|
+
await asyncio.sleep(0.01)
|
|
56
|
+
task.cancel()
|
|
57
|
+
with pytest.raises(asyncio.CancelledError): await task
|
|
58
|
+
assert len(q.waiters) == 0
|
|
59
|
+
await q.publish({"i": 1})
|
|
60
|
+
assert (await q.consume())["i"] == 1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def test_named_error_type():
|
|
67
|
+
error = QueuesError("probe")
|
|
68
|
+
assert str(error) == "probe"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from queues_sdk import Queues
|
|
4
|
+
from queues_sdk.watermarks import default_watermarks
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def test_soft_spill_and_drain_fifo():
|
|
11
|
+
holder = {"memory": int(100), "disk": int(0)}
|
|
12
|
+
q = Queues(maxsize=1024*4, spill_batch=1024, watermarks=lambda: (int(holder["memory"]), int(holder["disk"])))
|
|
13
|
+
for i in range(3): await q.publish({"m": i}, name="probe")
|
|
14
|
+
assert sum(len(v) for v in q.pending.values()) == 3
|
|
15
|
+
holder["memory"] = 0
|
|
16
|
+
drained = []
|
|
17
|
+
pulled = await q.drain("probe", drained)
|
|
18
|
+
assert int(pulled) == 3 and drained == [{"m": 0}, {"m": 1}, {"m": 2}]
|
|
19
|
+
dropped = await q.discard("probe")
|
|
20
|
+
assert int(dropped) == 0 and not q.pending.get("probe")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def test_hard_soaks_in_ram_without_disk_write():
|
|
27
|
+
holder = {"memory": int(0), "disk": int(100)}
|
|
28
|
+
q = Queues(maxsize=1024*4, spill_batch=1024, watermarks=lambda: (int(holder["memory"]), int(holder["disk"])))
|
|
29
|
+
for i in range(1103): await q.publish({"m": i}, name="probe")
|
|
30
|
+
assert sum(len(v) for v in q.pending.values()) == 1103
|
|
31
|
+
import pathlib
|
|
32
|
+
directory = pathlib.Path(q.pressure_spill_path).expanduser() / "probe"
|
|
33
|
+
assert not (directory.is_dir() and any(directory.glob("*.jsonl")))
|
|
34
|
+
holder["disk"] = 0
|
|
35
|
+
drained = []
|
|
36
|
+
pulled = await q.drain("probe", drained)
|
|
37
|
+
assert int(pulled) == 1103 and [x["m"] for x in drained][:3] == [0, 1, 2]
|
|
38
|
+
await q.discard("probe")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def test_discard_reports_dropped():
|
|
45
|
+
holder = {"memory": int(100), "disk": int(0)}
|
|
46
|
+
q = Queues(maxsize=1024*4, watermarks=lambda: (int(holder["memory"]), int(holder["disk"])))
|
|
47
|
+
for i in range(3): await q.publish({"m": i}, name="probe")
|
|
48
|
+
dropped = await q.discard("probe")
|
|
49
|
+
assert int(dropped) == 3
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def test_unnamed_bypasses_gate(q: Queues):
|
|
56
|
+
for i in range(6): await q.publish({"i": i})
|
|
57
|
+
assert [item["i"] for item in q.db] == [0, 1, 2, 3, 4, 5]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def test_state_three_modes():
|
|
64
|
+
holder = {"memory": int(0), "disk": int(0)}
|
|
65
|
+
q = Queues(watermarks=lambda: (int(holder["memory"]), int(holder["disk"])))
|
|
66
|
+
assert q.state() == "normal"
|
|
67
|
+
holder["memory"] = 100
|
|
68
|
+
assert q.state() == "soft"
|
|
69
|
+
holder["memory"] = 0; holder["disk"] = 100
|
|
70
|
+
assert q.state() == "hard"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import queues_sdk
|
|
2
|
+
|
|
3
|
+
from queues_sdk import Queues
|
|
4
|
+
from queues_sdk.watermarks import default_watermarks
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_version_exported():
|
|
11
|
+
assert queues_sdk.__version__ == "0.1.0"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_default_watermarks_shape():
|
|
18
|
+
memory, disk = default_watermarks()
|
|
19
|
+
assert 0 <= int(memory) <= 100 and 0 <= int(disk) <= 100
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_custom_thresholds():
|
|
26
|
+
q = Queues(memory_max=97, disk_max=98, spill_batch=512, maxsize=1024)
|
|
27
|
+
assert int(q.pressure_memory_max) == 97 and int(q.pressure_disk_max) == 98
|
|
28
|
+
assert int(q.pressure_spill_batch) == 512 and int(q.db.maxlen) == 1024
|
|
29
|
+
assert int(q.almightydb_service_logger_store.maxsize) == 1024*4
|