oc_connect
Loading...
Searching...
No Matches
outbox.py
Go to the documentation of this file.
1"""Durable at-most-once outbox with retry, backoff, and destination caps.
2
3Dependencies
4------------
5- oc_connect.audit (AuditStore, content_hash, timestamp - audits every queue and delivery outcome)
6- oc_connect.workspace (comms_root - locates the outbox in the comms bus outbound lane)
7- oc_diagnostics (_dbg structured logger)
8- option_c_connector_contracts (DeliveryReceipt, LaneId, MessageDirection, RiskClass - receipts and lane fields)
9- pydantic (BaseModel, ConfigDict - the frozen strict OutboxItem model)
10"""
11from __future__ import annotations
12
13import json
14import os
15import secrets as _secrets
16import time
17from collections.abc import Callable
18from datetime import datetime, timedelta, timezone
19from pathlib import Path
20from typing import Literal
21
22from oc_diagnostics import _dbg
23from option_c_connector_contracts import (
24 DeliveryReceipt,
25 LaneId,
26 MessageDirection,
27 RiskClass,
28)
29from pydantic import BaseModel, ConfigDict
30
31from .audit import AuditRecord, AuditStore, content_hash, timestamp, utc_now
32from .workspace import comms_root
33
34OutboxState = Literal["queued", "sent", "failed"]
35
36
37def default_outbox_path(root: str | Path | None = None) -> Path:
38 """Return the durable outbox inside the comms bus outbound lane (§3)."""
39 return Path(comms_root(root)) / "outbound" / "outbox.json"
40
41
42class OutboxItem(BaseModel):
43 """One durable message; bodies live here, never in the audit file.
44
45 See :class:`~oc_connect.audit.AuditRecord` for why ``lane`` and
46 ``direction`` default to the single plan-219 lane while ``risk_class``
47 defaults to ``None``: for a migrated record the lane is a fact and the risk
48 class was never recorded.
49
50 ``transport_ref`` holds the connector's own message identifier for a
51 delivered item. It is what an inbound reply's ``reply_to`` metadata
52 resolves against, so correlation needs no second index file (§4).
53 """
54
55 model_config = ConfigDict(frozen=True, extra="forbid", strict=True)
56
57 connector: str
58 grant_id: str
59 destination: str
60 content: str
61 content_hash: str
62 idempotency_key: str
63 state: OutboxState
64 attempts: int
65 dispatch_claimed: bool
66 created_at_utc: str
67 updated_at_utc: str
68 next_attempt_at_utc: str | None = None
69 sent_at_utc: str | None = None
70 error_code: str | None = None
71 lane: LaneId = "outbound-telegram"
72 direction: MessageDirection = "outbound"
73 risk_class: RiskClass | None = None
74 transport_ref: str | None = None
75
76
77class OutboxFileError(ValueError):
78 """The outbox file is unreadable or malformed."""
79
80
81class Outbox:
82 """Persist messages and drive bounded delivery through an injected transport."""
83
85 self,
86 path: str | Path | None = None,
87 *,
88 audit: AuditStore | None = None,
89 now: Callable[[], datetime] = utc_now,
90 sleep: Callable[[float], None] = time.sleep,
91 max_attempts: int = 3,
92 backoff_seconds: tuple[float, ...] = (0.25, 0.5),
93 rate_limit_count: int = 10,
94 rate_limit_window_seconds: float = 60.0,
95 root: str | Path | None = None,
96 ) -> None:
97 if max_attempts < 1:
98 raise ValueError("max_attempts must be at least one")
99 if len(backoff_seconds) < max_attempts - 1:
100 raise ValueError("backoff_seconds must cover every retry")
101 if rate_limit_count < 1 or rate_limit_window_seconds <= 0:
102 raise ValueError("rate limit values must be positive")
103 self.path = Path(path) if path is not None else default_outbox_path(root)
104 self.audit = audit or AuditStore(root=root)
105 self._now = now
106 self._sleep = sleep
107 self.max_attempts = max_attempts
108 self.backoff_seconds = backoff_seconds
109 self.rate_limit_count = rate_limit_count
110 self.rate_limit_window_seconds = rate_limit_window_seconds
111
112 def _load(self) -> dict[str, OutboxItem]:
113 if not self.path.is_file():
114 return {}
115 try:
116 payload = json.loads(self.path.read_text(encoding="utf-8"))
117 items = {
118 item.idempotency_key: item
119 for item in (
120 OutboxItem.model_validate(raw)
121 for raw in payload.get("items", [])
122 )
123 }
124 except (OSError, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc:
125 raise OutboxFileError(
126 f"could not parse connector outbox at {self.path} "
127 f"({type(exc).__name__})"
128 ) from None
129 return items
130
131 def _write(self, items: dict[str, OutboxItem]) -> None:
132 self.path.parent.mkdir(parents=True, exist_ok=True)
133 temporary = self.path.with_name(
134 f".tmp-{self.path.name}-{_secrets.token_hex(8)}"
135 )
136 payload = {
137 "version": 1,
138 "items": [items[key].model_dump(mode="json") for key in sorted(items)],
139 }
140 try:
141 with temporary.open("w", encoding="utf-8", newline="\n") as handle:
142 json.dump(payload, handle, indent=2, sort_keys=True)
143 handle.write("\n")
144 handle.flush()
145 os.fsync(handle.fileno())
146 os.replace(temporary, self.path)
147 finally:
148 if temporary.exists():
149 try:
150 temporary.unlink()
151 except OSError:
152 pass
153
155 self,
156 *,
157 connector: str,
158 grant_id: str,
159 destination: str,
160 content: str,
161 idempotency_key: str,
162 risk_class: RiskClass | None = None,
163 lane: LaneId = "outbound-telegram",
164 direction: MessageDirection = "outbound",
165 ) -> OutboxItem:
166 """Persist one queued item, deduplicating by its caller-owned key."""
167 fields = (connector, grant_id, destination, content, idempotency_key)
168 if not all(value.strip() for value in fields):
169 raise ValueError("outbox fields must not be blank")
170 items = self._load()
171 digest = content_hash(content)
172 existing = items.get(idempotency_key)
173 if existing is not None:
174 if (
175 existing.connector == connector
176 and existing.grant_id == grant_id
177 and existing.destination == destination
178 and existing.content_hash == digest
179 ):
180 return existing
181 raise ValueError(
182 f"idempotency key {idempotency_key!r} already names different content or scope"
183 )
184 stamped = timestamp(self._now())
185 item = OutboxItem(
186 connector=connector,
187 grant_id=grant_id,
188 destination=destination,
189 content=content,
190 content_hash=digest,
191 idempotency_key=idempotency_key,
192 state="queued",
193 attempts=0,
194 dispatch_claimed=False,
195 created_at_utc=stamped,
196 updated_at_utc=stamped,
197 lane=lane,
198 direction=direction,
199 risk_class=risk_class,
200 )
201 items[idempotency_key] = item
202 self._write(items)
203 self.audit.append(
204 connector=connector,
205 grant_id=grant_id,
206 destination=destination,
207 outcome="queued",
208 digest=digest,
209 idempotency_key=idempotency_key,
210 attempts=0,
211 lane=lane,
212 direction=direction,
213 risk_class=risk_class,
214 )
215 return item
216
217 def get(self, idempotency_key: str) -> OutboxItem:
218 """Return one item or raise its public key."""
219 try:
220 return self._load()[idempotency_key]
221 except KeyError:
222 raise KeyError(f"outbox item {idempotency_key!r} does not exist") from None
223
224 def list_items(self) -> tuple[OutboxItem, ...]:
225 """Return items in deterministic idempotency-key order."""
226 items = self._load()
227 return tuple(items[key] for key in sorted(items))
228
229 @staticmethod
230 def _parse_time(value: str) -> datetime:
231 return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
232
233 def _rate_limited(self, item: OutboxItem, items: dict[str, OutboxItem]) -> bool:
234 cutoff = self._now().astimezone(timezone.utc) - timedelta(
235 seconds=self.rate_limit_window_seconds
236 )
237 sent = sum(
238 1
239 for other in items.values()
240 if other.destination == item.destination
241 and other.state == "sent"
242 and other.sent_at_utc is not None
243 and self._parse_time(other.sent_at_utc) >= cutoff
244 )
245 return sent >= self.rate_limit_count
246
247 @staticmethod
248 def _receipt(item: OutboxItem, record: AuditRecord) -> DeliveryReceipt:
249 return DeliveryReceipt(
250 connector=item.connector,
251 destination=item.destination,
252 idempotency_key=item.idempotency_key,
253 outcome=record.outcome,
254 audit_id=record.audit_id,
255 attempts=item.attempts,
256 )
257
259 self,
260 item: OutboxItem,
261 outcome: Literal["sent", "failed", "deferred"],
262 *,
263 error_code: str | None = None,
264 ) -> DeliveryReceipt:
265 record = self.audit.append(
266 connector=item.connector,
267 grant_id=item.grant_id,
268 destination=item.destination,
269 outcome=outcome,
270 digest=item.content_hash,
271 idempotency_key=item.idempotency_key,
272 attempts=item.attempts,
273 error_code=error_code,
274 lane=item.lane,
275 direction=item.direction,
276 risk_class=item.risk_class,
277 )
278 return self._receipt(item, record)
279
280 def find_by_transport_ref(self, transport_ref: str) -> OutboxItem | None:
281 """Return the delivered item a connector message id belongs to.
282
283 This is the correlation seam (§4): an inbound reply carries the
284 ``message_id`` of the outbound message it answers, and that id was
285 recorded here at delivery. Resolution therefore needs no side index
286 that could drift from the outbox.
287 """
288 if not transport_ref.strip():
289 return None
290 items = self._load()
291 for key in sorted(items):
292 candidate = items[key]
293 if candidate.transport_ref == transport_ref:
294 return candidate
295 return None
296
298 self,
299 idempotency_key: str,
300 send: Callable[[OutboxItem], str | None],
301 ) -> DeliveryReceipt:
302 """Process one item through a durable at-most-once dispatch claim.
303
304 *send* may return the connector's message identifier; when it does, the
305 reference is persisted on the item so inbound replies can correlate
306 against it.
307 """
308 items = self._load()
309 try:
310 item = items[idempotency_key]
311 except KeyError:
312 raise KeyError(f"outbox item {idempotency_key!r} does not exist") from None
313
314 if item.state in ("sent", "failed"):
315 existing = self.audit.latest(idempotency_key)
316 if existing is None or existing.outcome not in ("sent", "failed"):
317 return self._record(item, item.state)
318 return self._receipt(item, existing)
319
320 if item.dispatch_claimed:
321 item = item.model_copy(
322 update={
323 "state": "failed",
324 "updated_at_utc": timestamp(self._now()),
325 "error_code": "ambiguous_delivery_after_restart",
326 }
327 )
328 items[idempotency_key] = item
329 self._write(items)
330 return self._record(
331 item,
332 "failed",
333 error_code="ambiguous_delivery_after_restart",
334 )
335
336 if self._rate_limited(item, items):
337 item = item.model_copy(
338 update={
339 "next_attempt_at_utc": timestamp(
340 self._now() + timedelta(seconds=self.rate_limit_window_seconds)
341 ),
342 "updated_at_utc": timestamp(self._now()),
343 }
344 )
345 items[idempotency_key] = item
346 self._write(items)
347 return self._record(item, "deferred", error_code="destination_rate_cap")
348
349 for attempt in range(item.attempts + 1, self.max_attempts + 1):
350 item = item.model_copy(
351 update={
352 "attempts": attempt,
353 "dispatch_claimed": True,
354 "updated_at_utc": timestamp(self._now()),
355 "next_attempt_at_utc": None,
356 "error_code": None,
357 }
358 )
359 items[idempotency_key] = item
360 self._write(items)
361 try:
362 transport_ref = send(item)
363 except Exception as exc:
364 error_code = type(exc).__name__
365 terminal = attempt >= self.max_attempts
366 item = item.model_copy(
367 update={
368 "state": "failed" if terminal else "queued",
369 "dispatch_claimed": False,
370 "updated_at_utc": timestamp(self._now()),
371 "error_code": error_code,
372 }
373 )
374 items[idempotency_key] = item
375 self._write(items)
376 _dbg(
377 "OC_CONNECT.OUTBOX",
378 "process",
379 f"transport attempt={attempt} connector={item.connector} "
380 f"destination={item.destination} error={error_code}",
381 2,
382 )
383 if terminal:
384 return self._record(item, "failed", error_code=error_code)
385 self._sleep(self.backoff_seconds[attempt - 1])
386 continue
387
388 item = item.model_copy(
389 update={
390 "state": "sent",
391 "dispatch_claimed": False,
392 "updated_at_utc": timestamp(self._now()),
393 "sent_at_utc": timestamp(self._now()),
394 "error_code": None,
395 "transport_ref": (
396 str(transport_ref) if transport_ref is not None else None
397 ),
398 }
399 )
400 items[idempotency_key] = item
401 self._write(items)
402 return self._record(item, "sent")
403
404 raise RuntimeError("outbox retry loop exhausted without a terminal receipt")
405
406
407__all__ = [
408 "Outbox",
409 "OutboxFileError",
410 "OutboxItem",
411 "OutboxState",
412 "default_outbox_path",
413]
None __init__(self, str|Path|None path=None, *, AuditStore|None audit=None, Callable[[], datetime] now=utc_now, Callable[[float], None] sleep=time.sleep, int max_attempts=3, tuple[float,...] backoff_seconds=(0.25, 0.5), int rate_limit_count=10, float rate_limit_window_seconds=60.0, str|Path|None root=None)
Definition outbox.py:96
bool _rate_limited(self, OutboxItem item, dict[str, OutboxItem] items)
Definition outbox.py:233
DeliveryReceipt _record(self, OutboxItem item, Literal["sent", "failed", "deferred"] outcome, *, str|None error_code=None)
Definition outbox.py:264
OutboxItem enqueue(self, *, str connector, str grant_id, str destination, str content, str idempotency_key, RiskClass|None risk_class=None, LaneId lane="outbound-telegram", MessageDirection direction="outbound")
Definition outbox.py:165
None _write(self, dict[str, OutboxItem] items)
Definition outbox.py:131
OutboxItem|None find_by_transport_ref(self, str transport_ref)
Definition outbox.py:280
DeliveryReceipt process(self, str idempotency_key, Callable[[OutboxItem], str|None] send)
Definition outbox.py:301
dict[str, OutboxItem] _load(self)
Definition outbox.py:112
datetime _parse_time(str value)
Definition outbox.py:230
tuple[OutboxItem,...] list_items(self)
Definition outbox.py:224
OutboxItem get(self, str idempotency_key)
Definition outbox.py:217
DeliveryReceipt _receipt(OutboxItem item, AuditRecord record)
Definition outbox.py:248
Path default_outbox_path(str|Path|None root=None)
Definition outbox.py:37