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.
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .mypy_cache/
11
+ .coverage
12
+ htmlcov/
13
+ .idea/
14
+ .vscode/
@@ -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
@@ -0,0 +1,2 @@
1
+ class QueuesError(RuntimeError):
2
+ """queues-sdk 业务异常:对外 API 的显式失败(与内部守卫背压语义区分)。"""
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