Skip to content

chore: NATS broker for micro services - #35305

Draft
sampaiodiego wants to merge 44 commits into
developfrom
nats-broker
Draft

sampaiodiego wants to merge 44 commits into
developfrom
nats-broker

Conversation

@sampaiodiego

@sampaiodiego sampaiodiego commented Feb 21, 2025 •

Copy link
Copy Markdown
Member

https://rocketchat.atlassian.net/browse/RDI-278

Proposed changes (including videos or screenshots)

This is a proof of concept. It adds NatsBroker, a second network broker for the microservices deployment. It talks to NATS directly instead of going through Moleculer. It uses the NATS services protocol for calls and discovery, and plain subjects for events. Moleculer stays the default. BROKER=nats selects the new broker in every service and in the monolith running in microservices mode.

Broker selection

  • startBroker() (ee/packages/network-broker/src/startBroker.ts) picks MoleculerBroker or NatsBroker from BROKER. The NATS factory (nats.ts) reads NATS_URL, falling back to TRANSPORTER, plus REQUEST_TIMEOUT and BROKER_LOCAL_ROUTING.
  • Api.setBroker() now throws once the API has started, so a broker can no longer be swapped under running services.

What NatsBroker implements

  • Calls: rpc.<service>.<method>, load balanced by a NATS queue group. node.<nodeID>.<service>.<method> pins a call to one instance and backs CallingOptions.nodeID. A service in the same process is called directly, so arguments keep their identity instead of going through EJSON. BROKER_LOCAL_ROUTING=false turns that off.
  • Events:
    • broadcast publishes on event.<name> and reaches every node.
    • emitToOne publishes on emit.<name>, where each service subscribes in a queue group named after itself, so one instance of each listening service receives it.
    • broadcastLocal stays in process.
    • Events and methods live under different prefixes, because some event names collide with <service>.<method>, e.g. accounts.login.
  • Errors: a throwing handler answers with a service error, and the caller rethrows it. MeteorError keeps its error, reason, details and isClientSafe.
  • Discovery: $node.list and $node.services are answered from a $SRV.PING round trip, with a short cache. Each service carries its node id as metadata. License enforcement uses that list, the same way it does under Moleculer.
  • Startup:
    • A service is registered only once its dependencies (settings and license by default) are reachable locally or remotely.
    • Services whose dependencies are met together are registered together, before their created()/started() hooks run.
    • A failing hook is logged instead of rejecting api.start().
  • Retries: a request that reaches no responder (503) is retried with backoff for about 4s, which covers peers that are still booting or being rolled. Nothing else is retried, because a timed-out request may already have run.
  • Streams: a Readable in a call's arguments or its result is replaced by an inbox subject. The other side pulls 256KB chunks from that subject, which provides backpressure. An idle timeout cleans up the subject when the consumer never pulls.
  • Context: hooks, remote handlers and local calls all run inside asyncLocalStorage, so ServiceClass.context is populated. Under NATS, ddp-streamer's login handler depends on this.
  • Shutdown: on SIGTERM/SIGINT every service runs stopped(), then the connection drains and the process exits. SKIP_PROCESS_EVENT_REGISTRATION=true disables this.

Shared with MoleculerBroker

  • The default-dependencies rule now lives in withDefaultDependencies.ts, used by both brokers.
  • startBroker from moleculer.ts is renamed to startMoleculerBroker.

Tests and docs

  • NatsBroker.test.ts runs the broker against an in-memory FakeNatsConnection. It covers calls, events, emitToOne, errors, retries, dependency waiting, lifecycle failures, streams, context and shutdown. nats.test.ts covers the signal handling.
  • docs/service-brokers.md describes all three brokers, everything that had to be rebuilt for NATS, how events reach other instances in each deployment, what is not implemented yet, and the configuration.

Not done yet

Warning

Tracing, metrics and namespaces are not implemented. Switching to BROKER=nats silently loses all three. See "Not implemented yet" in docs/service-brokers.md.

  • Tracing: the caller's trace context is not sent with a call, and handlers do not run in a tracer span, so a trace stops at the first remote call.
  • Metrics: broker.metrics is undefined. ddp-streamer's rocketchat_subscription, users_connected and users_logged are not recorded, and no Prometheus reporter is exported (no MS_METRICS equivalent).
  • Namespaces: there is no MS_NAMESPACE equivalent. Subjects and service names are global to the NATS server, so two deployments on one server would answer each other's calls, receive each other's events, and discover each other's services. Until this lands, give each deployment its own NATS account.

Also missing:

  • broadcastToServices is a no-op.
  • $node.connected / $node.disconnected are never emitted. Presence relies on onNodeDisconnected to drop the sessions of a node that died.
  • $services.changed and the other broker lifecycle events are never emitted. Nothing in the codebase listens to them today.

Issue(s)

Steps to test or reproduce

  • Unit tests: yarn testunit in ee/packages/network-broker.
  • End to end: the CI e2e workflow has a broker input that is passed to docker compose as BROKER. The workflow checks that the requested broker actually started. The input defaults to nats on this branch so CI exercises the new broker. That default has to go before merge.
  • Locally: start the microservices compose with BROKER=nats.

Further comments

The nats dependency was bumped to ^2.29.1 in every app so the repo keeps a single version.

🤖 Generated with Claude Code

@dionisio-bot

dionisio-bot Bot commented Feb 21, 2025 •

Copy link
Copy Markdown
Contributor

Looks like this PR is not ready to merge, because of the following issues:

  • This PR is still a draft
  • This PR is missing the 'stat: QA assured' label
  • GitHub is still computing mergeability — this will refresh shortly
  • This PR is missing the required milestone or project

Please fix the issues and try again

If you have any trouble, please check the PR guidelines

@changeset-bot

changeset-bot Bot commented Feb 21, 2025 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: d82927a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@codecov

codecov Bot commented Feb 21, 2025 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.84615% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.33%. Comparing base (a6ae198) to head (d82927a).
⚠️ Report is 1 commits behind head on develop.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop   #35305      +/-   ##
===========================================
+ Coverage    71.27%   71.33%   +0.05%     
===========================================
  Files         4861     4866       +5     
  Lines       207137   207663     +526     
  Branches     36571    36303     -268     
===========================================
+ Hits        147631   148130     +499     
- Misses       54382    54415      +33     
+ Partials      5124     5118       -6     
Flag Coverage Δ
e2e 58.94% <ø> (+0.02%) ⬆️
e2e-api 45.87% <ø> (-0.02%) ⬇️
unit 72.13% <93.84%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ggazzo

ggazzo commented Feb 26, 2025

Copy link
Copy Markdown
Member

@kody start-review

@kody-ai

kody-ai Bot commented Feb 26, 2025 •

Copy link
Copy Markdown

Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Security ✅
Code Style ✅
Kody Rules ✅
Refactoring ✅
Error Handling ✅
Maintainability ✅
Potential Issues ✅
Documentation And Comments ✅
Performance And Optimization ✅

Access your configuration settings here.

​

Comment thread ee/packages/network-broker/src/NatsBroker.ts Outdated
Comment thread ee/packages/network-broker/src/moleculer.ts
Comment thread ee/packages/network-broker/src/NatsBroker.ts
Comment thread ee/packages/network-broker/src/MoleculerBroker.ts
@coderabbitai

coderabbitai Bot commented Apr 15, 2026 •

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@rc-layne

rc-layne Bot commented Apr 28, 2026 •

Copy link
Copy Markdown

⚠️ Layne — scan incomplete

Layne could not analyze all changed content. Review the Check Run summary before merging.

Comment thread packages/core-services/src/lib/Api.ts Outdated
this.services.forEach((service) => this.broker?.createService(service));

if (this.started) {
void this.broker.start();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

why does it need to be started here?

sampaiodiego and others added 28 commits October 8, 2026 19:40
NatsBroker awaited `created()`/`started()` directly, so a service whose hook
rejected rejected `api.start()` too. Nothing catches that, so the unhandled
rejection killed the container - silently, taking every other service hosted
in the same process with it.

Endpoints are already registered before any hook runs, so a service that fails
to initialize can still answer with its defaults. Log the failure and keep
going instead.
Moleculer holds a registry and waits for a service to appear; nats has no such
thing, so a request to a subject nobody listens on fails at once with 503. That
is the normal state of affairs while a peer boots or is rolled, and every
caller was left to handle it - or not, as the crash on startup showed.

Back off and retry for a few seconds. Only 503 is retried: it means nothing
received the request, so a second attempt cannot duplicate a side effect. A
timeout may have been delivered, so it still surfaces to the caller.

`isNatsError` is declared by the nats typings but not exported at runtime, so
the check uses `NatsError` itself.
Services that read configuration from another service need to do it on first
use rather than at boot, and need a failed attempt to be retried rather than
remembered. Three of them are about to want exactly that.
`started()` fetched `Accounts_LoginExpiration` from the settings service, which
runs in another process. During a cold start it is not reachable yet, so the
read threw and took the whole account-service down with it.

The field is already kept current by `onSettingChanged`; the boot read only
primed it. Prime it on the first login instead, where a transient failure
fails that one login rather than the process.
`started()` fetched seven settings from the settings service, which runs in
another process. During a cold start it is not reachable yet, so the read threw
and took the whole omnichannel-transcript-service down with it.

All seven fields already have defaults and are kept current by
`onSettingChanged`; the boot read only primed them. Prime them on the first
transcript instead, so a transient failure fails that one job - which the
queue already retries - rather than the process.
`started()` read four settings from the settings service, which runs in another
process. During a cold start it is not reachable yet, so the read threw and
took the whole authorization-service down with it - and even once that is
survivable, it left the service unconfigured: `this.pdp` stays null, and
`checkUsernamesMatchAttributes` silently skips the check in that state.

Keep the boot read, but make it repeatable and run it again on the two decision
paths, so a service that came up before the settings service configures itself
on the first decision rather than staying open until a setting happens to
change.

The read now only fills in what is still unknown, and leaves an already chosen
PDP alone: settings events arrive independently of it, so whatever they
delivered is newer than what it just fetched.
Moleculer dispatches a call to a locally registered service straight to the
instance, without going near the serializer, and call sites depend on it:
`ee/server/configuration/abac.ts` hands `LDAPEnterprise` a mongo cursor, which
only survives because the argument arrives by reference. NatsBroker sent
everything over nats, so EJSON met the cursor and threw `Converting circular
structure to JSON`.

Route through a LocalServiceRegistry that tracks the services running in this
process. Calls to them keep argument and result identity, and skip a round trip
through the nats server on the way. `getCallableMethods` now defines what a
service answers to in one place, so the local and remote paths cannot expose a
different set.

Set BROKER_LOCAL_ROUTING=false to turn it off and send everything over the wire
again.
LocalBroker already indexed its services by `<service>.<method>` and dispatched
straight to the instance - the same thing NatsBroker just grew, down to its own
copy of the method enumeration in both createService and destroyService.

Move LocalServiceRegistry and getInstanceMethods into core-services, which both
brokers already depend on, and let LocalBroker route through the registry too.
MoleculerBroker takes getInstanceMethods from the same place.

This also stops LocalBroker exposing lifecycle hooks and event handlers as
callable: `api.call('some-service.started')` used to re-run the hook. Nothing
calls those through a broker.
Covers all three implementations rather than only the new one: how a proxied
call reaches a broker at all, what LocalBroker and MoleculerBroker each provide,
and what NatsBroker had to rebuild to match.
Priming only covered the two decision paths, but five room attribute mutators
and four other entry points read `this.pdp` as well. In microservices mode the
monolith does not register AbacService at all, so every call lands on
authorization-service - whose `started()` loses the race with the settings
service and leaves `this.pdp` null. `ensurePdpAvailable` then reported the PDP
as unavailable rather than as not yet configured, and the ABAC API answered 400
`error-pdp-unavailable` to 35 requests.

Prime at `ensurePdpAvailable` instead, which is the chokepoint all five
mutators already go through, and at the entry points that read `this.pdp`
without it. `computeEffectiveStoreType` primes too, so attribute store
selection stops depending on whether the boot read happened to succeed - that
was only left out earlier because `started()` was gone at the time, and with it
back the extra call is a memoised no-op.

Two specs mocked core-services without `Settings`, which only showed up once
those paths started reading it.
…st use

NatsBroker ignored the dependencies every service declares - `settings` and
`license` implicitly, plus any passed to `api.registerService` - so services
that read configuration in `started()` lost the race with the monolith. The
workaround was `primeOnce`: read on first use, retry on failure. That changed
when and where those services configure themselves compared to Moleculer.

Reproduce Moleculer's `waitForServices` instead. A service is registered, and
its hooks run, only once every dependency is available: a local one as soon as
it registers, a remote one through discovery every second. It waits forever
by default, like Moleculer; a `dependencyTimeout` constructor option bounds
the wait, which the tests use so that an unmet dependency fails fast instead
of hanging.

With that in place, revert the priming in Account, OmnichannelTranscript and
AbacService back to their boot reads, and delete `primeOnce`. ddp-streamer now
declares `meteor` as a dependency, since the client versions it publishes come
from MeteorService, and loads them in `started()` again - still after the
socket server is listening, and still non fatally.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
IBroker gained emitToOne on develop (#42415) for domain events that one
instance of each listening service must handle, such as room.user-activity
reaching FederationMatrix. Each service now also subscribes to emit.<event>
in a queue group named after itself, so NATS delivers each emit to exactly
one of its instances.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
As PID 1 in a container node ignores SIGTERM without a listener, so under
NatsBroker every container was killed at the end of the grace period: no
service ran stopped(), nothing was drained, and exit hooks such as the
coverage writer never ran.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants