babelqueue 1.16.0__tar.gz → 1.16.1__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.16.0 → babelqueue-1.16.1}/.github/workflows/ci.yml +49 -1
- {babelqueue-1.16.0 → babelqueue-1.16.1}/CHANGELOG.md +21 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/PKG-INFO +3 -3
- {babelqueue-1.16.0 → babelqueue-1.16.1}/README.md +2 -2
- {babelqueue-1.16.0 → babelqueue-1.16.1}/pyproject.toml +1 -1
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/__init__.py +1 -1
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/app.py +23 -8
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/artemis_transport.py +3 -2
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/asb_transport.py +3 -2
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/kafka_transport.py +46 -6
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/pulsar_transport.py +3 -2
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/sqs_transport.py +3 -2
- babelqueue-1.16.1/tests/conformance/CONFORMANCE_VERSION +1 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/manifest.json +10 -6
- babelqueue-1.16.1/tests/fakes/confluent_kafka/__init__.py +73 -0
- babelqueue-1.16.1/tests/fakes/confluent_kafka/admin.py +20 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_app.py +5 -3
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_artemis_transport.py +5 -5
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_asb_conformance.py +3 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_asb_transport.py +5 -5
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_kafka_conformance.py +2 -0
- babelqueue-1.16.1/tests/test_kafka_integration.py +169 -0
- babelqueue-1.16.1/tests/test_kafka_integration_logic.py +44 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_kafka_transport.py +162 -8
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_pulsar_transport.py +5 -5
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_sqs_transport.py +6 -8
- babelqueue-1.16.0/tests/conformance/CONFORMANCE_VERSION +0 -1
- {babelqueue-1.16.0 → babelqueue-1.16.1}/.github/FUNDING.yml +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/.github/dependabot.yml +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/.github/workflows/release.yml +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/.gitignore +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/LICENSE +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/celery.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/codec.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/contracts.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/dead_letter.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/django/__init__.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/django/apps.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/django/management/__init__.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/django/management/commands/__init__.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/django/management/commands/babelqueue_worker.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/exceptions.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/gdpr.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/headers.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/idempotency.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/otel.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/outbox.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/pika_transport.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/py.typed +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/redis_transport.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/redrive.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/replay.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/routing.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/schema.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/src/babelqueue/transport.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/data-array-rejected.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/empty-data-roundtrip.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-attempts.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-max-retries.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-source.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-meta-ts.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/forbidden-key-timestamp.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/invalid-missing-urn.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/invalid-unknown-schema-version.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/order-created.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unicode-and-numbers.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unknown-lang.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unknown-meta-roundtrip.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/unknown-toplevel-roundtrip.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/fixtures/urn-alias.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/conformance/schema/message-envelope.schema.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/fixtures/dead-lettered.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/fixtures/order-created.json +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_artemis_conformance.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_behaviour_conformance.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_celery.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_codec.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_conformance.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_dead_letter.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_django.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_gdpr.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_headers.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_idempotency.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_otel.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_outbox.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_overhead.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_pika_transport.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_pulsar_conformance.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_redis_transport.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_redrive.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_replay.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_schema.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_schema_conformance.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_shutdown.py +0 -0
- {babelqueue-1.16.0 → babelqueue-1.16.1}/tests/test_sqs_conformance.py +0 -0
|
@@ -4,6 +4,7 @@ on:
|
|
|
4
4
|
push:
|
|
5
5
|
branches: [main]
|
|
6
6
|
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
7
8
|
|
|
8
9
|
permissions:
|
|
9
10
|
contents: read
|
|
@@ -111,6 +112,53 @@ jobs:
|
|
|
111
112
|
AWS_SECRET_ACCESS_KEY: test
|
|
112
113
|
run: pytest --cov=babelqueue --cov-report=term-missing --cov-fail-under=90
|
|
113
114
|
|
|
115
|
+
integration-live:
|
|
116
|
+
name: Integration (live Kafka)
|
|
117
|
+
# Opt-in, not a required check: runs on push to main and on demand, never on pull requests,
|
|
118
|
+
# and is deliberately NOT in ci-green's needs. (The `integration` job above is the required,
|
|
119
|
+
# coverage-gated one for Redis / RabbitMQ / SQS.)
|
|
120
|
+
if: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
|
121
|
+
runs-on: ubuntu-latest
|
|
122
|
+
timeout-minutes: 20
|
|
123
|
+
services:
|
|
124
|
+
kafka:
|
|
125
|
+
image: apache/kafka:3.9.0
|
|
126
|
+
ports:
|
|
127
|
+
- 9092:9092
|
|
128
|
+
env:
|
|
129
|
+
KAFKA_NODE_ID: 1
|
|
130
|
+
KAFKA_PROCESS_ROLES: broker,controller
|
|
131
|
+
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
|
|
132
|
+
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
|
|
133
|
+
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
|
|
134
|
+
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
|
|
135
|
+
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
|
|
136
|
+
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
|
|
137
|
+
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
|
|
138
|
+
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
|
|
139
|
+
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
|
|
140
|
+
options: >-
|
|
141
|
+
--health-cmd "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092"
|
|
142
|
+
--health-interval 10s --health-timeout 10s --health-retries 12 --health-start-period 20s
|
|
143
|
+
steps:
|
|
144
|
+
- uses: actions/checkout@v7
|
|
145
|
+
|
|
146
|
+
- name: Setup Python
|
|
147
|
+
uses: actions/setup-python@v7
|
|
148
|
+
with:
|
|
149
|
+
python-version: '3.12'
|
|
150
|
+
|
|
151
|
+
- name: Install (kafka extra + dev)
|
|
152
|
+
run: |
|
|
153
|
+
python -m pip install --upgrade pip
|
|
154
|
+
pip install -e ".[kafka,dev]"
|
|
155
|
+
|
|
156
|
+
- name: Live Kafka tests
|
|
157
|
+
env:
|
|
158
|
+
BQ_INTEGRATION: "1"
|
|
159
|
+
BQ_KAFKA_BROKERS: localhost:9092
|
|
160
|
+
run: pytest tests/test_kafka_integration.py -v
|
|
161
|
+
|
|
114
162
|
conformance:
|
|
115
163
|
name: Conformance suite in sync
|
|
116
164
|
runs-on: ubuntu-latest
|
|
@@ -118,7 +166,7 @@ jobs:
|
|
|
118
166
|
- uses: actions/checkout@v7
|
|
119
167
|
- name: Verify vendored conformance matches the canonical suite
|
|
120
168
|
run: |
|
|
121
|
-
git clone --depth 1 --branch v1.
|
|
169
|
+
git clone --depth 1 --branch v1.2.0 https://github.com/BabelQueue/conformance.git "$RUNNER_TEMP/conformance"
|
|
122
170
|
diff -u "$RUNNER_TEMP/conformance/CONFORMANCE_VERSION" "tests/conformance/CONFORMANCE_VERSION"
|
|
123
171
|
diff -ru "$RUNNER_TEMP/conformance/manifest.json" "tests/conformance/manifest.json"
|
|
124
172
|
diff -ru "$RUNNER_TEMP/conformance/fixtures" "tests/conformance/fixtures"
|
|
@@ -9,6 +9,27 @@ The envelope wire format is versioned separately by `meta.schema_version`
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [1.16.1] - 2026-10-11
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **Schema-version gate rejection now dead-letters the incoming body, byte-identical.** A message
|
|
16
|
+
whose `bq-schema-version` (`bq_schema_version` on Artemis) is unsupported is rejected *without
|
|
17
|
+
decoding it* (broker-bindings §3.7/§4.7/§5.7/§6.7/§7.7) and, when the DLQ is enabled, the DLQ
|
|
18
|
+
record is the raw body as received, with no `dead_letter` block. Previously the core decoded
|
|
19
|
+
the envelope and wrote an annotated `dead_letter{reason:"unknown_urn"}` record. This aligns the
|
|
20
|
+
Python SDK with Java, .NET and Node. A DLQ consumer that expected the annotation for gated
|
|
21
|
+
messages is affected; a failed DLQ write still leaves the message unsettled (rewound on Kafka),
|
|
22
|
+
and DLQ off / `UnknownUrnStrategy.DELETE` still drops it. No wire or API change.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **Kafka: a record whose settlement raised was silently lost on a consume-loop restart.** When a
|
|
26
|
+
retry / release / dead-letter `publish` failed (or the loop was otherwise interrupted before
|
|
27
|
+
`ack`), `consume()` ended with the record neither acked nor rewound while `poll` had already
|
|
28
|
+
advanced the position; the restarted loop received the *next* record and acking it committed
|
|
29
|
+
past the unsettled one (Kafka offsets are cumulative). `KafkaTransport` now remembers the
|
|
30
|
+
record `pop` handed out and re-seeks it on the next `pop` unless it was acked or rewound.
|
|
31
|
+
No API or wire change.
|
|
32
|
+
|
|
12
33
|
## [1.16.0] - 2026-10-10
|
|
13
34
|
|
|
14
35
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: babelqueue
|
|
3
|
-
Version: 1.16.
|
|
3
|
+
Version: 1.16.1
|
|
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
|
|
@@ -186,8 +186,8 @@ ASCII whitespace) follows the normal path; any other value must be exactly `"1"`
|
|
|
186
186
|
untrimmed; Azure Service Bus and Artemis also accept the integer `1`). A message carrying
|
|
187
187
|
anything else is not decoded: it reaches the runtime on the reserved route
|
|
188
188
|
`babelqueue:unsupported-schema-version:<v>` (the version percent-escaped) and is **never
|
|
189
|
-
retried** — it goes straight to the dead-letter queue (
|
|
190
|
-
|
|
189
|
+
retried** — it goes straight to the dead-letter queue (the incoming body, byte-identical,
|
|
190
|
+
with no `dead_letter` block) when `dead_letter` is enabled, and is dropped otherwise, whatever `max_attempts` is.
|
|
191
191
|
It does not depend on a broker `RedrivePolicy`. If the dead-letter write fails the message is
|
|
192
192
|
left unacknowledged (an error is logged), is never retried and never reaches a handler; the
|
|
193
193
|
dead-letter write is attempted again when the broker brings it back, which depends on the
|
|
@@ -132,8 +132,8 @@ ASCII whitespace) follows the normal path; any other value must be exactly `"1"`
|
|
|
132
132
|
untrimmed; Azure Service Bus and Artemis also accept the integer `1`). A message carrying
|
|
133
133
|
anything else is not decoded: it reaches the runtime on the reserved route
|
|
134
134
|
`babelqueue:unsupported-schema-version:<v>` (the version percent-escaped) and is **never
|
|
135
|
-
retried** — it goes straight to the dead-letter queue (
|
|
136
|
-
|
|
135
|
+
retried** — it goes straight to the dead-letter queue (the incoming body, byte-identical,
|
|
136
|
+
with no `dead_letter` block) when `dead_letter` is enabled, and is dropped otherwise, whatever `max_attempts` is.
|
|
137
137
|
It does not depend on a broker `RedrivePolicy`. If the dead-letter write fails the message is
|
|
138
138
|
left unacknowledged (an error is logged), is never retried and never reaches a handler; the
|
|
139
139
|
dead-letter write is attempted again when the broker brings it back, which depends on the
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "babelqueue"
|
|
7
|
-
version = "1.16.
|
|
7
|
+
version = "1.16.1"
|
|
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"
|
|
@@ -279,7 +279,11 @@ class BabelQueue:
|
|
|
279
279
|
with _headers_scope(received.headers), _replay_scope(
|
|
280
280
|
bool(received.headers.get(HEADER_REPLAY_BYPASS))
|
|
281
281
|
):
|
|
282
|
-
|
|
282
|
+
# A version-gated delivery is rejected without decoding its body (broker-bindings
|
|
283
|
+
# §3.7/§4.7/§5.7/§6.7/§7.7): the raw body goes to the DLQ byte-identical, so there is
|
|
284
|
+
# no envelope to read here.
|
|
285
|
+
gated = received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX)
|
|
286
|
+
envelope = {} if gated else EnvelopeCodec.decode(received.body)
|
|
283
287
|
urn = str(envelope.get("job") or envelope.get("urn") or "")
|
|
284
288
|
if received.route_urn and envelope:
|
|
285
289
|
# Transport-supplied routing override; body untouched. Only for a body that
|
|
@@ -479,7 +483,7 @@ class BabelQueue:
|
|
|
479
483
|
# gate would be bypassed or the message would loop. It goes straight to the DLQ (or is
|
|
480
484
|
# dropped when no DLQ is configured).
|
|
481
485
|
if received.route_urn.startswith(_UNSUPPORTED_SCHEMA_ROUTE_PREFIX):
|
|
482
|
-
self._settle_version_gated(received
|
|
486
|
+
self._settle_version_gated(received)
|
|
483
487
|
return
|
|
484
488
|
# An undecodable body or a body naming no URN can never gain a handler either, so
|
|
485
489
|
# releasing it unchanged would redeliver it forever. It takes the bounded FAIL path
|
|
@@ -495,11 +499,21 @@ class BabelQueue:
|
|
|
495
499
|
# (never kills the loop).
|
|
496
500
|
raise UnknownUrnError(f"No handler mapped for URN [{urn or '(empty)'}].")
|
|
497
501
|
|
|
498
|
-
def
|
|
499
|
-
"""
|
|
502
|
+
def _dead_letter_target(self, received: ReceivedMessage) -> str:
|
|
503
|
+
"""The queue a dead-lettered delivery is written to (configured DLQ or ``<queue><suffix>``)."""
|
|
504
|
+
return self.dead_letter_queue or (received.queue + self.dead_letter_suffix)
|
|
505
|
+
|
|
506
|
+
def _settle_version_gated(self, received: ReceivedMessage) -> None:
|
|
507
|
+
"""Dead-letter a version-gated delivery directly (no retry); drop it when no DLQ is set.
|
|
508
|
+
|
|
509
|
+
The gate rejects the message *without decoding it*, so the DLQ record is the incoming raw
|
|
510
|
+
body, byte-identical, with no ``dead_letter`` block (matching the Java, .NET and Node
|
|
511
|
+
SDKs). A failed DLQ write propagates: the delivery is left unacknowledged and handed back
|
|
512
|
+
(see :meth:`dispatch`), never retried.
|
|
513
|
+
"""
|
|
500
514
|
if self.dead_letter_enabled or self.on_unknown_urn == UnknownUrnStrategy.DEAD_LETTER:
|
|
501
|
-
|
|
502
|
-
self.
|
|
515
|
+
self._publish_for(received, self._dead_letter_target(received), received.body)
|
|
516
|
+
self._ack_settled(received)
|
|
503
517
|
return
|
|
504
518
|
self._ack_settled(received)
|
|
505
519
|
|
|
@@ -537,8 +551,9 @@ class BabelQueue:
|
|
|
537
551
|
error=(str(exc) if exc is not None else None),
|
|
538
552
|
exception=(type(exc).__name__ if exc is not None else None),
|
|
539
553
|
)
|
|
540
|
-
|
|
541
|
-
|
|
554
|
+
self._publish_for(
|
|
555
|
+
received, self._dead_letter_target(received), EnvelopeCodec.encode(annotated)
|
|
556
|
+
)
|
|
542
557
|
self._ack_settled(received)
|
|
543
558
|
|
|
544
559
|
|
|
@@ -47,8 +47,9 @@ _logger = logging.getLogger("babelqueue.artemis")
|
|
|
47
47
|
#: U+0085, U+00A0, U+2028, U+3000 ...
|
|
48
48
|
_BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
|
|
49
49
|
#: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
|
|
50
|
-
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime
|
|
51
|
-
#:
|
|
50
|
+
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
|
|
51
|
+
#: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
|
|
52
|
+
#: rejected ``bq_schema_version`` is named in the logged error.
|
|
52
53
|
UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
|
|
53
54
|
|
|
54
55
|
JMS_TYPE_ANNOTATION = "x-opt-jms-type"
|
|
@@ -38,8 +38,9 @@ _logger = logging.getLogger("babelqueue.asb")
|
|
|
38
38
|
#: U+0085, U+00A0, U+2028, U+3000 ...
|
|
39
39
|
_BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
|
|
40
40
|
#: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
|
|
41
|
-
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime
|
|
42
|
-
#:
|
|
41
|
+
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
|
|
42
|
+
#: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
|
|
43
|
+
#: rejected ``bq-schema-version`` is named in the logged error.
|
|
43
44
|
UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
|
|
44
45
|
|
|
45
46
|
|
|
@@ -8,7 +8,9 @@ envelope fields onto native Kafka record headers (UTF-8 byte strings): ``bq-job`
|
|
|
8
8
|
``bq-source-lang`` / ``bq-attempts`` — so a Java/.NET/... peer can route on ``bq-job``
|
|
9
9
|
without parsing the body — with the record timestamp mirroring ``meta.created_at``.
|
|
10
10
|
Consuming is process-then-commit: ``poll`` reserves a record (``enable.auto.commit=false``)
|
|
11
|
-
and ``ack`` commits the offset only after the handler returns (at-least-once).
|
|
11
|
+
and ``ack`` commits the offset only after the handler returns (at-least-once). A record that is
|
|
12
|
+
neither acked nor rewound (its settlement raised) is re-sought by the next ``pop``, so a
|
|
13
|
+
restarted consume loop redelivers it instead of committing past it. Kafka has no
|
|
12
14
|
native delivery count, so the ``bq-attempts`` header is the authoritative retry counter (the
|
|
13
15
|
body's ``attempts`` is the fallback for non-BabelQueue producers); the runtime owns retry by
|
|
14
16
|
republishing with attempts+1 and dead-letters to ``<queue>.dlq``.
|
|
@@ -38,8 +40,9 @@ _logger = logging.getLogger("babelqueue.kafka")
|
|
|
38
40
|
#: U+0085, U+00A0, U+2028, U+3000 ...
|
|
39
41
|
_BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
|
|
40
42
|
#: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
|
|
41
|
-
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime
|
|
42
|
-
#:
|
|
43
|
+
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
|
|
44
|
+
#: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
|
|
45
|
+
#: rejected ``bq-schema-version`` is named in the logged error.
|
|
43
46
|
UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
|
|
44
47
|
|
|
45
48
|
Headers = List[Tuple[str, bytes]]
|
|
@@ -66,6 +69,8 @@ class KafkaTransport(Transport):
|
|
|
66
69
|
self._producer = producer
|
|
67
70
|
self._consumer_factory = consumer_factory
|
|
68
71
|
self._consumers: Dict[str, Any] = {}
|
|
72
|
+
# Per queue: the record handed out by ``pop`` that has been neither acked nor rewound yet.
|
|
73
|
+
self._pending: Dict[str, Any] = {}
|
|
69
74
|
|
|
70
75
|
# -- helpers ------------------------------------------------------------
|
|
71
76
|
|
|
@@ -197,6 +202,7 @@ class KafkaTransport(Transport):
|
|
|
197
202
|
|
|
198
203
|
def pop(self, queue: str, timeout: float = 1.0) -> Optional[ReceivedMessage]:
|
|
199
204
|
wait = timeout if timeout and timeout > 0 else 1.0
|
|
205
|
+
self._rewind_abandoned(queue)
|
|
200
206
|
message = self._consumer(queue).poll(wait)
|
|
201
207
|
if message is None or message.error() is not None:
|
|
202
208
|
return None
|
|
@@ -215,18 +221,27 @@ class KafkaTransport(Transport):
|
|
|
215
221
|
unsupported,
|
|
216
222
|
SCHEMA_VERSION,
|
|
217
223
|
)
|
|
218
|
-
|
|
224
|
+
received = ReceivedMessage(
|
|
219
225
|
body=self._payload(message),
|
|
220
226
|
queue=queue,
|
|
221
227
|
handle=message,
|
|
222
228
|
route_urn=UNSUPPORTED_SCHEMA_ROUTE_PREFIX + quote(unsupported, safe=""),
|
|
223
229
|
)
|
|
230
|
+
self._pending[queue] = message
|
|
231
|
+
return received
|
|
224
232
|
body = self._reconcile(self._payload(message), headers)
|
|
225
|
-
|
|
233
|
+
received = ReceivedMessage(body=body, queue=queue, handle=message)
|
|
234
|
+
# Track the record as in flight only once ``pop`` has built it. A decode error above
|
|
235
|
+
# escapes before this point and must not leave the record pending: the next ``pop`` would
|
|
236
|
+
# seek back to it and fail identically, wedging the partition forever.
|
|
237
|
+
self._pending[queue] = message
|
|
238
|
+
return received
|
|
226
239
|
|
|
227
240
|
def ack(self, message: ReceivedMessage) -> None:
|
|
228
241
|
if message.handle is None:
|
|
229
242
|
return
|
|
243
|
+
# Settled: from here on the record is no longer "in flight", whatever the commit does.
|
|
244
|
+
self._pending.pop(message.queue, None)
|
|
230
245
|
self._consumer(message.queue).commit(message=message.handle, asynchronous=False)
|
|
231
246
|
|
|
232
247
|
def rewind(self, message: ReceivedMessage) -> None:
|
|
@@ -236,6 +251,31 @@ class KafkaTransport(Transport):
|
|
|
236
251
|
handle = message.handle
|
|
237
252
|
if handle is None:
|
|
238
253
|
return
|
|
254
|
+
self._seek_back(message.queue, handle)
|
|
255
|
+
if self._pending.get(message.queue) is handle:
|
|
256
|
+
del self._pending[message.queue]
|
|
257
|
+
|
|
258
|
+
def _rewind_abandoned(self, queue: str) -> None:
|
|
259
|
+
"""Re-seek a record that ``pop`` handed out but that was never acked or rewound.
|
|
260
|
+
|
|
261
|
+
That happens when the runtime's settlement raised (a failed retry / DLQ / release
|
|
262
|
+
``publish``): ``poll`` has already advanced the in-memory position, so without this the
|
|
263
|
+
next ``pop`` (a restarted consume loop) would return the *following* record, and acking it
|
|
264
|
+
would commit past the unsettled one — losing it. Seeking back makes it redeliver instead.
|
|
265
|
+
"""
|
|
266
|
+
handle = self._pending.get(queue)
|
|
267
|
+
if handle is None:
|
|
268
|
+
return
|
|
269
|
+
try:
|
|
270
|
+
self._seek_back(queue, handle)
|
|
271
|
+
except Exception: # noqa: BLE001 - already logged, naming the record
|
|
272
|
+
# A seek can only fail here for a partition that is no longer assigned (rebalance); the
|
|
273
|
+
# group then resets the position to the committed offset, which is that record. Do not
|
|
274
|
+
# wedge the consumer on it.
|
|
275
|
+
pass
|
|
276
|
+
self._pending.pop(queue, None)
|
|
277
|
+
|
|
278
|
+
def _seek_back(self, queue: str, handle: Any) -> None:
|
|
239
279
|
try:
|
|
240
280
|
from confluent_kafka import TopicPartition
|
|
241
281
|
except ImportError: # only reachable with an injected consumer
|
|
@@ -244,7 +284,7 @@ class KafkaTransport(Transport):
|
|
|
244
284
|
TopicPartition = namedtuple("TopicPartition", "topic partition offset") # type: ignore[misc]
|
|
245
285
|
topic, partition, offset = handle.topic(), handle.partition(), handle.offset()
|
|
246
286
|
try:
|
|
247
|
-
self._consumer(
|
|
287
|
+
self._consumer(queue).seek(TopicPartition(topic, partition, offset))
|
|
248
288
|
except Exception:
|
|
249
289
|
# Name the record so an operator can recover it by hand: if the seek fails the
|
|
250
290
|
# commit of a later record on this partition may skip it.
|
|
@@ -59,8 +59,9 @@ _BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
|
|
|
59
59
|
#: ... that fits a signed 32-bit int, matching the Java and .NET consumers (GR-5).
|
|
60
60
|
_MAX_ATTEMPTS = 2**31 - 1
|
|
61
61
|
#: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
|
|
62
|
-
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime
|
|
63
|
-
#:
|
|
62
|
+
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
|
|
63
|
+
#: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
|
|
64
|
+
#: rejected ``bq-schema-version`` is named in the logged error.
|
|
64
65
|
UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
|
|
65
66
|
#: Separates the rejected version from the delivery's original ``bq-job`` in a gate route.
|
|
66
67
|
_GATE_JOB_PARAM = "?bq-job="
|
|
@@ -54,8 +54,9 @@ logger = logging.getLogger("babelqueue.sqs")
|
|
|
54
54
|
#: U+0085, U+00A0, U+2028, U+3000 ...
|
|
55
55
|
_BLANK_ASCII_PATTERN = re.compile(r"[ \t\n\x0b\x0c\r]*")
|
|
56
56
|
#: Prefix of the reserved ``route_urn`` a version-gated message is delivered under. It is not a
|
|
57
|
-
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime
|
|
58
|
-
#:
|
|
57
|
+
#: ``urn:babel:`` URN, so no handler can legitimately own it; the runtime dead-letters it
|
|
58
|
+
#: directly (never retried, never decoded; the DLQ record is the incoming raw body). The
|
|
59
|
+
#: rejected ``bq-schema-version`` is named in the logged error.
|
|
59
60
|
UNSUPPORTED_SCHEMA_ROUTE_PREFIX = "babelqueue:unsupported-schema-version:"
|
|
60
61
|
|
|
61
62
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.2.0
|
|
@@ -222,7 +222,7 @@
|
|
|
222
222
|
]
|
|
223
223
|
},
|
|
224
224
|
"schema_version_gate": {
|
|
225
|
-
"description": "Consumer-side schema-version gate (broker-bindings.md §4.7). The consumer MUST read the ASB ApplicationProperties entry `bq-schema-version` BEFORE decoding the body. Blank means the empty string or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) - the same set in every SDK (GR-5): an absent or blank value falls through to the normal decode path (the body meta.schema_version check still applies afterwards). Otherwise the value is compared as-is, never trimmed: exactly \"1\" decodes, and anything else is rejected WITHOUT decoding (poison/DLQ path) - including \"2\", \"01\", \"x\", values padded with whitespace (\" 1\", \"1 \"), and values made of non-ASCII whitespace (NBSP U+00A0, U+0085, U+3000) or a BOM (U+FEFF), which are NOT blank. A unicode-aware blank check or a trim() before comparing fails these cases. Each case supplies the property 'value' (or 'absent': true) and the 'expect'ed verdict; the message body is always the valid envelope in 'fixture', so a 'reject' verdict can only come from the property gate. ASB application properties are AMQP-typed, so the JSON-number cases (a number, not a string) stand for a native AMQP number: any AMQP integral type (byte/short/int/long, signed or unsigned) with value 1 decodes; any other numeric value (2, 0, non-integral 1.5) and any other type rejects. JSON numbers carry no width, so a runner MUST exercise integral 1 and 2 at both int32 and int64 width. A floating-point 1.0 is
|
|
225
|
+
"description": "Consumer-side schema-version gate (broker-bindings.md §4.7). The consumer MUST read the ASB ApplicationProperties entry `bq-schema-version` BEFORE decoding the body. Blank means the empty string or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) - the same set in every SDK (GR-5): an absent or blank value falls through to the normal decode path (the body meta.schema_version check still applies afterwards). Otherwise the value is compared as-is, never trimmed: exactly \"1\" decodes, and anything else is rejected WITHOUT decoding (poison/DLQ path) - including \"2\", \"01\", \"x\", values padded with whitespace (\" 1\", \"1 \"), and values made of non-ASCII whitespace (NBSP U+00A0, U+0085, U+3000) or a BOM (U+FEFF), which are NOT blank. A unicode-aware blank check or a trim() before comparing fails these cases. Each case supplies the property 'value' (or 'absent': true) and the 'expect'ed verdict; the message body is always the valid envelope in 'fixture', so a 'reject' verdict can only come from the property gate. ASB application properties are AMQP-typed, so the JSON-number cases (a number, not a string) stand for a native AMQP number: any AMQP integral type (byte/short/int/long, signed or unsigned) with value 1 decodes; any other numeric value (2, 0, non-integral 1.5) and any other type rejects. JSON numbers carry no width, so a runner MUST exercise integral 1 and 2 at both int32 and int64 width. A floating-point 1.0 is a native AMQP float/double, NOT integral 1, and rejects: because JSON parsers in several languages (Go, Node) cannot tell 1.0 from 1, that case carries an explicit 'value_type': 'float' marker and a runner MUST hand the SDK a real floating-point value (float32 and float64 where the language has both), never an integer. A language with a single number type (JavaScript: AMQP float 1.0 arrives as the number 1) cannot represent the case and its runner skips it explicitly. A case without 'value_type' keeps the plain meaning above. A JSON null 'value' stands for a present-but-null application property: it is treated exactly like an absent one (the property is simply not set) and decodes, so the runner MUST put the property key into the message with a null value rather than omit it.",
|
|
226
226
|
"property": "bq-schema-version",
|
|
227
227
|
"fixture": "fixtures/order-created.json",
|
|
228
228
|
"cases": [
|
|
@@ -247,7 +247,9 @@
|
|
|
247
247
|
{ "value": 1, "expect": "decode" },
|
|
248
248
|
{ "value": 2, "expect": "reject" },
|
|
249
249
|
{ "value": 0, "expect": "reject" },
|
|
250
|
-
{ "value": 1.5, "expect": "reject" }
|
|
250
|
+
{ "value": 1.5, "expect": "reject" },
|
|
251
|
+
{ "value": 1.0, "value_type": "float", "expect": "reject" },
|
|
252
|
+
{ "value": null, "expect": "decode" }
|
|
251
253
|
]
|
|
252
254
|
}
|
|
253
255
|
},
|
|
@@ -327,7 +329,7 @@
|
|
|
327
329
|
]
|
|
328
330
|
},
|
|
329
331
|
"schema_version_gate": {
|
|
330
|
-
"description": "Consumer-side schema-version gate (broker-bindings.md §6.7). The consumer MUST read the Kafka record header `bq-schema-version` BEFORE decoding the body. Blank means the empty string or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) - the same set in every SDK (GR-5): an absent or blank value falls through to the normal decode path (the body meta.schema_version check still applies afterwards). Otherwise the value is compared as-is, never trimmed: exactly \"1\" decodes, and anything else is rejected WITHOUT decoding (poison/DLQ path) - including \"2\", \"01\", \"x\", values padded with whitespace (\" 1\", \"1 \"), and values made of non-ASCII whitespace (NBSP U+00A0, U+0085, U+3000) or a BOM (U+FEFF), which are NOT blank. A unicode-aware blank check or a trim() before comparing fails these cases. Each case supplies the property 'value' (or 'absent': true) and the 'expect'ed verdict; the message body is always the valid envelope in 'fixture', so a 'reject' verdict can only come from the property gate.",
|
|
332
|
+
"description": "Consumer-side schema-version gate (broker-bindings.md §6.7). The consumer MUST read the Kafka record header `bq-schema-version` BEFORE decoding the body. Blank means the empty string or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) - the same set in every SDK (GR-5): an absent or blank value falls through to the normal decode path (the body meta.schema_version check still applies afterwards). Otherwise the value is compared as-is, never trimmed: exactly \"1\" decodes, and anything else is rejected WITHOUT decoding (poison/DLQ path) - including \"2\", \"01\", \"x\", values padded with whitespace (\" 1\", \"1 \"), and values made of non-ASCII whitespace (NBSP U+00A0, U+0085, U+3000) or a BOM (U+FEFF), which are NOT blank. A unicode-aware blank check or a trim() before comparing fails these cases. Each case supplies the property 'value' (or 'absent': true) and the 'expect'ed verdict; the message body is always the valid envelope in 'fixture', so a 'reject' verdict can only come from the property gate. A JSON null 'value' stands for a present-but-null Kafka record header (Kafka allows a null header value): it is treated exactly like an absent one (the header carries no version) and decodes, so the runner MUST put the header key into the record with a null value rather than omit it, and an SDK MUST NOT stringify the null (a header whose value became the text \"null\" would be rejected).",
|
|
331
333
|
"property": "bq-schema-version",
|
|
332
334
|
"fixture": "fixtures/order-created.json",
|
|
333
335
|
"cases": [
|
|
@@ -348,7 +350,8 @@
|
|
|
348
350
|
{ "value": "\u3000", "expect": "reject" },
|
|
349
351
|
{ "value": "\ufeff", "expect": "reject" },
|
|
350
352
|
{ "value": "\u00a01", "expect": "reject" },
|
|
351
|
-
{ "value": "1\u3000", "expect": "reject" }
|
|
353
|
+
{ "value": "1\u3000", "expect": "reject" },
|
|
354
|
+
{ "value": null, "expect": "decode" }
|
|
352
355
|
]
|
|
353
356
|
}
|
|
354
357
|
},
|
|
@@ -379,7 +382,7 @@
|
|
|
379
382
|
]
|
|
380
383
|
},
|
|
381
384
|
"schema_version_gate": {
|
|
382
|
-
"description": "Consumer-side schema-version gate (broker-bindings.md §7.7). The consumer MUST read the Artemis application property `bq_schema_version` (underscores, ADR-0017) BEFORE decoding the body. Blank means the empty string or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) - the same set in every SDK (GR-5): an absent or blank value falls through to the normal decode path (the body meta.schema_version check still applies afterwards). Otherwise the value is compared as-is, never trimmed: exactly \"1\" decodes, and anything else is rejected WITHOUT decoding (poison/DLQ path) - including \"2\", \"01\", \"x\", values padded with whitespace (\" 1\", \"1 \"), and values made of non-ASCII whitespace (NBSP U+00A0, U+0085, U+3000) or a BOM (U+FEFF), which are NOT blank. A unicode-aware blank check or a trim() before comparing fails these cases. Each case supplies the property 'value' (or 'absent': true) and the 'expect'ed verdict; the message body is always the valid envelope in 'fixture', so a 'reject' verdict can only come from the property gate.",
|
|
385
|
+
"description": "Consumer-side schema-version gate (broker-bindings.md §7.7). The consumer MUST read the Artemis application property `bq_schema_version` (underscores, ADR-0017) BEFORE decoding the body. Blank means the empty string or only ASCII whitespace (space, \\t, \\n, \\v, \\f, \\r) - the same set in every SDK (GR-5): an absent or blank value falls through to the normal decode path (the body meta.schema_version check still applies afterwards). Otherwise the value is compared as-is, never trimmed: exactly \"1\" decodes, and anything else is rejected WITHOUT decoding (poison/DLQ path) - including \"2\", \"01\", \"x\", values padded with whitespace (\" 1\", \"1 \"), and values made of non-ASCII whitespace (NBSP U+00A0, U+0085, U+3000) or a BOM (U+FEFF), which are NOT blank. A unicode-aware blank check or a trim() before comparing fails these cases. Each case supplies the property 'value' (or 'absent': true) and the 'expect'ed verdict; the message body is always the valid envelope in 'fixture', so a 'reject' verdict can only come from the property gate. A JSON null 'value' stands for a present-but-null application property: it is treated exactly like an absent one (the property carries no version) and decodes, so the runner MUST put the property key into the message with a null value rather than omit it. A reader whose message API cannot carry a null application property MUST skip this case explicitly (and assert that it skips) rather than silently drop it.",
|
|
383
386
|
"property": "bq_schema_version",
|
|
384
387
|
"fixture": "fixtures/order-created.json",
|
|
385
388
|
"cases": [
|
|
@@ -400,7 +403,8 @@
|
|
|
400
403
|
{ "value": "\u3000", "expect": "reject" },
|
|
401
404
|
{ "value": "\ufeff", "expect": "reject" },
|
|
402
405
|
{ "value": "\u00a01", "expect": "reject" },
|
|
403
|
-
{ "value": "1\u3000", "expect": "reject" }
|
|
406
|
+
{ "value": "1\u3000", "expect": "reject" },
|
|
407
|
+
{ "value": null, "expect": "decode" }
|
|
404
408
|
]
|
|
405
409
|
}
|
|
406
410
|
},
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""In-process stand-in for the slice of confluent_kafka the live tests and KafkaTransport use.
|
|
2
|
+
|
|
3
|
+
Single partition per topic, per-group committed offsets, cumulative commit, seek, and
|
|
4
|
+
position-from-committed on a new consumer — the semantics the transport relies on."""
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
_LOCK = threading.RLock()
|
|
8
|
+
TOPICS = {} # topic -> list[Message]
|
|
9
|
+
COMMITTED = {} # (group, topic) -> next offset to read
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TopicPartition:
|
|
13
|
+
def __init__(self, topic, partition=0, offset=-1001):
|
|
14
|
+
self.topic, self.partition, self.offset = topic, partition, offset
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Message:
|
|
18
|
+
def __init__(self, topic, offset, value, headers):
|
|
19
|
+
self._t, self._o, self._v, self._h = topic, offset, value, headers
|
|
20
|
+
|
|
21
|
+
def value(self): return self._v
|
|
22
|
+
def headers(self): return self._h
|
|
23
|
+
def error(self): return None
|
|
24
|
+
def topic(self): return self._t
|
|
25
|
+
def partition(self): return 0
|
|
26
|
+
def offset(self): return self._o
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Producer:
|
|
30
|
+
def __init__(self, conf): pass
|
|
31
|
+
|
|
32
|
+
def produce(self, topic, value=None, headers=None, timestamp=None, **kw):
|
|
33
|
+
with _LOCK:
|
|
34
|
+
log = TOPICS.setdefault(topic, []) # auto-create, like the real broker default
|
|
35
|
+
log.append(Message(topic, len(log), value, list(headers or [])))
|
|
36
|
+
|
|
37
|
+
def poll(self, t): return 0
|
|
38
|
+
def flush(self, *a): return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Consumer:
|
|
42
|
+
def __init__(self, conf):
|
|
43
|
+
self.group = conf["group.id"]
|
|
44
|
+
self.topic = None
|
|
45
|
+
self.position = None
|
|
46
|
+
|
|
47
|
+
def subscribe(self, topics):
|
|
48
|
+
self.topic = topics[0]
|
|
49
|
+
self.position = COMMITTED.get((self.group, self.topic), 0)
|
|
50
|
+
|
|
51
|
+
def poll(self, timeout=None):
|
|
52
|
+
with _LOCK:
|
|
53
|
+
log = TOPICS.get(self.topic, [])
|
|
54
|
+
if self.position < len(log):
|
|
55
|
+
m = log[self.position]
|
|
56
|
+
self.position += 1
|
|
57
|
+
return m
|
|
58
|
+
import time
|
|
59
|
+
time.sleep(min(timeout or 0.05, 0.05))
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def commit(self, message=None, asynchronous=True):
|
|
63
|
+
with _LOCK:
|
|
64
|
+
key = (self.group, message.topic())
|
|
65
|
+
COMMITTED[key] = max(COMMITTED.get(key, 0), message.offset() + 1)
|
|
66
|
+
|
|
67
|
+
def seek(self, tp):
|
|
68
|
+
self.position = tp.offset
|
|
69
|
+
|
|
70
|
+
def committed(self, partitions, timeout=None):
|
|
71
|
+
return [TopicPartition(p.topic, p.partition, COMMITTED.get((self.group, p.topic), -1001)) for p in partitions]
|
|
72
|
+
|
|
73
|
+
def close(self): pass
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import concurrent.futures
|
|
2
|
+
from . import TOPICS, _LOCK
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class NewTopic:
|
|
6
|
+
def __init__(self, name, **kw): self.name = name
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AdminClient:
|
|
10
|
+
def __init__(self, conf): pass
|
|
11
|
+
|
|
12
|
+
def create_topics(self, topics):
|
|
13
|
+
out = {}
|
|
14
|
+
for t in topics:
|
|
15
|
+
with _LOCK:
|
|
16
|
+
TOPICS.setdefault(t.name, [])
|
|
17
|
+
f = concurrent.futures.Future()
|
|
18
|
+
f.set_result(None)
|
|
19
|
+
out[t.name] = f
|
|
20
|
+
return out
|
|
@@ -265,9 +265,11 @@ class AppTest(unittest.TestCase):
|
|
|
265
265
|
self.assertEqual(seen, []) # never reaches the handler
|
|
266
266
|
self.assertEqual(app.transport.size("orders"), 0)
|
|
267
267
|
self.assertEqual(app.transport.size("orders.dlq"), 1)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
268
|
+
# The gate rejects without decoding: the DLQ record is the incoming raw body,
|
|
269
|
+
# byte-identical, with no dead_letter block (even for an undecodable body).
|
|
270
|
+
dead = app.transport._queues["orders.dlq"][0] # noqa: SLF001
|
|
271
|
+
self.assertEqual(dead, body)
|
|
272
|
+
self.assertNotIn("dead_letter", dead)
|
|
271
273
|
|
|
272
274
|
def test_version_gated_message_whose_dead_letter_write_fails_is_not_retried(self) -> None:
|
|
273
275
|
from babelqueue.app import _UNSUPPORTED_SCHEMA_ROUTE_PREFIX
|
|
@@ -301,7 +301,8 @@ class ArtemisSchemaVersionGateTest(unittest.TestCase):
|
|
|
301
301
|
# max_attempts > 1: a gated message is dead-lettered directly, never retried
|
|
302
302
|
for value in ("2", "x", "2&x=%"):
|
|
303
303
|
with self.subTest(value=value):
|
|
304
|
-
|
|
304
|
+
incoming = body()
|
|
305
|
+
transport, receiver = self._transport(incoming, {"bq_schema_version": value})
|
|
305
306
|
app = BabelQueue(transport=transport, max_attempts=5, dead_letter=True)
|
|
306
307
|
seen: list = []
|
|
307
308
|
|
|
@@ -318,10 +319,9 @@ class ArtemisSchemaVersionGateTest(unittest.TestCase):
|
|
|
318
319
|
publish.assert_called_once()
|
|
319
320
|
queue, dead_body = publish.call_args.args
|
|
320
321
|
self.assertEqual("orders.dlq", queue)
|
|
321
|
-
|
|
322
|
-
self.assertEqual(
|
|
323
|
-
self.
|
|
324
|
-
self.assertIn(self.PREFIX + quote(value, safe=""), dead["dead_letter"]["error"])
|
|
322
|
+
# rejected without decoding: the DLQ record is the incoming body, byte-identical
|
|
323
|
+
self.assertEqual(incoming, dead_body)
|
|
324
|
+
self.assertNotIn("dead_letter", dead_body)
|
|
325
325
|
|
|
326
326
|
|
|
327
327
|
class ArtemisUrlTest(unittest.TestCase):
|
|
@@ -65,6 +65,9 @@ class AsbConformanceTest(unittest.TestCase):
|
|
|
65
65
|
self.assertEqual(EnvelopeCodec.decode(valid_body)["job"], "urn:babel:orders:created")
|
|
66
66
|
|
|
67
67
|
for case in gate["cases"]:
|
|
68
|
+
if case.get("value_type") == "float":
|
|
69
|
+
# An explicit floating-point case (e.g. 1.0): the reader must hand over a real float.
|
|
70
|
+
self.assertIsInstance(case["value"], float, "schema_version_gate case %r" % (case,))
|
|
68
71
|
carrier = self._gate_carrier(case)
|
|
69
72
|
rejected = AsbTransport._unsupported_schema_version(carrier)
|
|
70
73
|
want = "reject" if case["expect"] == "reject" else "decode"
|
|
@@ -277,7 +277,8 @@ class AsbSchemaVersionGateTest(unittest.TestCase):
|
|
|
277
277
|
# max_attempts > 1: a gated message is dead-lettered directly, never retried
|
|
278
278
|
for value in ("2", "x", 2, "2&x=%"):
|
|
279
279
|
with self.subTest(value=value):
|
|
280
|
-
|
|
280
|
+
incoming = body()
|
|
281
|
+
receiver = FakeReceiver([FakeReceived(incoming, 1, {"bq-schema-version": value})])
|
|
281
282
|
app = BabelQueue(
|
|
282
283
|
transport=AsbTransport(client=FakeClient(receiver=receiver)),
|
|
283
284
|
max_attempts=5,
|
|
@@ -298,10 +299,9 @@ class AsbSchemaVersionGateTest(unittest.TestCase):
|
|
|
298
299
|
publish.assert_called_once()
|
|
299
300
|
queue, dead_body = publish.call_args.args
|
|
300
301
|
self.assertEqual(queue, "orders.dlq")
|
|
301
|
-
|
|
302
|
-
self.assertEqual(
|
|
303
|
-
self.
|
|
304
|
-
self.assertIn(self.PREFIX + quote(str(value), safe=""), dead["dead_letter"]["error"])
|
|
302
|
+
# rejected without decoding: the DLQ record is the incoming body, byte-identical
|
|
303
|
+
self.assertEqual(dead_body, incoming)
|
|
304
|
+
self.assertNotIn("dead_letter", dead_body)
|
|
305
305
|
|
|
306
306
|
|
|
307
307
|
@unittest.skipUnless(HAVE_ASB, "azure-servicebus not installed")
|
|
@@ -52,6 +52,8 @@ class KafkaConformanceTest(unittest.TestCase):
|
|
|
52
52
|
def _gate_carrier(self, case: dict):
|
|
53
53
|
if "absent" in case:
|
|
54
54
|
return []
|
|
55
|
+
if case["value"] is None: # JSON null: header present with a null value == absent (§6.7)
|
|
56
|
+
return [(self.gate["property"], None)]
|
|
55
57
|
return [(self.gate["property"], case["value"].encode("utf-8"))]
|
|
56
58
|
|
|
57
59
|
def test_schema_version_gate(self) -> None:
|