Class CaldavMirrorVerificationService

java.lang.Object
org.exoplatform.caldav.service.CaldavMirrorVerificationService

@Service public class CaldavMirrorVerificationService extends Object
Checks that the copies eXo pushed are still there, and still what eXo wrote — and reads back the one field the user is allowed to change on them.

The mirror calendar is a projection: eXo is authoritative and nothing ever syncs back from it, with a single deliberate exception since EXO-89681 — the owner's own PARTSTAT, so a meeting can be answered from the calendar on their phone. Until this ran, even the projection was a claim rather than a guarantee — a copy deleted from someone's phone stayed deleted, and a copy a server rewrote stayed rewritten, both silently. Nothing in eXo would ever notice, because nothing ever looked.

One ETag listing of the collection answers both questions at once, and it is the same confirm-by-reading-back discipline the mirror's own creation already needs: an href that is gone means the copy was deleted, and an ETag that moved means somebody rewrote it. Only the second case costs a fetch, and only when the ETag actually moved.

What "what eXo wrote" means. Not bytes — meaning. The copy is compared against the object eXo would render for the same event now, by IcsEquivalence, and nothing is recorded to compare against. Two byte baselines were tried and neither can be captured: a digest of what eXo sent made every copy on a re-serialising server look tampered with from the first pass, and a digest of what the server was seen to store, read back after the write, caught BlueMind mid-settle — it finished afterwards without moving the ETag, so all 19 copies of a live account were judged altered and rewritten every five minutes, for ever. A CalDAV object is a structured document and a server re-serialising it is behaving normally; the fix is to understand the content rather than to learn one server's serialisation.

The ETag also decides direction. Comparing PARTSTAT values alone cannot tell "answered on the phone" from "answered in eXo and not pushed yet" — both read as a difference. An ETag that moved away from the one this connector recorded proves the client wrote the object after eXo did, so the answer on it is the user's latest word and is adopted; an ETag that still matches proves the copy is untouched, so any difference is eXo-side and the ordinary push simply overwrites the copy. And the answer is read before the object is repaired, deliberately: when eXo changed the meeting while the user accepted it on their phone, both differ at once, and a repair-first pass would overwrite the acceptance before anything read it.

It gives up, and giving up stops the work and not only the writing. An object that keeps disappearing — a server refusing writes it pretends to accept, a rule on the account deleting what eXo sends — is left alone after a few attempts and said out loud, rather than re-pushed on every sync for ever. The count is held in memory on purpose: a restart forgives, which is the right bias when the cause is usually temporary and the alternative is a database column recording that a server misbehaved once.

That check used to sit after the object had been fetched, re-rendered, compared and named in the log, so an abandoned copy still cost a round trip and a log line every five minutes for ever — everything except the one step that could have changed something. The version each abandoned object settled at is now remembered (settled), and a pass that finds the listing still publishing it moves on without a fetch. It is the version rather than a flag deliberately: a copy the user answers on their phone moves its ETag, and that copy is examined again and its answer read, because abandonment is a statement about eXo's writing and never about the user's.

And once, when a setting changes, it looks at everything. Everything above converges towards the current render only when something moves the remote ETag — which is exactly what an administrator changing a setting does not do. Flip the answer-links switch and every copy already on the server keeps the links it was given, for ever, because no version ever moves and this pass never looks. So the registration carries a stamp of when a copy-affecting setting last changed, the mirror pair carries the value it has already applied, and a pair that is behind gets one round with the ETag gate removed: every mapped object is rendered, fetched and compared on its content. Which settings move that stamp is one decision in one place — CopySettingsFingerprint.

And when the setting that changed was where the copies go, they are moved rather than stranded. The destination is part of the same fingerprint, so the round a destination change owes is this one — and before a single content comparison runs, CaldavMirrorRelocationService points the pair at the new collection and carries the backlog across, object by object, reading any answer off a copy before the mapping stops watching it. The stamp is then written only if that finished: a copy the server would not let go of leaves the change owed, and the next sweep tries again.

Compare then repair; never a mass re-push. The round is this pass minus one gate, and nothing else. It does not write what it has not first found different: on a server that re-serialises or stamps what it stores — which is every server this add-on has met — an unconditional rewrite of everything is the churn incident of EXO-89716 and EXO-89756 back again, at the scale of a whole account at once. A converged mirror comes out of a settings round having written nothing at all, and the price it does pay is honest and bounded: one fetch per copy, once, instead of one listing.

Idempotent and resumable by construction. The stamp is written after the walk, so a crash halfway leaves it where it was and the next sweep re-runs a comparison that rewrites nothing already converged. The repair cap and the answer-before-repair ordering come for free, being the same code. Two things the round deliberately does not change: an abandoned copy at its settled version stays skipped — giving up is a statement about eXo's writing that a setting does not retract — and an answer is still only adopted off a copy whose ETag actually moved, because the direction rule is the ETag's other job and the round removes it only as a gate, never as evidence.

  • Constructor Details

    • CaldavMirrorVerificationService

      public CaldavMirrorVerificationService()
  • Method Details

    • verify

      public org.exoplatform.caldav.model.MirrorVerification verify(long userIdentityId, String username)
      Compares every copy eXo pushed for a user against what the server holds.
      Parameters:
      userIdentityId - identity of the user whose mirror is checked
      username - their eXo login, which the credentials provider maps to their account on the server
      Returns:
      what the pass found and did
    • forgetRepairs

      public void forgetRepairs(long userIdentityId)
      Forgets what is known about an account's repairs.

      The settled versions go with them: they are the second half of the same state, and a settled version left behind after the repair counts were cleared would be consulted by hasSettled(long, org.exoplatform.caldav.model.ObjectSync, java.util.Map<java.lang.String, java.lang.String>) for an object that is no longer abandoned — harmless today because the count is checked first, and a trap for the next change that reorders those two checks.

      Both maps stay keyed by user and href, and the statement the budget is now keyed on (EXO-89863) lives in the value rather than in the key. That was the deciding argument for putting it there: a key carrying a statement would have to be matched by prefix here too, and any spelling of it that this sweep did not anticipate would survive the account that owned it — an entry attributed to a user who no longer exists, in a map nothing else ever prunes. One key shape, one prefix, nothing left behind.

      Parameters:
      userIdentityId - identity of the user