Skip to content

Commit ecff244

Browse files
committed
Add api cleanupEmptyFolders
1 parent e7b2d84 commit ecff244

File tree

8 files changed

+382
-1
lines changed

8 files changed

+382
-1
lines changed

src/main/java/org/commonjava/service/storage/controller/StorageController.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@
4646
import static org.apache.commons.lang3.StringUtils.isNotBlank;
4747
import static org.commonjava.service.storage.util.Utils.getDuration;
4848
import static org.commonjava.service.storage.util.Utils.sort;
49+
import static org.commonjava.service.storage.util.Utils.depth;
50+
import static org.commonjava.service.storage.util.Utils.getParentPath;
51+
import static org.commonjava.service.storage.util.Utils.getAllCandidates;
52+
import static org.commonjava.service.storage.util.Utils.normalizeFolderPath;
4953
import static org.commonjava.storage.pathmapped.util.PathMapUtils.ROOT_DIR;
5054

5155
@Startup
@@ -269,6 +273,77 @@ public void purgeEmptyFilesystems()
269273
ret.forEach( filesystem -> fileManager.purgeFilesystem( filesystem ));
270274
}
271275

276+
/**
277+
* Cleans up (deletes) the given empty folders and, if possible, their parent folders up to the root.
278+
* <p>
279+
* Optimization details:
280+
* <ul>
281+
* <li>Collects all input folders and their ancestors as candidates for deletion.</li>
282+
* <li>Sorts candidates by depth (deepest first) to ensure children are processed before parents.</li>
283+
* <li>Attempts to delete each folder only once (tracked in the processed set).</li>
284+
* <li>If a folder is not empty, marks it and all its ancestors as processed, since their parents must also be non-empty.</li>
285+
* <li>Tracks succeeded and failed deletions in the result object.</li>
286+
* <li>Returns a BatchDeleteResult with all attempted deletions for client inspection.</li>
287+
* </ul>
288+
* This approach avoids redundant deletion attempts and is efficient for overlapping directory trees.
289+
*/
290+
public BatchDeleteResult cleanupEmptyFolders(String filesystem, Collection<String> paths) {
291+
Set<String> allCandidates = getAllCandidates(paths);
292+
// Sort by depth, deepest first
293+
List<String> sortedCandidates = new ArrayList<>(allCandidates);
294+
sortedCandidates.sort((a, b) -> Integer.compare(depth(b), depth(a)));
295+
logger.debug("Sorted candidate folders for cleanup (deepest first): {}", sortedCandidates);
296+
297+
Set<String> succeeded = new HashSet<>();
298+
Set<String> failed = new HashSet<>();
299+
Set<String> processed = new HashSet<>();
300+
for (String folder : sortedCandidates) {
301+
if (processed.contains(folder)) {
302+
continue;
303+
}
304+
processed.add(folder);
305+
try {
306+
if (!fileManager.isDirectory(filesystem, folder)) {
307+
logger.debug("Path is not a directory or does not exist, skipping: {} in filesystem: {}", folder, filesystem);
308+
continue;
309+
}
310+
String[] contents = fileManager.list(filesystem, folder);
311+
if (contents == null || contents.length == 0) {
312+
boolean deleted = fileManager.delete(filesystem, normalizeFolderPath(folder));
313+
if (deleted) {
314+
succeeded.add(folder);
315+
logger.debug("Folder deleted: {} in filesystem: {}", folder, filesystem);
316+
} else {
317+
failed.add(folder);
318+
}
319+
} else {
320+
logger.debug("Folder not empty, skipping cleanup: {} in filesystem: {}, contents: {}", folder, filesystem, Arrays.toString(contents));
321+
// Mark this folder and all its ancestors as processed
322+
markAncestorsProcessed(folder, processed);
323+
}
324+
} catch (Exception e) {
325+
logger.warn("Failed to clean up folder: {} in filesystem: {}. Error: {}", folder, filesystem, e.getMessage());
326+
failed.add(folder);
327+
markAncestorsProcessed(folder, processed);
328+
}
329+
}
330+
logger.info("Cleanup empty folders result: succeeded={}, failed={}", succeeded, failed);
331+
BatchDeleteResult result = new BatchDeleteResult();
332+
result.setFilesystem(filesystem);
333+
result.setSucceeded(succeeded);
334+
result.setFailed(failed);
335+
return result;
336+
}
337+
338+
// Mark the given folder and all its ancestors as processed
339+
private void markAncestorsProcessed(String folder, Set<String> processed) {
340+
String current = folder;
341+
while (current != null && !current.isEmpty() && !current.equals("/")) {
342+
processed.add(current);
343+
current = getParentPath(current);
344+
}
345+
}
346+
272347
/**
273348
* By default, the pathmap storage will override existing paths. Here we must check:
274349
* 1. all paths exist in source

src/main/java/org/commonjava/service/storage/jaxrs/StorageMaintResource.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.commonjava.service.storage.dto.BatchDeleteResult;
2121
import org.commonjava.service.storage.util.ResponseHelper;
2222
import org.commonjava.storage.pathmapped.model.Filesystem;
23+
import org.commonjava.service.storage.dto.BatchDeleteRequest;
2324
import org.eclipse.microprofile.openapi.annotations.Operation;
2425
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
2526
import org.eclipse.microprofile.openapi.annotations.responses.APIResponses;
@@ -94,4 +95,26 @@ public Response purgeFilesystem( final @PathParam( "filesystem" ) String filesys
9495
logger.debug( "Purge filesystem result: {}", result );
9596
return responseHelper.formatOkResponseWithJsonEntity( result );
9697
}
98+
99+
/**
100+
* Cleans up multiple empty folders in a filesystem.
101+
*
102+
* @param request BatchDeleteRequest containing the filesystem and the set of folder paths to attempt to clean up.
103+
* Only empty folders will be deleted; non-empty folders will be skipped.
104+
* If a parent folder becomes empty as a result, it will also be deleted recursively up to the root.
105+
*/
106+
@Operation( summary = "Cleanup multiple empty folders in a filesystem. Always returns 200 OK for easier client handling; "
107+
+ "failures are reported in the result object." )
108+
@APIResponses( { @APIResponse( responseCode = "200",
109+
description = "Cleanup done (some folders may have failed, see result object)." ) } )
110+
@Consumes( APPLICATION_JSON )
111+
@Produces( APPLICATION_JSON )
112+
@DELETE
113+
@Path( "folders/empty" )
114+
public Response cleanupEmptyFolders( BatchDeleteRequest request )
115+
{
116+
logger.info( "Cleanup empty folders: filesystem={}, paths={}", request.getFilesystem(), request.getPaths() );
117+
BatchDeleteResult result = controller.cleanupEmptyFolders( request.getFilesystem(), request.getPaths() );
118+
return Response.ok(result).build();
119+
}
97120
}

src/main/java/org/commonjava/service/storage/util/Utils.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
import java.util.Collections;
2020
import java.util.LinkedList;
2121
import java.util.List;
22+
import java.util.Set;
23+
import java.util.HashSet;
24+
import java.util.Collection;
2225

2326
public class Utils {
2427
public static Duration getDuration( String timeout )
@@ -59,4 +62,68 @@ public static String[] sort(String[] list) {
5962
Collections.sort(ret);
6063
return ret.toArray(new String[0]);
6164
}
65+
66+
/**
67+
* Returns the depth (number of slashes) in the path, ignoring root.
68+
*/
69+
public static int depth(String path) {
70+
if (path == null || path.isEmpty() || path.equals("/")) {
71+
return 0;
72+
}
73+
String normalized = path.endsWith("/") && path.length() > 1 ? path.substring(0, path.length() - 1) : path;
74+
int depth = 0;
75+
for (char c : normalized.toCharArray()) {
76+
if (c == '/') depth++;
77+
}
78+
return depth;
79+
}
80+
81+
/**
82+
* Returns the parent path of a given path, or null if at root.
83+
*/
84+
public static String getParentPath(String path) {
85+
if (path == null || path.isEmpty() || path.equals("/")) {
86+
return null;
87+
}
88+
String normalized = path.endsWith("/") && path.length() > 1 ? path.substring(0, path.length() - 1) : path;
89+
int lastSlashIndex = normalized.lastIndexOf('/');
90+
if (lastSlashIndex == -1) {
91+
return null;
92+
}
93+
return normalized.substring(0, lastSlashIndex);
94+
}
95+
96+
/**
97+
* Given a collection of folder paths, returns a set of all those folders and their ancestors,
98+
* up to but not including root.
99+
*/
100+
public static Set<String> getAllCandidates(Collection<String> paths) {
101+
Set<String> allCandidates = new HashSet<>();
102+
if (paths != null) {
103+
for (String path : paths) {
104+
String current = path;
105+
while (current != null && !current.isEmpty() && !current.equals("/")) {
106+
allCandidates.add(current);
107+
current = getParentPath(current);
108+
}
109+
}
110+
}
111+
return allCandidates;
112+
}
113+
114+
/**
115+
* Normalize a folder path for deletion: ensures a trailing slash (except for root).
116+
* This helps avoid issues with path handling in the underlying storage system.
117+
*/
118+
public static String normalizeFolderPath(String path) {
119+
if (path == null || path.isEmpty() || "/".equals(path)) {
120+
return "/";
121+
}
122+
// Add trailing slashes
123+
String normalized = path;
124+
while (!normalized.endsWith("/")) {
125+
normalized = path + "/";
126+
}
127+
return normalized;
128+
}
62129
}

src/test/java/org/commonjava/service/storage/StorageControllerIT.java

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import org.commonjava.service.storage.controller.StorageController;
2020
import org.commonjava.service.storage.dto.BatchCleanupResult;
2121
import org.commonjava.service.storage.dto.FileInfoObj;
22+
import org.commonjava.service.storage.dto.BatchDeleteResult;
2223
import org.junit.jupiter.api.Test;
2324

2425
import jakarta.inject.Inject;
@@ -27,6 +28,7 @@
2728
import java.util.Collection;
2829
import java.util.HashSet;
2930
import java.util.Set;
31+
import java.io.OutputStream;
3032

3133
import static org.junit.jupiter.api.Assertions.*;
3234

@@ -80,4 +82,86 @@ public void testCleanup()
8082
assertNull( result );
8183
}
8284

85+
/**
86+
* Test cleanupEmptyFolders for:
87+
* - Deleting empty folders (should succeed)
88+
* - Not deleting non-empty folders (should remain)
89+
* - Recursive parent cleanup (parents deleted if they become empty)
90+
* - Overlapping folder trees (shared parents handled efficiently)
91+
* - Correct reporting in BatchDeleteResult (succeeded/failed)
92+
* - Actual state of the storage after cleanup
93+
*/
94+
@Test
95+
public void testCleanupEmptyFolders_recursiveAndOverlapping() throws Exception {
96+
// Setup: create a nested directory structure
97+
// Structure:
98+
// root/
99+
// a/
100+
// b/ (empty)
101+
// c/ (contains file)
102+
// d/ (empty)
103+
// e/f/g/ (empty)
104+
String root = "test-root";
105+
String a = root + "/a";
106+
String b = a + "/b";
107+
String c = a + "/c";
108+
String d = root + "/d";
109+
String e = root + "/e";
110+
String f = e + "/f";
111+
String g = f + "/g";
112+
String fileInC = c + "/file.txt";
113+
114+
// Create directories (by writing and deleting a dummy file)
115+
createEmptyDirectory(filesystem, b);
116+
createEmptyDirectory(filesystem, c);
117+
createEmptyDirectory(filesystem, d);
118+
createEmptyDirectory(filesystem, g);
119+
// Add a file to c (so c and a are not empty)
120+
try (OutputStream out = fileManager.openOutputStream(filesystem, fileInC)) {
121+
out.write("data".getBytes());
122+
}
123+
124+
// Sanity check: all directories exist
125+
assertTrue(fileManager.isDirectory(filesystem, b));
126+
assertTrue(fileManager.isDirectory(filesystem, c));
127+
assertTrue(fileManager.isDirectory(filesystem, d));
128+
assertTrue(fileManager.isDirectory(filesystem, g));
129+
assertTrue(fileManager.isDirectory(filesystem, f));
130+
assertTrue(fileManager.isDirectory(filesystem, e));
131+
assertTrue(fileManager.isDirectory(filesystem, a));
132+
assertTrue(fileManager.isDirectory(filesystem, root));
133+
134+
// Call cleanupEmptyFolders on [b, d, g]
135+
Set<String> toCleanup = new HashSet<>();
136+
toCleanup.add(b); // should delete b, then a (if a becomes empty)
137+
toCleanup.add(d); // should delete d
138+
toCleanup.add(g); // should delete g, f, e (if they become empty)
139+
BatchDeleteResult result = controller.cleanupEmptyFolders(filesystem, toCleanup);
140+
141+
// Check results
142+
// b, d, g, f, e should be deleted (a and root remain because c is not empty)
143+
assertTrue(result.getSucceeded().contains(b));
144+
assertTrue(result.getSucceeded().contains(d));
145+
assertTrue(result.getSucceeded().contains(g));
146+
assertTrue(result.getSucceeded().contains(f));
147+
assertTrue(result.getSucceeded().contains(e));
148+
// a, c, root should not be deleted
149+
assertFalse(result.getSucceeded().contains(a));
150+
assertFalse(result.getSucceeded().contains(c));
151+
assertFalse(result.getSucceeded().contains(root));
152+
// No failures expected
153+
assertTrue(result.getFailed().isEmpty());
154+
// Check actual state
155+
assertFalse(fileManager.isDirectory(filesystem, b));
156+
assertFalse(fileManager.isDirectory(filesystem, d));
157+
assertFalse(fileManager.isDirectory(filesystem, g));
158+
assertFalse(fileManager.isDirectory(filesystem, f));
159+
assertFalse(fileManager.isDirectory(filesystem, e));
160+
assertTrue(fileManager.isDirectory(filesystem, a));
161+
assertTrue(fileManager.isDirectory(filesystem, c));
162+
assertTrue(fileManager.isDirectory(filesystem, root));
163+
// File in c should still exist
164+
assertTrue(fileManager.exists(filesystem, fileInC));
165+
}
166+
83167
}

src/test/java/org/commonjava/service/storage/StorageIT.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ protected void prepareFile( InputStream in, String filesystem, String path ) thr
8585
}
8686
}
8787

88+
protected void createEmptyDirectory(String filesystem, String dir) throws Exception {
89+
String dummyFile = dir + "/.keep";
90+
try (OutputStream out = fileManager.openOutputStream(filesystem, dummyFile)) {
91+
out.write(0);
92+
}
93+
fileManager.delete(filesystem, dummyFile);
94+
}
95+
8896
/**
8997
* Override this if your test case don't need to prepare the PATH
9098
* @return

0 commit comments

Comments
 (0)