Skip to content

Cross Process Cache Sync

Petrus Pradella edited this page Aug 13, 2026 · 12 revisions

Cross-Process Cache Sync (CacheSync)

What this page covers: how to keep Caching Managers fresh when several application instances share one backend. A CachingManager only sees its own writes, so when instance A updates an entity, instances B…N serve their stale copy until TTL. CacheSync closes that gap — the single entry point that wires a backend-native push feed (ChangeFeedStorage) where one exists and falls back to polling (PollingCacheSync) where it doesn't, so the same wiring works on any backend.

📌 Note — this lives in the optional everydatabase-manager add-on (…manager.sync), on top of a core capability (…changefeed). It is complementary to Optimistic Locking: optimistic locking resolves write conflicts (lock_version); cache sync resolves read staleness. The two compose — the lock version is also the freshness token polling rides on.


The problem, in one picture

flowchart LR
    A["Instance A<br/>saveAndCache(guild)"] -->|write| DB[(shared backend)]
    DB -->|"push: change feed<br/>OR poll: versions()"| S["Instance B<br/>CacheSync"]
    S -->|"SAVE → invalidate(key)<br/>DELETE → evict(key)"| M["B's CachingManager"]
    M -->|next resolve| R["reloads authoritative state"]
Loading

Without cache sync, instance B's cache is an island: it never learns that A changed the guild. With it, A's write nudges B's cell to reload on the next access.


The 30-second version

import br.com.finalcraft.everydatabase.manager.*;
import br.com.finalcraft.everydatabase.manager.cache.CachePolicy;
import br.com.finalcraft.everydatabase.manager.sync.CacheSync;
import java.time.Duration;

RefRegistry registry = new RefRegistry();
CachingManager<UUID, Guild>  guilds  = registry.manager(GUILDS,  storage, CachePolicy.always());
CachingManager<UUID, Player> players = registry.manager(PLAYERS, storage, CachePolicy.ttl(Duration.ofSeconds(30)));

CacheSync sync = CacheSync.attach(storage)
        .pollEvery(Duration.ofSeconds(10))   // only used if `storage` can't push (MySQL/MariaDB)
        .bind(guilds)
        .bind(players)
        .start();

// ... on shutdown:
sync.close();

That's the whole integration. A write through any instance now invalidates that entity's cache in every other instance bound through CacheSync. You don't need to know your backend: switching MySQL → Mongo is no code change, it just gets faster (push instead of poll).

See it end-to-end in the tests: the backend-agnostic AbstractCacheSyncTest (a writer+reader pair on one shared DB), plus CacheSyncTest (routing/own-origin) and PollingCacheSyncTest.


How it decides: push vs poll

CacheSync hides which mechanism a backend uses. It groups bound managers by their change source — the storage passed to attach(storage), or (under auto()) each manager's own CachingManager.storage() — and per group picks:

  • Push — if that source implements ChangeFeedStorage (MongoDB, PostgreSQL, InMemory, LocalFile, GroupedFile), it subscribes to the native change feed. Events arrive as writes happen; pollEvery is ignored.
  • Poll — otherwise (MySQL/MariaDB, H2) it runs a PollingCacheSync every pollEvery(...) interval, version-checking the keys currently in the cache.
CacheSync.attach(storage)
        .pollEvery(Duration.ofSeconds(10))   // required only if `storage` can't push
        .bind(guilds)
        .start();

⚠️ Gotcha — if a bound manager's backend cannot push and you set no pollEvery, start() throws IllegalStateException naming the storage and telling you to add .pollEvery(Duration) (or back the manager with a push-capable storage). It never silently does nothing.


Per-backend matrix

Push matters wherever instances genuinely share state: the networked backends, and a directory two processes both write (a network mount, or one process holding two storages). Polling works everywhere through Repository.versions(...).

Backend Push (ChangeFeedStorage) Transport Poll fallback Update detection on poll
MongoDB Change Streams (resumable; needs a replica set) n/a (push)
PostgreSQL LISTEN/NOTIFY (fire-and-forget) n/a (push)
InMemory local-write callback (per-process reference impl) n/a (push)
MySQL / MariaDB Jedis pub/sub (opt-in) ✅ (primary) ✅ versioned only
H2 Jedis pub/sub (opt-in) ❌ deletes only
LocalFile OS watch service (WatchService) ✅ (full scan) ✅ file stamp
GroupedFile OS watch service (WatchService) ✅ (full scan) ✅ file stamp

A few footnotes:

  • MongoDB is the best push: Change Streams are resumable, so an instance that briefly reconnects misses nothing within the oplog window. Both SAVE and DELETE propagate — the entity key is the document _id, so a delete event carries the key with no pre-images. Requires a replica set (already needed for Transactions); the docker-compose Mongo is a 1-node replica set, so the Mongo sync tests run by default.
  • PostgreSQL needs zero extra infra (pg_notify after each write, a dedicated listener connection), but is fire-and-forget: an instance disconnected during a NOTIFY misses it → pair the managers with a CachePolicy.ttl(...) safety net.
  • MySQL/MariaDB has no native pub/sub — polling is the intended path.
  • LocalFile / GroupedFile push through the operating system's own file-watch notification, one daemon thread per storage. This is the only feed that sees a change made outside the application — an administrator editing a YAML file by hand invalidates caches exactly like a write through the API. Two caveats: the event carries no origin (a file system has nowhere to record who wrote a file), so a local write echoes back as one self-invalidation; and on macOS the JDK falls back to an internal polling watcher with second-scale latency.
  • H2 is embedded and single-node, so cross-process sync is mostly moot; polling there catches deletes only, because it does not enforce lock_version (every existing key reports version 0, so an in-place update is invisible to the poller — see Optimistic Locking). The file backends have no lock column either, but stamp each key from its own file, so polling detects their updates too.
  • Jedis pub/sub (the Transport column) is an opt-in third mechanism giving the feedless backends real push (lower latency than polling, no per-key version-check load on the DB). See the pub/sub transport section below.

ChangeFeedStorage — the core capability behind push

Push is built on a small core capability — the same "capabilities are interfaces, not flags" idiom as Transactions and Schema Migrations (see Architecture Overview). A backend that can observe its own changes implements:

public interface ChangeFeedStorage extends Storage {
    String originId();                                  // stable id of THIS instance
    ChangeSubscription subscribe(ChangeListener listener);
}

A listener receives immutable ChangeEvents carrying just enough to act — never entity content (same privacy posture as Logging & Diagnostics):

Field Type Meaning
collection() String the changed entity's collection
key() String the key in its persisted form (key.toString())
op() ChangeOp SAVE or DELETE
version() long the lock_version after the change, or -1 when unknown/unversioned
originId() String the producing instance's originId(), or empty when the source can't attribute it

You normally never touch this — CacheSync subscribes for you. The raw subscription is the escape hatch to drive something other than a cache:

if (storage instanceof ChangeFeedStorage feed) {
    ChangeSubscription sub = feed.subscribe(event -> log.info("changed: {}", event));
    // ... sub.close() to stop; closing the storage closes all its subscriptions.
}

📌 Note — delivery is at-least-once, unordered, and (Postgres) lossy. That's safe for cache invalidation: invalidate only marks a cell stale, and the actual reload re-publishes through the cell's monotonic stamp, so a duplicate or out-of-order event can never regress a newer local write nor resurrect a newer delete. Don't use a change feed as a reliable event log.


CacheSync.attach(...) — one backend

Use attach(storage) when every bound manager lives on the same storage. It subscribes once (push) or runs one poller (poll) for the whole group:

CacheSync sync = CacheSync.attach(storage)
        .pollEvery(Duration.ofSeconds(10))   // ignored if `storage` pushes
        .bind(guilds)
        .bind(players)
        .onError(t -> log.warn("cache-sync", t))   // optional: surface key-parse / poll errors
        .start();

CacheSync.auto() — managers on different backends

When your managers are spread across different backends (the [[One Entity, Many Databases|One-Entity-Many-Databases]] pattern), auto() routes each one by its own CachingManager.storage() — push where the backend supports it, poll where it doesn't, in one binder:

CacheSync sync = CacheSync.auto()
        .pollEvery(Duration.ofSeconds(10))   // fallback for the non-push managers
        .bind(guildsOnMongo)     // -> push (change stream)
        .bind(walletsOnMySql)    // -> poll (version polling)
        .start();

📌 Noteauto() needs each manager to carry its storage, which is true for any manager built through RefRegistry.manager(...) or the CachingManager(descriptor, storage, …) constructors.


Pub/sub transport over Redis/Valkey (everydatabase-manager-jedis)

The third mechanism: an explicit pub/sub transport, decoupled from the data backend. Where a backend has no native feed (MySQL/MariaDB, H2), CacheSync would otherwise poll; a transport replaces that poll with real push over a Redis/Valkey channel — lower latency, no periodic versions(...) load on your database.

🧭 When is it worth it?

  • Use it on a feedless backend (MySQL/MariaDB/H2): the sweet spot. You go from "poll every N seconds" to instant push, and the database stops answering version-check queries on a timer.
  • 🤷 Already on Mongo, PostgreSQL, or a file backend? They have a native change feed (Change Streams / LISTEN/NOTIFY / the OS watch service) that already pushes — adding Jedis buys you little and just adds a server to run. Only reach for it to get one uniform channel across mixed backends, or to move invalidation traffic off the database. Otherwise stick with the native feed.
  • Single instance? You don't need cache sync at all.

An optional add-on module — declare it alongside everydatabase-manager and everydatabase-core (it brings the Jedis client; nothing else does). Available from 1.0.5:

implementation 'br.com.finalcraft.everydatabase:everydatabase-manager-jedis:1.3.0'
implementation 'br.com.finalcraft.everydatabase:everydatabase-manager:1.3.0'
implementation 'br.com.finalcraft.everydatabase:everydatabase-core:1.3.0'

Wiring it: .via(transport)

import br.com.finalcraft.everydatabase.manager.sync.CacheSync;
import br.com.finalcraft.everydatabase.manager.sync.CacheSyncTransport;
import br.com.finalcraft.everydatabase.manager.sync.jedis.JedisCacheSyncConfig;
import br.com.finalcraft.everydatabase.manager.sync.jedis.JedisCacheSyncTransport;

// One Jedis client speaks to both Redis AND Valkey (identical RESP wire protocol).
CacheSyncTransport transport = JedisCacheSyncTransport.connect(
        new JedisCacheSyncConfig("localhost", 6379));

CacheSync sync = CacheSync.attach(mySqlStorage)   // any backend, including feedless ones
        .via(transport)                            // push over pub/sub instead of polling
        .bind(guilds)
        .bind(players)
        .start();

// on shutdown:
sync.close();        // stops the subscription + clears the publish hooks
transport.close();   // the transport's lifecycle is yours (close it after the syncs that use it)

.via(transport) takes precedence over a native feed and over polling. Each bound manager publishes a tiny signal on every local write; the other instances invalidate/evict the matching key. The local in-memory cache stays the source of live objects — the transport only signals "reload", so it never touches the identity map (it is not a shared L2 cache of values).

flowchart LR
    A["Instance A<br/>saveAndCache(guild)"] -->|"repository.save() (MySQL/...)"| DB[(any backend)]
    A -->|"PUBLISH everydatabase:changes {coll,key,SAVE,origin}"| R[(Redis / Valkey)]
    R -->|SUBSCRIBE| B["Instance B<br/>CacheSync.via(transport)"]
    B -->|"SAVE → invalidate(key)"| M["B's CachingManager"]
Loading

⚠️ Only manager-mediated writes propagate. The signal is published from the manager's write path (saveAndCache / deleteAndEvict / saveAllAndCache). A raw repository().save(...), a Moving Data Between Backends transfer, or a migration does not publish — write through the manager if you want the invalidation to fan out.

Configuration

Minimal form needs only host + port; everything else defaults. For production use the builder:

JedisCacheSyncConfig cfg = JedisCacheSyncConfig.builder("redis.internal", 6380)
        .ssl(true)
        .username("cache-sync").password(secret)     // Redis 6+ ACL
        .connectTimeoutMs(3000).socketTimeoutMs(3000)
        .channel("myapp:changes")                    // channel isolation, see below
        .build();
Setting Default Notes
host / port — / 6379 required host; standard port
password / username none password-only AUTH, or ACL user (Redis 6+)
database 0 SELECTed on connect (pub/sub itself is server-global)
ssl false TLS
connectTimeoutMs / socketTimeoutMs 2000 / 2000 bound a hung handshake
channel everydatabase:changes the pub/sub channel; see below

Channel isolation. Pub/sub channels are global per server (not scoped by database index). If two unrelated apps (or prod + staging) share one Redis/Valkey and both keep the default channel, A's signals reach B. Give each app its own channel with .channel("myapp:changes") / config.withChannel(...) when you share a server.

📌 Note — a malformed or foreign payload on the channel (e.g. an unrelated app publishing to a colliding channel) is surfaced to your error handler (onError(...) / counted in parseFailures()), not dropped silently. So a channel collision is observable — if you see parse failures climbing, something else is publishing to your channel; give the app its own .channel(...).

Scoping by physical store (backend identity)

Since 1.1.0, a signal is scoped by the physical store it came from, not merely the collection name. Every Storage answers backendIdentity() — a stable identity of the underlying store that never contains credentials (host:port/db for a server, an absolute path plus a machine discriminator for a file/local backend). This scoping works on two levels:

  • In-memory routing (CacheSync, for every transport and the native feed): an event carries its origin backendId, and CacheSync only invalidates the managers that share that identity. Two apps that share the Redis channel but keep separate physical stores no longer cross-invalidate.
  • Physical channel (Jedis transport specifically): the effective channel is <channelBase>:<backendId>, and an instance subscribes only to the channels of the stores it actually reads. This is a second, orthogonal axis to channel(): the prefix isolates applications, the **:<backendId> suffix isolates stores within one application. The base channel is always subscribed too, so a producer that sends no backendId (a pre-1.1.0 peer) still reaches everyone — backward-compatible.

When two backends are textually different but you know they are the same physical store (two localhost:3306 from different hosts' point of view, or two file directories a shared mount makes one), or you simply want to declare a store shareable, set sharedIdentity("...") on the config — when present, it is the backend identity, on every *Config.

⚠️ Gotcha — SyncParticipation and machine-local stores. A tri-state syncParticipation (RECOMMENDED default / ALWAYS / NEVER) on every *Config gates the transport's publish side only (the native feed and the receive side are untouched). Under the default RECOMMENDED, a machine-local backend — H2 mem:, LocalFile / GroupedFile, SQL / Mongo on localhost — does not publish on the transport. That saves traffic for the common single-process case, but it is exactly the setup people reach for when simulating "two instances" on one box: two local processes syncing via Redis/Valkey over such a backend will silently stop seeing each other's writes. The fix is to tell the library they are the same store — declare the same sharedIdentity("...") on both configs, or syncParticipation(SyncParticipation.ALWAYS). ALWAYS on a machine-local backend still requires a sharedIdentity, or the bind fails fast with IllegalStateException — publishing to a channel nobody else subscribes to would be a silent no-op, so it is refused loudly rather than swallowed.

Automatic fallback to polling

If the transport drops (server unreachable), CacheSync falls back to version polling until it reconnects, then steps back to push — on by default. The fallback cadence is pollEvery(...) if set, otherwise 30s. Disable with .transportFallback(false) to rely purely on the transport + a TTL:

CacheSync.attach(storage).via(transport)
        .pollEvery(Duration.ofSeconds(15))   // fallback cadence while the transport is down
        .transportFallback(true)             // default; .transportFallback(false) to disable
        .bind(guilds).start();

📌 With the fallback enabled, use one transport per CacheSync (a transport drives a single connectivity listener). Sharing one across several syncs would leave the earlier syncs' fallback inactive — give each its own, or disable the fallback.

Many backends, one channel (auto() + via())

A single transport is backend-agnostic — in auto() mode it syncs managers spread across different backends through one channel, routing by collection:

CacheSync sync = CacheSync.auto()
        .via(transport)              // one Redis/Valkey channel for all of them
        .bind(guildsOnMySql)
        .bind(walletsOnH2)
        .bind(profilesOnMongo)
        .start();

⚠️ Because routing is purely by collection name, every bound manager must use a unique collection — two managers on the same name (even on different backends) is rejected at start().

Valkey and Redis are interchangeable here: the same Jedis client works against both unchanged (Valkey is a RESP-compatible fork). CI exercises the transport against both.


Observability

The cache and the cache-sync layer expose always-on, dependency-free metrics (counting is ~free; only reading a snapshot allocates). For the core's event log see Logging & Diagnostics.

Cache metrics — manager.stats()

CacheStats s = guilds.stats();
s.hitCount(); s.missCount(); s.hitRate();
s.loadSuccessCount(); s.loadFailureCount();
s.invalidationCount(); s.evictionCount(); s.liveSize();

Counts manager-mediated reads (resolve/peek/getAll); a Ref that has memoized its cell reads lock-free and is not counted. Never carries entity content. resetStats() zeroes the counters.

Cache-sync state — cacheSync.mode() / cacheSync.stats()

The single most useful operational signal is which mechanism you're on right now:

cacheSync.mode();                 // FEED | POLL | TRANSPORT_PUSH | TRANSPORT_FALLBACK_POLL | IDLE
cacheSync.transportConnected();   // is the transport up?
cacheSync.timeInFallbackMillis(); // total time the transport has spent disconnected

CacheSyncStats st = cacheSync.stats();
st.signalsReceived(); st.signalsApplied(); st.signalsSkippedOwnOrigin();
st.signalsUnmapped(); st.parseFailures();

📌 Note — while a transport's push is live, mode() (and stats()) report TRANSPORT_PUSH — the steady state of a wired-and-connected transport. TRANSPORT_FALLBACK_POLL shows up only once the sync has actually fallen back to polling on a transport drop — not when fallback is merely disabled or the transport hasn't reported connectivity yet.

Register a CacheSyncObserver to be notified on transitions (e.g. log "transport down → fallback"):

cacheSync.observe(new CacheSyncObserver() {
    @Override public void onTransportDisconnected() { log.warn("cache-sync: transport down, polling"); }
    @Override public void onTransportConnected()    { log.info("cache-sync: transport back, push");   }
    @Override public void onModeChange(CacheSyncMode mode) { meter.gauge("cachesync.mode", mode); }
});

The Jedis transport also exposes its own counters: transport.publishCount(), transport.publishFailureCount(), transport.reconnectCount(), transport.connected().

📌 Don't instrument "drops". A dropped signal on a fire-and-forget bus is unobservable by the receiver — what's actionable is publish failures, reconnects, and time in fallback, which is exactly what's exposed. A TTL policy is the self-heal for the drops you can't see.


Recovering the key: KeyParsers and custom parsers

The push path receives the key as a String (its persisted toString()) and must turn it back into the cache's K. The built-in KeyParsers cover the common key-contract types out of the box — String, UUID, Long, Integer — so bind(manager) just works for those:

sync.bind(guilds);   // UUID key -> UUID.fromString, resolved automatically

For a composite / record / wrapper key (no general inverse of toString()), pass an explicit String -> K parser:

sync.bind(sessions, str -> Session.Id.parse(str));   // your own key parser

📌 Note — the parser is only used on the push path; polling compares cached keys directly and never re-parses. An unparseable key is routed to onError(...) and skipped — it never throws into the backend's delivery thread and never breaks the feed.


Own-origin skip (and when to disable it)

By default CacheSync skips events this instance produced — when event.originId() matches the attached storage's originId(). Your own write already refreshed your cache write-through, so re-invalidating it would just cause a wasted reload.

CacheSync.attach(storage).pollEvery(d).bind(guilds).includeOwnOrigin().start();

Call .includeOwnOrigin() to process your own events too — for the rare topology of several caches over one storage in one process (or an in-process test where the writer's own change must still fan out).

📌 Note — when the source can't attribute origin (Mongo's oplog, a DB trigger), the event's origin is empty and the skip simply never fires. The instance then reloads its own just-written key — harmless (write-through already had the value; the reload re-reads the same state), just one extra read. The skip only kicks in where the library authors the payload (Postgres, InMemory).


PollingCacheSync — the pull primitive

For backends without a push feed, CacheSync's fallback delegates to PollingCacheSync. You can also use it directly when you want polling without going through the facade:

import br.com.finalcraft.everydatabase.manager.sync.PollingCacheSync;

PollingCacheSync poller = PollingCacheSync.every(Duration.ofSeconds(10))
        .bind(guilds)
        .bind(players)
        .start();
// ... poller.close() on shutdown.

Each tick reads the current lock_version of every bound manager's currently cached keys — a cheap key+version read, bounded by cache size, not table size — via the repository primitive:

CompletableFuture<Map<K, Long>> versions(Collection<K> keys);   // key + version only, never the body

and then:

  • invalidates a key whose backend version increased since the last poll (another instance wrote it), so the next read reloads it;
  • evicts a key cached here but missing from the backend (deleted elsewhere).

Polling limitations (all documented on the class)

  • Latency is one poll interval — not instant.
  • Updates need versioning. Detecting an in-place update requires a versioned descriptor (@OptimisticLock). A non-versioned descriptor — or H2 — reports version 0 for every existing key, so polling then catches only deletes.
  • First-observation gap. A key is assumed as fresh as the backend the first time it is polled (it was usually just loaded); a write in the brief window between a cache load and that key's first poll can be missed. All later writes are caught — pair with a CachePolicy.ttl(...) if that window matters.

💡 TippollOnce() (on both CacheSync and PollingCacheSync) runs one cycle synchronously. It's how the tests stay deterministic instead of waiting a tick; production relies on the scheduled interval.


Lifecycle & threading

  • start() is idempotent; so is close(). Bind every manager before start()bind(...) after start throws.
  • Push runs on the backend's listener thread (Mongo change-stream cursor / Postgres LISTEN connection); poll runs on a single daemon thread named everydatabase-cache-poller. Neither keeps the JVM alive — but always close() your CacheSync on shutdown to stop them cleanly. See Concurrency & Threading.
  • isRunning() reports whether the sync started with at least one push or poll group.

Guarantees (and the safety net)

Property What it means
At-least-once an event may be duplicated or reordered; the cell stamp makes that harmless
Lossy (Postgres) a NOTIFY can be dropped on disconnect → keep a TTL as the safety net
Safe by construction invalidate only marks stale; the reload reads authoritative state, stamp-guarded
Eventually consistent other instances converge on the next read after an event (push) or poll tick

🧭 Decision — wire CacheSync whenever more than one instance writes the same data through a shared backend. Use push backends (Mongo/Postgres) for near-instant invalidation; on a poll backend pick an interval you can tolerate as staleness, and always keep a CachePolicy.ttl(...) as the backstop for a dropped or missed event. Single-instance deployment? You don't need this at all.


See also

Clone this wiki locally