Skip to content

Commit a955b73

Browse files
committed
cross node bus post
1 parent 8f3126f commit a955b73

2 files changed

Lines changed: 174 additions & 0 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
---
2+
title: 'Cross-node bus: finally, pub/sub that actually crosses nodes'
3+
author: [gallayl]
4+
tags:
5+
[
6+
'Architecture',
7+
'distributed-systems',
8+
'redis',
9+
'entity-sync',
10+
'rest-service',
11+
'identity',
12+
'cross-node-bus',
13+
]
14+
date: '2026-06-09T12:00:00.000Z'
15+
draft: false
16+
image: img/021-cross-node-bus.png
17+
excerpt: 'FuryStack now ships a real cross-node event bus and a Redis Streams adapter, so identity invalidation and entity sync finally work across pods instead of just within one lonely process.'
18+
---
19+
20+
If your FuryStack app has ever been deployed as more than one pod, you already know the classic bug:
21+
22+
- user logs out on Pod A
23+
- Pod B keeps serving stale identity until the cache TTL expires
24+
- the whole system acts like the other nodes are imaginary friends
25+
26+
That illusion is over.
27+
28+
## What shipped
29+
30+
The new release adds a tiny public bus abstraction in `@furystack/cross-node-bus` plus a real Redis Streams adapter in `@furystack/redis-cross-node-bus`.
31+
32+
That means the framework now has one shared, transport-agnostic primitive for:
33+
34+
- identity events (`IdentityEventBus`)
35+
- entity change broadcasts (`EntityChangeBus`)
36+
- app-defined typed facades over the bus
37+
38+
The implementation is intentionally straightforward, because a distributed event bus should be predictable and reliable before it tries to be clever.
39+
40+
## Why this matters
41+
42+
Before this, FuryStack's internal `EventHub` and cache invalidations were strictly process-local. The app could run on N pods behind a load balancer, and every cross-cutting channel still behaved like a single-node app.
43+
44+
The new bus changes that:
45+
46+
- logout and session invalidation events now propagate across nodes
47+
- entity change notifications now fan out from one write node to every sibling
48+
- adapters can assign sequence ids, support replay, and let reconnecting consumers catch up without guessing
49+
50+
In other words: the system now treats the fleet as one cooperative actor instead of N independent silos.
51+
52+
## The simple API that makes this work
53+
54+
The core package defines one shared token: `CrossNodeBus`.
55+
56+
That token exposes a small interface:
57+
58+
- `publish(topic, payload)`
59+
- `subscribe(topic, handler)`
60+
- `subscribeRemoteOnly(topic, handler)`
61+
- `subscribeForeign(prefix, topic, handler)`
62+
- `replay(topic, fromSeq)`
63+
64+
And yes, it also exposes a stable `nodeId` and a capability descriptor so the framework can refuse to start if the bound adapter does not support the features it needs.
65+
66+
That last bit is important. `EntityChangeBus` requires `replay` and `assignsSequence`, and we now fail loudly at startup instead of letting a non-replaying adapter serve stale data forever.
67+
68+
## What the packages do
69+
70+
### `@furystack/cross-node-bus`
71+
72+
This is the abstraction. It includes:
73+
74+
- the `CrossNodeBus` token
75+
- `InProcessCrossNodeBus` default factory for single-node apps
76+
- a shared `BusMessage` envelope with `originId`, `emittedAt`, optional `seq`, and version pinning
77+
- a lightweight in-process replay ring buffer for reconnecting subscribers
78+
- a testing harness for multi-instance in-process simulations
79+
80+
The default adapter is intentionally unexciting. If you do not bind a transport adapter, the bus still works in one process exactly like before.
81+
82+
### `@furystack/redis-cross-node-bus`
83+
84+
This is the production-grade adapter that actually talks to Redis Streams.
85+
86+
It supports:
87+
88+
- persistence
89+
- server-assigned sequence ids
90+
- replay from an arbitrary stream position
91+
- prefix-based multi-service isolation
92+
- explicit cross-prefix subscription via `subscribeForeign`
93+
94+
The Redis adapter also duplicates the Redis client for the read loop, so every subscriber can boot without requiring consumer groups or orphaned group state.
95+
96+
## Identity and entity sync now live on the same shared bus
97+
98+
The two framework facades that landed with this release are the ones where the difference is most visible:
99+
100+
- `IdentityEventBus` in `@furystack/rest-service` now publishes logout/invalidation events to the bus and invalidates cached user resolution across nodes.
101+
- `EntityChangeBus` in `@furystack/entity-sync-service` now publishes model-level deltas on the bus and uses replay to restore reconnecting clients.
102+
103+
That means a logout on one node invalidates the same cache entry everywhere, and a model update on one node is no longer invisible to the rest of the cluster.
104+
105+
## What “cross-node” actually looks like
106+
107+
The implementation is deliberately opinionated about one thing: the bus is a broadcast notification layer, not a distributed state store.
108+
109+
So:
110+
111+
- the bus carries events, not entire object graphs
112+
- receivers still re-read their local store when they need the canonical state
113+
- duplicates are fine, because the system is already designed for them
114+
- ordering is only guaranteed per-publisher, not globally across the fleet
115+
116+
That is the practical design FuryStack needs right now: a broadcast notification layer, not a distributed state machine.
117+
118+
## How apps should use it
119+
120+
App authors do not need to use low-level Redis or wire-format details. The recommended pattern is:
121+
122+
- bind `CrossNodeBus` to a transport adapter
123+
- define a typed facade over the bus
124+
- publish typed domain events on that facade
125+
- subscribe using the facade's local event API
126+
127+
This is the same pattern the framework facades already use, and it means custom app buses can be built with the same DX as `EventHub<T>` while still getting cross-node delivery.
128+
129+
Example sketch:
130+
131+
```ts
132+
const AppEventBus = defineService({
133+
name: 'my-app/AppEventBus',
134+
lifetime: 'singleton',
135+
factory: ({ inject, onDispose }) => {
136+
const bus = inject(CrossNodeBus);
137+
const local = new EventHub<{ event: AppEvent }>();
138+
139+
const handle = bus.subscribe('app/events', message => {
140+
local.emit('event', message.payload as AppEvent);
141+
});
142+
143+
onDispose(() => handle[Symbol.dispose]());
144+
145+
return {
146+
subscribe: (handler: (e: AppEvent) => void) => local.subscribe('event', handler),
147+
publish: (event: AppEvent) => bus.publish('app/events', event),
148+
};
149+
},
150+
});
151+
```
152+
153+
This release ships that pattern in the framework and makes it the recommended extension point for app-defined coordination.
154+
155+
## So what changed for release users?
156+
157+
If you are already on FuryStack 2026, the new bus means:
158+
159+
- single-node apps keep working as before
160+
- multi-node apps can bind `@furystack/redis-cross-node-bus`
161+
- identity invalidation becomes fleet-wide instead of pod-local
162+
- entity sync uses replay and sequence numbers instead of fragile local changelogs
163+
164+
If your app needs cross-service isolation, `topicPrefix` gives each service its own wire namespace, and `subscribeForeign` makes cross-service eavesdrop explicit.
165+
166+
## Final note
167+
168+
This is the kind of feature that is boring to write and exciting to use.
169+
170+
You do not need a new framework API for every cross-node event you want to publish. You only need a small shared bus, a typed facade, and a shipping-quality adapter.
171+
172+
`@furystack/cross-node-bus` is that shared bus. `@furystack/redis-cross-node-bus` is the adapter that makes it real.
173+
174+
If your app runs on more than one pod, go try it. If your app does not, congrats — no upgrade urgency, but the package still ships with a really clean in-process default.
2.15 MB
Loading

0 commit comments

Comments
 (0)