Skip to content

Add NATS Transport Unit Tests, related documentation and prefix support for stream names.#2541

Open
maxyloon wants to merge 33 commits into
celery:mainfrom
maxyloon:nats
Open

Add NATS Transport Unit Tests, related documentation and prefix support for stream names.#2541
maxyloon wants to merge 33 commits into
celery:mainfrom
maxyloon:nats

Conversation

@maxyloon
Copy link
Copy Markdown

@maxyloon maxyloon commented May 9, 2026

closes #2103

Implement support for NATS using its built-in persistence layer JetStream.

What's in this PR

Original implementation by @joeriddles. (PR: #2299) This PR builds on that work to bring it up to date with main and add the items needed for merge:

  • Unit tests (t/unit/transport/test_nats.py) — 72 tests covering Message, QoS, Channel, and Transport with no live NATS server required
  • Configurable stream/consumer name prefixes via stream_name_prefix and consumer_name_prefix transport options (default STREAM_ / CONSUMER_)
  • Setup extraskombu[nats] extra pointing to requirements/extras/nats.txt
  • Docs — NATS row in the README transport comparison table, NATS URL examples and transport options documented in the userguide connections page, kombu.transport.nats added to the API reference index
  • Dependency updates — rebased onto current main; nats-py[nkeys]>=2.9.0,<3.0.0 confirmed compatible with nats-py 2.14.0
  • Lint — all flake8 warnings resolved (F841 unused variables removed)

Testing

Install the extra and run unit tests (no server needed):

pip install "kombu[nats]"
python -m pytest t/unit/transport/test_nats.py -v

Integration tests (requires a running NATS server with JetStream):

nats-server --jetstream
python -m pytest t/integration/test_nats.py -v -E nats

Example

You can test using either:

  • Installing the NATS server locally and running it with JetStream enabled: nats-server --js
  • Using the demo NATS server at demo.nats.io

The transport can be tested using examples/nats_receive.py and examples/nats_send.py:

  1. Start the local NATS server (optional)
  2. Run python -m examples.nats_receive (append --demo if using the demo server)
  3. In another window, run python -m examples.nats_send (append --demo if using the demo server)

In the receive window, you should see something like:

Received message: 'hello world'
  properties:
{   'body_encoding': 'base64',
    'delivery_info': {'exchange': 'exchange', 'routing_key': 'messages'},
    'delivery_mode': 2,
    'delivery_tag': 'fa01f1a9-f666-46a0-af71-35e5164e1ad9',
    'priority': 0}
  delivery_info:
{'exchange': 'exchange', 'routing_key': 'messages'}

@auvipy
Copy link
Copy Markdown
Member

auvipy commented May 9, 2026

thanks max

@codecov
Copy link
Copy Markdown

codecov Bot commented May 9, 2026

Codecov Report

❌ Patch coverage is 92.07317% with 26 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.95%. Comparing base (3cbc3c9) to head (c3ec1ce).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
kombu/transport/nats.py 92.07% 24 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2541      +/-   ##
==========================================
+ Coverage   82.66%   82.95%   +0.29%     
==========================================
  Files          79       80       +1     
  Lines       10231    10559     +328     
  Branches     1170     1203      +33     
==========================================
+ Hits         8457     8759     +302     
- Misses       1573     1597      +24     
- Partials      201      203       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@auvipy auvipy requested review from auvipy and Copilot and removed request for Copilot May 9, 2026 16:06
@maxyloon
Copy link
Copy Markdown
Author

maxyloon commented May 9, 2026

No problem. Let me know if there is anything else I can do to get it merged.

@auvipy auvipy requested a review from Copilot May 10, 2026 05:38
@auvipy auvipy added this to the 5.7.0 milestone May 10, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new NATS JetStream Kombu transport, along with supporting packaging/docs updates and a thorough unit test suite. It extends Kombu’s virtual transport layer to target JetStream streams/consumers (including configurable naming prefixes) and wires the transport into project tooling and documentation.

Changes:

  • Added kombu.transport.nats implementing a JetStream-backed virtual transport with configurable stream/consumer name prefixes.
  • Added NATS unit + integration tests, plus tox/docker integration plumbing.
  • Added packaging extras (kombu[nats]) and documentation references/examples for the new transport.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
tox.ini Adds NATS integration env + docker service; installs NATS extra in certain tox envs.
t/unit/transport/test_nats.py Adds unit tests for Message/QoS/Channel/Transport with mocked nats-py IO.
t/integration/test_nats.py Adds integration tests (marked env('nats')) for basic transport operations.
t/integration/docker/Dockerfile.nats Adds a JetStream-enabled NATS server container for tox integration runs.
setup.py Adds nats extra to setup extras mapping.
requirements/extras/nats.txt Defines nats-py[nkeys] extra requirement range.
README.rst Adds NATS JetStream mention + transport comparison row.
kombu/transport/nats.py New NATS JetStream transport implementation.
kombu/transport/__init__.py Registers nats transport alias.
examples/nats_send.py Adds example producer for NATS transport.
examples/nats_receive.py Adds example consumer for NATS transport.
docs/userguide/connections.rst Documents NATS URL examples + transport comparison entry.
docs/reference/kombu.transport.nats.rst Adds API reference page for the NATS transport module.
docs/reference/index.rst Adds NATS transport page to Sphinx reference index.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread kombu/transport/nats.py
Comment thread kombu/transport/nats.py
Comment thread kombu/transport/nats.py Outdated
Comment thread kombu/transport/nats.py Outdated
Comment thread kombu/transport/nats.py
Comment on lines +107 to +120
class QoS(virtual.QoS):
"""Quality of Service guarantees."""

_not_yet_acked = {}

def can_consume(self):
"""Return true if the channel can be consumed from."""
return not self.prefetch_count or len(self._not_yet_acked) < self.prefetch_count

def can_consume_max_estimate(self):
if self.prefetch_count:
return self.prefetch_count - len(self._not_yet_acked)
return 1

Comment thread tox.ini Outdated
Comment thread README.rst
Comment thread kombu/transport/nats.py
Comment thread t/unit/transport/test_nats.py Outdated
Comment thread tox.ini Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Member

@auvipy auvipy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maxyloon please cross check the suggestions locally and fix which are relevant

maxyloon and others added 4 commits May 11, 2026 08:03
- QoS._not_yet_acked: moved from class attribute to instance __init__ to
  prevent shared state across channels
- QoS.can_consume_max_estimate: clamp result at 0 via max(0, ...) to avoid
  returning negative values when _not_yet_acked exceeds prefetch_count
- Channel._consumers → _js_consumers: rename to avoid collision with
  virtual.Channel._consumers which tracks Kombu consumer tags
- Channel._open(): use 'hostname or DEFAULT_HOST' fallback so a None
  hostname doesn't produce 'nats://None:4222'
- Channel.close(): remove the asyncio.all_tasks() loop cancellation that
  would cancel unrelated coroutines in the same event loop
- Channel._delete(): always attempt stream deletion (not only when in
  cache); use _streams.discard() in a finally block so the cache stays
  consistent even on NotFoundError
- Channel._put(): read message expiration property and pass it as a
  'Nats-TTL' header (value in '<ms>ms' format) so NATS JetStream expires
  the message after the requested duration
- tox.ini: fix nats deps selector to include pypy3.11-linux and 3.10-linux;
  remove undefined 3.14t-linux factor
- docs/userguide/connections.rst: document all NATS transport options
  including per-message TTL
@maxyloon
Copy link
Copy Markdown
Author

So I realized that the implementation uses embedds metadata in the body (like most virtual transports), but nats supports binary payloads and I wanted to make sure protocol binding for use with things like cloudevents would work with some configuration. Therefore I added a clean-body mode. So it uses headers instead for metadata and should avoid nested serialization problems

  • Add optional clean-body mode for the NATS transport:
    • New transport options:
      • nats_clean_body (bool, default False) — if True, the transport publishes the application payload directly as the NATS message body (bytes) and places Kombu metadata into configurable NATS headers.
      • nats_metadata_header_prefix (str, default "Kombu-") — prefix for Kombu metadata headers (e.g. Kombu-Content-Type).
      • nats_metadata_header_names (dict, optional) — override per-field header suffixes (defaults: Content-Type, Content-Encoding, Headers, Properties, Delivery-Info).
    • Behavior:
      • Backwards-compatible by default (nats_clean_body=False).
      • In clean-body mode the transport does NOT JSON-wrap or otherwise alter the payload bytes — serialization remains the caller/serializer's responsibility.
      • Kombu metadata is carried as NATS headers (JSON-encoded when structured). Nats-TTL header handling is unchanged.
      • Consumer auto-detects mode by presence of the configured metadata headers; legacy envelope parsing is preserved.
    • Safety notes:
      • The nats_metadata_header_prefix must not start with Nats- (reserved); code emits a warning for such prefixes.
    • Tests & verification:
      • New unit tests covering helper functions, round-trip behavior, custom prefix/names, TTL interactions (total n tests added).
      • Manual integration confirmed: raw bytes published and received identical when nats_clean_body=True.

Copy link
Copy Markdown
Author

@maxyloon maxyloon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@auvipy
Copy link
Copy Markdown
Member

auvipy commented May 11, 2026

isn't it a complete new backend? why should we think about backward compatibility here then?

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.

Comment thread kombu/transport/nats.py Outdated
Comment thread kombu/transport/nats.py
Comment on lines +195 to +196
else:
body_bytes = b""
Comment thread kombu/transport/nats.py
Comment on lines +108 to +112
"""Get or create the global event loop."""
global _event_loop
if _event_loop is None:
_event_loop = asyncio.new_event_loop()
asyncio.set_event_loop(_event_loop)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see

Copy link
Copy Markdown
Author

@maxyloon maxyloon May 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The transport should not install a global asyncio event loop.

I see Kombu’s virtual transports are synchronous, so the NATS transport should behave as a sync adapter over nats-py’s async API. I’ll change this to keep a private event loop owned by each Channel, avoid asyncio.set_event_loop().

I think this keeps asyncio isolated from the rest of Celery/Kombu and avoids replacing an application’s existing thread-default loop.

Comment thread kombu/transport/nats.py
_event_loop: asyncio.AbstractEventLoop | None = None


def get_event_loop() -> asyncio.AbstractEventLoop:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will asyncio work well with current celery and kombu code base properly?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have a local branch that works now, will push it shortly. Taking some time verify some setups and sceanarios to make it fully celery compatible. Here is the docs from my working branch. Once I find it stable enough i'll open a PR for celery too.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no problem take your time

@maxyloon
Copy link
Copy Markdown
Author

isn't it a complete new backend? why should we think about backward compatibility here then?

Not sure exactly what you mean here, but I think the “backward compatibility” point here is less about compatibility with an existing released NATS backend, since this is new, and more about compatibility between the two wire formats introduced by this PR. And no I don't think its a complete new backend, just a question of wireformat which should be transient to the user.

My thinking is that; The main thing we should preserve is consumer-side interoperability: a consumer should be able to read both formats regardless of whether the publisher used envelope-in-body mode or clean-body mode (might refactor it to raw for better abstraction). That matters if a queue already contains messages when nats_clean_body is changed, or if different producers are temporarily configured differently.

So I agree the publish path can be strict and explicit, but the receive path should remain tolerant: detect Kombu metadata headers → clean-body mode; otherwise treat the payload as the full Kombu JSON envelope.

External semantics outside the module will remain the same, but raw/clean mode with skip serializing and deserializing the wireformat,

@auvipy
Copy link
Copy Markdown
Member

auvipy commented May 11, 2026

isn't it a complete new backend? why should we think about backward compatibility here then?

Not sure exactly what you mean here, but I think the “backward compatibility” point here is less about compatibility with an existing released NATS backend, since this is new, and more about compatibility between the two wire formats introduced by this PR. And no I don't think its a complete new backend, just a question of wireformat which should be transient to the user.

My thinking is that; The main thing we should preserve is consumer-side interoperability: a consumer should be able to read both formats regardless of whether the publisher used envelope-in-body mode or clean-body mode (might refactor it to raw for better abstraction). That matters if a queue already contains messages when nats_clean_body is changed, or if different producers are temporarily configured differently.

So I agree the publish path can be strict and explicit, but the receive path should remain tolerant: detect Kombu metadata headers → clean-body mode; otherwise treat the payload as the full Kombu JSON envelope.

External semantics outside the module will remain the same, but raw/clean mode with skip serializing and deserializing the wireformat,

that is more clear now and make sense.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NATS support

5 participants