Skip to content

Refactor fast clean: fix MCLEAN-102, session-scoped sharing, per-module config - #328

Open
gnodet wants to merge 1 commit into
masterfrom
quick-fix/fast-clean-session-scoped-batch-retry
Open

Refactor fast clean: fix MCLEAN-102, session-scoped sharing, per-module config#328
gnodet wants to merge 1 commit into
masterfrom
quick-fix/fast-clean-session-scoped-batch-retry

Conversation

@gnodet

@gnodet gnodet commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Refactors the fast clean (-Dmaven.clean.fast=true) BackgroundCleaner to fix the MCLEAN-102 performance regression and three regressions introduced by the per-module refactoring in PR #286.

What this fixes

# Bug Root Cause Fix
1 MCLEAN-102 — fast mode 50% slower on 12-core Windows System.gc() stop-the-world per file + per-file retry sleeps from background thread Batch retry: walk once without retry, sleep once (250ms), retry all failures
2 Thread proliferation — 500 threads in 500-module reactor Per-module BackgroundCleaner, each with its own ExecutorService Session-scoped instance via SessionData.computeIfAbsent()
3 500 session listeners Per-module session.registerListener(this) Single listener registered in constructor
4 Leftover cleanup lost init() scan removed in PR #286 when singleton was dropped Restored: scanForLeftovers() queues leftover dirs for background deletion

Design

BackgroundCleaner no longer extends Cleaner. It is a standalone session-scoped service that owns only the shared infrastructure (executor, session listener, staging directory, fast mode).

Each module creates its own Cleaner with per-module configuration (force, retryOnError, followSymlinks, etc.) and attaches the shared BackgroundCleaner via Cleaner.setBackgroundCleaner(). The per-module force/retryOnError values are passed to fastDelete() and captured alongside each directory (via a DeferredDeletion record for at-end mode) so that background deletion respects the originating module's configuration — even in multi-module builds where modules configure the clean plugin differently.

Changes

  • BackgroundCleaner.java (major rewrite):

    • No longer extends Cleaner — standalone session-scoped service
    • SessionData.Key<BackgroundCleaner> + getOrCreate() factory — one instance, one thread, one listener per session
    • fastDelete(Path, boolean force, boolean retryOnError) — per-module config captured per-call
    • deleteInBackground() — uses SimpleFileVisitor with batch retry instead of per-file System.gc() + sleep
    • DeferredDeletion record — captures per-module config for at-end/defer modes
    • scanForLeftovers() — scans fast directory for dirs left by killed previous builds
    • fastDelete() is synchronized for parallel build safety (-T)
  • Cleaner.java:

    • Added backgroundCleaner field + setBackgroundCleaner() setter
    • fastDelete() and fastDeleteError() delegate to BackgroundCleaner, passing per-instance force/retryOnError
    • setWritable(): private staticstatic (package-private) so BackgroundCleaner can reuse it
  • CleanMojo.java:

    • Always creates a per-module Cleaner with that module's configuration
    • If fast=true, gets/creates the shared BackgroundCleaner and attaches it

Before vs After (500-module reactor)

Metric Before After
Background threads 500 1
Session listeners 500 1
System.gc() calls (Windows) thousands 0
Per-file sleeps (background) 50–1050ms × N 0
Batch sleeps 0 1 × 250ms per directory
Leftover cleanup
Per-module config respected ✅ (separate instances) ✅ (per-call params)

Design notes

  • Foreground path unchanged: the traditional Cleaner.tryDelete() keeps System.gc() + per-file retry — it runs in the main thread where it can't cause stop-the-world on other threads, and it may genuinely need to release JVM-held file handles.
  • Batch retry respects retryOnError: if user sets -Dmaven.clean.retryOnError=false, no retry happens even in background.
  • Remaining failures deferred to next build: any files that survive the batch retry will be cleaned up by the leftover scan on the next fast=true build.

Test plan

  • mvn test passes (15 tests, 0 failures)
  • CI integration tests pass (fast-delete, fast-delete-default)
  • Manual test on Windows with large reactor to verify MCLEAN-102 fix
  • Manual test: kill build mid-clean, verify leftovers cleaned on next run
  • Manual test with -T 4C parallel build to verify session-scoped sharing

🤖 Generated with Claude Code

Refactors BackgroundCleaner to fix the MCLEAN-102 performance regression
(50% slower on Windows due to System.gc() stop-the-world pauses from the
background thread) and three regressions from PR #286 (thread proliferation,
listener proliferation, lost leftover cleanup).

BackgroundCleaner is now a standalone session-scoped service (no longer
extends Cleaner). Each module gets its own Cleaner with per-module
configuration and attaches the shared BackgroundCleaner via
Cleaner.setBackgroundCleaner(). Per-module force/retryOnError values are
passed to fastDelete() and captured per-directory so background deletion
respects the originating module's configuration.

Key changes:
- Session-scoped via SessionData.computeIfAbsent(): one instance, one
  thread, one listener per session (was one per module)
- Batch retry replaces per-file System.gc() + sleep: walk once, sleep
  once (250ms), retry all failures together
- scanForLeftovers() restores cleanup of dirs from killed previous builds
- fastDelete() is synchronized for parallel build safety

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gnodet
gnodet force-pushed the quick-fix/fast-clean-session-scoped-batch-retry branch from e756fb7 to e681b19 Compare August 1, 2026 23:12
@gnodet gnodet changed the title Refactor fast clean: session-scoped sharing, batch retry (MCLEAN-102) Refactor fast clean: fix MCLEAN-102, session-scoped sharing, per-module config Aug 1, 2026
@gnodet
gnodet marked this pull request as ready for review August 1, 2026 23:13
/**
* A cleaner potentially executed by background threads.
* A session-scoped service that moves directories to a staging area and deletes them in a background thread.
* A single instance is shared across all modules in a reactor build via {@link SessionData},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we mean "sub-projects" (instead of "modules") in Maven 4 terminology?

* Returns an error message to show to user if the fast delete failed.
*/
@Override
String fastDeleteError(IOException e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this method no longer override a method from the parent class, I presume that it can be made private. Then, if this method appears to be invoked in only one place, maybe it can be completely removed and replaced by moving this code inline at the invocation place, if that make the code easier to follow.

*/
@Override
boolean fastDelete(Path baseDir) throws IOException {
synchronized boolean fastDelete(Path baseDir, boolean force, boolean retryOnError) throws IOException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this method does not override anymore a method of the parent class, try to make this method private.

* @return whether this method was able to register the background task
* @throws IOException if an error occurred while preparing the task before execution in a background thread
*/
boolean fastDelete(Path baseDir) throws IOException {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If BackgroundCleaner no longer extent Cleaner, can this method be made private? If this method is invoked in only one location, should the code be moved there?

/**
* Returns an error message to show to user if the fast delete failed.
*/
String fastDeleteError(IOException e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If BackgroundCleaner no longer extent Cleaner, can this method be made private? If this method is invoked in only one location, should the code be moved there?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants