Skip to content

Commit 8f8ba6c

Browse files
committed
feat: graceful shutdown, SQS release via ChangeMessageVisibility, forbidden-key drop (1.14.0)
Add graceful shutdown to BabelQueue.consume() with a shutdown timeout and stop(), a Redeliverer protocol for native releases, and conformance runners for the new manifest sections. SQS retries, unknown-URN releases and shutdown releases always use ChangeMessageVisibility with a 0 s default delay; a RedrivePolicy is recommended against poison loops. The five forbidden envelope keys are dropped on decode and encode with a warning, transport header projections go through parse_envelope without false warnings, and a failed acknowledgement after a successful handler is logged instead of being treated as a handler failure.
1 parent a5b9006 commit 8f8ba6c

21 files changed

Lines changed: 1428 additions & 53 deletions

‎.github/dependabot.yml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: "pip"
4+
directory: "/"
5+
schedule:
6+
interval: "weekly"
7+
8+
- package-ecosystem: "github-actions"
9+
directory: "/"
10+
schedule:
11+
interval: "weekly"

‎CHANGELOG.md‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,55 @@ The envelope wire format is versioned separately by `meta.schema_version`
99

1010
## [Unreleased]
1111

12+
## [1.14.0] - 2026-10-01
13+
14+
### Added
15+
- **Graceful shutdown** of `BabelQueue.consume()`: on SIGTERM/SIGINT the loop sets a stop flag,
16+
takes no new message, lets the in-flight handler finish (or releases the message unchanged once
17+
the new `shutdown_timeout` — default 30 s — expires), restores the previous signal handlers and
18+
closes the transport. Handlers are installed only on the main thread (`handle_signals=False`
19+
opts out); a second signal forces an immediate exit with the message released. A plain
20+
`KeyboardInterrupt` keeps its previous behaviour. New `BabelQueue.stop()` ends the loop from any
21+
thread after the in-flight message.
22+
- `Redeliverer` protocol (`redeliver(message, body, delay)`) — a transport's native
23+
release; the runtime uses it for retries, unknown-URN `RELEASE` and shutdown releases, and
24+
falls back to publish + ack otherwise. New `retry_backoff` / `unknown_urn_release_delay`
25+
settings feed its delay.
26+
- Conformance runners for the `roundtrip`, `data_shape`, `forbidden_keys` and
27+
`payload_schema_unicode` sections (none skipped).
28+
- `.github/dependabot.yml` (pip + GitHub Actions, weekly).
29+
30+
### Changed
31+
- **SQS release** (broker-bindings §3.5): retries, unknown-URN `RELEASE` and shutdown releases
32+
**always** call `ChangeMessageVisibility` instead of re-sending, so the broker's
33+
`ApproximateReceiveCount` carries the attempt count. There is no send + delete fallback. The
34+
default delay for `retry_backoff` and `unknown_urn_release_delay` is **0 s** (immediate
35+
redelivery); a delay outside 0..43200 s (including `inf`/`nan`) is clamped with a warning on the
36+
`babelqueue.sqs` logger. A message without a receipt handle is not released (warning); one
37+
without a receive count is released but its attempts cannot advance (warning).
38+
**Poison-loop risk:** a message that always fails is redelivered immediately until
39+
`max_attempts` — configure a native SQS `RedrivePolicy` (`maxReceiveCount` ≥ `max_attempts`) as
40+
the backstop and a non-zero `retry_backoff` where needed. A shutdown release also counts as a
41+
receive on SQS, so keep `shutdown_timeout` above the longest handler time.
42+
- Delivery semantics are documented as **at-least-once**: a handler that finishes right as the
43+
shutdown deadline fires may be released and run again.
44+
- The SQS, Kafka, Pulsar, Azure Service Bus and Artemis transports' header/property projections
45+
read the envelope without logging a false forbidden-key drop (the body is sent unchanged).
46+
47+
### Fixed
48+
- The five forbidden envelope keys (`timestamp`, `meta.max_retries`, `meta.attempts`,
49+
`meta.source`, `meta.ts` — message-envelope §10) are dropped on decode with a warning on the
50+
`babelqueue.codec` logger, and never written on encode. **Transitional:** encode currently drops
51+
them with a warning; a future MINOR (K-15 / R1-E0) will reject them instead. A raw-body release
52+
republish now strips them too (a clean body is re-sent byte for byte); DLQ redrive still restores
53+
the original bytes unchanged.
54+
- `BabelQueue.stop()` called before `consume()` starts is now honoured instead of being cleared.
55+
- A failed acknowledgement of an already-processed message (e.g. SQS `DeleteMessage` after a
56+
successful handler, an unknown-URN `DELETE`, or a dead-letter publish) is no longer treated as a
57+
handler failure: the message is **not** released or retried; the failure is logged at `ERROR`
58+
on the new `babelqueue.app` logger and the broker redelivers it on its own (SQS: after the
59+
visibility timeout). The consume loop keeps running.
60+
1261
## [1.13.0] - 2026-06-21
1362

1463
### Added

‎README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,27 @@ app.run() # consume forever (Ctrl-C to stop)
120120
and `memory://` (in-process, great for tests/local). Bring your own by passing
121121
`transport=...`.
122122

123+
- **Graceful shutdown:** SIGTERM/SIGINT finish the in-flight handler, or release
124+
the message unchanged once `shutdown_timeout` expires. Delivery is
125+
**at-least-once** — keep handlers idempotent (see the idempotency helper).
126+
127+
### SQS release and poison messages
128+
129+
On a handler failure, unknown-URN `release` or shutdown release, the SQS
130+
transport **always** releases with `ChangeMessageVisibility` (broker-bindings
131+
§3.5) — it never re-sends a copy. The broker's `ApproximateReceiveCount` is the
132+
attempt counter, so a shutdown release also consumes an attempt. The default
133+
delay is **0 s** (`retry_backoff` / `unknown_urn_release_delay`), i.e. the
134+
message is visible again immediately; delays outside 0..43200 s are clamped with
135+
a warning.
136+
137+
**Poison-loop risk:** a message that always fails is redelivered at once until
138+
`max_attempts` is reached. If the receive count is unavailable the SDK cannot
139+
advance attempts at all. **Configure a native `RedrivePolicy`**
140+
(`maxReceiveCount` ≥ `max_attempts`, pointing at `<queue>.dlq`) on every SQS
141+
queue as the broker-side backstop, and set a non-zero `retry_backoff` if the
142+
handler's dependencies need time to recover.
143+
123144
### Sharing a Redis queue with Laravel
124145

125146
By default the Redis transport owns its queue end-to-end (`RPUSH` to produce;

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "babelqueue"
7-
version = "1.13.0"
7+
version = "1.14.0"
88
description = "Polyglot Queues, Simplified — the Python core: the canonical BabelQueue wire-envelope codec, contracts and dead-letter helpers."
99
readme = "README.md"
1010
requires-python = ">=3.9"

‎src/babelqueue/__init__.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@
2929
from .exceptions import BabelQueueError, DecryptError, UnknownUrnError
3030
from .replay import HEADER_REPLAY_BYPASS, bypass_external_effects, is_replay
3131
from .routing import UnknownUrnStrategy
32-
from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Transport
32+
from .transport import HeaderPublisher, InMemoryTransport, ReceivedMessage, Redeliverer, Transport
3333

34-
__version__ = "1.13.0"
34+
__version__ = "1.14.0"
3535

3636
__all__ = [
3737
"BabelQueue",
@@ -45,6 +45,7 @@
4545
"InMemoryTransport",
4646
"ReceivedMessage",
4747
"HeaderPublisher",
48+
"Redeliverer",
4849
"BabelQueueError",
4950
"UnknownUrnError",
5051
"DecryptError",

0 commit comments

Comments
 (0)