Sync Cluster Bus
Fan @absolutejs/sync updates across every node in a multi-instance deployment. Two first-party adapters speak the same ClusterBus interface — Postgres LISTEN/NOTIFY (with overflow spill) and Redis pub/sub (with native geo-replication). Pick by what you're already running.
#Why a Cluster Bus
A single SyncEngine instance fans out changes to its own subscribers in-process. Multiple instances behind a load balancer each see only their own writes — without a cluster bus, two clients hitting different nodes never converge.
engine.connectCluster(bus) wires the commit stream to the bus — every committed change becomes a ClusterMessage envelope broadcast to every other instance.The bus interface itself is minimal — two methods:
type ClusterBus = {
publish(message: ClusterMessage): Promise<void>;
subscribe(
handler: (message: ClusterMessage) => void,
): Promise<() => Promise<void>>;
};#Adapters
Both adapters implement the same ClusterBus interface, so swapping is one constructor change — the engine doesn't know which bus is underneath.
| Package | Version | Transport | Description |
|---|---|---|---|
@absolutejs/sync-bus-pg | 0.2.3 | Postgres LISTEN/NOTIFY | Horizontal scale on the Postgres you already run — no extra infrastructure. An overflow spill table handles payloads above the 8KB NOTIFY cap. |
@absolutejs/sync-bus-redis | 0.1.1 | Redis PUBLISH/SUBSCRIBE | Faster fan-out and a native geo-replication story on managed Redis. Works with any Redis client (ioredis, node-redis, …) via a narrow interface. |
#Postgres Quick Start
@absolutejs/sync-bus-pg uses pg_notify + LISTEN with an overflow spill table for payloads above the 8KB NOTIFY cap.
import postgres from 'postgres';
import { createPostgresClusterBus } from '@absolutejs/sync-bus-pg';
import { createSyncEngine } from '@absolutejs/sync';
const sql = postgres(process.env.DATABASE_URL!);
const bus = createPostgresClusterBus({ sql });
const engine = createSyncEngine({ instanceId: 'shard-A' });
await engine.connectCluster(bus);
// Optionally prune the spill table on a schedule:
setInterval(() => bus.vacuum(60_000), 60_000);#Postgres Adapter
Postgres's native pub/sub means no extra infrastructure if you already run PG, and delivery of inline broadcasts is at-least-once on local commit. createPostgresClusterBus() accepts:
| Option | Default | Description |
|---|---|---|
sql | required | A postgres.js client. |
channel | 'absolutejs_sync_cluster' | The NOTIFY channel name. |
spill | 'overflow' | Strategy for oversized payloads — 'overflow', 'always', or 'never'. |
onError | — | Hook that fires on subscribe-side failures. |
Three spill strategies cover different durability / speed trade-offs:
bus.vacuum(olderThanMs) prunes spill rows older than the cutoff, on a schedule you control. Inline messages never touch the table — only the rare oversized batch does.
NOTIFY broadcasts to every listener, including the publisher's own, so a spill row must outlive the broadcast. Deleting on consume would race other listeners off the row — pruning by age is the safe policy.#Postgres Metrics
bus.metrics() returns cumulative counters since createPostgresClusterBus(). A healthy small-payload workload keeps publishedSpilled near zero.
spillFetchFailed means vacuum() is racing the receivers — widen the vacuum window or shrink the spill rate.#Redis Quick Start
@absolutejs/sync-bus-redis uses PUBLISH / SUBSCRIBE with a dedicated subscriber connection.
import Redis from 'ioredis';
import { createRedisClusterBus } from '@absolutejs/sync-bus-redis';
import { createSyncEngine } from '@absolutejs/sync';
const publisher = new Redis(process.env.REDIS_URL!);
const subscriber = publisher.duplicate(); // MUST be a separate connection
const bus = createRedisClusterBus({ publisher, subscriber });
const engine = createSyncEngine({ instanceId: 'shard-A' });
await engine.connectCluster(bus);#Redis Adapter
No 8KB payload cap, lower latency at high subscriber counts, and native geo-replication on managed Redis. Delivery is at-most-once — a disconnected subscriber misses messages while down; pair with engine.exportChangeLog() for shard-reboot resume. createRedisClusterBus() accepts:
| Option | Default | Description |
|---|---|---|
publisher | required | Any RedisCommandClient — used for PUBLISH. |
subscriber | required | A dedicated connection for SUBSCRIBE — publisher.duplicate(). |
channel | 'absolutejs_sync_cluster' | The pub/sub channel name. |
onError | — | Hook that fires on subscribe-side failures. |
duplicate() and node-redis's createClient() both give you a second connection.The narrow RedisPublisher + RedisSubscriber interfaces mean the adapter doesn't peer-dep a specific client — the README shows the wrapping for ioredis (EventEmitter-based) vs node-redis (callback-based).
#Redis Metrics
bus.metrics() returns cumulative counters since createRedisClusterBus():
PUBLISH won't error when the cluster unwires. A drop to 0 when you expect peers means subscribers disconnected — replication lag, a network partition, or a region failover dropping the duplex. Pair with engine.metrics() (totalSubscriptions across instances) to spot the cluster halving silently.#Choosing an Adapter
Pick by deployment, not in the abstract — the adapter that rides infrastructure you already run is almost always the right one, and swapping later is one constructor change.
| sync-bus-pg | sync-bus-redis | |
|---|---|---|
| Delivery | At-least-once on local commit for inline broadcasts. | At-most-once — a disconnected subscriber misses messages while down. |
| Payload size | 8KB NOTIFY cap; the spill table carries anything larger. | No cap. |
| Fan-out latency | Fine at small subscriber counts. | Lower at high subscriber counts (10+). |
| Cross-region | Not a fit — PG logical replication is too heavy for bus traffic. | Native geo-replication on managed Redis (Redis Cluster, ElastiCache Global Datastore, Memorystore, Upstash). |
| Extra infrastructure | None if you already run Postgres for the durable store. | None if you already run Redis for cache / queue / rate-limit. |
| Replay | The spill table can hold oversized payloads forensically. | Pair with engine.exportChangeLog() for shard-reboot resume. |