This is an automated email from the ASF dual-hosted git repository. chibenwa pushed a commit to branch 3.9.x in repository https://gitbox.apache.org/repos/asf/james-project.git
commit feea54c3f0fa12091e9a463ad10e7808e9794393 Author: Benoit TELLIER <[email protected]> AuthorDate: Wed Jun 17 15:02:17 2026 +0200 [ENHANCEMENT] have SolveMailboxInconsistencies address all possible failures --- .../operate/webadmin/admin-mailboxes-extend.adoc | 40 +++++++++++++++++++--- .../task/SolveMailboxInconsistenciesService.java | 32 +++++++++++++---- .../SolveMailboxInconsistenciesServiceTest.java | 21 ++++++++---- src/site/markdown/server/manage-webadmin.md | 32 ++++++++++++++--- 4 files changed, 102 insertions(+), 23 deletions(-) diff --git a/docs/modules/servers/pages/distributed/operate/webadmin/admin-mailboxes-extend.adoc b/docs/modules/servers/pages/distributed/operate/webadmin/admin-mailboxes-extend.adoc index ecdb8b2006..a1dabd7290 100644 --- a/docs/modules/servers/pages/distributed/operate/webadmin/admin-mailboxes-extend.adoc +++ b/docs/modules/servers/pages/distributed/operate/webadmin/admin-mailboxes-extend.adoc @@ -13,6 +13,32 @@ a task]. The `I-KNOW-WHAT-I-M-DOING` header is mandatory (you can read more information about it in the warning section below). +Optional query parameters: + +* `maxIterations` strictly positive integer, defaults to `1`. +Reconciliation is run up to a fixpoint: fixing an inconsistency in one +pass (dropping a stale path, merging a ghost mailbox...) can surface a +new inconsistency that only a subsequent pass detects. The task re-runs +as long as a pass keeps applying fixes, bounded by `maxIterations` to +guard against oscillation. A value of `1` keeps the historical +single-pass behaviour. +* `autoMerge` boolean, defaults to `false`. When `true`, conflicting +entries where two *different* mailboxes resolve to the same path (the +historical "ghost mailbox") are resolved automatically: the mailbox +registered in the path table (the source of truth) is kept, and the +squatting one is merged into it (its messages and rights are moved over, +then its projection is dropped), reusing the +link:#_correcting_ghost_mailbox[ghost mailbox] merging machinery. This +is *destructive* (mail data is moved), only attempted once a strong read +confirms the loser is a genuine ghost not registered anywhere else, and +left `false` by default so that an admin explicitly opts in. Combine it +with `maxIterations > 1` so that any residual left by a merge converges +within the same task run. + +.... +curl -XPOST '/mailboxes?task=SolveInconsistencies&maxIterations=5&autoMerge=true' +.... + The scheduled task will have the following type `solve-mailbox-inconsistencies` and the following `additionalInformation`: @@ -33,14 +59,18 @@ The scheduled task will have the following type "mailboxPath":"#private:user:mailboxName2", "mailboxId":"464765a0-e4e7-11e4-aba4-710c1de3782b" } - }] + }], + "runningOptions":{ + "maxIterations": 5, + "autoMerge": true + } } .... -Note that conflicting entry inconsistencies will not be fixed and will -require to explicitly use link:#_correcting_ghost_mailbox[ghost mailbox] -endpoint in order to merge the conflicting mailboxes and prevent any -message loss. +Note that, unless `autoMerge` is enabled, conflicting entry +inconsistencies will not be fixed and will require to explicitly use +link:#_correcting_ghost_mailbox[ghost mailbox] endpoint in order to +merge the conflicting mailboxes and prevent any message loss. *WARNING*: this task can cancel concurrently running legitimate user operations upon dirty read. As such this task should be run offline. diff --git a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/mail/task/SolveMailboxInconsistenciesService.java b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/mail/task/SolveMailboxInconsistenciesService.java index e345faa0d7..9061e2d818 100644 --- a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/mail/task/SolveMailboxInconsistenciesService.java +++ b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/mail/task/SolveMailboxInconsistenciesService.java @@ -251,12 +251,11 @@ public class SolveMailboxInconsistenciesService { .flatMap(state -> { boolean cleanGhost = state.getT1() && state.getT2() && state.getT3(); if (!cleanGhost) { - // State no longer matches the clean-ghost picture (already reconciled, or the - // loser owns another path): do not merge. - if (state.getT1() && state.getT2()) { - // Still conflicting but the loser is registered elsewhere: leave it to an admin. - return reportConflict(context); - } + // State no longer matches the clean-ghost picture: either already reconciled, + // or the loser owns another path. In the latter case the loser is a genuine + // mailbox whose stale projection gets realigned onto its registered path by the + // same-mailbox conflict resolution, making this ghost disappear without a merge. + // We therefore leave it to that resolution rather than destroying or reporting it. return Mono.just(Result.COMPLETED); } return mergingRunner.runReactive(loserId, winnerId, new MailboxMergingTask.Context(0)) @@ -316,7 +315,26 @@ public class SolveMailboxInconsistenciesService { })) .thenReturn(Result.COMPLETED); })) - .switchIfEmpty(Mono.defer(() -> reportConflict(context))); + // The projection points to a path it does not own (unregistered, or held by another + // mailbox), while the conflicting path *is* registered to this mailbox: the projection + // is simply stale. Realign it onto the path the source of truth vouches for. + .switchIfEmpty(Mono.defer(() -> realignStaleProjection(context, mailboxDAO, currentProjection, conflictingEntry))); + } + + private Mono<Result> realignStaleProjection(Context context, CassandraMailboxDAO mailboxDAO, + Mailbox currentProjection, Mailbox conflictingEntry) { + MailboxPath stalePath = currentProjection.generateAssociatedPath(); + Mailbox realigned = new Mailbox(conflictingEntry.generateAssociatedPath(), + currentProjection.getUidValidity(), currentProjection.getMailboxId()); + return mailboxDAO.save(realigned) + .then(Mono.fromRunnable(() -> { + LOGGER.info("Inconsistency fixed for mailbox {}: realigned stale projection from path {} to registered path {}", + realigned.getMailboxId().serialize(), + stalePath.asString(), + realigned.generateAssociatedPath().asString()); + context.addFixedInconsistency(realigned.getMailboxId()); + })) + .thenReturn(Result.COMPLETED); } private Mono<Result> reportConflict(Context context) { diff --git a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/task/SolveMailboxInconsistenciesServiceTest.java b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/task/SolveMailboxInconsistenciesServiceTest.java index ea92010ccc..1512595411 100644 --- a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/task/SolveMailboxInconsistenciesServiceTest.java +++ b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/task/SolveMailboxInconsistenciesServiceTest.java @@ -458,9 +458,11 @@ class SolveMailboxInconsistenciesServiceTest { } @Test - void fixMailboxInconsistenciesShouldNotAutoMergeWhenLoserIsRegisteredElsewhere() { + void fixMailboxInconsistenciesShouldRealignLoserInsteadOfMergingWhenLoserIsRegisteredElsewhere() { // CASSANDRA_ID_1 squats path "abc" (owned by CASSANDRA_ID_2) but also legitimately owns path - // "xyz": it is not a clean ghost, so the path table vouches for it and it must not be merged away. + // "xyz": it is not a clean ghost. Rather than being merged away, its stale projection is + // realigned onto its registered path "xyz", which makes the ghost on "abc" disappear without + // any merge. The whole picture becomes consistent within a single run. Mailbox pathOwnerProjection = new Mailbox(MAILBOX_PATH, UID_VALIDITY_2, CASSANDRA_ID_2); Mailbox loserOtherPath = new Mailbox(NEW_MAILBOX_PATH, UID_VALIDITY_1, CASSANDRA_ID_1); mailboxDAO.save(MAILBOX).block(); @@ -468,11 +470,16 @@ class SolveMailboxInconsistenciesServiceTest { mailboxPathV3DAO.save(MAILBOX_2).block(); mailboxPathV3DAO.save(loserOtherPath).block(); - testee.fixMailboxInconsistencies(new Context(), new SolveMailboxInconsistenciesService.RunningOptions(1, true)).block(); + Context context = new Context(); + testee.fixMailboxInconsistencies(context, new SolveMailboxInconsistenciesService.RunningOptions(1, true)).block(); - verify(mergingRunner, never()).runReactive(any(), any(), any()); - // The ghost projection is preserved (not destroyed by an auto-merge). - assertThat(mailboxDAO.retrieveAllMailboxes().collectList().block()) - .contains(MAILBOX); + SoftAssertions.assertSoftly(softly -> { + verify(mergingRunner, never()).runReactive(any(), any(), any()); + softly.assertThat(mailboxDAO.retrieveAllMailboxes().collectList().block()) + .containsExactlyInAnyOrder(pathOwnerProjection, loserOtherPath); + softly.assertThat(mailboxPathV3DAO.listAll().collectList().block()) + .containsExactlyInAnyOrder(MAILBOX_2, loserOtherPath); + softly.assertThat(context.snapshot().getConflictingEntries()).isEmpty(); + }); } } \ No newline at end of file diff --git a/src/site/markdown/server/manage-webadmin.md b/src/site/markdown/server/manage-webadmin.md index 14f57fc1b2..a3cbbaf00f 100644 --- a/src/site/markdown/server/manage-webadmin.md +++ b/src/site/markdown/server/manage-webadmin.md @@ -832,6 +832,26 @@ Will schedule a task for fixing inconsistencies for the mailbox deduplicated obj The `I-KNOW-WHAT-I-M-DOING` header is mandatory (you can read more information about it in the warning section below). +Optional query parameters: + + - `maxIterations` strictly positive integer, defaults to `1`. Reconciliation is run up to a fixpoint: + fixing an inconsistency in one pass (dropping a stale path, merging a ghost mailbox...) can surface a + new inconsistency that only a subsequent pass detects. The task re-runs as long as a pass keeps applying + fixes, bounded by `maxIterations` to guard against oscillation. A value of `1` keeps the historical + single-pass behaviour. + - `autoMerge` boolean, defaults to `false`. When `true`, conflicting entries where two **different** + mailboxes resolve to the same path (the historical "ghost mailbox") are resolved automatically: the + mailbox registered in the path table (the source of truth) is kept, and the squatting one is merged into + it (its messages and rights are moved over, then its projection is dropped), reusing the + [ghost mailbox](#correcting-ghost-mailbox) merging machinery. This is **destructive** (mail data is + moved), only attempted once a strong read confirms the loser is a genuine ghost not registered anywhere + else, and left `false` by default so that an admin explicitly opts in. Combine it with `maxIterations > 1` + so that any residual left by a merge converges within the same task run. + +``` +curl -XPOST '/mailboxes?task=SolveInconsistencies&maxIterations=5&autoMerge=true' +``` + The scheduled task will have the following type `solve-mailbox-inconsistencies` and the following `additionalInformation`: ``` @@ -850,13 +870,17 @@ The scheduled task will have the following type `solve-mailbox-inconsistencies` "mailboxPath":"#private:user:mailboxName2", "mailboxId":"464765a0-e4e7-11e4-aba4-710c1de3782b" } - }] + }], + "runningOptions":{ + "maxIterations": 5, + "autoMerge": true + } } ``` -Note that conflicting entry inconsistencies will not be fixed and will require to explicitly use -[ghost mailbox](#correcting-ghost-mailbox) endpoint in order to merge the conflicting mailboxes and prevent any message -loss. +Note that, unless `autoMerge` is enabled, conflicting entry inconsistencies will not be fixed and will +require to explicitly use [ghost mailbox](#correcting-ghost-mailbox) endpoint in order to merge the +conflicting mailboxes and prevent any message loss. **WARNING**: this task can cancel concurrently running legitimate user operations upon dirty read. As such this task should be run offline. --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
