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.
- {babelqueue-1.11.0 → babelqueue-1.12.0}/CHANGELOG.md +25 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/PKG-INFO +42 -1
- {babelqueue-1.11.0 → babelqueue-1.12.0}/README.md +41 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/pyproject.toml +1 -1
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/__init__.py +17 -2
- babelqueue-1.12.0/src/babelqueue/outbox.py +360 -0
- babelqueue-1.12.0/tests/test_outbox.py +263 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/.github/FUNDING.yml +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/.github/workflows/ci.yml +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/.github/workflows/release.yml +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/.gitignore +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/LICENSE +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/app.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/artemis_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/asb_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/kafka_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/pulsar_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/redrive.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/sqs_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/manifest.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_app.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_artemis_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_asb_conformance.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_asb_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_celery.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_codec.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_conformance.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_django.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_headers.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_idempotency.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_kafka_conformance.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_kafka_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_otel.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_overhead.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_pulsar_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_redrive.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_replay.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_schema.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.11.0 → babelqueue-1.12.0}/tests/test_sqs_conformance.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{babelqueue-1.11.0 → babelqueue-1.12.0}/src/babelqueue/django/management/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{babelqueue-1.11.0 → babelqueue-1.12.0}/tests/conformance/schema/message-envelope.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|