Skip to content

Write Back and Conflict Resolution

Petrus Pradella edited this page Jul 15, 2026 · 1 revision

Write-Back & Conflict Resolution (everydatabase-manager)

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 optional everydatabase-manager add-on. This is the layer beneath CachingManager.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.


The problem it exists to solve

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.


The 30-second version

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.


The input contract (get this wrong and you lose writes)

⚠️ Gotchaentities must 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.


FlushMode — who gets told about a failure

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 StorageWriteException is 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.

Conflict resolution: the five branches

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 afterAdoptADOPT_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.

Re-installing the canonical cell

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.


ConflictHooks — the per-type seams

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.

⚠️ Gotchalock(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 — the canonical adopt

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.


The ahead-write guard

public boolean refuseAheadWrite(Object entity, String what, Object key);   // true => do NOT flush

Refuses 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.


Health counters

long lastWriteFailureAt();        // epoch millis of the last failed write; 0 = none
int  drainWriteFailureCount();    // failed writes since the last call, resetting it

drainWriteFailureCount() 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).


See also

Clone this wiki locally