Bug 636342: External Storage - Document Attachments does not delete blobs on attachment delete#8392
Bug 636342: External Storage - Document Attachments does not delete blobs on attachment delete#8392Groenbech96 wants to merge 3 commits into
Conversation
…delete The OnAfterDeleteEvent subscriber called DeleteFromExternalStorage(Rec), which started with Rec.Find() and exited as soon as that returned false because the row had already been deleted. The blob in Azure was never removed, telemetry tag 0000RNT never fired, and customers' Azure storage bills grew silently for every deleted attachment. Extract the blob-delete + telemetry logic into a path-based helper, keep DeleteFromExternalStorage for callers that still hold a live row (page action, sync report), and rewrite the OnAfterDelete subscriber to gate on Rec's field values (Stored Externally, External File Path, Skip Delete On Copy, Source Environment Hash) and call the helper directly, without Find() or Modify(). Also drop the dead post-call Modify() block that attempted to clear Skip Delete On Copy on the already-deleted row. Add regression tests covering the subscriber path: a delete with the feature enabled removes the blob; Skip Delete On Copy preserves the shared blob from a copied attachment; "Delete from External Storage = false" preserves the blob. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| // The Document Attachment row is already deleted at this point, so we cannot | ||
| // Find() or Modify() it. Delete the blob using the field values still carried | ||
| // on Rec, bypassing the record-based DeleteFromExternalStorage entry point. | ||
| TryDeleteExternalFile(Rec."External File Path", Rec); |
There was a problem hiding this comment.
Blob deletion failure silently orphans files
The return value of TryDeleteExternalFile in OnAfterDeleteDocumentAttachment is discarded. If the external blob deletion fails (e.g., storage unavailable, permissions error), the blob is permanently orphaned with no error log, telemetry event, or way to retroactively identify the stranded file.
Recommendation:
- Capture and act on the return value. At minimum, emit a telemetry failure event so operators can detect and clean up orphaned blobs.
| TryDeleteExternalFile(Rec."External File Path", Rec); | |
| if not TryDeleteExternalFile(Rec."External File Path", Rec) then | |
| DAFeatureTelemetry.LogFileDeletionFailed(Rec); |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
Missing test for cross-environment guard in OnAfterDeleteThe new Recommendation:
Line mapping was unavailable, so this was posted as an issue comment. 👍 useful · ❤️ especially valuable · 👎 wrong - reply with why |
Covers the IsFileFromAnotherEnvironmentOrCompany early-exit branch in OnAfterDeleteDocumentAttachment - blobs owned by another environment must not be deleted by the local environment's row-delete trigger. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks @github-actions — added |
| exit(true); | ||
| end; | ||
|
|
||
| // Use the stored external file path |
There was a problem hiding this comment.
"Try" name without [TryFunction] attribute
TryDeleteExternalFile uses the AL Try-prefix convention, which implies the function catches runtime errors and returns false instead of throwing. Without the [TryFunction] attribute, any unhandled error thrown by ExternalFileStorage.DeleteFile() or FileScenarioCU.GetSpecificFileAccount() will still propagate as an unhandled exception, violating the contract callers expect from a Try-prefixed procedure.
Recommendation:
- Either add
[TryFunction]to the procedure so runtime errors are caught and converted to afalsereturn, or rename the procedure toDeleteExternalFile(dropping the misleadingTryprefix) and let exceptions propagate naturally.
| // Use the stored external file path | |
| [TryFunction] | |
| local procedure TryDeleteExternalFile(ExternalFilePath: Text; DocumentAttachmentForTelemetry: Record "Document Attachment"): Boolean |
👍 useful · ❤️ especially valuable · 👎 wrong - reply with why
The OnAfterDelete regression tests asserted blob presence via CheckIfFileExistInExternalStorage, which routes to the Test File Storage Connector mock. The mock's CreateFile, DeleteFile and FileExists are empty by design, so FileExists always returned false, failing the precondition and the post-delete asserts. Record DeleteFile invocations on Test File Connector Setup (Last Deleted File Path) and expose them through FileConnectorMock.GetLastDeletedPath. The four subscriber tests now assert DeleteFile was (or was not) called with the stored External File Path, which is the actual contract under test for the subscriber path.
Fixes AB#636342 — External Storage – Document Attachments does not delete attachments on external storage.
The bug
When a row in
Table 1173 "Document Attachment"is deleted in a BC28 environment that has the External Storage – Document Attachments app installed and configured with the default setup record, the corresponding blob in the configured Azure Blob container is not deleted. No error or warning surfaces to the user. The opt-in setting"Delete from External Storage" = true(default) is non-functional on the automatic deletion path. Every deleted attachment silently orphans its blob, so customer Azure storage bills grow monotonically.Customer impact:
Root cause
In
App/src/DocumentAttachmentIntegration/DAExternalStorageImpl.Codeunit.al:The
OnAfterDeleteDocumentAttachmentsubscriber fires onDatabase::"Document Attachment", OnAfterDeleteEvent. By the time it runs, the row has already been deleted. The subscriber calledExternalStorageImpl.DeleteFromExternalStorage(Rec), whose first non-trivial line was:Find()always returned false for the deleted row, so the procedure exited before it ever reachedExternalFileStorage.DeleteFile(ExternalFilePath)or theLogFileDeletedtelemetry call. A second latent defect:MarkAsNotUploadedToExternal()callsModify(), which would also fail on a deleted row. The fix has to avoid bothFind()andModify()on the subscriber's code path.A third dead-code defect: after the (silently failing) delete call, the subscriber attempted to clear
Rec."Skip Delete On Copy"viaRec.Modify()— also impossible on a deleted row, and pointless because the row is gone.The fix
TryDeleteExternalFile(ExternalFilePath; DocumentAttachmentForTelemetry). NoFind(), noModify(), no record-state assumptions — justGetSpecificFileAccount→DeleteFile→LogFileDeleted.DeleteFromExternalStorage(used by the page action and the sync report, where the row is still live) to delegate the blob-delete + telemetry to the helper and then callMarkAsNotUploadedToExternal()on success. Behavior preserved for those callers.OnAfterDeleteEventsubscriber to gate onRec's still-populated field values (Stored Externally,External File Path,Skip Delete On Copy,IsFileFromAnotherEnvironmentOrCompany) and call the helper directly withRec."External File Path". Remove the deadModify()block.The choice to keep one helper instead of duplicating a "by path" overload follows the existing convention for this internal codeunit — one method per concern, no parameter-only overloads in an internal impl.
Tests
New tests in
Test/src/DAExtStorageImplTests.Codeunit.al, regionOnAfterDelete Subscriber Tests:RecordDeleteRemovesBlobFromExternalStorage— the direct regression test. Upload a file, delete the row, assert the blob is gone viaCheckIfFileExistInExternalStorage. Would have failed before the fix.RecordDeleteKeepsBlobWhenSkipDeleteOnCopyIsSet— guards the copied-attachment case: when a row is created via the attachment-copy flow,Skip Delete On Copyis set and the shared source blob must NOT be deleted when the copy is removed.RecordDeleteKeepsBlobWhenDeleteFromExternalStorageDisabled— guards the user opt-out: when the setting is off, deleting the row must not touch the blob.The existing
DeleteFromExternalSucceedsForUploadedFile,DeleteFailsWhenFeatureDisabled, andDeleteSkippedWhenSkipDeleteOnCopyIsSettests still cover the manualDeleteFromExternalStorageentry point used by the page action and the sync report.Risk
Low. The
TryDeleteExternalFilehelper is private to the codeunit.DeleteFromExternalStorage's observable behavior is unchanged for its two existing callers (DocumentAttachmentExternal.Page.al:202,DAExternalStorageSync.Report.al:75) — same gating, sameMarkAsNotUploadedToExternalon success, same telemetry. Only the subscriber's behavior changes, and that change is from "silently does nothing" to "actually deletes the blob," which is the documented and intended behavior. The fix is bounded to one app; no platform, no other app, no schema change.🤖 Generated with Claude Code