Skip to content

Commit fd30175

Browse files
fix: prune every version on console folder delete, so it works on versioned buckets
1 parent fd8e949 commit fd30175

2 files changed

Lines changed: 91 additions & 17 deletions

File tree

alarik/Sources/Controllers/Internal/InternalBucketController.swift

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -725,39 +725,58 @@ struct InternalBucketController: RouteCollection {
725725
throw Abort(.internalServerError, reason: "Invalid prefix for deletion")
726726
}
727727

728-
var marker: String? = nil
728+
// Enumerate every VERSION and delete marker under the prefix, cluster-wide - NOT just
729+
// the current objects. A folder delete is a permanent prune, so on a versioning-enabled
730+
// bucket each stored version must be removed by its real versionId. The previous code
731+
// listed only current objects (a ListObjectsV2-style listing) and then deleted them at
732+
// the non-versioned path (versionId nil) - but a versioned object lives at a
733+
// `.versions/<id>` path, so that delete found nothing, removed nothing, and still
734+
// reported success. That is exactly the "folder delete says Successful but nothing is
735+
// gone" symptom on a versioned bucket. `listAllVersions` reports a non-versioned object
736+
// (matching S3) as versionId "null", which `deleteVersion` maps back to the plain path,
737+
// so this single loop prunes versioned and non-versioned buckets alike.
738+
var keyMarker: String? = nil
739+
var versionIdMarker: String? = nil
729740
var failedKeys = 0
730741
repeat {
731-
let (objects, _, isTruncated, nextMarker) = try await ClusterListingService.listObjects(
732-
req: req, bucketName: bucketName, prefix: sanitizedPrefix, delimiter: nil,
733-
maxKeys: 1000, marker: marker)
734-
for object in objects {
742+
let (versions, deleteMarkers, _, isTruncated, nextKeyMarker, nextVersionIdMarker) =
743+
try await ClusterListingService.listAllVersions(
744+
req: req, bucketName: bucketName, prefix: sanitizedPrefix, delimiter: nil,
745+
keyMarker: keyMarker, versionIdMarker: versionIdMarker, maxKeys: 1000)
746+
for object in versions + deleteMarkers {
747+
let targetVersionId = object.versionId ?? "null"
735748
// A per-key delete can fail (a responsible peer being unreachable) - unlike
736749
// the local-disk-only deletePrefix this replaced, whose only failure mode was
737750
// a local IO error. Rather than silently reporting the whole folder deleted
738751
// when some objects survived, count the failures and surface them below.
739-
let outcome: S3Service.ObjectDeleteOutcome
740752
do {
741-
outcome = try await ClusterReplicationService.deleteObjectClusterWide(
742-
req: req, bucketName: bucketName, key: object.key, versionId: nil,
743-
versioningStatus: .disabled)
753+
_ = try await ClusterReplicationService.deleteObjectClusterWide(
754+
req: req, bucketName: bucketName, key: object.key,
755+
versionId: targetVersionId, versioningStatus: .disabled)
744756
} catch {
745757
failedKeys += 1
746758
req.logger.warning(
747-
"Folder delete: failed to delete '\(object.key)' under '\(sanitizedPrefix)': \(error)"
759+
"Folder delete: failed to delete '\(object.key)' (version \(targetVersionId)) under '\(sanitizedPrefix)': \(error)"
748760
)
749761
continue
750762
}
751-
await NotificationService.emit(
752-
event: .objectRemovedDelete, bucketName: bucketName, key: object.key,
753-
size: nil, etag: nil, versionId: outcome.versionId,
754-
requestId: req.id, sourceIP: req.remoteAddress?.ipAddress, app: req.application)
763+
// A delete marker being removed isn't itself an object-removed event; only emit
764+
// for a real object version.
765+
if !object.isDeleteMarker {
766+
await NotificationService.emit(
767+
event: .objectRemovedDelete, bucketName: bucketName, key: object.key,
768+
size: nil, etag: nil, versionId: targetVersionId,
769+
requestId: req.id, sourceIP: req.remoteAddress?.ipAddress, app: req.application)
770+
}
755771
await ReplicationService.enqueueDelete(
756772
app: req.application, bucketName: bucketName, key: object.key,
757-
versionId: nil)
773+
versionId: targetVersionId)
758774
}
759-
marker = isTruncated ? nextMarker : nil
760-
} while marker != nil
775+
// Advance past what was just processed. Marker positions are absolute in the sorted
776+
// key/version space, so deleting items at or before the marker never skips the rest.
777+
keyMarker = isTruncated ? nextKeyMarker : nil
778+
versionIdMarker = isTruncated ? nextVersionIdMarker : nil
779+
} while keyMarker != nil
761780

762781
if failedKeys > 0 {
763782
throw Abort(

alarik/Tests/Controllers/Internal/InternalBucketControllerTests.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1697,6 +1697,61 @@ struct InternalBucketControllerTests {
16971697
}
16981698
}
16991699

1700+
@Test("Delete folder - Versioned objects are actually pruned, every version")
1701+
func testDeleteFolderVersionedPrunesAllVersions() async throws {
1702+
// The regression this guards: folder delete used to list current objects and then delete
1703+
// them at the NON-versioned path (versionId nil, status .disabled). On a versioning-enabled
1704+
// bucket the objects live at `.versions/<id>` paths, so that delete found nothing, removed
1705+
// nothing, and still returned 204 - "Deletion Successful" while everything remained.
1706+
try await withApp { app in
1707+
let token = try await createUserAndLogin(app)
1708+
try await createBucket(app, token: token, name: "vdelete-bucket")
1709+
1710+
// Two versions of one key, plus a second key, all under the folder - and a keeper
1711+
// outside it. All written through the VERSIONED storage path.
1712+
try await putVersionedObject(app, bucketName: "vdelete-bucket", key: "vfolder/file1.txt", content: "v1")
1713+
try await putVersionedObject(app, bucketName: "vdelete-bucket", key: "vfolder/file1.txt", content: "v2")
1714+
try await putVersionedObject(app, bucketName: "vdelete-bucket", key: "vfolder/file2.txt", content: "data")
1715+
try await putVersionedObject(app, bucketName: "vdelete-bucket", key: "keep.txt", content: "keep")
1716+
1717+
try await app.test(
1718+
.DELETE, "/api/v1/objects?bucket=vdelete-bucket&key=vfolder/",
1719+
beforeRequest: { req in
1720+
req.headers.bearerAuthorization = BearerAuthorization(token: token)
1721+
},
1722+
afterResponse: { res async in
1723+
#expect(res.status == .noContent)
1724+
})
1725+
1726+
// The folder's objects must be gone from the current-object listing; only the keeper
1727+
// remains. (Under the bug, the versioned files survived and still appeared here.)
1728+
try await app.test(
1729+
.GET, "/api/v1/objects?bucket=vdelete-bucket",
1730+
beforeRequest: { req in
1731+
req.headers.bearerAuthorization = BearerAuthorization(token: token)
1732+
},
1733+
afterResponse: { res async throws in
1734+
#expect(res.status == .ok)
1735+
let page = try res.content.decode(Page<ObjectMeta.ResponseDTO>.self)
1736+
#expect(page.items.count == 1)
1737+
#expect(page.items.first?.key == "keep.txt")
1738+
})
1739+
1740+
// And it must be a genuine prune - EVERY historical version of a pruned key removed,
1741+
// not merely hidden behind a delete marker.
1742+
try await app.test(
1743+
.GET, "/api/v1/objects/versions?bucket=vdelete-bucket&key=vfolder/file1.txt",
1744+
beforeRequest: { req in
1745+
req.headers.bearerAuthorization = BearerAuthorization(token: token)
1746+
},
1747+
afterResponse: { res async throws in
1748+
#expect(res.status == .ok)
1749+
let versions = try res.content.decode([ObjectMeta.ResponseDTO].self)
1750+
#expect(versions.isEmpty)
1751+
})
1752+
}
1753+
}
1754+
17001755
@Test("Delete folder - Nested folder deletion works")
17011756
func testDeleteNestedFolder() async throws {
17021757
try await withApp { app in

0 commit comments

Comments
 (0)