-
Notifications
You must be signed in to change notification settings - Fork 1
Write Back and Conflict Resolution
What this page covers: WriteBackFlusher — the generic persist + conflict-resolution engine over
a CachingManager. It writes an already-collected dirty set in one batch, reacts per key (transient
error → retry later; lock conflict → adopt the winner; success → callback), and always re-installs
the same live instance as the canonical cached cell so references held elsewhere stay flushable.
📌 Note — package
br.com.finalcraft.everydatabase.manager.writeback, in the optionaleverydatabase-manageradd-on. This is the layer beneathCachingManager.flushDirty(): read Caching Managers → Write-back first for dirty tracking and the manager-level flush. Reach for the flusher when you own the flush pipeline.
saveAllAndCache evicts a conflicted cell. Without a deliberate re-install, a reference held by
other code would keep accumulating changes into an instance the cache no longer knows about — and
those changes would be silently lost. Every branch of this engine therefore ends the same way:
the same held instance goes back in as the canonical cell.
That is the subtle part, and it's why this isn't left to each consumer to re-implement.
import br.com.finalcraft.everydatabase.manager.writeback.*;
WriteBackFlusher flusher = new WriteBackFlusher(myManagerLog); // null log => ManagerLog.SILENT
// The caller collects + mark-cleans the dirty set first (see the input contract below).
flusher.persistBatch(accounts, dirtyAccounts, FlushMode.BACKGROUND, "account",
AccountConflictHooks.INSTANCE,
acc -> acc.setPersisted(true)) // onPersisted; may be null
.join();public <K, V extends IDirtyable> CompletableFuture<Void> persistBatch(
CachingManager<K, V> manager, List<V> entities, FlushMode mode,
String what, ConflictHooks<K, ? super V> hooks, Consumer<? super V> onPersisted);what is a human-readable id of the entity kind, used in logs and exception messages.
See every branch pinned in
WriteBackFlusherTest.
⚠️ Gotcha —entitiesmust already be collected and mark-cleaned by the caller before the call. Clearing the flag before persisting is precisely what makes a concurrent change re-mark the entity and get picked up by the next flush instead of being lost. The collection pass owns the entity's lock and any veto (a frozen or read-only type). The flusher re-marks dirty on failure only.
Instantiate one flusher per flush pipeline — it owns the health counters. It does not serialize anything itself: run at most one flush at a time per manager, since two flushers over one manager would race on the same dirty set.
The two flush paths want opposite things: the periodic pass has nobody to report to and must keep running; an explicit flush is a durability request whose caller has to learn the write didn't land.
| Mode | On failure | Use for |
|---|---|---|
BACKGROUND |
logs only; the returned future always completes normally (retry on the next flush) | the periodic/shutdown pass |
FORCED |
the future completes exceptionally | a caller-initiated save |
Under FORCED:
- a write that did not land →
StorageWriteException; - a lost race →
OptimisticConflictException; -
both → the
StorageWriteExceptionis primary and the conflict is attached as a suppressed exception. A caller-initiated save must never report success for a write that did not land.
When the batch report flags a key as conflicted, the flusher re-reads the stored winner and decides under the live instance's lock, in exactly this order:
| # | Condition (tested in order) | What happens | Adopts? |
|---|---|---|---|
| 1 | the re-read of the winner itself failed | re-mark dirty; the whole race retries on the next flush | nothing |
| 2 | the winner row vanished (deleted between the failed save and the re-read) |
resetLockForRecreate + re-mark dirty, so the next flush re-creates the row |
nothing |
| 3 | the type merges (mergesOnConflict()) |
adoptStoredState combines both sides and owns the entire resolution — it runs ahead of the dirty check and gets no afterAdopt
|
both sides |
| 4 | the live instance was re-dirtied while resolving | adopt only the winner's lock version and keep the local values, so the next flush wins cleanly instead of conflicting forever | lock version only |
| 5 | the live instance is still clean | copy the winner's state into it and run afterAdopt — ADOPT_WINNER
|
full state |
Every branch re-installs the same held instance as the canonical cached cell.
ADOPT_WINNER (branch 5) is the heart of the design: the stored winner is re-adopted into the same
live instance, under that instance's lock, and re-installed as the canonical cell. Held references
keep pointing at a valid, current, still-flushable object — no swap, no orphan.
📌 Note — branch 4 exists to break a livelock. If a re-dirtied instance adopted the winner's values, the local mutation would vanish; if it kept the winner's lock version too, it would conflict forever. Taking the values from one side and the lock version from the other is what makes the next flush win cleanly.
seedIfAbsent is keep-first, so a concurrent resolve may have cold-loaded a foreign copy into the
window. The flusher evicts that duplicate (loudly, if it already carries unsaved changes) and retries
the seed, keeping every held reference flushable. If it still can't win, it gives up loudly
(a SEVERE line naming the risk that held references may no longer be flushed) rather than silently
orphaning the held instance — and without failing the flush.
The flusher knows the protocol; the implementation knows the concrete entity. One implementation per entity type, usually a singleton.
public interface ConflictHooks<K, V extends IDirtyable> {
K storageKey(V live); // the key its manager's descriptor extracts
ReentrantLock lock(V live); // the very lock the entity's own mutators take
void adoptStoredState(V live, V stored); // copy the winner in, keeping live's identity
void adoptStoredLockVersion(V live, V stored); // branch 4: lock version only
void resetLockForRecreate(V live); // branch 2: let the next flush re-create the row
default void afterAdopt(V live) { } // branch 5 only
default boolean mergesOnConflict() { return false; }
}Resolution calls every method with the entity's lock held — including mergesOnConflict(), which
is queried inside the resolution to pick the branch. The only exceptions are storageKey (called
while the batch is being keyed) and lock itself, which is what hands the lock over to be taken.
⚠️ Gotcha —lock(V)must return the very lock the entity's own mutators take. The resolution decides and mutates under it; a different lock silently buys nothing.
mergesOnConflict() is the right answer when a whole-row adopt would drop what the other instance
wrote (think a counter both sides incremented). It hands the whole resolution to adoptStoredState,
which must then combine both sides, re-mark dirty, and adopt the winner's lock version itself.
PersistedState.copyInto(live, stored);The building block of an adoptStoredState implementation, so every type adopts the same way and
none drifts when a persisted field is added. It reflectively copies every persisted (Jackson-visible)
field across the whole class hierarchy: non-static, non-transient, non-@JsonIgnore. Runtime
wiring — locks, dirty flags, attached references — is untouched, which is exactly why the live
instance survives the copy with its identity intact. Both instances must be the same concrete type.
public boolean refuseAheadWrite(Object entity, String what, Object key); // true => do NOT flushRefuses to persist an entity written by a newer payload schema version. Its decode already dropped the newer fields (the codec ignores unknown properties), so flushing it would permanently erase them while keeping the newer version stamp. The caller is expected to skip the entity: it stays dirty and cached, and this process is effectively read-only for that row until it's updated. Warns once per type.
Entities that don't implement EntitySchema are never refused — a consumer that doesn't use
payload versioning can ignore this guard entirely. See
Payload Schema Evolution.
long lastWriteFailureAt(); // epoch millis of the last failed write; 0 = none
int drainWriteFailureCount(); // failed writes since the last call, resetting itdrainWriteFailureCount() resets on read by design, so a periodic tick logs one aggregate line
instead of a per-key flood (the per-key detail stays at FINE).
-
Caching Managers — dirty tracking,
flushDirty,BatchSaveReport, and the freeze that vetoes a collection pass. -
Payload Schema Evolution — the schema axis behind
refuseAheadWrite. -
Optimistic Locking — the
lock_versionthe whole conflict path is built on, and which backends enforce it. - Caching & References — write-through, the default this is the alternative to.
- Cross-Process Cache Sync — how another instance's write reaches your cache in the first place.
-
The Async API — the
CompletableFuturemodel and howFORCEDfailures surface.
EveryDatabase · Home · made by Petrus Pradella
Getting Started
Core Concepts
Working with Data
Backends
- Choosing a Backend
- MySQL & MariaDB
- PostgreSQL
- H2
- MongoDB
- Local Files
- Grouped Files
- In-Memory
- Benchmarks
Manager Module
- Caching & References
- Typed References (Ref)
- Caching Managers
- Cache Policies & Freshness
- Cross-Process Cache Sync
- Write-Back & Conflict Resolution
- Payload Schema Evolution
- One Entity, Many Databases
Operations
Advanced
Reference
Contributing