-
Notifications
You must be signed in to change notification settings - Fork 1
Cross Process Cache Sync
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-manageradd-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.
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"]
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.
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), plusCacheSyncTest(routing/own-origin) andPollingCacheSyncTest.
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), itsubscribes to the backend-native change feed. Events arrive as the writes happen;pollEveryis ignored. -
Poll — otherwise (MySQL/MariaDB, H2, LocalFile) it runs a
PollingCacheSynceverypollEvery(...)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 nopollEvery,start()throwsIllegalStateExceptionnaming the storage and telling you to add.pollEvery(Duration)(or back the manager with a push-capable storage). It never silently does nothing.
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_notifyafter each write, a dedicated listener connection), but it's fire-and-forget: an instance disconnected during aNOTIFYmisses it → pair the managers with aCachePolicy.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 version0, so an in-place update is invisible to the poller — see Optimistic Locking).
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:
invalidateonly 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.
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();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();📌 Note —
auto()needs each manager to carry its storage, which is true for any manager built throughRefRegistry.manager(...)or theCachingManager(descriptor, storage, …)constructors.
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 automaticallyFor 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.
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).
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 bodyand 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).
- 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 version0for 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.
💡 Tip —
pollOnce()(on bothCacheSyncandPollingCacheSync) runs one cycle synchronously. It's how the tests stay deterministic instead of waiting a tick; production relies on the scheduled interval.
-
start()is idempotent; so isclose(). Bind every manager beforestart()—bind(...)after start throws. - Push runs on the backend's listener thread (Mongo change-stream cursor / Postgres
LISTENconnection); poll runs on a single daemon thread namedeverydatabase-cache-poller. Neither keeps the JVM alive — but alwaysclose()yourCacheSyncon shutdown to stop them cleanly. See Concurrency & Threading. -
isRunning()reports whether the sync started with at least one push or poll group.
| 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
CacheSyncwhenever 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 aCachePolicy.ttl(...)as the backstop for a dropped or missed event. Single-instance deployment? You don't need this at all.
-
Caching Managers — the
invalidate/evictsink that cache sync drives, and the cell/stamp model that makes a late invalidation safe. - Cache Policies & Freshness — the TTL safety net for fire-and-forget and poll backends.
-
Optimistic Locking — the
lock_versiontoken polling rides on; the complementary write-side guard. -
One Entity, Many Databases — managers across backends, the
auto()case. - MongoDB · PostgreSQL · MySQL & MariaDB — per-backend transport details.
- Concurrency & Threading — the listener/poller threads.
- Design rationale:
specs/SPEC_cache_sync.md; the Redis push add-on brief:specs/SPEC_cache_sync_redis.md.
EveryDatabase · Home · made by Petrus Pradella
Getting Started
Core Concepts
Working with Data
Backends
Manager Module
- Caching & References
- Typed References (Ref)
- Caching Managers
- Cache Policies & Freshness
- Cross-Process Cache Sync
- One Entity, Many Databases
Operations
Advanced
Reference
Contributing