babelqueue 1.11.0__tar.gz → 1.12.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.
Files changed (75) hide show
  1. {babelqueue-1.11.0 → babelqueue-1.12.0}/CHANGELOG.md +25 -0
  2. {babelqueue-1.11.0 → babelqueue-1.12.0}/PKG-INFO +42 -1
  3. {babelqueue-1.11.0 → babelqueue-1.12.0}/README.md +41 -0
  4. {babelqueue-1.11.0 → babelqueue-1.12.0}/pyproject.toml +1 -1
  5. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/__init__.py +17 -2
  6. babelqueue-1.12.0/src/babelqueue/outbox.py +360 -0
  7. babelqueue-1.12.0/tests/test_outbox.py +263 -0
  8. {babelqueue-1.11.0 → babelqueue-1.12.0}/.github/FUNDING.yml +0 -0
  9. {babelqueue-1.11.0 → babelqueue-1.12.0}/.github/workflows/ci.yml +0 -0
  10. {babelqueue-1.11.0 → babelqueue-1.12.0}/.github/workflows/release.yml +0 -0
  11. {babelqueue-1.11.0 → babelqueue-1.12.0}/.gitignore +0 -0
  12. {babelqueue-1.11.0 → babelqueue-1.12.0}/LICENSE +0 -0
  13. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/app.py +0 -0
  14. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/artemis_transport.py +0 -0
  15. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/asb_transport.py +0 -0
  16. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/celery.py +0 -0
  17. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/codec.py +0 -0
  18. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/contracts.py +0 -0
  19. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/dead_letter.py +0 -0
  20. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/django/__init__.py +0 -0
  21. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/django/apps.py +0 -0
  22. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/django/management/__init__.py +0 -0
  23. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
  24. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
  25. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/exceptions.py +0 -0
  26. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/headers.py +0 -0
  27. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/idempotency.py +0 -0
  28. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/kafka_transport.py +0 -0
  29. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/otel.py +0 -0
  30. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/pika_transport.py +0 -0
  31. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/pulsar_transport.py +0 -0
  32. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/py.typed +0 -0
  33. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/redis_transport.py +0 -0
  34. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/redrive.py +0 -0
  35. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/replay.py +0 -0
  36. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/routing.py +0 -0
  37. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/schema.py +0 -0
  38. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/sqs_transport.py +0 -0
  39. {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/transport.py +0 -0
  40. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
  41. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
  42. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
  43. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/fixtures/order-created.json +0 -0
  44. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
  45. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/fixtures/urn-alias.json +0 -0
  46. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/manifest.json +0 -0
  47. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
  48. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/fixtures/dead-lettered.json +0 -0
  49. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/fixtures/order-created.json +0 -0
  50. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_app.py +0 -0
  51. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_artemis_conformance.py +0 -0
  52. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_artemis_transport.py +0 -0
  53. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_asb_conformance.py +0 -0
  54. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_asb_transport.py +0 -0
  55. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_celery.py +0 -0
  56. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_codec.py +0 -0
  57. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_conformance.py +0 -0
  58. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_dead_letter.py +0 -0
  59. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_django.py +0 -0
  60. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_headers.py +0 -0
  61. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_idempotency.py +0 -0
  62. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_kafka_conformance.py +0 -0
  63. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_kafka_transport.py +0 -0
  64. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_otel.py +0 -0
  65. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_overhead.py +0 -0
  66. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_pika_transport.py +0 -0
  67. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_pulsar_conformance.py +0 -0
  68. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_pulsar_transport.py +0 -0
  69. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_redis_transport.py +0 -0
  70. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_redrive.py +0 -0
  71. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_replay.py +0 -0
  72. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_schema.py +0 -0
  73. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_schema_conformance.py +0 -0
  74. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_sqs_conformance.py +0 -0
  75. {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_sqs_transport.py +0 -0
@@ -9,6 +9,31 @@ The envelope wire format is versioned separately by `meta.schema_version`
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [1.12.0] - 2026-06-21
13
+
14
+ ### Added
15
+ - **Transactional outbox helper** (ADR-0029) — the optional `babelqueue.outbox` module ports the
16
+ PHP `BabelQueue\Outbox` helper to Python, removing the producer **dual write**: the message is
17
+ persisted **into your database, in the same transaction** as the business data (so it commits or
18
+ rolls back atomically with it), and a separate **relay** publishes the durable rows afterwards.
19
+ No distributed transaction; exactly-once *handoff* into the broker, then at-least-once on the wire
20
+ (the consumer dedupes on `meta.id` via the idempotency helper, the consumer-side mirror, ADR-0022).
21
+ The core stays **stdlib-only** (GR-7): `OutboxStore` is an abstract `typing.Protocol` the caller
22
+ binds to their own DB — the module ships only the in-memory `InMemoryOutboxStore` reference and
23
+ pulls in **no** DB driver. `Outbox.write(envelope)` encodes via the frozen `EnvelopeCodec` and
24
+ delegates to `OutboxStore.save` **inside the transaction the caller already opened** — it does not
25
+ begin/commit anything (the caller owns the transaction boundary). `OutboxRelay.flush()` publishes
26
+ one batch through the existing publish-only `Transport`, marking each row published **only after**
27
+ the transport accepts it, or failed (caught → `mark_failed`, row left pending, with a bounded
28
+ linear backoff via an injectable sleeper) so one poison row never blocks the batch;
29
+ `OutboxRelay.drain()` loops while a pass makes progress, with a safety ceiling. The relay
30
+ publishes the **stored bytes verbatim** — it never decodes, rebuilds or re-encodes the envelope —
31
+ so `trace_id` is preserved end-to-end and the body is byte-compatible across SDKs (GR-1/GR-4/GR-5).
32
+ Unit-tested without a broker (write stores the encoded envelope byte-identical; relay publishes via
33
+ a fake `Transport` + marks published; a raising publish → `mark_failed`, row still pending, batch
34
+ continues; `drain` loops to empty and stops on no-progress; backoff grows linearly and caps via the
35
+ injected sleeper). The envelope is unchanged (`schema_version: 1`); this is purely additive.
36
+
12
37
  ## [1.11.0] - 2026-06-21
13
38
 
14
39
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: babelqueue
3
- Version: 1.11.0
3
+ Version: 1.12.0
4
4
  Summary: Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers.
5
5
  Project-URL: Homepage, https://babelqueue.com
6
6
  Project-URL: Source, https://github.com/BabelQueue/babelqueue-python
@@ -234,6 +234,47 @@ def on_created(data, meta): ...
234
234
  python manage.py babelqueue_worker --queue orders # run the consumer
235
235
  ```
236
236
 
237
+ ## Transactional outbox (optional)
238
+
239
+ The `babelqueue.outbox` helper (ADR-0029) removes the producer **dual write**: "commit the
240
+ business row" and "publish to the broker" are two systems that can disagree on a crash. Instead the
241
+ message is persisted **into your database, in the same transaction** as the business data — so it
242
+ commits or rolls back atomically with it — and a separate **relay** publishes the durable rows
243
+ afterwards. No distributed transaction; exactly-once *handoff* into the broker, then at-least-once
244
+ on the wire (the consumer dedupes on `meta.id` — see the idempotency helper, the mirror of this).
245
+
246
+ The core stays **stdlib-only**: `OutboxStore` is an abstract `Protocol` you bind to **your own DB**
247
+ (the core ships no driver). The stored value is the `EnvelopeCodec`-encoded envelope **byte-for-byte
248
+ unchanged** (frozen, `schema_version: 1`); the relay publishes those exact bytes — it never decodes,
249
+ rebuilds or re-encodes — so `trace_id` is preserved end-to-end.
250
+
251
+ ```python
252
+ from babelqueue import BabelQueue, EnvelopeCodec
253
+ from babelqueue.outbox import Outbox, OutboxRelay, InMemoryOutboxStore
254
+
255
+ store = InMemoryOutboxStore() # production: your own OutboxStore adapter, DB-backed
256
+ outbox = Outbox(store)
257
+
258
+ # write side — YOU own the transaction boundary (this is the whole point):
259
+ with db.transaction(): # your own open transaction
260
+ db.insert_order(order) # the business write
261
+ envelope = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042}, queue="orders")
262
+ outbox.write(envelope) # same connection, same tx — both, or neither
263
+
264
+ # read/publish side — run on a short interval, after the business tx commits:
265
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
266
+ relay = OutboxRelay(app.transport, store)
267
+ relay.drain() # publish all pending rows; flush() does one batch
268
+ ```
269
+
270
+ `Outbox.write` only encodes and calls `OutboxStore.save` — it does **not** begin or commit anything.
271
+ A `save` runs inside the transaction you already opened; you commit both together. `OutboxRelay`
272
+ marks a row published only **after** the transport accepts it; a publish that raises is recorded via
273
+ `mark_failed` (with a bounded, injectable-sleeper backoff) and left pending for a later pass, so one
274
+ poison row never blocks the batch. Implement `OutboxStore` over your DB (claim rows oldest-first,
275
+ ideally with `SELECT … FOR UPDATE SKIP LOCKED` so two relays don't double-publish); `InMemoryOutboxStore`
276
+ is the reference for tests and single-process demos (no real transaction).
277
+
237
278
  ## OpenTelemetry tracing (optional)
238
279
 
239
280
  `pip install "babelqueue[otel]"` adds the optional `babelqueue.otel` module — the core never
@@ -182,6 +182,47 @@ def on_created(data, meta): ...
182
182
  python manage.py babelqueue_worker --queue orders # run the consumer
183
183
  ```
184
184
 
185
+ ## Transactional outbox (optional)
186
+
187
+ The `babelqueue.outbox` helper (ADR-0029) removes the producer **dual write**: "commit the
188
+ business row" and "publish to the broker" are two systems that can disagree on a crash. Instead the
189
+ message is persisted **into your database, in the same transaction** as the business data — so it
190
+ commits or rolls back atomically with it — and a separate **relay** publishes the durable rows
191
+ afterwards. No distributed transaction; exactly-once *handoff* into the broker, then at-least-once
192
+ on the wire (the consumer dedupes on `meta.id` — see the idempotency helper, the mirror of this).
193
+
194
+ The core stays **stdlib-only**: `OutboxStore` is an abstract `Protocol` you bind to **your own DB**
195
+ (the core ships no driver). The stored value is the `EnvelopeCodec`-encoded envelope **byte-for-byte
196
+ unchanged** (frozen, `schema_version: 1`); the relay publishes those exact bytes — it never decodes,
197
+ rebuilds or re-encodes — so `trace_id` is preserved end-to-end.
198
+
199
+ ```python
200
+ from babelqueue import BabelQueue, EnvelopeCodec
201
+ from babelqueue.outbox import Outbox, OutboxRelay, InMemoryOutboxStore
202
+
203
+ store = InMemoryOutboxStore() # production: your own OutboxStore adapter, DB-backed
204
+ outbox = Outbox(store)
205
+
206
+ # write side — YOU own the transaction boundary (this is the whole point):
207
+ with db.transaction(): # your own open transaction
208
+ db.insert_order(order) # the business write
209
+ envelope = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042}, queue="orders")
210
+ outbox.write(envelope) # same connection, same tx — both, or neither
211
+
212
+ # read/publish side — run on a short interval, after the business tx commits:
213
+ app = BabelQueue("redis://localhost:6379/0", queue="orders")
214
+ relay = OutboxRelay(app.transport, store)
215
+ relay.drain() # publish all pending rows; flush() does one batch
216
+ ```
217
+
218
+ `Outbox.write` only encodes and calls `OutboxStore.save` — it does **not** begin or commit anything.
219
+ A `save` runs inside the transaction you already opened; you commit both together. `OutboxRelay`
220
+ marks a row published only **after** the transport accepts it; a publish that raises is recorded via
221
+ `mark_failed` (with a bounded, injectable-sleeper backoff) and left pending for a later pass, so one
222
+ poison row never blocks the batch. Implement `OutboxStore` over your DB (claim rows oldest-first,
223
+ ideally with `SELECT … FOR UPDATE SKIP LOCKED` so two relays don't double-publish); `InMemoryOutboxStore`
224
+ is the reference for tests and single-process demos (no real transaction).
225
+
185
226
  ## OpenTelemetry tracing (optional)
186
227
 
187
228
  `pip install "babelqueue[otel]"` adds the optional `babelqueue.otel` module — the core never
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "babelqueue"
7
- version = "1.11.0"
7
+ version = "1.12.0"
8
8
  description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -11,18 +11,26 @@ and dead-letter helpers. Framework adapters (Celery, Django, ...) build on this.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- from . import dead_letter, headers, idempotency, redrive, replay
14
+ from . import dead_letter, headers, idempotency, outbox, redrive, replay
15
15
  from .app import BabelQueue
16
16
  from .codec import SCHEMA_VERSION, SOURCE_LANG, EnvelopeCodec
17
17
  from .contracts import HasTraceId, PolyglotMessage
18
18
  from .headers import headers_from_context
19
19
  from .idempotency import IdempotencyStore, InMemoryStore
20
+ from .outbox import (
21
+ InMemoryOutboxStore,
22
+ Outbox,
23
+ OutboxRecord,
24
+ OutboxRelay,
25
+ OutboxRelayResult,
26
+ OutboxStore,
27
+ )
20
28
  from .exceptions import BabelQueueError, UnknownUrnError
21
29
  from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
22
30
  from .routing import UnknownUrnStrategy
23
31
  from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
24
32
 
25
- __version__ = "1.11.0"
33
+ __version__ = "1.12.0"
26
34
 
27
35
  __all__ = [
28
36
  "BabelQueue",
@@ -41,6 +49,13 @@ __all__ = [
41
49
  "dead_letter",
42
50
  "headers",
43
51
  "idempotency",
52
+ "outbox",
53
+ "Outbox",
54
+ "OutboxStore",
55
+ "OutboxRecord",
56
+ "OutboxRelay",
57
+ "OutboxRelayResult",
58
+ "InMemoryOutboxStore",
44
59
  "redrive",
45
60
  "replay",
46
61
  "is_replay",
@@ -0,0 +1,360 @@
1
+ """Optional transactional-outbox helper (ADR-0029): atomic write + relayed publish.
2
+
3
+ The Python mirror of the PHP ``BabelQueue\\Outbox`` helper. It removes the producer
4
+ **dual write** — "commit the business row" *and* "publish to the broker" are two systems
5
+ that can disagree on a crash. Instead the message is written **into the same database, in
6
+ the same transaction** as the business data (so it commits or rolls back atomically with
7
+ it), and a separate :class:`OutboxRelay` publishes the durable rows afterwards. No
8
+ distributed transaction; exactly-once *handoff* into the broker, then at-least-once on the
9
+ wire as always (the consumer dedupes on ``meta.id`` — :mod:`babelqueue.idempotency`,
10
+ ADR-0022, is the consumer-side mirror of this producer-side helper).
11
+
12
+ from babelqueue import EnvelopeCodec
13
+ from babelqueue.outbox import Outbox, OutboxRelay, InMemoryOutboxStore
14
+
15
+ store = InMemoryOutboxStore() # production: a DB-backed OutboxStore adapter
16
+ outbox = Outbox(store)
17
+
18
+ # write side — the CALLER owns the transaction boundary (this is the whole point):
19
+ with db.transaction(): # the caller's own open transaction
20
+ db.insert_order(order) # the business write
21
+ envelope = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042})
22
+ outbox.write(envelope) # same connection, same tx — commits or rolls back together
23
+
24
+ # read/publish side — run on a short interval, after the business tx commits:
25
+ relay = OutboxRelay(app.transport, store)
26
+ relay.drain() # publish all pending rows through the Transport
27
+
28
+ The helper is intentionally tiny and **stdlib-only** (GR-7): :class:`OutboxStore` is an
29
+ abstract persistence contract the caller binds to their own DB — the core ships only the
30
+ in-memory :class:`InMemoryOutboxStore` reference and pulls in **no** DB driver. The stored
31
+ value is the :class:`~babelqueue.codec.EnvelopeCodec`-encoded envelope, **byte-for-byte
32
+ unchanged** (GR-1): the relay publishes exactly those bytes — it never decodes, rebuilds or
33
+ re-encodes — so ``trace_id`` is preserved end-to-end (GR-4) and the body is byte-compatible
34
+ across SDKs (GR-5). The outbox's own columns (id, queue, attempts) are bookkeeping *around*
35
+ the envelope, never a field *on* the wire.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import time
41
+ from dataclasses import dataclass
42
+ from typing import Any, Callable, Dict, List, Mapping, Protocol, runtime_checkable
43
+
44
+ from .codec import EnvelopeCodec
45
+ from .transport import Transport
46
+
47
+ #: Sleep for the given number of seconds (the relay's backoff seam; default :func:`time.sleep`).
48
+ Sleeper = Callable[[float], None]
49
+
50
+
51
+ @dataclass
52
+ class OutboxRecord:
53
+ """One pending row read back from an :class:`OutboxStore` for the :class:`OutboxRelay`.
54
+
55
+ It pairs the store's own bookkeeping (``id``, ``attempts``) with the verbatim, frozen
56
+ wire envelope (``body``) and the queue it should go to. ``body`` is the exact
57
+ :meth:`~babelqueue.codec.EnvelopeCodec.encode` output handed to :meth:`OutboxStore.save`;
58
+ the relay publishes these bytes unchanged (GR-1/GR-5), so ``trace_id`` is preserved
59
+ end-to-end (GR-4) without the relay ever decoding or rebuilding the envelope.
60
+ """
61
+
62
+ id: str #: The outbox row id (the store's primary key, not ``meta.id``).
63
+ body: str #: The frozen, encoded envelope JSON, byte-for-byte as stored.
64
+ queue: str #: The logical queue the relay should publish to.
65
+ attempts: int = 0 #: How many times the relay has already tried to publish this row.
66
+
67
+
68
+ @dataclass
69
+ class OutboxRelayResult:
70
+ """Summary of one :meth:`OutboxRelay.flush` pass (or a whole :meth:`OutboxRelay.drain`):
71
+ how many pending rows were published and how many failed (and were left pending for a
72
+ later retry)."""
73
+
74
+ published: int = 0
75
+ failed: int = 0
76
+
77
+ @property
78
+ def attempted(self) -> int:
79
+ """Total rows the relay attempted in this pass."""
80
+ return self.published + self.failed
81
+
82
+
83
+ @runtime_checkable
84
+ class OutboxStore(Protocol):
85
+ """The persistence seam for the transactional outbox (ADR-0029) — the durable "outbox"
86
+ table that an :class:`Outbox` writer fills and an :class:`OutboxRelay` drains.
87
+
88
+ **The transaction boundary is the CALLER'S.** The core never opens, commits or rolls
89
+ back anything: :meth:`save` is invoked from *inside* a transaction the caller already
90
+ began (around its own ``INSERT INTO orders …``), and the caller commits both together.
91
+ This keeps the core free of any DB driver (GR-7): the core defines this contract; a
92
+ concrete adapter binds it to a real connection. The reference :class:`InMemoryOutboxStore`
93
+ is for tests and single-process demos.
94
+
95
+ The stored value is the **frozen wire envelope, byte-for-byte unchanged** (GR-1): an
96
+ :meth:`~babelqueue.codec.EnvelopeCodec.encode` JSON string. The outbox adds its own
97
+ bookkeeping columns (id, queue, attempts, status) *around* the envelope; it never adds a
98
+ field *to* it. What the relay publishes is the same bytes that were stored.
99
+ """
100
+
101
+ def save(self, encoded: str, queue: str) -> str:
102
+ """Persist one encoded envelope into the outbox, **within the transaction the caller
103
+ has already opened** around its business write. Return the new row's outbox id (the
104
+ store's own primary key, NOT ``meta.id``), which the caller may keep for correlation.
105
+ The body is stored verbatim; do not re-encode or mutate it."""
106
+ ...
107
+
108
+ def fetch_unpublished(self, limit: int) -> List[OutboxRecord]:
109
+ """Reserve up to ``limit`` rows that are pending publish, **oldest first**, so a relay
110
+ can forward them. Implementations SHOULD lock/claim the rows they return (e.g.
111
+ ``SELECT … FOR UPDATE SKIP LOCKED``, or a ``picked_at`` claim) so two concurrent relays
112
+ do not both publish the same row; at-least-once still tolerates a rare double send.
113
+ Return an empty list when the outbox is drained."""
114
+ ...
115
+
116
+ def mark_published(self, ids: List[str]) -> None:
117
+ """Mark the given outbox rows as successfully published (so they are never relayed
118
+ again). Called by the relay only **after** the transport accepted the message."""
119
+ ...
120
+
121
+ def mark_failed(self, id: str, error: str) -> None:
122
+ """Record a failed publish attempt for one row: increment its attempt counter and
123
+ store the last error, leaving it pending so a later relay pass retries it
124
+ (at-least-once). The store MAY move a row past a max-attempts threshold to a
125
+ terminal/parked state, but that policy is the adapter's, not the core's. ``error`` is
126
+ a short, human-readable failure reason (never secrets)."""
127
+ ...
128
+
129
+
130
+ class Outbox:
131
+ """The **write side** of the transactional outbox (ADR-0029): turn a BabelQueue envelope
132
+ into a stored outbox row, so the message is persisted *atomically with the business data*
133
+ and a separate :class:`OutboxRelay` publishes it later.
134
+
135
+ Usage — the caller owns the transaction boundary (this is the whole point)::
136
+
137
+ with db.transaction(): # the caller's own open transaction
138
+ db.insert_order(order) # the business write
139
+ envelope = EnvelopeCodec.make("urn:babel:orders:created", {"order_id": 1042})
140
+ outbox.write(envelope) # same connection, same tx — both, or neither
141
+
142
+ Because both writes share one transaction, a crash can never leave the business row
143
+ committed without its message (the classic dual-write bug) — they commit or roll back
144
+ together. The handoff to the broker becomes a *local* problem the relay solves.
145
+
146
+ This helper only encodes via the frozen :class:`~babelqueue.codec.EnvelopeCodec` (GR-1 —
147
+ the envelope bytes are stored unchanged; the outbox never adds an envelope field) and
148
+ delegates persistence to the injected :class:`OutboxStore`, which the caller binds to
149
+ their own DB (GR-7). It does **not** begin/commit anything.
150
+ """
151
+
152
+ def __init__(self, store: OutboxStore) -> None:
153
+ self._store = store
154
+
155
+ def write(self, envelope: Mapping[str, Any]) -> str:
156
+ """Encode the envelope (frozen codec, bytes unchanged) and persist it via the store,
157
+ inside the transaction the caller has already opened. Return the new outbox row id
158
+ (for the caller's own correlation, if wanted).
159
+
160
+ ``envelope`` is a canonical envelope from :meth:`~babelqueue.codec.EnvelopeCodec.make`
161
+ / :meth:`~babelqueue.codec.EnvelopeCodec.from_message`.
162
+ """
163
+ queue = self._queue_of(envelope)
164
+ return self._store.save(EnvelopeCodec.encode(envelope), queue)
165
+
166
+ @staticmethod
167
+ def _queue_of(envelope: Mapping[str, Any]) -> str:
168
+ """The logical queue the message targets: its ``meta.queue``, falling back to
169
+ ``"default"``. Captured at write time so the relay can publish to the right queue
170
+ without decoding the body."""
171
+ meta = envelope.get("meta")
172
+ if isinstance(meta, Mapping):
173
+ queue = meta.get("queue")
174
+ if isinstance(queue, str) and queue != "":
175
+ return queue
176
+ return "default"
177
+
178
+
179
+ class OutboxRelay:
180
+ """The **read/publish side** of the transactional outbox (ADR-0029): drain pending rows the
181
+ :class:`Outbox` writer committed and forward each onto the broker through the frozen
182
+ :class:`~babelqueue.transport.Transport` contract, marking every row published or failed.
183
+
184
+ Run it on a short interval (a worker loop, a scheduled command) *after* the business
185
+ transaction commits. Because the message was committed atomically with the business data,
186
+ the relay is the only thing standing between "row exists" and "broker has it" — and it only
187
+ ever reads already-durable rows, so it never invents work.
188
+
189
+ **Semantics — at-least-once handoff:**
190
+
191
+ - A row is marked **published only after** :meth:`~babelqueue.transport.Transport.publish`
192
+ returns; if the process dies between publish and :meth:`OutboxStore.mark_published`, the
193
+ row stays pending and is published **again** on the next pass. That is at-least-once: a
194
+ downstream consumer must dedupe on the canonical ``meta.id`` (:func:`babelqueue.idempotency.wrap`
195
+ is exactly that guard, the consumer-side mirror — ADR-0022).
196
+ - A publish that **raises** is caught, :meth:`OutboxStore.mark_failed` records the error and
197
+ bumps the attempt count, and the row stays pending for a later retry. One poison row never
198
+ blocks the rest of the batch.
199
+ - **``trace_id`` is preserved end-to-end** (GR-4): the relay publishes the stored bytes
200
+ *verbatim* — it never decodes, rebuilds or re-encodes the envelope — so the body that
201
+ reaches the broker is byte-identical to what was stored (GR-1/GR-5).
202
+
203
+ **Backoff:** between a failed publish and the next attempt within the same pass the relay
204
+ sleeps for a bounded, linearly-growing delay (capped), to avoid hammering a broker that is
205
+ briefly down. The sleeper is injectable so tests stay instant.
206
+ """
207
+
208
+ #: Hard safety ceiling on :meth:`drain` passes when the caller passes ``0``.
209
+ DEFAULT_DRAIN_CEILING = 10000
210
+
211
+ def __init__(
212
+ self,
213
+ transport: Transport,
214
+ store: OutboxStore,
215
+ *,
216
+ batch_size: int = 100,
217
+ backoff_step: float = 0.05,
218
+ backoff_cap: float = 5.0,
219
+ sleeper: Sleeper = time.sleep,
220
+ ) -> None:
221
+ """``transport`` is where published rows go (the same publish-only seam every producer
222
+ uses); ``store`` is the outbox to drain. ``batch_size`` is how many rows to reserve and
223
+ publish per :meth:`flush`. ``backoff_step`` (seconds) is the base delay added per prior
224
+ attempt and ``backoff_cap`` (seconds) is the upper bound on a single backoff sleep.
225
+ ``sleeper`` sleeps the given number of seconds (default :func:`time.sleep`); inject a
226
+ no-op or a recorder in tests."""
227
+ self._transport = transport
228
+ self._store = store
229
+ self._batch_size = batch_size
230
+ self._backoff_step = backoff_step
231
+ self._backoff_cap = backoff_cap
232
+ self._sleeper = sleeper
233
+
234
+ def flush(self) -> OutboxRelayResult:
235
+ """Publish one batch of pending rows. Each row the transport accepts is marked
236
+ published; each that raises is marked failed (with a backoff before continuing) and left
237
+ pending. Return a per-pass tally. Call it repeatedly (a loop / cron) to drain the outbox;
238
+ :meth:`drain` loops until it is empty."""
239
+ records = self._store.fetch_unpublished(self._batch_size)
240
+
241
+ published_ids: List[str] = []
242
+ failed = 0
243
+
244
+ for record in records:
245
+ try:
246
+ # Publish the stored bytes verbatim — never decode/rebuild/re-encode (GR-1).
247
+ self._transport.publish(record.queue, record.body)
248
+ published_ids.append(record.id)
249
+ except Exception as exc: # noqa: BLE001 - one poison row must not abort the batch
250
+ self._store.mark_failed(record.id, self._reason(exc))
251
+ failed += 1
252
+ self._sleep(self._backoff_for(record.attempts))
253
+
254
+ if published_ids:
255
+ self._store.mark_published(published_ids)
256
+
257
+ return OutboxRelayResult(len(published_ids), failed)
258
+
259
+ def drain(self, max_passes: int = 0) -> OutboxRelayResult:
260
+ """Drain the outbox by repeatedly calling :meth:`flush` while each pass keeps making
261
+ progress (publishes at least one row), then return the cumulative tally. The loop stops
262
+ as soon as a pass publishes nothing — the outbox is empty, or only currently failing rows
263
+ remain (those are left pending for a future :meth:`drain` call once the broker recovers).
264
+ ``max_passes`` is a hard safety ceiling so a degenerate store can never spin forever
265
+ (``0`` = a generous internal default)."""
266
+ ceiling = max_passes if max_passes > 0 else self.DEFAULT_DRAIN_CEILING
267
+ published = 0
268
+ failed = 0
269
+
270
+ for _pass in range(ceiling):
271
+ result = self.flush()
272
+ published += result.published
273
+ failed += result.failed
274
+
275
+ # No progress this pass → drained, or only failing rows remain. Stop.
276
+ if result.published == 0:
277
+ break
278
+
279
+ return OutboxRelayResult(published, failed)
280
+
281
+ def _backoff_for(self, prior_attempts: int) -> float:
282
+ """The backoff (seconds) for a row that has already failed ``prior_attempts`` times: a
283
+ linear step per attempt, capped. Kept simple and deterministic so the budget is obvious."""
284
+ delay = self._backoff_step * max(1, prior_attempts + 1)
285
+ return min(delay, self._backoff_cap)
286
+
287
+ def _sleep(self, seconds: float) -> None:
288
+ if seconds > 0:
289
+ self._sleeper(seconds)
290
+
291
+ @staticmethod
292
+ def _reason(exc: BaseException) -> str:
293
+ """A short, safe failure reason from a raised error (type + message, no traceback)."""
294
+ return f"{type(exc).__name__}: {exc}"
295
+
296
+
297
+ class InMemoryOutboxStore:
298
+ """Process-local reference :class:`OutboxStore` backed by a dict — for tests and
299
+ single-process demos. It has **no real transaction**: :meth:`save` just appends, so it
300
+ cannot deliver the atomic-with-the-business-write guarantee a production store gives. Use a
301
+ database-backed adapter in production.
302
+
303
+ It still faithfully models the relay contract: rows are pending until :meth:`mark_published`,
304
+ :meth:`fetch_unpublished` returns them oldest-first, and :meth:`mark_failed` bumps the attempt
305
+ count and stores the last error while leaving the row pending for retry.
306
+ """
307
+
308
+ def __init__(self) -> None:
309
+ # Insertion order is preserved by dict, so iteration is naturally oldest-first.
310
+ self._rows: Dict[str, Dict[str, Any]] = {}
311
+ self._sequence = 0
312
+
313
+ def save(self, encoded: str, queue: str) -> str:
314
+ self._sequence += 1
315
+ # A non-numeric id keeps the key a genuine string and mirrors the PHP reference.
316
+ row_id = f"ob-{self._sequence}"
317
+ self._rows[row_id] = {
318
+ "body": encoded,
319
+ "queue": queue,
320
+ "attempts": 0,
321
+ "published": False,
322
+ "error": "",
323
+ }
324
+ return row_id
325
+
326
+ def fetch_unpublished(self, limit: int) -> List[OutboxRecord]:
327
+ records: List[OutboxRecord] = []
328
+ for row_id, row in self._rows.items():
329
+ if row["published"]:
330
+ continue
331
+ records.append(OutboxRecord(row_id, row["body"], row["queue"], row["attempts"]))
332
+ if len(records) >= limit:
333
+ break
334
+ return records
335
+
336
+ def mark_published(self, ids: List[str]) -> None:
337
+ for row_id in ids:
338
+ row = self._rows.get(row_id)
339
+ if row is not None:
340
+ row["published"] = True
341
+
342
+ def mark_failed(self, id: str, error: str) -> None:
343
+ row = self._rows.get(id)
344
+ if row is not None:
345
+ row["attempts"] += 1
346
+ row["error"] = error
347
+
348
+ def pending_count(self) -> int:
349
+ """Test/inspection helper: the number of rows still pending publish."""
350
+ return sum(1 for row in self._rows.values() if not row["published"])
351
+
352
+ def attempts_of(self, id: str) -> int:
353
+ """Test/inspection helper: the recorded attempt count for one row (0 if unknown)."""
354
+ row = self._rows.get(id)
355
+ return row["attempts"] if row is not None else 0
356
+
357
+ def last_error_of(self, id: str) -> str:
358
+ """Test/inspection helper: the last recorded error for one row ('' if none)."""
359
+ row = self._rows.get(id)
360
+ return row["error"] if row is not None else ""
@@ -0,0 +1,263 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+ from typing import List, Tuple
5
+
6
+ from babelqueue.codec import EnvelopeCodec
7
+ from babelqueue.outbox import (
8
+ InMemoryOutboxStore,
9
+ Outbox,
10
+ OutboxRecord,
11
+ OutboxRelay,
12
+ OutboxRelayResult,
13
+ )
14
+ from babelqueue.transport import Transport
15
+
16
+
17
+ class FakeTransport(Transport):
18
+ """A publish-only fake that records what the relay forwarded, optionally raising for a
19
+ configured set of bodies so a "poison row" can be simulated without a broker."""
20
+
21
+ def __init__(self, fail_bodies: Tuple[str, ...] = ()) -> None:
22
+ self.published: List[Tuple[str, str]] = [] # (queue, body) in publish order
23
+ self._fail_bodies = set(fail_bodies)
24
+
25
+ def publish(self, queue: str, body: str) -> None:
26
+ if body in self._fail_bodies:
27
+ raise RuntimeError("broker down")
28
+ self.published.append((queue, body))
29
+
30
+ def pop(self, queue: str, timeout: float = 1.0): # pragma: no cover - unused by the relay
31
+ return None
32
+
33
+ def ack(self, message) -> None: # pragma: no cover - unused by the relay
34
+ return None
35
+
36
+
37
+ class RecordingSleeper:
38
+ """An injected sleeper that records each requested delay instead of sleeping, so backoff
39
+ growth/capping is asserted without real time passing."""
40
+
41
+ def __init__(self) -> None:
42
+ self.delays: List[float] = []
43
+
44
+ def __call__(self, seconds: float) -> None:
45
+ self.delays.append(seconds)
46
+
47
+
48
+ def _envelope(order_id: int, trace_id: str = "trace-abc") -> dict:
49
+ return EnvelopeCodec.make(
50
+ "urn:babel:orders:created", {"order_id": order_id}, trace_id=trace_id
51
+ )
52
+
53
+
54
+ class OutboxWriteTest(unittest.TestCase):
55
+ def test_write_stores_encoded_envelope_verbatim(self) -> None:
56
+ store = InMemoryOutboxStore()
57
+ outbox = Outbox(store)
58
+ envelope = _envelope(1042)
59
+
60
+ row_id = outbox.write(envelope)
61
+
62
+ [record] = store.fetch_unpublished(10)
63
+ self.assertEqual(record.id, row_id)
64
+ # The stored bytes are exactly the codec output — never decoded/rebuilt/re-encoded.
65
+ self.assertEqual(record.body, EnvelopeCodec.encode(envelope))
66
+
67
+ def test_write_captures_meta_queue(self) -> None:
68
+ store = InMemoryOutboxStore()
69
+ outbox = Outbox(store)
70
+ envelope = EnvelopeCodec.make("urn:babel:orders:created", {}, queue="orders")
71
+
72
+ outbox.write(envelope)
73
+
74
+ [record] = store.fetch_unpublished(10)
75
+ self.assertEqual(record.queue, "orders")
76
+
77
+ def test_write_falls_back_to_default_queue(self) -> None:
78
+ store = InMemoryOutboxStore()
79
+ outbox = Outbox(store)
80
+ # An envelope with no usable meta.queue → "default".
81
+ outbox.write({"job": "urn:x", "trace_id": "t", "data": {}, "meta": {}, "attempts": 0})
82
+
83
+ [record] = store.fetch_unpublished(10)
84
+ self.assertEqual(record.queue, "default")
85
+
86
+
87
+ class OutboxRelayTest(unittest.TestCase):
88
+ def test_flush_publishes_and_marks_published(self) -> None:
89
+ store = InMemoryOutboxStore()
90
+ outbox = Outbox(store)
91
+ transport = FakeTransport()
92
+ outbox.write(EnvelopeCodec.make("urn:babel:orders:created", {}, queue="orders"))
93
+
94
+ result = OutboxRelay(transport, store).flush()
95
+
96
+ self.assertEqual(result, OutboxRelayResult(published=1, failed=0))
97
+ self.assertEqual(result.attempted, 1)
98
+ self.assertEqual(len(transport.published), 1)
99
+ self.assertEqual(transport.published[0][0], "orders") # (queue, body) order
100
+ self.assertEqual(store.pending_count(), 0) # marked published → no longer pending
101
+
102
+ def test_relay_publishes_bytes_verbatim_preserving_trace_id(self) -> None:
103
+ store = InMemoryOutboxStore()
104
+ outbox = Outbox(store)
105
+ transport = FakeTransport()
106
+ envelope = _envelope(7, trace_id="keep-me-1234")
107
+ encoded = EnvelopeCodec.encode(envelope)
108
+ outbox.write(envelope)
109
+
110
+ OutboxRelay(transport, store).flush()
111
+
112
+ # The body that reached the transport is byte-identical to what was stored (GR-1/GR-5)…
113
+ _queue, published_body = transport.published[0]
114
+ self.assertEqual(published_body, encoded)
115
+ # …and trace_id survives end-to-end (GR-4) without the relay ever decoding.
116
+ self.assertEqual(EnvelopeCodec.decode(published_body)["trace_id"], "keep-me-1234")
117
+
118
+ def test_failed_publish_marks_failed_leaves_pending_and_continues_batch(self) -> None:
119
+ store = InMemoryOutboxStore()
120
+ outbox = Outbox(store)
121
+ # Build each envelope once (make() mints a fresh meta.id/created_at per call), so the
122
+ # bytes the fake transport fails on are exactly the bytes that were stored.
123
+ e1, e2, e3 = _envelope(1), _envelope(2), _envelope(3)
124
+ good1 = EnvelopeCodec.encode(e1)
125
+ poison = EnvelopeCodec.encode(e2)
126
+ good2 = EnvelopeCodec.encode(e3)
127
+ # Write three rows; the middle one is the poison row that always fails to publish.
128
+ id1 = outbox.write(e1)
129
+ poison_id = outbox.write(e2)
130
+ id3 = outbox.write(e3)
131
+
132
+ transport = FakeTransport(fail_bodies=(poison,))
133
+ sleeper = RecordingSleeper()
134
+ result = OutboxRelay(transport, store, sleeper=sleeper).flush()
135
+
136
+ # The two good rows published; the poison row failed but did not abort the batch.
137
+ self.assertEqual(result, OutboxRelayResult(published=2, failed=1))
138
+ self.assertEqual([b for _q, b in transport.published], [good1, good2])
139
+ # Poison row stays pending for a later retry, with its attempt counted + error stored.
140
+ self.assertEqual(store.pending_count(), 1)
141
+ self.assertEqual(store.attempts_of(poison_id), 1)
142
+ self.assertIn("RuntimeError: broker down", store.last_error_of(poison_id))
143
+ # The good rows are gone (published), the poison id remains.
144
+ remaining = [r.id for r in store.fetch_unpublished(10)]
145
+ self.assertEqual(remaining, [poison_id])
146
+ self.assertNotIn(id1, remaining)
147
+ self.assertNotIn(id3, remaining)
148
+ # A backoff was slept once for the single failure.
149
+ self.assertEqual(len(sleeper.delays), 1)
150
+
151
+ def test_drain_loops_until_empty(self) -> None:
152
+ store = InMemoryOutboxStore()
153
+ outbox = Outbox(store)
154
+ # More rows than one batch → drain must loop across multiple flush passes.
155
+ for i in range(5):
156
+ outbox.write(_envelope(i))
157
+ transport = FakeTransport()
158
+
159
+ result = OutboxRelay(transport, store, batch_size=2).drain()
160
+
161
+ self.assertEqual(result.published, 5)
162
+ self.assertEqual(result.failed, 0)
163
+ self.assertEqual(store.pending_count(), 0)
164
+ self.assertEqual(len(transport.published), 5)
165
+
166
+ def test_drain_stops_when_only_failing_rows_remain(self) -> None:
167
+ store = InMemoryOutboxStore()
168
+ outbox = Outbox(store)
169
+ e = _envelope(99)
170
+ poison = EnvelopeCodec.encode(e)
171
+ outbox.write(e)
172
+ # A transport that always fails → no progress → drain must not spin forever.
173
+ transport = FakeTransport(fail_bodies=(poison,))
174
+ sleeper = RecordingSleeper()
175
+
176
+ result = OutboxRelay(transport, store, sleeper=sleeper).drain()
177
+
178
+ self.assertEqual(result.published, 0)
179
+ self.assertEqual(result.failed, 1) # exactly one pass: it published nothing, so it stopped
180
+ self.assertEqual(store.pending_count(), 1)
181
+
182
+ def test_backoff_grows_with_attempts_and_caps(self) -> None:
183
+ store = InMemoryOutboxStore()
184
+ outbox = Outbox(store)
185
+ e = _envelope(1)
186
+ poison = EnvelopeCodec.encode(e)
187
+ poison_id = outbox.write(e)
188
+ transport = FakeTransport(fail_bodies=(poison,))
189
+ sleeper = RecordingSleeper()
190
+ relay = OutboxRelay(
191
+ transport, store, backoff_step=0.05, backoff_cap=0.2, sleeper=sleeper
192
+ )
193
+
194
+ # Each flush retries the same poison row, whose attempts climb 0,1,2,3,4…
195
+ # backoff_for(attempts) = 0.05 * (attempts + 1), capped at 0.2:
196
+ # attempts=0 → 0.05, 1 → 0.10, 2 → 0.15, 3 → 0.20, 4 → 0.20 (capped).
197
+ for _ in range(5):
198
+ relay.flush()
199
+
200
+ self.assertEqual(store.attempts_of(poison_id), 5)
201
+ self.assertEqual(
202
+ [round(d, 2) for d in sleeper.delays],
203
+ [0.05, 0.10, 0.15, 0.20, 0.20],
204
+ )
205
+ # Growth then a hard cap.
206
+ self.assertEqual(max(sleeper.delays), 0.20)
207
+
208
+ def test_default_sleeper_is_skipped_for_zero_delay(self) -> None:
209
+ # A non-positive backoff never calls the sleeper (e.g. backoff_step=0).
210
+ store = InMemoryOutboxStore()
211
+ outbox = Outbox(store)
212
+ e = _envelope(1)
213
+ poison = EnvelopeCodec.encode(e)
214
+ outbox.write(e)
215
+ transport = FakeTransport(fail_bodies=(poison,))
216
+ sleeper = RecordingSleeper()
217
+
218
+ OutboxRelay(transport, store, backoff_step=0.0, sleeper=sleeper).flush()
219
+
220
+ self.assertEqual(sleeper.delays, [])
221
+
222
+
223
+ class InMemoryOutboxStoreTest(unittest.TestCase):
224
+ def test_fetch_unpublished_is_oldest_first_and_limited(self) -> None:
225
+ store = InMemoryOutboxStore()
226
+ ids = [store.save(f"body-{i}", "q") for i in range(3)]
227
+
228
+ records = store.fetch_unpublished(2)
229
+
230
+ self.assertEqual([r.id for r in records], ids[:2]) # oldest-first, capped at limit
231
+ self.assertTrue(all(isinstance(r, OutboxRecord) for r in records))
232
+
233
+ def test_mark_published_removes_from_pending(self) -> None:
234
+ store = InMemoryOutboxStore()
235
+ a = store.save("a", "q")
236
+ b = store.save("b", "q")
237
+
238
+ store.mark_published([a])
239
+
240
+ self.assertEqual(store.pending_count(), 1)
241
+ self.assertEqual([r.id for r in store.fetch_unpublished(10)], [b])
242
+
243
+ def test_mark_failed_bumps_attempts_and_records_error(self) -> None:
244
+ store = InMemoryOutboxStore()
245
+ a = store.save("a", "q")
246
+
247
+ store.mark_failed(a, "boom")
248
+ store.mark_failed(a, "boom-again")
249
+
250
+ self.assertEqual(store.attempts_of(a), 2)
251
+ self.assertEqual(store.last_error_of(a), "boom-again")
252
+ self.assertEqual(store.pending_count(), 1) # still pending after failures
253
+
254
+ def test_unknown_ids_are_ignored(self) -> None:
255
+ store = InMemoryOutboxStore()
256
+ store.mark_published(["nope"]) # no row → no error
257
+ store.mark_failed("nope", "x") # no row → no error
258
+ self.assertEqual(store.attempts_of("nope"), 0)
259
+ self.assertEqual(store.last_error_of("nope"), "")
260
+
261
+
262
+ if __name__ == "__main__":
263
+ unittest.main()
File without changes
File without changes