Class CaldavEventPropagationService
The half of the feature that was never built. A copy was written when somebody asked for it — the organiser's browser, on saving — and then never written again by anything an edit could reach. So an organiser who moved a meeting moved it on their own phone and on nobody else's: the attendees' copies kept the original time, the old location and the old video link, and their owners acted on them.
It was masked, on one family of servers, by a defect: BlueMind re-serialises what it is handed, every copy therefore looked tampered with, and the verification pass rewrote all of them on every sweep. That accident is what delivered updates. EXO-89716 stops it — correctly — and on a byte-stable server such as Stalwart there was never anything to stop.
What it writes to, and what it refuses to
For an edit or a deletion: only where a copy already exists. The set is read from the mapping table, not from agenda's attendee list, and that is the whole guard: an attendee who has never had a copy of this meeting does not acquire one from somebody editing it. Conflating the two would push a year of past meetings at the first person to connect an account.
For a creation the set is agenda's attendee list, because handing out a
copy is precisely what a creation means — and it is bounded by construction:
one meeting, the people invited to it, once. What each of them gets is still
not decided here: propagateCreation(long, long) asks
CaldavPendingInvitationService, which already owns that question for
the background seeding pass, so the answer does not depend on which path
arrived at it.
A mapping row with no href is not a copy. It is the tombstone a removal leaves, and treating it as a destination would re-create on the server exactly the object somebody deleted.
Cost
Per edit: one indexed query per page of holders, then per holder one GET (the object is merged into, never replaced), one PUT, and one GET to read back what the server stored. Three round trips per attendee who has a copy — attendees who have none cost nothing at all, because they are not in the query's answer. A holder whose server is unreachable costs one timeout and does not touch the others.
It runs on the listener's asynchronous thread and fans out on that same
thread, deliberately: a thread of its own would leave the kernel's container
behind, and the transactional write that records what was pushed would then
fail as a warning nobody reads. The one exception is the members of an
invited space, whose number follows the space rather than the roster: they
are handed to a thread of this add-on's own, whose task establishes the
container itself (seedSpaceMembersInContainer(long, java.util.Set<java.lang.Long>)).
What happens when a write does not land (EXO-89773)
It is recorded, and tried again. Before this, a push that failed left nothing behind but a log line saying the verification pass would retry — and that pass never could. Its first gate compares the version the server publishes for a copy against the version eXo recorded, and an edit that never reached the server does not move the server's version, so the copy was judged untouched before anything was fetched or compared. The copy stayed wrong for ever, and nothing said so. A failed removal had no safety net at all: a destroyed event renders to nothing, and the verification pass deliberately concludes nothing from an empty render, so no pass would ever take that meeting out of somebody's calendar.
It was masked until this week by the same accident that masked the missing listeners: BlueMind moved every ETag, so every copy looked altered and the repair pass delivered pending edits as a side effect. EXO-89716 and EXO-89756 removed that churn, correctly, and with it a safety net nobody knew was load-bearing.
So the obligation is written down on eXo's own side, before the write
is attempted, as a row of CALDAV_PENDING_PUSH: this copy does not yet
show what eXo holds. A write that lands deletes it. A write that does not
leaves it, and retryOwedPushes(long) — run by the background sweep, one
account at a time — attempts it again, up to a bound, so a server that
refuses for ever is argued with a few times and then left alone and said out
loud.
Recorded before rather than after, and that ordering is the whole of what it
buys over a catch block: a PUT that times out ambiguously, a thread
killed mid-fan-out and a platform restarted between two attendees all leave
no exception for anybody to catch, and all of them leave the obligation
standing. The cost is one small write per holder per edit on the happy path,
against the three network round trips that holder already costs.
What it deliberately does not do. It does not re-render every copy on every sweep to find out which are behind — that is precisely the churn EXO-89716 and EXO-89756 spent two days removing. A converged account has an empty obligation table and the retry costs it one index lookup that answers no rows.
-
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescriptionvoidchangedOnTheServer(long eventId, long objectSyncId) Announces that the next change to this event is the server's own, read through the given mapping, so that copy is not written back to.voidnotChangedAfterAll(long eventId) Withdraws an announcement whose change was never applied.longowedCopies(long userIdentityId) How many of a user's calendar copies eXo holds a write for and has not managed to make.intpropagateAnswer(long eventId, long answererIdentityId, String response) Carries one attendee's answer out to every other attendee's copy of the same meeting.intpropagateCreation(long eventId, long authorIdentityId) Copies a meeting that has just been created into the calendar of everybody invited to it.intpropagateDeletion(long eventId) Removes every existing copy of a meeting eXo has destroyed.intpropagateUpdate(long eventId, Set<org.exoplatform.agenda.constant.AgendaEventModificationType> modificationTypes) Rewrites every existing copy of a meeting that has just been edited.intretryOwedPushes(long userIdentityId) Makes the writes eXo owes one account's copies and has not managed to make.voidseedSpaceMembersInContainer(long eventId, Set<Long> leftOut) Writes the copies of the members of the spaces invited to a meeting, on the thread they were handed to, within a container.
-
Constructor Details
-
CaldavEventPropagationService
public CaldavEventPropagationService()
-
-
Method Details
-
changedOnTheServer
public void changedOnTheServer(long eventId, long objectSyncId) Announces that the next change to this event is the server's own, read through the given mapping, so that copy is not written back to.The write that echoed the server to itself (EXO-90190)
The inbound pass applies a change it read from a collection by asking agenda to update the event, and agenda broadcasts that update like any other. The listener then carried the "edit" to every holder of a copy — including the very mapping it had just been read through. That rewrite was redundant on a good day: the object already carried the content, and eXo re-rendered it over the client's own authoring, bumped the etag, and made every other reader of the collection import the same change a second time. On a bad day, with the event's remote identity lost in the update, the rewrite minted a fresh UID and wrote a second object beside the first — the duplication the live rig reproduced twice.
Only the origin is skipped. Every other holder still receives the change: a meeting the organiser moved from their phone must still reach the copy each attendee holds on their own account. That is the fan-out this service exists for, and it is not touched.
The announcement is consumed by the first broadcast for the event that meets it, whichever thread carries it. Agenda broadcasts exactly once per update or deletion, so one announcement covers one change and nothing more: a genuine edit made in eXo a moment later arrives as its own broadcast, finds nothing announced, and is carried to the origin like to everyone else. Should the two broadcasts cross, each reads the event as it stands when it runs, so whichever is not skipped writes the later state — the order does not matter to what the copy ends up showing.
- Parameters:
eventId- the agenda event the change is about to be applied toobjectSyncId- the mapping the change was read through, whose copy is the change's source and must not be rewritten for it
-
notChangedAfterAll
public void notChangedAfterAll(long eventId) Withdraws an announcement whose change was never applied.Called when agenda refused the update or deletion: no broadcast followed, so nothing would consume the announcement, and left in place it would silence the next genuine edit of the event until it expired.
- Parameters:
eventId- the agenda event the announcement was made for
-
propagateCreation
public int propagateCreation(long eventId, long authorIdentityId) Copies a meeting that has just been created into the calendar of everybody invited to it.The asymmetry with
propagateUpdate(long, java.util.Set<org.exoplatform.agenda.constant.AgendaEventModificationType>)is the whole point of having a second method rather than one. An edit rewrites where a copy already exists — the set is read from the mapping table — and hands a first copy only to the people it invites. A creation is the one moment where handing out a copy is the whole instruction, so the set is read from agenda's attendee list instead, and it is bounded by construction: one meeting, the people invited to it, once.The decision about each of them is not taken here. It belongs to
CaldavPendingInvitationService, which already owns the question "does this user get a copy of this meeting" for the background seeding pass — the account has to be connected, copies have to be enabled on it, the meeting has to be CONFIRMED rather than a date poll, the user must not have declined it, and a meeting that already has a copy is left to the machinery that owns it. Asking the same service here is what keeps the answer the same whichever path arrives at it, and it is the guard that makes a second trigger on the same creation write nothing.Keeping the answer the same is not a figure of speech; it stopped being true once and had to be restored (EXO-89796). While the shared decision also refused any event of a calendar the user owns, the two paths diverged in practice: an event somebody made for themselves was copied by their browser on save and never seeded afterwards, so the same event was copied or not according only to whether it predated the connection. Where an event belongs is
CaldavPushService.pushAgendaEvent(long, String, long)'s question and it answers it — own calendar to its own collection, anything else to the mirror — so the shared decision no longer duplicates it.A space invited to a meeting reaches the members who have an account (EXO-91031). A space is one attendee row standing for its members, and the copy of each member names them (
AgendaEventIcsMapper). The seeding pass alone reaches a member only when the sweep reaches their account, so their copies are handed to this add-on's own thread (handOffSpaceMembers(long, org.exoplatform.agenda.model.Event, java.util.List<org.exoplatform.agenda.model.EventAttendee>, java.util.Set<java.lang.Long>)), which reads the accounts the sweep visits and writes for those whose user is a member of an invited space: the cost follows the number of connected accounts, not the size of the space, is paid only when a space is on the roster, and never on the listener thread. The author's own copy is not written here, and that is the double push.The person who created the meeting is skipped, because their browser already pushes their own copy on save — agenda's connector panel calls
pushEventthe moment the event is created, for the current user and only for them (AgendaConnector.vue). That was the one path that worked before this method existed, and it is why the defect looked like a feature that worked: whoever created an event saw a copy appear, and every other attendee got nothing.Writing it here as well is not a harmless duplicate. Measured on a rig (2026-08-27): both writers mint the same stable iCalendar UID, both PUT the same object, and the second one to record its mapping row dies on
UQ_CALDAV_OBJECT_SYNC_UID— a constraint violation logged as an ERROR with a stack trace, on the ordinary path, on every creation. The check that a copy already exists cannot prevent it: both writers read "no copy" before either had written one.What this costs, said out loud: an event created without a browser — through the REST API or an MCP tool — leaves its author's own copy to the background seeding pass, which writes it on the next sweep of that account. That is exactly what happened to them before this method existed, so it is a gap this does not close rather than one it opens; closing it means moving the author's copy off the browser and onto the server, which is a change to how agenda's connector panel saves, not to this listener.
That sentence was a promise the seeding pass could not keep until EXO-89796, and this is the one line worth reading twice, because it is how the defect stayed invisible: the pass refused own-calendar events outright and listed only events with an attendee row, so an author's own copy was left to a pass that would never write it. The pass now asks after the events of the user's own calendars too, and the promise holds.
- Parameters:
eventId- the agenda event that was just createdauthorIdentityId- whoever created it, as the broadcast names them; skipped, and 0 or less means the broadcast named nobody and everybody invited is written to- Returns:
- how many copies were written
-
seedSpaceMembersInContainer
Writes the copies of the members of the spaces invited to a meeting, on the thread they were handed to, within a container.- Parameters:
eventId- the agenda eventleftOut- the users whose copy is not this task's to write
-
propagateUpdate
public int propagateUpdate(long eventId, Set<org.exoplatform.agenda.constant.AgendaEventModificationType> modificationTypes) Rewrites every existing copy of a meeting that has just been edited.A cancellation arrives here too, and needs no branch of its own: eXo keeps the event and marks it CANCELLED, the mapper reads that, and the object written carries
STATUS:CANCELLED. The attendee's client shows the meeting struck through rather than making it disappear — which matters, because a meeting that disappears is indistinguishable from a synchronisation that broke.An edit can make a meeting stop deserving a copy
Adding a second date option to a confirmed meeting turns it back into a date poll —
AgendaEventServiceImpl.updateEventcallscheckAndComputeDateOptions, which setsTENTATIVEand stretches the event over the envelope of the options — and that is reachable from the REST API, not only from the poll screen. So the copies written while it was a meeting have to be retired, not rewritten: rewriting one would replace a real meeting on somebody's calendar with the multi-day blockCaldavCopyPolicyexists to keep off it. Refusing new writes in the push core is what stops a poll acquiring a copy; this is what takes away the copy it already had.Asked before
worthCarrying(java.util.Set<org.exoplatform.agenda.constant.AgendaEventModificationType>), deliberately. What agenda broadcasts when date options are added isDATE_OPTION_CREATED— which is on the invisible list, because for a poll that stays a poll an option inside the envelope changes nothing a copy states. Asking the cheap question first would therefore have made the one edit that must retire a copy the one edit this method returns early from, and the retirement would never have run. The status is read from the event itself rather than inferred from the modification set for exactly that reason: the set describes what moved, and what matters here is where the event ended up.And a confirmation can make one start deserving it
The other direction is not symmetrical and cannot be. When a poll is confirmed, nobody holds a copy — none was ever written — so there is nothing for a rewrite to reach, and the loop below would count zero and stop. The confirmation is therefore seeded like a creation. See
seedConfirmedPoll(long, Event)for what it is keyed on, which is the one detail in this whole change that fails silently if it is got wrong.And an edit can invite somebody who holds no copy
An attendee or a space added by the edit is invited now, and their first copy is written once the holders are dealt with, by
seedNewlyInvited(long, org.exoplatform.agenda.model.Event, java.util.Set<java.lang.Long>, java.util.Set<org.exoplatform.agenda.constant.AgendaEventModificationType>), rather than at the next sweep of their account.- Parameters:
eventId- the agenda event that was editedmodificationTypes- what agenda says moved, as the broadcast carried it; null or empty is treated as "something did"- Returns:
- how many copies were rewritten, removed, or newly written
-
propagateDeletion
public int propagateDeletion(long eventId) Removes every existing copy of a meeting eXo has destroyed.Removal, not a cancelled tombstone, and the asymmetry with
propagateUpdate(long, java.util.Set<org.exoplatform.agenda.constant.AgendaEventModificationType>)is the point: a copy shows what agenda holds, and here agenda holds nothing. A tombstone would be unreclaimable — eXo has forgotten the event, so no later pass could verify it, repair it or ever decide to clear it, and its mapping row would name an event id that no longer resolves. The attendee is not left to guess either way: agenda sends them a cancellation notification on the same call that destroys the event.- Parameters:
eventId- the agenda event that was destroyed- Returns:
- how many copies were removed
-
propagateAnswer
Carries one attendee's answer out to every other attendee's copy of the same meeting.The half of answering that stopped at the answerer (EXO-89868)
CaldavPushService.pushAnsweropens by reading the answerer's own account and rewrites the copy on it. So an acceptance reached the accepter's own phone and nothing else: the organiser's copy, and every other attendee's, went on displaying the answer it last carried. eXo's own screens were right throughout, which is what made it invisible — nothing in the product disagreed out loud, and the organiser read a stale RSVP in the client they actually live in.This is the fan-out, and it changes the mirror's rule from "your copy reflects your answer" to "every attendee's copy reflects everyone's answers".
pushAnsweris left exactly as it was and still writes the answerer's own copy; this writes the others.Whose copies, and whose name on them
The holders are read from the mapping table, never from agenda's attendee list — the same guard
propagateUpdate(long, java.util.Set<org.exoplatform.agenda.constant.AgendaEventModificationType>)rests on: answering a meeting must not hand a copy to somebody who has never had one, or the first person to accept anything would push a year of meetings at every colleague who has since connected an account. Tombstones are skipped byholdersOf(long, boolean)for the same reason it always skips them.The answerer is taken out of that set, and not as an optimisation. Their own copy is
pushAnswer's, both run off the same listener, and two conditional writes racing for the same object would make one of them fail its If-Match — a self-inflicted conflict reported as a divergence.On each of those copies it is the answerer's addresses that decide which ATTENDEE line is rewritten, and that crossing over is the whole mechanism. A copy written for Bob spells Bob by the address his own CalDAV account answers to and everybody else — Alice included — by their eXo profile address, so Alice's answer is found on Bob's copy under Alice's profile address. Both of hers are offered anyway (
addressesNaming), because being handed one address and told it is the one is how this propagation silently matched nothing on a live rig once already.Why NEEDS-ACTION is not fanned out
Because something else already carries it, N times over. The reset to NEEDS-ACTION that follows a material edit is broadcast once per attendee, and the edit that caused it has already been carried to every copy by
propagateUpdate(long, java.util.Set<org.exoplatform.agenda.constant.AgendaEventModificationType>)— which renders every attendee's current answer, resets included. Fanning out on each reset as well would turn one edit of an eight-person meeting into fifty-six extra conditional writes saying what fifty-six writes have just said.The skip is on the PARTSTAT this would write, not on the response name, and that is deliberately wider than the rule it implements.
IcsText.partStatmaps anything it does not recognise toNEEDS-ACTION, so a response agenda grows tomorrow and this add-on has no word for would otherwise fan a reset out to every copy in the name of an answer nobody gave. Skipping on the token covers both, and can only ever decline to write a reset.An answer propagated onto a series' occurrences is not a reset and is not skipped. Agenda saves-without-sending there too, but what it saves is a real answer, and the copies of the other attendees have to learn it like any other.
Why an organiser's own answer is not fanned out either
Because no copy carries it. A copy names whoever called the meeting on its ORGANIZER line and never on an ATTENDEE line, so there is no participation status of theirs anywhere for a targeted rewrite to find — and, worse, the
NOT_NAMEDthat resulted queued a full rewrite per holder that could never satisfy the obligation. SeeCaldavPushService.isOrganizerOf(long, long)for what was measured on the rig and for the product question this deliberately leaves open. It is asked there rather than here so that one notion of who organises an event serves both halves of answering: EXO-89895 found the answerer's own copy warning for the same structural reason, and a rule recognising one side of that pair and not the other is the defect shape this delivery has already paid for repeatedly.What is written down before anything is written out
An obligation per holder, before the first network call, exactly as
propagateUpdate(long, java.util.Set<org.exoplatform.agenda.constant.AgendaEventModificationType>)records them and for the same reason: a thread killed at the third of fifty holders must leave the other forty-seven recorded as owed. It is aPendingPushKind.REWRITEon the same table with no schema change, and it is the right kind rather than a near-enough one — the retry renders the event as it now stands, and the event as it now stands carries this answer. A copy that could not be reached by the targeted write therefore converges through a full rewrite, which is slower and blunter and is the fallback, not the path.- Parameters:
eventId- the agenda event answered, master or occurrenceanswererIdentityId- identity of the user whose answer was recorded; their own copy is not written hereresponse- the answer as agenda holds it, e.g.ACCEPTED- Returns:
- how many other attendees' copies now carry the answer
-
retryOwedPushes
public int retryOwedPushes(long userIdentityId) Makes the writes eXo owes one account's copies and has not managed to make.The half of the fix that converges. The listeners are the only delivery path there is, and a listener is a single attempt against somebody else's server: this is what turns that attempt into an outcome. It reads what is owed rather than deciding it — the obligation was recorded when the meeting changed, by whoever was carrying the change out — so a copy nobody owes anything to is not read, not rendered and not written, on this pass or any other.
What it costs a converged account: one index lookup, answering nothing. That is the whole reason the obligation is a row in a table of its own rather than a column on the mapping row or a comparison the sweep makes for itself. Re-rendering every copy on every pass to find out which are behind is the churn EXO-89716 and EXO-89756 removed, and re-introducing it would be a worse defect than the one this fixes.
Wider than the verification pass on purpose: that pass scopes to the MIRROR pair, so a copy sitting in one of the user's own calendars was outside it entirely. An obligation names the copy, whichever collection it lives in.
- Parameters:
userIdentityId- whose calendar's copies are settled- Returns:
- how many owed writes landed this pass
-
owedCopies
public long owedCopies(long userIdentityId) How many of a user's calendar copies eXo holds a write for and has not managed to make.The number that makes the backstop visible. Everything about the obligation table was, until now, addressed to whoever reads the server log: a copy that could not be written left a row, the sweep settled it minutes later, and the person whose calendar it was had no way to tell that anything was outstanding — the meeting simply was not there, which is indistinguishable from the feature being broken.
What it deliberately does not count, and this is the part worth reading twice. A meeting that has just been created and could not be copied is not in here, and cannot be: an obligation names a mapping row, and a copy that was never written has none. Only a rewrite and a removal are recorded, because only those address a copy that already exists. What carries a missed creation is the seeding pass — a window query, not a recorded obligation — so this number is honest about this kind of lateness and silent about the other. The cure for the other is not a queue to look at, it is
CaldavSyncService.establishDestinations(long): give the copies somewhere to go at the moment the account is connected, and there is no lateness to narrate.Abandoned obligations are left out for the same reason the number exists: it is read as "eXo is still trying". A copy eXo has given up on is a different sentence, said to the administrator once, at WARN, by
refuse(long, org.exoplatform.caldav.model.PendingPush, java.lang.String, java.lang.String).- Parameters:
userIdentityId- whose calendar's copies are counted- Returns:
- how many writes are outstanding, zero when the store cannot be read — an unreadable count is not evidence of a backlog, and claiming one that may not exist is worse than saying nothing
-