Autarkia-Msgqueue 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.
@@ -0,0 +1,28 @@
1
+ """Autarkia-Msgqueue: reliable offline message queue for WebSocket reconnects."""
2
+
3
+ from .queue import (
4
+ DEFAULT_OFFLINE_TTL,
5
+ PROACTIVE_OFFLINE_TTL,
6
+ FlushResult,
7
+ OfflineMessageQueue,
8
+ QueueConfig,
9
+ )
10
+ from .replay_guard import (
11
+ OFFLINE_REPLAY_MAX_AGE_SECONDS,
12
+ is_stale_offline_replay,
13
+ offline_replay_age,
14
+ )
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = [
19
+ "__version__",
20
+ "OfflineMessageQueue",
21
+ "QueueConfig",
22
+ "FlushResult",
23
+ "DEFAULT_OFFLINE_TTL",
24
+ "PROACTIVE_OFFLINE_TTL",
25
+ "OFFLINE_REPLAY_MAX_AGE_SECONDS",
26
+ "offline_replay_age",
27
+ "is_stale_offline_replay",
28
+ ]
File without changes
@@ -0,0 +1,246 @@
1
+ """An in-memory offline message queue for WebSocket reconnects.
2
+
3
+ Semantics worth keeping:
4
+
5
+ - **Per-type TTL.** Proactive messages (goodnight, morning, status) are stale
6
+ within minutes; ordinary messages can wait a day.
7
+ - **message_id dedupe.** Re-enqueuing the same outbound message never replays
8
+ it twice.
9
+ - **Discard after delivery elsewhere.** If the same message reached the phone
10
+ app, it must not be replayed to the desktop client.
11
+ - **Atomic replay.** Only successfully sent messages leave the queue; a failed
12
+ send is pushed back to the front and stops the flush so order is preserved.
13
+ - **Per-user flush lock.** Two reconnect paths cannot pop the same deque.
14
+ - **Routing filter.** With two personas sharing one user id, a connection only
15
+ takes messages addressed to it; others stay for the right connection.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import copy
22
+ import logging
23
+ import time
24
+ from collections import deque
25
+ from dataclasses import dataclass
26
+ from typing import Any, Awaitable, Callable, Deque, Dict, List, Optional, Tuple
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ PROACTIVE_OFFLINE_TTL = 10 * 60
31
+ DEFAULT_OFFLINE_TTL = 24 * 3600
32
+ _PROACTIVE_MESSAGE_TYPES = frozenset({"proactive_message"})
33
+
34
+ Sender = Callable[[Dict[str, Any]], Awaitable[bool]]
35
+ Accepts = Callable[[Dict[str, Any]], bool]
36
+
37
+
38
+ @dataclass
39
+ class QueueConfig:
40
+ default_ttl_seconds: float = DEFAULT_OFFLINE_TTL
41
+ proactive_ttl_seconds: float = PROACTIVE_OFFLINE_TTL
42
+ proactive_types: frozenset[str] = _PROACTIVE_MESSAGE_TYPES
43
+
44
+
45
+ @dataclass
46
+ class FlushResult:
47
+ sent: int = 0
48
+ expired: int = 0
49
+ failed: int = 0
50
+ skipped: int = 0
51
+ remaining: int = 0
52
+
53
+ @property
54
+ def ok(self) -> bool:
55
+ return self.failed == 0
56
+
57
+
58
+ class OfflineMessageQueue:
59
+ """Per-user offline queues with atomic replay."""
60
+
61
+ def __init__(
62
+ self,
63
+ config: Optional[QueueConfig] = None,
64
+ *,
65
+ clock: Optional[Callable[[], float]] = None,
66
+ ) -> None:
67
+ self.config = config or QueueConfig()
68
+ self._clock = clock or time.time
69
+ self._queues: Dict[str, Deque[Tuple[float, Dict[str, Any]]]] = {}
70
+ self._locks: Dict[str, asyncio.Lock] = {}
71
+ self._locks_loop: Optional[asyncio.AbstractEventLoop] = None
72
+
73
+ # -- storage -----------------------------------------------------------
74
+ def _ttl_for(self, message: Dict[str, Any]) -> float:
75
+ msg_type = str(message.get("type") or "").strip().lower()
76
+ is_proactive = msg_type in self.config.proactive_types or bool(
77
+ message.get("is_proactive")
78
+ )
79
+ return (
80
+ float(self.config.proactive_ttl_seconds)
81
+ if is_proactive
82
+ else float(self.config.default_ttl_seconds)
83
+ )
84
+
85
+ def store_offline_message(
86
+ self, user_id: str, message: Dict[str, Any], *, now: Optional[float] = None
87
+ ) -> bool:
88
+ """Deep-copy and enqueue one message. Returns whether it was stored."""
89
+ try:
90
+ message_id = str(message.get("message_id") or "").strip()
91
+ queue = self._queues.setdefault(user_id, deque())
92
+ if message_id and any(
93
+ str(queued.get("message_id") or "").strip() == message_id
94
+ for _, queued in queue
95
+ ):
96
+ logger.debug(
97
+ "Duplicate offline message ignored for %s (message_id=%s)",
98
+ user_id,
99
+ message_id,
100
+ )
101
+ return False
102
+ queue.append((float(now if now is not None else self._clock()), copy.deepcopy(message)))
103
+ logger.info(
104
+ "Stored offline message for %s, queue size: %s", user_id, len(queue)
105
+ )
106
+ return True
107
+ except Exception as error: # noqa: BLE001 - one bad payload must not break the queue
108
+ logger.error("Failed to store offline message: %s", error)
109
+ return False
110
+
111
+ def discard_offline_message(self, user_id: str, message_id: str) -> int:
112
+ """Remove every queued message with this id (delivered elsewhere)."""
113
+ target = str(message_id or "").strip()
114
+ queue = self._queues.get(user_id)
115
+ if not target or queue is None:
116
+ return 0
117
+ kept = [
118
+ (queued_at, message)
119
+ for queued_at, message in queue
120
+ if str(message.get("message_id") or "").strip() != target
121
+ ]
122
+ removed = len(queue) - len(kept)
123
+ if removed:
124
+ queue.clear()
125
+ queue.extend(kept)
126
+ if not queue:
127
+ self._queues.pop(user_id, None)
128
+ logger.info(
129
+ "Discarded %s offline message(s) for %s (message_id=%s)",
130
+ removed,
131
+ user_id,
132
+ target,
133
+ )
134
+ return removed
135
+
136
+ def pending_count(self, user_id: str) -> int:
137
+ return len(self._queues.get(user_id, ()))
138
+
139
+ def pending_users(self) -> List[str]:
140
+ return list(self._queues)
141
+
142
+ def clear(self) -> None:
143
+ self._queues.clear()
144
+
145
+ # -- replay ------------------------------------------------------------
146
+ def _get_lock(self, user_id: str) -> asyncio.Lock:
147
+ loop = asyncio.get_running_loop()
148
+ if self._locks_loop is not loop:
149
+ self._locks = {}
150
+ self._locks_loop = loop
151
+ lock = self._locks.get(user_id)
152
+ if lock is None:
153
+ lock = asyncio.Lock()
154
+ self._locks[user_id] = lock
155
+ return lock
156
+
157
+ async def flush(
158
+ self,
159
+ user_id: str,
160
+ sender: Sender,
161
+ *,
162
+ accepts: Optional[Accepts] = None,
163
+ now: Optional[float] = None,
164
+ ) -> FlushResult:
165
+ """Deliver queued messages in order until one fails or the queue empties.
166
+
167
+ Args:
168
+ user_id: Queue owner.
169
+ sender: ``async (payload) -> bool``; false means "retry later".
170
+ accepts: Optional routing predicate; false leaves the message for
171
+ another connection and continues scanning.
172
+ now: Timestamp override (tests).
173
+ """
174
+ queue = self._queues.get(user_id)
175
+ if not queue:
176
+ return FlushResult()
177
+
178
+ async with self._get_lock(user_id):
179
+ queue = self._queues.get(user_id)
180
+ if not queue:
181
+ return FlushResult()
182
+
183
+ result = FlushResult()
184
+ current_time = float(now if now is not None else self._clock())
185
+ scan_count = len(queue)
186
+
187
+ for _ in range(scan_count):
188
+ queued_at, message = queue.popleft()
189
+
190
+ if current_time - queued_at >= self._ttl_for(message):
191
+ result.expired += 1
192
+ logger.info(
193
+ "Dropping expired offline message: type=%s age=%.1fmin",
194
+ message.get("type"),
195
+ (current_time - queued_at) / 60.0,
196
+ )
197
+ continue
198
+
199
+ if accepts is not None and not accepts(message):
200
+ queue.append((queued_at, message))
201
+ result.skipped += 1
202
+ continue
203
+
204
+ payload = copy.deepcopy(message)
205
+ payload["is_offline_replay"] = True
206
+ payload["offline_queued_at"] = queued_at
207
+
208
+ try:
209
+ sent = await sender(payload)
210
+ if not sent:
211
+ raise ConnectionError("offline send returned false")
212
+ except Exception as error: # noqa: BLE001 - keep the message
213
+ queue.appendleft((queued_at, message))
214
+ result.failed += 1
215
+ logger.warning(
216
+ "Failed to flush offline message to %s: %s. Kept for retry.",
217
+ user_id,
218
+ error,
219
+ )
220
+ break
221
+
222
+ result.sent += 1
223
+
224
+ result.remaining = len(queue)
225
+ if not queue:
226
+ self._queues.pop(user_id, None)
227
+ logger.info(
228
+ "Offline flush done for %s: sent=%s expired=%s failed=%s "
229
+ "skipped=%s remaining=%s",
230
+ user_id,
231
+ result.sent,
232
+ result.expired,
233
+ result.failed,
234
+ result.skipped,
235
+ result.remaining,
236
+ )
237
+ return result
238
+
239
+
240
+ __all__ = [
241
+ "OfflineMessageQueue",
242
+ "QueueConfig",
243
+ "FlushResult",
244
+ "PROACTIVE_OFFLINE_TTL",
245
+ "DEFAULT_OFFLINE_TTL",
246
+ ]
@@ -0,0 +1,52 @@
1
+ """Receiver-side staleness guard for replayed messages.
2
+
3
+ The sender already applies a TTL, but old payloads, manual flushes, a raised
4
+ TTL, or mismatched client/server versions can still deliver a stale proactive
5
+ message. The receiver checks the age independently — the second half of the
6
+ two-sided TTL.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from typing import Any, Callable, Dict, Optional
13
+
14
+ OFFLINE_REPLAY_MAX_AGE_SECONDS = 10 * 60
15
+
16
+
17
+ def offline_replay_age(
18
+ data: Dict[str, Any],
19
+ *,
20
+ now: Optional[float] = None,
21
+ clock: Optional[Callable[[], float]] = None,
22
+ ) -> Optional[float]:
23
+ """Age in seconds for a replayed message; ``None`` when not a replay."""
24
+ if not isinstance(data, dict) or not data.get("is_offline_replay"):
25
+ return None
26
+ queued_at = data.get("offline_queued_at")
27
+ if queued_at is None:
28
+ queued_at = data.get("timestamp")
29
+ try:
30
+ current = float(now if now is not None else (clock or time.time)())
31
+ return max(0.0, current - float(queued_at))
32
+ except (TypeError, ValueError):
33
+ return None
34
+
35
+
36
+ def is_stale_offline_replay(
37
+ data: Dict[str, Any],
38
+ *,
39
+ max_age_seconds: float = OFFLINE_REPLAY_MAX_AGE_SECONDS,
40
+ now: Optional[float] = None,
41
+ clock: Optional[Callable[[], float]] = None,
42
+ ) -> bool:
43
+ """Whether a replay is older than the accepted age (non-replays are fresh)."""
44
+ age = offline_replay_age(data, now=now, clock=clock)
45
+ return age is not None and age > float(max_age_seconds)
46
+
47
+
48
+ __all__ = [
49
+ "OFFLINE_REPLAY_MAX_AGE_SECONDS",
50
+ "offline_replay_age",
51
+ "is_stale_offline_replay",
52
+ ]
@@ -0,0 +1,242 @@
1
+ Metadata-Version: 2.5
2
+ Name: Autarkia-Msgqueue
3
+ Version: 0.1.0
4
+ Summary: Reliable offline message queue for WebSocket clients: per-type TTL, message-id dedupe, discard-after-elsewhere-delivery, atomic replay, per-user locks and a receiver-side staleness guard.
5
+ Project-URL: Homepage, https://qishihao.icu/projects/autarkia
6
+ Project-URL: Repository, https://github.com/hakituo/Autarkia-Msgqueue
7
+ Project-URL: Roadmap, https://github.com/hakituo/Autarkia-tool/blob/main/ROADMAP.md
8
+ Project-URL: Autarkia Runtime, https://github.com/hakituo/Autarkia
9
+ Project-URL: Author, https://qishihao.icu
10
+ Author-email: Shihao Qi <qishihao512@gmail.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: at-least-once,autarkia,message-replay,offline-queue,reconnect,ttl,websocket
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+
28
+ <p align="center">
29
+ <picture>
30
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/hakituo/Autarkia-Msgqueue/main/autarkia-dark.svg">
31
+ <img src="https://raw.githubusercontent.com/hakituo/Autarkia-Msgqueue/main/autarkia.svg" alt="Autarkia" width="300">
32
+ </picture>
33
+ </p>
34
+
35
+ <p align="center">
36
+ <b>Autarkia-Msgqueue</b><br>
37
+ <sub>WebSocket 离线消息可靠性队列 · Reliable offline queues for WebSocket clients</sub>
38
+ </p>
39
+
40
+ <p align="center">
41
+ <a href="https://pypi.org/project/Autarkia-Msgqueue/"><img alt="PyPI" src="https://img.shields.io/pypi/v/autarkia-msgqueue.svg"></a>
42
+ <a href="https://pypi.org/project/Autarkia-Msgqueue/"><img alt="Python" src="https://img.shields.io/pypi/pyversions/autarkia-msgqueue.svg"></a>
43
+ <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
44
+ </p>
45
+
46
+ <p align="center">
47
+ <a href="#中文">简体中文</a> · <a href="#english">English</a>
48
+ </p>
49
+
50
+ ---
51
+
52
+ ## 中文
53
+
54
+ **按类型分档的 TTL、`message_id` 去重、已在别端投递后丢弃、原子重放、per-user flush 锁、接收端时效守卫**——把「客户端离线时暂存消息」这件小事做对。
55
+
56
+ 来自 [Autarkia](https://qishihao.icu/projects/autarkia) agent 运行时:手机 App 和桌面 bot 共用一个 user id,同一条「晚安」被反复投递。整个系列见[组件库路线图](https://github.com/hakituo/Autarkia-tool/blob/main/ROADMAP.md)。
57
+
58
+ ### 为什么
59
+
60
+ 存消息很容易,不烦人很难:
61
+
62
+ - **已经投递过还会重放**:主动消息已经送到手机 App,队列里给桌面端留的那份就是重复打扰,必须能按 id 丢掉。
63
+ - **隔夜重放**:「晚安」第二天早上九点再发毫无意义。主动消息要分钟级 TTL,普通消息要一天——一个 TTL 两边都不对。
64
+ - **队列内重复**:同一个 `message_id` 入队两次就会重放两次。
65
+ - **失败即丢**:先出队再发送,socket 一断消息就没了。
66
+ - **并发 flush**:两条重连路径同时掏同一个 deque。
67
+ - **投错人**:两个人设共用一个 user id 时,一条连接只能取走发给自己的消息。
68
+ - **接收端也过期**:客户端 TTL 调大、手工重放、版本不一致,都可能让旧消息晚到,所以接收端独立再查一次年龄。
69
+
70
+ ### 安装
71
+
72
+ ```bash
73
+ pip install autarkia-msgqueue
74
+ ```
75
+
76
+ 零依赖,Python 3.10+。
77
+
78
+ ### 快速开始
79
+
80
+ ```python
81
+ import asyncio
82
+ from autarkia_msgqueue import OfflineMessageQueue
83
+
84
+ queue = OfflineMessageQueue()
85
+
86
+ # 客户端离线:入队(深拷贝)
87
+ queue.store_offline_message(
88
+ "user-1",
89
+ {"type": "proactive_message", "message_id": "evt-9", "text": "晚安"},
90
+ )
91
+
92
+ # 已在别处(手机 App)投递过:不要再重放给桌面端
93
+ queue.discard_offline_message("user-1", "evt-9")
94
+
95
+ async def on_reconnect(user_id):
96
+ async def send(payload) -> bool:
97
+ return await websocket_send(user_id, payload) # 返回假值/抛异常 = 稍后重试
98
+
99
+ result = await queue.flush(user_id, send, accepts=lambda m: m.get("persona") == "a")
100
+ print(result) # FlushResult(sent=0, expired=0, failed=0, skipped=0, remaining=0)
101
+ ```
102
+
103
+ 接收端守卫:
104
+
105
+ ```python
106
+ from autarkia_msgqueue import is_stale_offline_replay
107
+
108
+ if is_stale_offline_replay(payload, max_age_seconds=600):
109
+ return # 太旧的重放直接丢
110
+ ```
111
+
112
+ ### 接口一览
113
+
114
+ | API | 保证 |
115
+ | --- | --- |
116
+ | `store_offline_message` | 深拷贝入队;重复或非法载荷返回 `False` |
117
+ | `discard_offline_message` | 删掉该 `message_id` 的全部副本,返回删除条数 |
118
+ | `flush` | FIFO 重放;遇到第一条失败即停止,其余保留 |
119
+ | `QueueConfig` | `default_ttl_seconds`、`proactive_ttl_seconds`、`proactive_types` |
120
+ | `FlushResult` | `sent` / `expired` / `failed` / `skipped` / `remaining`,以及 `.ok` |
121
+ | `offline_replay_age`、`is_stale_offline_replay` | 接收端对重放消息的年龄判断 |
122
+
123
+ 重放载荷会带 `is_offline_replay: True` 与 `offline_queued_at`(epoch 秒);队列里的原始消息不会被改写。
124
+
125
+ ### 设计说明
126
+
127
+ - **TTL 按消息类型**:`type == "proactive_message"` 或 `is_proactive` 走 `proactive_ttl_seconds`(默认 10 分钟),其余走 `default_ttl_seconds`(默认 24 小时)。过期判据是 `age >= ttl`。
128
+ - **原子重放**:只有 `sender` 返回真值后消息才出队;失败则推回队首并停止本轮,顺序在 socket 抖动后仍然保持。
129
+ - **扫描窗口**:`flush` 只遍历拿到锁时已存在的消息,重放期间新入队的留给下一轮。
130
+ - **路由谓词**:`accepts(payload)` 返回 `False` 时消息留在队列继续扫描,一条连接不会偷走另一个人设的消息。
131
+ - **锁按 user + 事件循环**:队列被另一个事件循环使用时锁会重建,模块级单例不会把锁漏到 `asyncio.run` 之间。
132
+ - **刻意只在内存里**:它是重连缓冲,不是消息存储;进程重启即丢,要持久化请自己做。
133
+ - **测试可确定性**:队列的 `clock=`、`store` / `flush` 的 `now=`、守卫的 `now=` 都能注入,不依赖墙钟。
134
+
135
+ ### 系列与链接
136
+
137
+ - 项目页:<https://qishihao.icu/projects/autarkia>
138
+ - 运行时仓库:<https://github.com/hakituo/Autarkia>
139
+ - 本包:<https://github.com/hakituo/Autarkia-Msgqueue>
140
+ - 组件库路线图:<https://github.com/hakituo/Autarkia-tool/blob/main/ROADMAP.md>
141
+ - 作者:Shihao Qi — <https://qishihao.icu>
142
+
143
+ ### 许可
144
+
145
+ MIT
146
+
147
+ ---
148
+
149
+ ## English
150
+
151
+ **Per-type TTL, message-id dedupe, discard-after-elsewhere-delivery, atomic replay, per-user flush locks, and a receiver-side staleness guard** — the small "store messages while the client is offline" problem, done correctly.
152
+
153
+ Extracted from the [Autarkia](https://qishihao.icu/projects/autarkia) agent runtime, where a phone app and a desktop bot shared one user id and kept re-delivering the same goodnight message. See the [component roadmap](https://github.com/hakituo/Autarkia-tool/blob/main/ROADMAP.md).
154
+
155
+ ### Why
156
+
157
+ Storing messages for offline clients is easy. Not annoying the user is not:
158
+
159
+ - **Replayed after delivery.** The same proactive message reached the phone app, so the queued copy for the desktop client is a duplicate. It has to be discarded by id.
160
+ - **Overnight replays.** "Goodnight" is worthless at 9 a.m. Proactive messages need minutes-scale TTL, ordinary messages a day; one TTL for both is wrong either way.
161
+ - **Duplicates in queue.** The same `message_id` enqueued twice replays twice.
162
+ - **Lost on failure.** Popping before a successful send loses the message when the socket dies mid-flush.
163
+ - **Concurrent flushes.** Two reconnect paths can empty the same deque under each other's feet.
164
+ - **Wrong recipient.** With two personas sharing a user id, a connection must only take messages addressed to it.
165
+ - **Stale receivers.** A client with an old TTL, a manual flush, or a mismatched version can still receive a replay hours later, so the receiver checks age independently.
166
+
167
+ ### Install
168
+
169
+ ```bash
170
+ pip install autarkia-msgqueue
171
+ ```
172
+
173
+ No dependencies. Python 3.10+.
174
+
175
+ ### Quick start
176
+
177
+ ```python
178
+ import asyncio
179
+ from autarkia_msgqueue import OfflineMessageQueue
180
+
181
+ queue = OfflineMessageQueue()
182
+
183
+ # The client is offline: park the message (deep-copied).
184
+ queue.store_offline_message(
185
+ "user-1",
186
+ {"type": "proactive_message", "message_id": "evt-9", "text": "晚安"},
187
+ )
188
+
189
+ # Delivered elsewhere (phone app) — never replay it to the desktop client.
190
+ queue.discard_offline_message("user-1", "evt-9")
191
+
192
+ async def on_reconnect(user_id):
193
+ async def send(payload) -> bool:
194
+ return await websocket_send(user_id, payload) # False / raise => retry later
195
+
196
+ result = await queue.flush(user_id, send, accepts=lambda msg: msg.get("persona") == "a")
197
+ print(result) # FlushResult(sent=0, expired=0, failed=0, skipped=0, remaining=0)
198
+ ```
199
+
200
+ #### Receiver side
201
+
202
+ ```python
203
+ from autarkia_msgqueue import is_stale_offline_replay
204
+
205
+ if is_stale_offline_replay(payload, max_age_seconds=600):
206
+ return # drop a replay that is too old to be meaningful
207
+ ```
208
+
209
+ ### Concepts
210
+
211
+ | API | Guarantee |
212
+ | --- | --- |
213
+ | `store_offline_message` | deep copy; returns `False` for duplicates or bad payloads |
214
+ | `discard_offline_message` | removes every copy of that `message_id`, returns the count |
215
+ | `flush` | FIFO replay; stops at the first failure and keeps the rest |
216
+ | `QueueConfig` | `default_ttl_seconds`, `proactive_ttl_seconds`, `proactive_types` |
217
+ | `FlushResult` | `sent` / `expired` / `failed` / `skipped` / `remaining`, plus `.ok` |
218
+ | `offline_replay_age`, `is_stale_offline_replay` | receiver-side age check on replays |
219
+
220
+ Replayed payloads carry `is_offline_replay: True` and `offline_queued_at` (epoch seconds); the stored copy is never mutated.
221
+
222
+ ### Design notes
223
+
224
+ - **TTL per message type.** `type == "proactive_message"` or `is_proactive` uses `proactive_ttl_seconds` (default 10 min); everything else uses `default_ttl_seconds` (default 24 h). Expiry is `age >= ttl`.
225
+ - **Atomic replay.** A message leaves the queue only after `sender` returns truthy. On failure it is pushed back to the front and the flush stops, so ordering survives a broken socket.
226
+ - **Scan window.** `flush` only walks the messages present when it takes the lock; anything enqueued mid-flush is left for the next round.
227
+ - **Routing predicate.** `accepts(payload)` returning `False` leaves the message queued and keeps scanning, so one connection never steals another persona's messages.
228
+ - **Locks are per user per event loop.** Locks are recreated when the queue is used from a different event loop, so a module-level singleton does not leak locks across `asyncio.run` calls.
229
+ - **In memory, by design.** The queue is a reconnect buffer, not a message store: a process restart drops it. Persist elsewhere if you need durability.
230
+ - **Deterministic tests.** `clock=` on the queue and `now=` on `store` / `flush` / the guard remove all wall-clock dependence.
231
+
232
+ ### The bigger picture
233
+
234
+ - Project page: <https://qishihao.icu/projects/autarkia>
235
+ - Runtime repository: <https://github.com/hakituo/Autarkia>
236
+ - This package: <https://github.com/hakituo/Autarkia-Msgqueue>
237
+ - Roadmap: <https://github.com/hakituo/Autarkia-tool/blob/main/ROADMAP.md>
238
+ - Author: Shihao Qi — <https://qishihao.icu>
239
+
240
+ ### License
241
+
242
+ MIT
@@ -0,0 +1,8 @@
1
+ autarkia_msgqueue/__init__.py,sha256=0L-ojA2LyozwpVEBDVyYmgxOp1j_B8Z5ql2fGSF8H8g,609
2
+ autarkia_msgqueue/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ autarkia_msgqueue/queue.py,sha256=TvE_01f7Xx64T5aN9Ygu_jT86DcHfDNlTX-WvxYeOTU,8545
4
+ autarkia_msgqueue/replay_guard.py,sha256=4j2X4Rj2rgmq-QHdw-5khox219i79tO1iUQNsJ7wA6E,1617
5
+ autarkia_msgqueue-0.1.0.dist-info/METADATA,sha256=IdiKIo5IIdF4Th5V5KWcodFTrz8LwhDb8DGKSHxhIvQ,11612
6
+ autarkia_msgqueue-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ autarkia_msgqueue-0.1.0.dist-info/licenses/LICENSE,sha256=KjCC0TPNgxUUjuiaiDm4bNjiDFPtghA1apRPKt_8cU0,1066
8
+ autarkia_msgqueue-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shihao Qi
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.