Skip to content

Commit b2c6f74

Browse files
committed
Add api cleanupEmptyFolders
1 parent e7b2d84 commit b2c6f74

File tree

5 files changed

+267
-0
lines changed

5 files changed

+267
-0
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: 14 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,17 @@ public Response purgeFilesystem( final @PathParam( "filesystem" ) String filesys
9495
logger.debug( "Purge filesystem result: {}", result );
9596
return responseHelper.formatOkResponseWithJsonEntity( result );
9697
}
98+
99+
@Operation( summary = "Cleanup multiple empty folders in a filesystem. Always returns 200 OK for easier client handling; partial failures (e.g., not empty or errors) are reported in the result object." )
100+
@APIResponses( { @APIResponse( responseCode = "200", description = "Cleanup done (some folders may have failed, see result object)." ) } )
101+
@Consumes( APPLICATION_JSON )
102+
@Produces( APPLICATION_JSON )
103+
@DELETE
104+
@Path( "folders/empty" )
105+
public Response cleanupEmptyFolders( BatchDeleteRequest request )
106+
{
107+
logger.info( "Cleanup empty folders: filesystem={}, paths={}", request.getFilesystem(), request.getPaths() );
108+
BatchDeleteResult result = controller.cleanupEmptyFolders( request.getFilesystem(), request.getPaths() );
109+
return Response.ok(result).build();
110+
}
97111
}

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: 92 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

@@ -36,6 +38,14 @@ public class StorageControllerIT extends StorageIT
3638
@Inject
3739
StorageController controller;
3840

41+
private void createEmptyDirectory(String filesystem, String dir) throws Exception {
42+
String dummyFile = dir + "/.keep";
43+
try (OutputStream out = fileManager.openOutputStream(filesystem, dummyFile)) {
44+
out.write(0);
45+
}
46+
fileManager.delete(filesystem, dummyFile);
47+
}
48+
3949
@Test
4050
public void testGetFileInfo() throws Exception
4151
{
@@ -80,4 +90,86 @@ public void testCleanup()
8090
assertNull( result );
8191
}
8292

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

src/test/java/org/commonjava/service/storage/util/UtilsTest.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
import java.time.Duration;
2121

2222
import static org.commonjava.service.storage.util.Utils.getDuration;
23+
import static org.commonjava.service.storage.util.Utils.getAllCandidates;
2324
import static org.junit.jupiter.api.Assertions.assertEquals;
25+
import java.util.Set;
26+
import java.util.HashSet;
27+
import java.util.Arrays;
2428

2529
public class UtilsTest
2630
{
@@ -40,4 +44,19 @@ public void getDurationTest()
4044
assertEquals( d1, d2 );
4145
}
4246

47+
/*
48+
* Test getAllCandidates for:
49+
* - Collecting all input folders and their ancestors
50+
* - Excluding root
51+
*/
52+
@Test
53+
public void testGetAllCandidates() {
54+
Set<String> input = new HashSet<>(Arrays.asList("a/b/c", "a/d"));
55+
Set<String> expected = new HashSet<>(Arrays.asList(
56+
"a/b/c", "a/b", "a", "a/d"
57+
));
58+
Set<String> result = getAllCandidates(input);
59+
assertEquals(expected, result);
60+
}
61+
4362
}

0 commit comments

Comments
 (0)