This is an automated email from the ASF dual-hosted git repository.

jbonofre pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/activemq.git


The following commit(s) were added to refs/heads/main by this push:
     new 726fa61811 [AMQ-9856] Journal move fails on Windows (#1654)
726fa61811 is described below

commit 726fa61811933e42933fb5af715ef1f01421b439
Author: Jean-Louis Monteiro <[email protected]>
AuthorDate: Tue Feb 17 16:54:35 2026 +0100

    [AMQ-9856] Journal move fails on Windows (#1654)
    
    * Exclude JournalArchiveTest from Windows builds to reduce test flakiness
    
    * AMQ-9856 Implement non-blocking file deletion and async cleanup for 
Windows
    
    * Enhance IOHelper: improve error handling in file operations and add unit 
tests for rename functionality
---
 .../java/org/apache/activemq/util/IOHelper.java    | 207 +++++++++++++++++++--
 .../java/org/apache/activemq/util/LockFile.java    |   2 +-
 .../org/apache/activemq/util/IOHelperTest.java     | 143 ++++++++++++++
 activemq-kahadb-store/pom.xml                      |   2 +-
 .../store/kahadb/disk/journal/DataFile.java        |   2 +-
 .../activemq/store/kahadb/disk/page/PageFile.java  |  13 +-
 6 files changed, 343 insertions(+), 26 deletions(-)

diff --git 
a/activemq-broker/src/main/java/org/apache/activemq/util/IOHelper.java 
b/activemq-broker/src/main/java/org/apache/activemq/util/IOHelper.java
index 008140df1a..37041aab00 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/util/IOHelper.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/util/IOHelper.java
@@ -28,20 +28,132 @@ import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Queue;
 import java.util.Stack;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * Collection of File and Folder utility methods.
  */
 public final class IOHelper {
 
+    private static final Logger LOG = LoggerFactory.getLogger(IOHelper.class);
+
     protected static final int MAX_DIR_NAME_LENGTH;
     protected static final int MAX_FILE_NAME_LENGTH;
     private static final int DEFAULT_BUFFER_SIZE = 4096;
+    private static final boolean IS_WINDOWS = System.getProperty("os.name", 
"").toLowerCase().contains("win");
+
+    // Async cleanup for files that couldn't be deleted immediately (Windows 
file locking)
+    private static final Queue<File> PENDING_DELETES = new 
ConcurrentLinkedQueue<>();
+    private static final ScheduledExecutorService CLEANUP_EXECUTOR;
+
+    static {
+        MAX_DIR_NAME_LENGTH = Integer.getInteger("MaximumDirNameLength", 200);
+        MAX_FILE_NAME_LENGTH = Integer.getInteger("MaximumFileNameLength", 64);
+
+        // Only start cleanup thread on Windows where file locking is an issue
+        if (IS_WINDOWS) {
+            CLEANUP_EXECUTOR = Executors.newSingleThreadScheduledExecutor(r -> 
{
+                final Thread t = new Thread(r, "IOHelper-AsyncCleanup");
+                t.setDaemon(true);
+                return t;
+            });
+            
CLEANUP_EXECUTOR.scheduleWithFixedDelay(IOHelper::processAsyncDeletes, 5, 5, 
TimeUnit.SECONDS);
+        } else {
+            CLEANUP_EXECUTOR = null;
+        }
+    }
 
     private IOHelper() {
     }
 
+    /**
+     * Process pending async deletes. Called periodically on Windows.
+     */
+    private static void processAsyncDeletes() {
+        int processed = 0;
+        File file;
+        while ((file = PENDING_DELETES.poll()) != null && processed < 100) {
+            processed++;
+            try {
+                if (file.exists()) {
+                    if (file.delete()) {
+                        LOG.debug("Async cleanup: deleted {}", file);
+                    } else {
+                        // Still locked, re-queue for later
+                        PENDING_DELETES.offer(file);
+                    }
+                }
+            } catch (final Exception e) {
+                LOG.warn("Async cleanup: failed to delete {}", file, e);
+            }
+        }
+    }
+
+    /**
+     * Schedule a file for async deletion. Used when immediate deletion fails 
on Windows.
+     * The file will be deleted in the background when it becomes unlocked.
+     */
+    public static void scheduleAsyncDelete(File file) {
+        if (file != null && file.exists()) {
+            if (IS_WINDOWS && CLEANUP_EXECUTOR != null) {
+                PENDING_DELETES.offer(file);
+                LOG.debug("Scheduled async delete for: {}", file);
+            } else {
+                // On non-Windows, just use deleteOnExit
+                file.deleteOnExit();
+            }
+        }
+    }
+
+    /**
+     * Non-blocking file deletion. Tries to delete once, and if it fails,
+     * schedules async cleanup instead of blocking with retries.
+     *
+     * This is safe to call from synchronized blocks as it never sleeps or 
retries.
+     *
+     * @param file the file to delete
+     * @return true if deleted immediately, false if scheduled for async 
cleanup
+     */
+    public static boolean deleteFileNonBlocking(File file) {
+        if (file == null || !file.exists()) {
+            return true;
+        }
+
+        // For directories, try to delete children first (non-blocking)
+        if (file.isDirectory()) {
+            final File[] children = file.listFiles();
+            if (children != null) {
+                for (final File child : children) {
+                    deleteFileNonBlocking(child);
+                }
+            }
+        }
+
+        // Single delete attempt - no retry
+        if (file.delete()) {
+            return true;
+        }
+
+        // Failed to delete - schedule for async cleanup
+        scheduleAsyncDelete(file);
+        return false;
+    }
+
+    /**
+     * Check if we're running on Windows.
+     */
+    public static boolean isWindows() {
+        return IS_WINDOWS;
+    }
+
     public static String getDefaultDataDirectory() {
         return getDefaultDirectoryPrefix() + "activemq-data";
     }
@@ -187,19 +299,91 @@ public final class IOHelper {
         return result;
     }
 
-    public static void moveFile(File src, File targetDirectory) throws 
IOException {
-        if (!src.renameTo(new File(targetDirectory, src.getName()))) {
+    /**
+     * Rename a file to a new name/path. Non-blocking on failure.
+     * Strategy: rename -> NIO move -> copy + async delete of source.
+     *
+     * @param src the source file
+     * @param dest the destination file (full path with new name)
+     */
+    public static void renameFile(File src, File dest) throws IOException {
+        if (src == null) {
+            throw new IOException("Source file is null");
+        }
+        if (dest == null) {
+            throw new IOException("Destination file is null");
+        }
+        if (!src.exists()) {
+            throw new IOException("Source file does not exist: " + src);
+        }
 
-            // If rename fails we must do a true deep copy instead.
-            Path sourcePath = src.toPath();
-            Path targetDirPath = targetDirectory.toPath();
+        final Path sourcePath = src.toPath();
+        final Path targetPath = dest.toPath();
 
-            try {
-                Files.move(sourcePath, 
targetDirPath.resolve(sourcePath.getFileName()), 
StandardCopyOption.REPLACE_EXISTING);
-            } catch (IOException ex) {
-                throw new IOException("Failed to move " + src + " to " + 
targetDirectory + " - " + ex.getMessage(), ex);
+        // Fast path: try rename
+        try {
+            if (src.renameTo(dest)) {
+                return;
             }
+            LOG.debug("rename failed for {} -> {}, trying NIO move", src, 
dest);
+        } catch (final Exception e) {
+            LOG.debug("rename threw exception for {} -> {}, trying NIO move", 
src, dest, e);
         }
+
+        // Try NIO move
+        try {
+            Files.move(sourcePath, targetPath, 
StandardCopyOption.REPLACE_EXISTING);
+            return;
+        } catch (final IOException e) {
+            LOG.debug("NIO move failed for {} -> {}, falling back to 
copy+delete", src, dest, e);
+        }
+
+        // Copy + async delete as last resort
+        Files.copy(sourcePath, targetPath, 
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
+        deleteFileNonBlocking(src);
+    }
+
+    /**
+     * Move a file to a target directory. Non-blocking - uses async cleanup 
for source on Windows.
+     * Strategy: rename -> NIO move -> copy + async delete of source.
+     *
+     * @param src the source file to move
+     * @param targetDirectory the target directory (must be a directory)
+     */
+    public static void moveFile(File src, File targetDirectory) throws 
IOException {
+        if (src == null) {
+            throw new IOException("Source file is null");
+        }
+        if (targetDirectory == null) {
+            throw new IOException("Target directory is null");
+        }
+        mkdirs(targetDirectory);
+        final File dest = new File(targetDirectory, src.getName());
+        final Path sourcePath = src.toPath();
+        final Path targetPath = dest.toPath();
+
+        // Fast path: try rename (works if same filesystem and no locks)
+        try {
+            if (src.renameTo(dest)) {
+                return;
+            }
+            LOG.debug("rename failed for {} -> {}, trying NIO move", src, 
dest);
+        } catch (final Exception e) {
+            LOG.debug("rename threw exception for {} -> {}, trying NIO move", 
src, dest, e);
+        }
+
+        // Try NIO move
+        try {
+            Files.move(sourcePath, targetPath, 
StandardCopyOption.REPLACE_EXISTING);
+            return;
+        } catch (final IOException e) {
+            LOG.debug("NIO move failed for {} -> {}, falling back to 
copy+delete", src, dest, e);
+        }
+
+        // Copy + async delete as last resort
+        Files.copy(sourcePath, targetPath, 
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
+        // Copy succeeded, schedule async cleanup of source (non-blocking)
+        deleteFileNonBlocking(src);
     }
 
     public static void moveFiles(File srcDirectory, File targetDirectory, 
FilenameFilter filter) throws IOException {
@@ -306,11 +490,6 @@ public final class IOHelper {
         }
     }
 
-    static {
-        MAX_DIR_NAME_LENGTH = Integer.getInteger("MaximumDirNameLength", 200);
-        MAX_FILE_NAME_LENGTH = Integer.getInteger("MaximumFileNameLength", 64);
-    }
-
     public static int getMaxDirNameLength() {
         return MAX_DIR_NAME_LENGTH;
     }
diff --git 
a/activemq-broker/src/main/java/org/apache/activemq/util/LockFile.java 
b/activemq-broker/src/main/java/org/apache/activemq/util/LockFile.java
index 2f89bc5b2b..413f1dcb58 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/util/LockFile.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/util/LockFile.java
@@ -136,7 +136,7 @@ public class LockFile {
         closeReadFile();
 
         if (locked && deleteOnUnlock) {
-            file.delete();
+            IOHelper.deleteFileNonBlocking(file);
         }
     }
 
diff --git 
a/activemq-broker/src/test/java/org/apache/activemq/util/IOHelperTest.java 
b/activemq-broker/src/test/java/org/apache/activemq/util/IOHelperTest.java
new file mode 100644
index 0000000000..011f3fbbf2
--- /dev/null
+++ b/activemq-broker/src/test/java/org/apache/activemq/util/IOHelperTest.java
@@ -0,0 +1,143 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.activemq.util;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.util.Objects;
+
+import static org.junit.Assert.*;
+
+public class IOHelperTest {
+
+    private Path baseDir;
+
+    @Before
+    public void setUp() throws IOException {
+        baseDir = Paths.get("target", "iohelper-test").toAbsolutePath();
+        if (Files.exists(baseDir)) {
+            // best effort cleanup
+            IOHelper.delete(new File(baseDir.toString()));
+        }
+        Files.createDirectories(baseDir);
+    }
+
+    @After
+    public void tearDown() throws IOException {
+        if (Files.exists(baseDir)) {
+            IOHelper.delete(new File(baseDir.toString()));
+        }
+    }
+
+    @Test
+    public void testMoveFileNioCreated() throws Exception {
+        Path srcDir = baseDir.resolve("src");
+        Path dstDir = baseDir.resolve("dst");
+        Files.createDirectories(srcDir);
+        Files.createDirectories(dstDir);
+
+        Path srcFile = srcDir.resolve("testfile.txt");
+        Files.writeString(srcFile, "hello world", StandardOpenOption.CREATE, 
StandardOpenOption.TRUNCATE_EXISTING);
+
+        File src = srcFile.toFile();
+        File targetDir = dstDir.toFile();
+
+        IOHelper.moveFile(src, targetDir);
+
+        Path moved = dstDir.resolve("testfile.txt");
+        assertTrue("moved file should exist", Files.exists(moved));
+        assertEquals("content", "hello world", Files.readString(moved));
+        assertFalse("original should not exist after move", 
Files.exists(srcFile));
+    }
+
+    @Test
+    public void testMoveFileToDirectory() throws Exception {
+        final Path srcDir = baseDir.resolve("src-dir");
+        final Path dstDir = baseDir.resolve("dst-dir");
+        Files.createDirectories(srcDir);
+        Files.createDirectories(dstDir);
+
+        final Path srcFile = srcDir.resolve("file.txt");
+        Files.writeString(srcFile, "dir move test", StandardOpenOption.CREATE, 
StandardOpenOption.TRUNCATE_EXISTING);
+
+        IOHelper.moveFile(srcFile.toFile(), dstDir.toFile());
+
+        assertTrue("moved file should exist in target dir", 
Files.exists(dstDir.resolve("file.txt")));
+        assertFalse("source should not exist", Files.exists(srcFile));
+    }
+
+    @Test
+    public void testDeleteFileNonBlocking() throws Exception {
+        final Path f = baseDir.resolve("nonblocking.txt");
+        Files.writeString(f, "test", StandardOpenOption.CREATE, 
StandardOpenOption.TRUNCATE_EXISTING);
+        final File file = f.toFile();
+
+        final boolean result = IOHelper.deleteFileNonBlocking(file);
+
+        assertTrue("file should be deleted or scheduled", result || 
!file.exists());
+    }
+
+    @Test
+    public void testDeleteFileNonBlockingDirectory() throws Exception {
+        final Path dir = baseDir.resolve("nonblocking-dir");
+        Files.createDirectories(dir);
+        Files.writeString(dir.resolve("child.txt"), "test", 
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
+        final File file = dir.toFile();
+
+        final boolean result = IOHelper.deleteFileNonBlocking(file);
+
+        // Directory may be deleted immediately or scheduled for async cleanup
+        assertTrue("dir deletion result", result || !file.exists() || 
Objects.requireNonNull(file.listFiles()).length == 0);
+    }
+
+    @Test
+    public void testRenameFile() throws Exception {
+        final Path srcFile = baseDir.resolve("rename-src.txt");
+        final Path destFile = baseDir.resolve("rename-dest.txt");
+        Files.writeString(srcFile, "rename test", StandardOpenOption.CREATE, 
StandardOpenOption.TRUNCATE_EXISTING);
+
+        IOHelper.renameFile(srcFile.toFile(), destFile.toFile());
+
+        assertTrue("renamed file should exist", Files.exists(destFile));
+        assertEquals("content preserved", "rename test", 
Files.readString(destFile));
+        assertFalse("source should not exist after rename", 
Files.exists(srcFile));
+    }
+
+    @Test(expected = IOException.class)
+    public void testRenameFileSourceDoesNotExist() throws Exception {
+        final File src = baseDir.resolve("does-not-exist.txt").toFile();
+        final File dest = baseDir.resolve("dest.txt").toFile();
+
+        IOHelper.renameFile(src, dest);
+    }
+
+    @Test
+    public void testIsWindows() {
+        // Just verify the method works without exceptions
+        final boolean isWindows = IOHelper.isWindows();
+        final String os = System.getProperty("os.name", "").toLowerCase();
+        assertEquals("isWindows detection", os.contains("win"), isWindows);
+    }
+}
diff --git a/activemq-kahadb-store/pom.xml b/activemq-kahadb-store/pom.xml
index cfab14db69..0f4804f926 100644
--- a/activemq-kahadb-store/pom.xml
+++ b/activemq-kahadb-store/pom.xml
@@ -318,7 +318,7 @@
         </plugins>
       </build>
     </profile>
-    
+
     <profile>
       <id>activemq.tests.aix.excludes</id>
       <activation>
diff --git 
a/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/disk/journal/DataFile.java
 
b/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/disk/journal/DataFile.java
index 4f8a828aa6..5e8fb7104e 100644
--- 
a/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/disk/journal/DataFile.java
+++ 
b/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/disk/journal/DataFile.java
@@ -100,7 +100,7 @@ public class DataFile extends LinkedNode<DataFile> 
implements Comparable<DataFil
     }
 
     public synchronized boolean delete() throws IOException {
-        return file.delete();
+        return IOHelper.deleteFileNonBlocking(file);
     }
 
     public synchronized void move(File targetDirectory) throws IOException{
diff --git 
a/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/disk/page/PageFile.java
 
b/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/disk/page/PageFile.java
index a1319278ba..cfb86d613d 100644
--- 
a/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/disk/page/PageFile.java
+++ 
b/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/disk/page/PageFile.java
@@ -350,20 +350,15 @@ public class PageFile {
 
     /**
      * @param file
-     * @throws IOException
      */
-    private void delete(File file) throws IOException {
-        if (file.exists() && !file.delete()) {
-            throw new IOException("Could not delete: " + file.getPath());
-        }
+    private void delete(File file) {
+        IOHelper.deleteFileNonBlocking(file);
     }
 
     private void archive(File file, String suffix) throws IOException {
         if (file.exists()) {
-            File archive = new File(file.getPath() + "-" + suffix);
-            if (!file.renameTo(archive)) {
-                throw new IOException("Could not archive: " + file.getPath() + 
" to " + file.getPath());
-            }
+            final File archiveFile = new File(file.getPath() + "-" + suffix);
+            IOHelper.renameFile(file, archiveFile);
         }
     }
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]
For further information, visit: https://activemq.apache.org/contact


Reply via email to