Skip to content

Cross Process Cache Sync

Petrus Pradella edited this page Jun 23, 2026 · 1 revision

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 ever sees its own writes, so when instance A updates an entity, instances B…N keep serving their stale cached copy until TTL. CacheSync closes that gap — it's 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 the same 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 you 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), it subscribes to the backend-native change feed. Events arrive as the writes happen; pollEvery is ignored.
  • Poll — otherwise (MySQL/MariaDB, H2, LocalFile) 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

The only backends where "many instances share state" is real are the networked ones, so that's where push matters. 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 ✅ (primary) ✅ versioned only
H2 ❌ deletes only
LocalFile (v1) ✅ (full scan) ❌ deletes only

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 it's fire-and-forget: an instance disconnected during a NOTIFY misses it → pair the managers with a CachePolicy.ttl(...) as the safety net.
  • MySQL/MariaDB has no native pub/sub — polling is the intended path there.
  • H2 / LocalFile are embedded/single-node, so cross-process sync is mostly moot; polling there catches deletes only because they don't enforce lock_version (every existing key reports version 0, so an in-place update is invisible to the poller — see Optimistic Locking).

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 directly — CacheSync subscribes for you. The raw subscription is the escape hatch if you want 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.


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(). The reasoning: your own write already refreshed your own 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 new 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. Use versioned entities for update propagation.
  • 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 landing 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