queues-sdk 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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__"]
queues_sdk/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
queues_sdk/core.py ADDED
@@ -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
queues_sdk/errors.py ADDED
@@ -0,0 +1,2 @@
1
+ class QueuesError(RuntimeError):
2
+ """queues-sdk 业务异常:对外 API 的显式失败(与内部守卫背压语义区分)。"""
queues_sdk/py.typed ADDED
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,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,10 @@
1
+ queues_sdk/__init__.py,sha256=k4y3HTy2XYnpJmBMjtX8zTzMelxndaDoaNn5QJm6Vtg,1145
2
+ queues_sdk/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
3
+ queues_sdk/core.py,sha256=JiaqzN0J7oNC-L2Vdvxr-0JAU49PUdvies1Isyg2mTY,9948
4
+ queues_sdk/errors.py,sha256=cS9NuGP79AMLBvEvn0pZl6Nb5II39GYwYL5yAFmHc60,138
5
+ queues_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ queues_sdk/watermarks.py,sha256=0JVd8aJIYUtk57eeQeFJxMxygyNnx-8Pe_e1RQ7GY_s,702
7
+ queues_sdk-0.1.0.dist-info/METADATA,sha256=um8H_2wad38-oESpru-HxCoovEc6VwvYGVAgHrNe3Ug,4374
8
+ queues_sdk-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ queues_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=IX60b71kGaHVYXcr6DRaALn1phbboivn1leualFq7ZE,1080
10
+ queues_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.