Skip to content

Commit b085255

Browse files
fix(folders): restore new-identifier-on-rename behavior while keeping SQL-based child update (#35176)
## Problem PR #35086 fixed a critical bug (#34655) where renaming a folder containing 20K+ contentlets would fail with: ``` ERROR: Cannot delete as this path has children Where: PL/pgSQL function check_child_assets() line 13 at RAISE ``` The root cause was that the old implementation discovered children via **Elasticsearch** — any unindexed content was silently skipped, leaving orphaned DB rows that caused the delete trigger to fire and fail. PR #35086 fixed this by switching to an **in-place rename** (no create/delete cycle), which eliminated both the ES dependency and the delete entirely. However, this introduced an unintended behavioral change: **the folder identifier (UUID) no longer changes when the folder URL changes**. This breaks the contract established by the deterministic identifier system (`DeterministicIdentifierAPIImpl`), where a folder's UUID is a SHA-256 hash of `assetType:hostname:parentPath:folderName`. After an in-place rename, the UUID stays tied to the old path, decoupling identity from URL. ## Fix This PR restores the original create-new/delete-old identity contract while **keeping the SQL-based child discovery** from PR #35086 — the actual fix for the root cause. ### What changes **`FolderFactoryImpl.renameFolder()`** — reverts the in-place mutation back to a create+delete cycle, but replaces the ES-based child discovery with the depth-first bulk SQL UPDATE: 1. `folder.setName(newName)` so `getNewFolderRecord()` picks up the new name 2. `getNewFolderRecord()` creates a new folder record — new inode + new deterministic identifier via `IdentifierAPI.createNew()` (URL change → identity change restored) 3. `clearIdentifierCacheForSubtree(oldPath)` — evict stale cache entries before mutation 4. `updateChildPaths(oldPath → newPath)` — depth-first bulk `UPDATE identifier SET parent_path = ?` sorted by path length, guaranteeing parent-before-child so the `identifier_parent_path_trigger` passes at every level without needing deferred constraints 5. Cache evictions (identifier, folder, nav) — unchanged from PR #35086 6. `updateOtherFolderReferences(newInode, oldInode)` — restored from pre-#35086 code; needed because the inode now changes (permissions, content-type structure references) 7. `delete(folder)` — safe because all children are moved before this runs, so `check_child_assets()` finds nothing 8. Mutates `folder.setInode()` + `folder.setIdentifier()` on the caller's reference so `FolderAPIImpl.refreshContentUnderFolder()` targets the correct new folder ### Why the delete is now safe The original bug was caused by ES-based child discovery missing unindexed items. With `updateChildPaths()`, child discovery is a direct SQL query against the `identifier` table — no ES dependency, no items skipped. Every child `parent_path` is updated before `delete()` runs, so `check_child_assets()` finds zero children and the trigger allows the delete. ### Why no deferred constraints are needed Depth-first ordering (sort by ascending path length = parent before child) ensures that by the time the `identifier_parent_path_trigger` validates each row's new `parent_path`, the parent folder row was already updated in the previous iteration. No `DEFERRABLE` constraint changes required. ### Identifier semantics | | Old (broken, pre-#35086) | PR #35086 (in-place) | This PR | |---|---|---|---| | URL change → new UUID | Yes (via ES, unreliable) | No | Yes (via SQL, reliable) | | Child discovery | Elasticsearch | Direct SQL | Direct SQL | | 20K+ item performance | 20K round-trips | 1 UPDATE/depth level | 1 UPDATE/depth level | | Constraint violation risk | Yes (unindexed items) | None (no delete) | None (children moved first) | ## Tests updated - **`renameFolder()`** — captures old identifier before rename; asserts old identifier is deleted, sub-folder identifiers are preserved (only `parent_path` updated), renamed folder has new UUID - **`renameFolder_updatesChildrenAndSubChildrenPaths()`** — asserts old parent identifier is deleted, new identifier exists with correct `asset_name`, sub-folder identifier is unchanged, all `parent_path` values are correct at every nesting level Fixes #34655 --------- Co-authored-by: Claude Sonnet 4.6 <[email protected]>
1 parent 5fa89df commit b085255

3 files changed

Lines changed: 102 additions & 85 deletions

File tree

dotCMS/src/main/java/com/dotmarketing/portlets/folders/business/FolderAPIImpl.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,11 @@ public boolean renameFolder(final Folder folder, final String newName,
174174
renamed = folderFactory.renameFolder(folder, newName, user, respectFrontEndPermissions);
175175

176176
// Nav cache eviction for the folder and sub-tree is handled inside the factory.
177-
// NOTE: the factory mutates the passed-in folder via folder.setName(newName), so
178-
// folder.getPath() returns the new path here. refreshContentUnderFolder depends on
179-
// this side-effect to queue the reindex against the renamed path. Do not refactor
180-
// the factory to work on a defensive copy without updating this call site.
177+
// NOTE: the factory mutates the passed-in folder: setName(newName), setInode(), and
178+
// setIdentifier() are all updated to reflect the newly created folder record.
179+
// refreshContentUnderFolder depends on these side-effects to target the renamed path
180+
// and the correct new inode/identifier. Do not refactor the factory to work on a
181+
// defensive copy without updating this call site.
181182
//
182183
// Queue async ES reindex. DotReindexStateException is caught here so a transient
183184
// reindex-queue failure does not roll back an otherwise successful rename.
@@ -676,7 +677,10 @@ public void save(final Folder folder, final String existingId,
676677
final boolean isNew = folder.getInode() == null;
677678
//if the folder was renamed, we will need to create a new identifier
678679
if (!folder.getName().equalsIgnoreCase(existingID.getAssetName())){
679-
folderFactory.renameFolder(folder, folder.getName(), user, respectFrontEndPermissions);
680+
if (!folderFactory.renameFolder(folder, folder.getName(), user, respectFrontEndPermissions)) {
681+
throw new DotDataException("Could not rename folder '" + existingID.getAssetName()
682+
+ "' to '" + folder.getName() + "': a folder with that name already exists.");
683+
}
680684
// Queue async ES reindex so content under the renamed folder is re-indexed at the new
681685
// path. folderFactory.renameFolder() mutates folder.setName(newName), so
682686
// folder.getPath() already returns the new path when this runs.

dotCMS/src/main/java/com/dotmarketing/portlets/folders/business/FolderFactoryImpl.java

Lines changed: 56 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import static com.dotmarketing.portlets.folders.business.FolderFactorySql.GET_CONTENT_TYPE_COUNT;
99

1010
import com.dotcms.browser.BrowserQuery;
11+
import com.dotcms.business.WrapInTransaction;
1112
import com.dotcms.system.SimpleMapAppContext;
1213
import com.dotcms.util.transform.DBTransformer;
1314
import com.dotcms.util.transform.TransformerLocator;
@@ -52,7 +53,6 @@
5253
import io.vavr.control.Try;
5354
import java.io.IOException;
5455
import java.sql.Connection;
55-
import java.sql.SQLException;
5656
import java.sql.Timestamp;
5757
import java.util.ArrayList;
5858
import java.util.Collections;
@@ -632,21 +632,20 @@ private boolean move(final Folder folder, final Object destination)
632632
return successOperation.getValue();
633633
}
634634

635-
private void updateOtherFolderReferences(final String newFolderInode, final String oldFolderInode) {
636-
final DotConnect dotConnect = new DotConnect();
637-
try {
638-
dotConnect.executeStatement("update structure set folder = '" + newFolderInode
639-
+ "' where folder = '" + oldFolderInode + "'");
640-
dotConnect.executeStatement("update permission set inode_id = '" + newFolderInode
641-
+ "' where inode_id = '" + oldFolderInode + "'");
642-
dotConnect.executeStatement("update permission_reference set asset_id = '"
643-
+ newFolderInode + "' where asset_id = '" + oldFolderInode + "'");
644-
645-
646-
} catch (SQLException e) {
647-
Logger.error(FolderFactoryImpl.class, e.getMessage(), e);
648-
throw new DotRuntimeException(e.getMessage(), e);
649-
}
635+
private void updateOtherFolderReferences(final String newFolderInode, final String oldFolderInode)
636+
throws DotDataException {
637+
final DotConnect dc = new DotConnect();
638+
dc.executeUpdate(
639+
"UPDATE structure SET folder = ? WHERE folder = ?", newFolderInode, oldFolderInode);
640+
APILocator.getContentTypeAPI(APILocator.systemUser())
641+
.search("folder='" + newFolderInode + "'", "mod_date", -1, 0)
642+
.forEach(CacheLocator.getContentTypeCache2()::remove);
643+
dc.executeUpdate(
644+
"UPDATE permission SET inode_id = ? WHERE inode_id = ?", newFolderInode, oldFolderInode);
645+
dc.executeUpdate(
646+
"UPDATE permission_reference SET asset_id = ? WHERE asset_id = ?", newFolderInode, oldFolderInode);
647+
APILocator.getPermissionAPI().removePermissionableFromCache(oldFolderInode);
648+
APILocator.getPermissionAPI().removePermissionableFromCache(newFolderInode);
650649
}
651650

652651
/**
@@ -778,6 +777,7 @@ protected boolean isChildFolder(final Folder childFolder, final Folder parentFol
778777
}
779778
}
780779

780+
@WrapInTransaction
781781
@Override
782782
protected boolean renameFolder(final Folder folder, final String newName, final User user,
783783
final boolean respectFrontEndPermissions) throws DotDataException, DotSecurityException {
@@ -805,41 +805,20 @@ protected boolean renameFolder(final Folder folder, final String newName, final
805805
// Snapshot sub-folder old-path data BEFORE any update so targeted cache eviction is possible
806806
final List<Map<String, Object>> subFolderSnapshot = loadSubFolderSnapshot(oldPath, hostId);
807807

808-
// Capture the old values before mutation so they can be restored if a subsequent DB
809-
// operation fails. @WrapInTransaction rolls back DB changes, but Java object state is
810-
// not rolled back automatically — callers that retain a reference after failure would
811-
// otherwise observe the new name permanently despite the rename not completing.
812-
final String oldFolderName = folder.getName();
813-
final String oldAssetName = ident.getAssetName();
814-
815-
// Update the folder record in-place.
816-
// save() uses upsertFolder keyed on the existing inode — the inode is NOT regenerated.
817-
// Because the inode is preserved, the old updateOtherFolderReferences calls (which updated
818-
// structure.folder, permission.inode_id, permission_reference.asset_id from oldInode to
819-
// newInode) are pure no-ops (SET x = X WHERE x = X) and have been removed.
820-
//
821-
// save() internally calls APILocator.getIdentifierAPI().find() to get the identifier for
822-
// FolderCache eviction. At this point the identifier cache still holds asset_name=oldName, so
823-
// save() correctly evicts the old path-keyed cache entry. The identifier cache is evicted on
824-
// the next line, AFTER save() reads it, preserving the correct ordering.
808+
// Set the new name on the folder object so getNewFolderRecord() picks it up via
809+
// initialFolder.getName(). Restore the original name if a subsequent DB operation fails,
810+
// since @WrapInTransaction rolls back DB changes but Java object state is not automatically
811+
// restored.
812+
folder.setName(newName);
813+
814+
// Create a new folder record
815+
// getNewFolderRecord() calls IdentifierAPI.createNew() which generates the deterministic hash
816+
// based on assetType:hostname:parentPath:folderName — changing the URL changes the identity.
817+
final Folder newFolder;
825818
try {
826-
folder.setName(newName);
827-
save(folder);
828-
829-
// Evict old identifier URI cache entry BEFORE mutating asset_name: removeFromCacheByIdentifier
830-
// looks up the identifier by ID from cache (still holds old URI) and evicts by that key.
831-
// After save(ident), the new URI key is stored normally.
832-
CacheLocator.getIdentifierCache().removeFromCacheByIdentifier(ident.getId());
833-
ident.setAssetName(newName);
834-
APILocator.getIdentifierAPI().save(ident);
835-
836-
// Evict again AFTER the DB identifier row has been updated. This prevents a race where the
837-
// identifier can be cached by another thread (e.g. reindex) after the pre-update eviction
838-
// but before/while the asset_name mutation becomes visible.
839-
CacheLocator.getIdentifierCache().removeFromCacheDirect(ident.getId(), hostId, null);
819+
newFolder = getNewFolderRecord(folder, user, parentPath, hostId);
840820
} catch (final DotDataException | RuntimeException e) {
841-
folder.setName(oldFolderName);
842-
ident.setAssetName(oldAssetName);
821+
folder.setName(ident.getAssetName()); // restore original name
843822
// A concurrent rename to the same destination path can race past the collision check and
844823
// hit the DB unique constraint on identifier(parent_path, asset_name, host_inode).
845824
// Treat this the same as a pre-checked collision: restore state and return false.
@@ -849,32 +828,49 @@ protected boolean renameFolder(final Folder folder, final String newName, final
849828
throw e;
850829
}
851830

852-
// Evict identifier cache for the entire subtree BEFORE the bulk UPDATE so the eviction can
853-
// reliably find the old-path-keyed cache entries. removeFromCacheByIdentifier() uses the
854-
// cached identifier's current URI (old path) to evict the path-keyed entry; if we waited
855-
// until after the UPDATE, those URI-keyed entries might already have been LRU-evicted and
856-
// the stale old-path keys would survive indefinitely.
831+
// Evict identifier cache for the entire old subtree BEFORE the bulk UPDATE so the eviction
832+
// can reliably find the old-path-keyed cache entries.
857833
clearIdentifierCacheForSubtree(oldPath, hostId);
858834

859835
// Bulk-update every child identifier's parent_path, processing parent folders before children.
860-
// The snapshot already contains all sub-folder paths so no extra SELECTs are needed.
836+
// The new folder identifier already exists in the DB (created above), so the trigger's
837+
// parent-path existence check passes at every depth level as parents are updated first.
861838
updateChildPaths(oldPath, newPath, hostId, subFolderSnapshot);
862839

863-
// Evict identifier caches rooted at the *new* parent path after the bulk update.
864-
// This closes the window where other threads could have cached descendants with the old
865-
// parent_path during the rename transaction.
840+
// Evict identifier caches rooted at the new path after the bulk update.
866841
clearIdentifierCacheForSubtree(newPath, hostId);
867842

868-
// Targeted folder cache eviction for affected sub-folders (using old path data from snapshot)
843+
// Targeted folder cache eviction for affected sub-folders (using old path data from snapshot).
869844
evictSubFolderCache(subFolderSnapshot, hostId);
870845

871-
// Nav cache cleanup — evict the renamed folder, its parent, and every sub-folder in the tree
846+
// Nav cache cleanup — evict the renamed folder, its parent, and every sub-folder in the tree.
847+
// Uses folder.getInode() (old inode) before it is replaced below.
872848
CacheLocator.getNavToolCache().removeNav(folder.getHostId(), folder.getInode());
873849
CacheLocator.getNavToolCache().removeNavByPath(hostId, parentPath);
874850
for (final Map<String, Object> row : subFolderSnapshot) {
875851
CacheLocator.getNavToolCache().removeNav(hostId, (String) row.get("inode"));
876852
}
877853

854+
// Transfer content-type structure and permission references from old inode to new inode.
855+
updateOtherFolderReferences(newFolder.getInode(), folder.getInode());
856+
857+
// Delete old folder. All children have been moved to the new path by updateChildPaths(),
858+
// so check_child_assets() finds no remaining children pointing to the old path and the
859+
// delete succeeds without a constraint violation.
860+
delete(folder);
861+
862+
// Update the caller's folder reference to the new inode and identifier so that
863+
// FolderAPIImpl.refreshContentUnderFolder() targets the correct renamed folder.
864+
// folder.getName() was already set to newName above.
865+
folder.setInode(newFolder.getInode());
866+
folder.setIdentifier(newFolder.getIdentifier());
867+
868+
// Evict the new folder's own identifier cache entry. clearIdentifierCacheForSubtree()
869+
// only covers items whose parent_path starts with newPath (the folder's children), not the
870+
// folder itself (whose parent_path is parentPath). Without this eviction, a prior URI-keyed
871+
// entry for the same path could be served until natural LRU expiry.
872+
CacheLocator.getIdentifierCache().removeFromCacheByIdentifier(newFolder.getIdentifier());
873+
878874
return true;
879875
}
880876

dotcms-integration/src/test/java/com/dotmarketing/portlets/folders/business/FolderAPITest.java

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,11 @@ public static void prepare() throws Exception {
151151

152152

153153
/**
154-
* Verifies the basic in-place rename behavior for a folder hierarchy.
154+
* Verifies the rename behavior for a folder hierarchy.
155155
* <p>
156-
* All folder identifiers are preserved (same UUIDs), while
157-
* {@code asset_name}/{@code parent_path} are updated consistently.
156+
* The renamed folder receives a new identifier (URL change = identity change). Sub-folder
157+
* identifiers are preserved but their {@code parent_path} values are updated consistently
158+
* via bulk SQL — no Elasticsearch dependency, so unindexed items are never missed.
158159
*/
159160
@Test
160161
public void renameFolder() throws Exception {
@@ -167,26 +168,33 @@ public void renameFolder() throws Exception {
167168
final Folder ftest3 = folderAPI
168169
.createFolders(ftest.getPath()+"/ff1/ff2/ff3", host, user, false);
169170

171+
// Capture the old top-level identifier BEFORE rename — the factory mutates ftest.
172+
final String oldFtestIdentifier = ftest.getIdentifier();
173+
170174
final String newFolderName = "folderTestXX" + System.currentTimeMillis();
171175
assertTrue(folderAPI.renameFolder(ftest, newFolderName, user, false));
172176

173-
final Identifier ident = identifierAPI.loadFromDb(ftest.getIdentifier());
177+
// Old top-level identifier is deleted (create+delete cycle).
178+
final Identifier oldIdent = identifierAPI.loadFromDb(oldFtestIdentifier);
179+
assertNull(oldIdent);
180+
181+
// Renamed folder has a new identifier UUID.
182+
assertNotEquals(oldFtestIdentifier, ftest.getIdentifier());
183+
184+
// Sub-folder identifiers are preserved — only parent_path is updated via bulk SQL.
174185
final Identifier ident1 = identifierAPI.loadFromDb(ftest1.getIdentifier());
175186
final Identifier ident2 = identifierAPI.loadFromDb(ftest2.getIdentifier());
176187
final Identifier ident3 = identifierAPI.loadFromDb(ftest3.getIdentifier());
177-
178-
assertNotNull(ident);
179188
assertNotNull(ident1);
180189
assertNotNull(ident2);
181190
assertNotNull(ident3);
182191

183-
assertEquals(newFolderName, ident.getAssetName());
184-
185192
final String newRootPath = StringPool.SLASH + newFolderName + StringPool.SLASH;
186193
assertEquals(newRootPath, ident1.getParentPath());
187194
assertEquals(newRootPath + "ff1" + StringPool.SLASH, ident2.getParentPath());
188195
assertEquals(newRootPath + "ff1" + StringPool.SLASH + "ff2" + StringPool.SLASH, ident3.getParentPath());
189196

197+
// Folder and sub-folders are findable by new paths.
190198
final Folder newFolder = folderAPI.findFolderByPath(newRootPath, host, user, false);
191199
final Folder newFolder1 = folderAPI.findFolderByPath(newRootPath + "ff1/", host, user, false);
192200
final Folder newFolder2 = folderAPI.findFolderByPath(newRootPath + "ff1/ff2/", host, user, false);
@@ -196,7 +204,10 @@ public void renameFolder() throws Exception {
196204
assertNotNull(newFolder1);
197205
assertNotNull(newFolder2);
198206
assertNotNull(newFolder3);
207+
// Top-level folder has a new identifier; ftest object was mutated to hold it.
199208
assertEquals(ftest.getIdentifier(), newFolder.getIdentifier());
209+
assertNotEquals(oldFtestIdentifier, newFolder.getIdentifier());
210+
// Sub-folders retain their original identifiers.
200211
assertEquals(ftest1.getIdentifier(), newFolder1.getIdentifier());
201212
assertEquals(ftest2.getIdentifier(), newFolder2.getIdentifier());
202213
assertEquals(ftest3.getIdentifier(), newFolder3.getIdentifier());
@@ -239,7 +250,8 @@ public void renameFolder_toExistingName_returnsFalse() throws DotDataException,
239250
* <li><b>Expected Result:</b>
240251
* <ul>
241252
* <li>Rename returns {@code true}.</li>
242-
* <li>Folder and sub-folder identifiers are unchanged (in-place rename).</li>
253+
* <li>The renamed folder receives a new identifier UUID (URL change = identity change).</li>
254+
* <li>Sub-folder identifier is preserved (only {@code parent_path} is updated).</li>
243255
* <li>All direct children's {@code parent_path} in the DB equals the new folder path.</li>
244256
* <li>All sub-children's {@code parent_path} equals the new sub-folder path.</li>
245257
* <li>Folder is findable by new path; old path resolves to nothing.</li>
@@ -268,6 +280,7 @@ public void renameFolder_updatesChildrenAndSubChildrenPaths() throws Exception {
268280
// File asset under /original/sub/
269281
final Contentlet fileInSub = new FileAssetDataGen(subFolder, "content-in-sub").nextPersisted();
270282

283+
// Capture old identifiers BEFORE rename — factory mutates the parentFolder object.
271284
final String parentIdentifierId = parentFolder.getIdentifier();
272285
final String subIdentifierId = subFolder.getIdentifier();
273286

@@ -277,15 +290,17 @@ public void renameFolder_updatesChildrenAndSubChildrenPaths() throws Exception {
277290

278291
assertTrue("renameFolder must return true", renamed);
279292

280-
// Folder identifier is preserved — in-place rename, no create+delete cycle
281-
final Identifier renamedParentIdent = identifierAPI.loadFromDb(parentIdentifierId);
282-
assertNotNull("Parent folder identifier must still exist after rename", renamedParentIdent);
283-
assertEquals("Parent folder identifier UUID must be unchanged",
284-
parentIdentifierId, renamedParentIdent.getId());
285-
assertEquals("Parent folder asset_name must reflect the new name",
286-
newName, renamedParentIdent.getAssetName());
287-
288-
// Sub-folder identifier is preserved and its parent_path is updated
293+
// Renamed folder gets a new identifier — create+delete cycle, URL change = identity change.
294+
final Identifier oldParentIdent = identifierAPI.loadFromDb(parentIdentifierId);
295+
assertNull("Old parent folder identifier must be deleted after rename", oldParentIdent);
296+
assertNotEquals("Renamed folder must have a new identifier UUID",
297+
parentIdentifierId, parentFolder.getIdentifier());
298+
final Identifier newParentIdent = identifierAPI.loadFromDb(parentFolder.getIdentifier());
299+
assertNotNull("New parent folder identifier must exist after rename", newParentIdent);
300+
assertEquals("New parent folder asset_name must reflect the new name",
301+
newName, newParentIdent.getAssetName());
302+
303+
// Sub-folder identifier is preserved and its parent_path is updated via bulk SQL.
289304
final Identifier renamedSubIdent = identifierAPI.loadFromDb(subIdentifierId);
290305
assertNotNull("Sub-folder identifier must still exist", renamedSubIdent);
291306
assertEquals("Sub-folder identifier UUID must be unchanged",
@@ -311,11 +326,13 @@ public void renameFolder_updatesChildrenAndSubChildrenPaths() throws Exception {
311326
assertEquals("File asset in sub-folder: parent_path must reflect the renamed sub-folder path",
312327
"/" + newName + "/sub/", fileInSubIdent.getParentPath());
313328

314-
// Folder is findable by new path and returns the same identifier
329+
// Folder is findable by new path and has the new identifier UUID.
315330
final Folder foundByNewPath = folderAPI.findFolderByPath("/" + newName + "/", site, user, false);
316331
assertNotNull("Renamed folder must be findable by new path", foundByNewPath);
317-
assertEquals("Folder found by new path must have the same identifier UUID",
332+
assertNotEquals("Folder found by new path must have a new identifier UUID (URL change = identity change)",
318333
parentIdentifierId, foundByNewPath.getIdentifier());
334+
assertEquals("Folder found by new path must match the mutated parentFolder identifier",
335+
parentFolder.getIdentifier(), foundByNewPath.getIdentifier());
319336

320337
// Old path no longer resolves to a folder — verified via DB to avoid a stale cache hit.
321338
// A DB query for asset_name=originalName under parentPath='/' must return no folder row.

0 commit comments

Comments
 (0)