Skip to content

feat: add NATS pub/sub backend - #4550

Merged
igor-kupczynski merged 4 commits into
mainfrom
nats-pubsub-backend
Jul 29, 2026
Merged

feat: add NATS pub/sub backend#4550
igor-kupczynski merged 4 commits into
mainfrom
nats-pubsub-backend

Conversation

@igor-kupczynski

@igor-kupczynski igor-kupczynski commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Description

Note

This is an experimental prototype. We are not launching this as a feature yet; we need to gain more experience running this internally first.

Adds NATS as an opt-in pub/sub backend, set:

  • SERVER_MSGQUEUE_PUBSUB_KIND=nats
  • SERVER_MSGQUEUE_PUBSUB_NATS_URL=...

It covers only the best-effort pub/sub side of the #4480 split: core NATS, at-most-once. Durable queues stay on RabbitMQ/Postgres.

The defaults are unchanged, NATS remain optional, until the config is set.

Type of change

  • New feature (non-breaking change which adds functionality)

What's Changed

internal/msgqueue/nats plus config, docs, and a compose service.

Technicalities:

  • Connect is synchronous and refuses to start if the server's max_payload is under the 16MiB. 16MiB is what RabbitMQ allows, so as a drop in replacement, we want to keep this.
  • SERVER_MSGQUEUE_PUBSUB_NATS_SUBJECT_PREFIX (default hatchet.pubsub) for isolating hatchet installations sharing a NATS server (not unlike like a RabbitMQ vhost or Postgres database).

Checklist

Changes have been:

  • Tested (unit, integration, or manually with steps specified)
  • Linted and formatted
  • Documented (where applicable)
  • Added to CHANGELOG (where applicable) -- see Keep a Changelog

Testing

docker compose up -d nats
go test -tags integration ./internal/msgqueue/nats/...
go test ./internal/msgqueue/...

🤖 AI Disclosure
  • I acknowledge that an LLM was used in the creation of this Pull Request, in accordance with Hatchet's AI_POLICY.md.

  • Details: Cursor agent implemented the backend, config/docs/compose wiring, and tests from a reviewed plan.

Selectable per deployment with SERVER_MSGQUEUE_PUBSUB_KIND=nats and
SERVER_MSGQUEUE_PUBSUB_NATS_URL. NATS implements only the best-effort pub/sub
side of the split introduced in #4480; durable queues stay on RabbitMQ and
Postgres.

Core NATS, no JetStream, so delivery is at-most-once. Connect is synchronous and
validates the server's max_payload against the 16MiB the durable RabbitMQ path
allows, because task stream events routinely exceed the NATS default of 1MiB and
a startup failure is easier to diagnose than a later oversized publish.
ReconnectBufSize(-1) makes publishes fail fast while disconnected rather than
silently buffering messages a best-effort subscriber will never wait for.

The other backends namespace installations at the connection level (a RabbitMQ
vhost, a Postgres database); NATS namespaces by subject, so
SERVER_MSGQUEUE_PUBSUB_NATS_SUBJECT_PREFIX is the parity knob for installations
that share a NATS server. Username and password are passed as connect options
rather than embedded in the URL so that reconnects to gossip-discovered cluster
peers authenticate too.
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hatchet-docs Ready Ready Preview, Comment Jul 28, 2026 5:09pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation sdk-go Related to the Go SDK engine Related to the core Hatchet engine labels Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=true). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=false). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

Comment thread internal/msgqueue/nats/pubsub.go Outdated
Comment on lines +16 to +18
// requiredMaxPayload is the minimum NATS server max_payload we accept.
// The durable RabbitMQ path allows 16MiB, so the NATS server must match.
const requiredMaxPayload = 16 * 1024 * 1024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this makes me a bit nervous since I think this can e.g. change across rabbit versions (pretty sure we ran into this)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. Our rabbitmq backend hardcodes its own client-side maxPayloadSize = 16MiB and never reads the broker's limit. Fixed in 830c4d6 by adding a single shared msgqueue.MaxMessageSize used by rabbit and NATS. If the contract ever changes, at least this will happen in one place.

Comment thread internal/msgqueue/nats/pubsub.go Outdated
Comment on lines +104 to +105
natsgo.MaxReconnects(-1), // reconnect indefinitely
natsgo.ReconnectBufSize(-1), // do not buffer pubs during disconnect

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should these be configurable?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think either makes sense as a tunable config:

  • MaxReconnects: any finite value permanently closes the connection once exhausted, leaving the engine without pub/sub until a process restart (so on a NATS outage, if the broker recovers after the MaxReconnects we'd never know). The rabbitmq backend also retries forever.
  • ReconnectBufSize(-1): with a buffer, publishes during a disconnect get queued and flushed on reconnect. But these signals are latency optimizations with polling paths as fallbacks; by the time a buffered publish flushed, polling would have likely covered it.

Documented the rationale inline in 830c4d6 as comments so it doesn't surprise the readers.

Comment thread internal/msgqueue/nats/pubsub.go Outdated
Comment on lines +128 to +130
if opts.username != "" || opts.password != "" {
connectOpts = append(connectOpts, natsgo.UserInfo(opts.username, opts.password))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

do these need to both be non-empty? or just one? I guess maybe it doesn't matter since the connect after this will fail if they're wrong

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for raising. NATS marshals this with omitempty, so UserInfo("", "") is the same as not setting it at all. Empty credentials never reach the broker.

In 830c4d6 I deleted the conditional and pass UserInfo unconditionally. Whether a lone username (or password) is acceptable is the server's call, and it fails loudly at the synchronous Connect on startup, as you said.

- Hoist the 16MiB limit into a shared msgqueue.MaxMessageSize: it is
  Hatchet's own message-size contract (never read from a broker), so one
  constant now moves the rabbitmq chunking, the NATS startup check, and
  the error messages together instead of drifting apart.
- Pass nats.UserInfo unconditionally: empty credentials are omitted from
  the CONNECT payload, so the either-set conditional was a no-op guard.
- Document why MaxReconnects(-1) and ReconnectBufSize(-1) are fixed
  rather than configurable: finite reconnects permanently kill pub/sub,
  and buffered publishes flushed after reconnect add nothing over the
  consumers' polling paths.
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Benchmark results

goos: linux
goarch: amd64
pkg: github.com/hatchet-dev/hatchet/internal/msgqueue
cpu: AMD Ryzen 9 7950X3D 16-Core Processor          
                              │ /tmp/old.txt │            /tmp/new.txt             │
                              │    sec/op    │    sec/op     vs base               │
CompressPayloads_1x10KiB-8      77.26µ ±  1%   77.46µ ±  2%        ~ (p=0.699 n=6)
CompressPayloads_10x10KiB-8     879.1µ ±  2%   892.4µ ±  3%        ~ (p=0.240 n=6)
CompressPayloads_10x100KiB-8    10.75m ±  1%   10.75m ±  1%        ~ (p=0.699 n=6)
CompressPayloads_Concurrent-8   55.47µ ± 13%   71.38µ ± 15%  +28.69% (p=0.002 n=6)
geomean                         448.6µ         479.9µ         +6.97%

                              │ /tmp/old.txt │            /tmp/new.txt            │
                              │     B/op     │     B/op      vs base              │
CompressPayloads_1x10KiB-8      10.93Ki ± 1%   10.93Ki ± 0%       ~ (p=0.963 n=6)
CompressPayloads_10x10KiB-8     108.9Ki ± 1%   108.3Ki ± 1%       ~ (p=0.132 n=6)
CompressPayloads_10x100KiB-8    2.920Mi ± 0%   2.920Mi ± 0%       ~ (p=0.394 n=6)
CompressPayloads_Concurrent-8   54.30Ki ± 0%   54.40Ki ± 0%       ~ (p=0.065 n=6)
geomean                         117.9Ki        117.8Ki       -0.09%

                              │ /tmp/old.txt │            /tmp/new.txt            │
                              │  allocs/op   │ allocs/op   vs base                │
CompressPayloads_1x10KiB-8        5.000 ± 0%   5.000 ± 0%       ~ (p=1.000 n=6) ¹
CompressPayloads_10x10KiB-8       32.00 ± 0%   32.00 ± 0%       ~ (p=1.000 n=6) ¹
CompressPayloads_10x100KiB-8      63.00 ± 0%   63.00 ± 0%       ~ (p=1.000 n=6) ¹
CompressPayloads_Concurrent-8     17.00 ± 0%   17.00 ± 0%       ~ (p=1.000 n=6) ¹
geomean                           20.35        20.35       +0.00%
¹ all samples are equal

Compared against main (60a9e11)

@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Optional test failure: The load-deadlock job failed on this PR (optimistic-scheduling=false). This check is non-mandatory and does not block merging, but may be worth investigating. View logs

@igor-kupczynski
igor-kupczynski merged commit 8256e87 into main Jul 29, 2026
65 of 67 checks passed
@igor-kupczynski
igor-kupczynski deleted the nats-pubsub-backend branch July 29, 2026 09:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation engine Related to the core Hatchet engine sdk-go Related to the Go SDK

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants