0% прочитано

The Database Can Roll Back, S3 Cannot: Cleaning Up Files After Failure

DB::transaction() can roll back SQL, but it cannot undo an S3 request that already succeeded. File workflows therefore need staging, compensating cleanup, after-commit work, and retryable reconciliation.

12 жовтня 2026 р. 11 хв читанняLaradock

After direct upload to S3 and staging before activation, the next important question appears: what happens when a storage operation succeeds but the following database operation fails?

With SQL we are used to DB::transaction(): if something fails inside the transaction, the database changes can roll back.

S3 or MinIO is not a participant in that transaction. If an object has already been copied, created, or deleted, a database rollback does not undo it.

I therefore do not model database work and object storage as one atomic operation. I design the order of steps and compensation for every failure point instead.

DB::transaction() protects database state only. Any S3 operation that succeeded before rollback remains a real external side effect until code explicitly compensates for it.

MySQL and S3 Have Different Transaction Boundaries

One business operation, but not one technical transaction
MySQL  |  +--> BEGIN  +--> UPDATE  +--> INSERT  +--> ROLLBACK  S3 / MinIO  |  +--> PUT  +--> COPY  +--> DELETE  Database rollback

For the user this may be one action: "replace the video."

For the system it is at least two separate infrastructure operations: changing database state and changing object storage state.

Those systems can succeed or fail independently. The design has to account for that explicitly.

The Dangerous Illusion: Put Everything Inside DB::transaction()

Database rollback will not delete the copied object
DB::transaction(function () use (    $temporaryPath,    $permanentPath,) {    Storage::disk('s3')->copy(        $temporaryPath,        $permanentPath,    );     File::create([        'path' => $permanentPath,    ]);     throw new RuntimeException(        'Something failed later.',    );

After the exception, the File record may disappear because of the rollback.

The object at $permanentPath remains in S3 if the copy already succeeded.

This is a classic orphan object: the bytes exist, but application state no longer references them.

I Map the Failure Points

Every side effect has its own failure boundary
1. tmp upload exists2. validation passes3. permanent copy succeeds4. database update starts5. database commit succeeds6. old object cleanup runs7. tmp cleanup runs  Possible failures: 3 succeeds, 4 fails-> new orphan candidate 5 succeeds, 6 fails-> old orphan candidate

Once the stages are explicit, it becomes easier to decide which inconsistent state is more dangerous.

For me, a database row pointing to a missing file is worse than an unused object left in storage.

I therefore order the steps so that partial failure tends to produce a removable orphan object rather than a broken database reference that affects users.

For a New File I Prepare Storage Before Changing Active Database State

The permanent reference appears after storage is confirmed
tmp object   |   vvalidate   |   vcopy to permanent path   |   vverify permanent object   |   vdatabase transaction   |   vcommit active reference

This order does not make the operation atomic.

It does preserve an important invariant: before database state considers the file active, the permanent object already exists.

If the database transaction fails afterward, an extra storage object remains. That requires cleanup, but application state does not point at a missing file.

If the Database Commit Fails, Compensate the New Permanent Object

SQL rollback plus explicit storage compensation
$disk = Storage::disk('s3'); $disk->copy(    $temporaryPath,    $permanentPath,); if (! $disk->exists($permanentPath)) {    throw new RuntimeException(        'Permanent object was not created.',    );} try {    DB::transaction(function () use (        $file,

The cleanup here is not an S3 rollback. It is a separate compensating action.

That distinction matters because compensation can fail too.

A cleanup failure should not disappear silently. It should at least be reported, and preferably retried or handled by a reconciliation process.

During Replacement I Do Not Delete the Old File First

Deleting the old file first creates a dangerous window
Storage::disk('s3')->delete(    $oldPath,); Storage::disk('s3')->copy(    $temporaryPath,    $newPath,); $file->update([    'path' => $newPath,]);

If copying the new file or updating the database fails after the first line, the old working resource has already been lost.

This is cleanup happening too early.

I keep the old object available until the new object is prepared and the database commit has confirmed the new active path.

It Is Safer to Delete the Old File After Commit

Old object cleanup begins only after the database commit
DB::transaction(function () use (    $file,    $newPath,    $oldPath,) {    $file->update([        'path' => $newPath,    ]);     DB::afterCommit(        static function () use (            $oldPath,        ): void {            Storage::disk('s3')                ->delete($oldPath);        }

Now database state switches to the new path first and commits successfully.

Only after the commit does deletion of the old object begin.

If deleting the old file fails, the user flow still works because the database already points to the new existing object.

afterCommit Does Not Guarantee Cleanup Will Succeed

DB::afterCommit() solves ordering, but it does not make an external request infallible.

After commit, the process can terminate, the network can fail, or S3 can temporarily reject the request.

Important cleanup therefore benefits from a retryable job or reconciliation mechanism that can finish the work later.

Cleanup Should Be Safe to Retry

Retrying cleanup should not create a new problem
if ($disk->exists($oldPath)) {    $disk->delete($oldPath);}

If cleanup runs again, the desired outcome should remain the same: the obsolete object is gone.

This matters especially for queued jobs where retries are a normal part of execution.

I do not want a repeated deletion attempt to become a new failure condition when the previous attempt already removed the object.

The Temporary Object Should Also Survive Until Activation Succeeds

tmp remains a fallback source until commit
tmp object   |   vcopy permanent object   |   vverify   |   vdatabase commit   |   vactive   |   vdelete tmp

If the temporary object is deleted immediately after the copy and the database commit then fails, a retry may no longer have a source object.

I therefore remove tmp/ after successful activation or leave it under the normal cleanup policy until the staging window expires.

A Cleanup Error Should Not Hide the Original Failure

Preserve the original failure
try {    $service->activateFile(        $upload,    );} catch (Throwable $original) {    try {        $cleanup->run(            $candidatePath,        );    } catch (Throwable $cleanupError) {        report($cleanupError);    }     throw $original;}

If the business operation failed because of a database exception, that should remain the primary error returned to the caller.

A compensation failure matters too, but it is a second failure that should be recorded separately.

Otherwise diagnostics may show only "delete failed" and hide the reason compensation started in the first place.

Even Good Synchronous Cleanup Benefits from Reconciliation

A final safety net for orphan objects
periodic reconciliation scan temporary / candidate objects        |        vcompare with application state        |        +--> referenced and valid        |      keep        |        +--> stale and unreferenced               delete

There is no guarantee that the process will always survive long enough to reach compensation code.

It can terminate between the storage request and the next line of PHP.

Periodic reconciliation is therefore the final safety layer: it finds old temporary or candidate objects that no longer have a valid application reference.

The Database Owns Active State, Storage Owns the Bytes

I want one clear answer to the question "which file is active right now?"

That answer comes from application state in the database.

S3 may contain temporary, candidate, old, and active objects at the same time. Mere object existence does not make an object active.

That is why orphan cleanup should reconcile storage against authoritative application state, not the other way around.

I Observe Cleanup as a Separate Part of the Operation

If cleanup is treated as "just the last line," its failures are easy to miss.

It is useful to observe failed compensation, failed old-file deletion, stale temporary objects, and reconciliation deletions separately.

This prevents object storage from accumulating garbage silently for months.

I Test Failures Between the Systems, Not Only the Happy Path

Failure-path tests for MySQL + S3
storage copy fails- database remains unchanged- old active file remains available storage copy succeedsdatabase transaction fails- new permanent candidate is cleaned up- old active file remains authoritative database commit succeedsold-file delete fails- database points to new file- cleanup is retryable tmp delete fails- active file still works

My Checklist for Database and S3 Operations

I verify:

  • Does the code avoid assuming that DB::transaction() can roll back S3?
  • Does the permanent object exist before the database considers it active?
  • Does a new object have a compensation path if the database commit fails?
  • Is the old active file kept until after commit?
  • Can post-commit cleanup be retried?
  • Does the temporary object survive long enough for safe retry?
  • Does a cleanup error preserve the primary failure?
  • Can orphan objects be found later through reconciliation?
  • Is database state the source of truth for the active file?
  • Do failure-path tests cover the boundary between storage and database operations?

I Do Not Invent an Atomic Transaction Where None Exists

MySQL and S3 can participate in one business operation without becoming one technical transaction.

Reliability therefore comes not from one DB::transaction(), but from ordering, explicit compensation, after-commit cleanup, retries, and reconciliation.

I prepare the new object first, commit authoritative database state second, and only then remove old or temporary objects.

Database rollback protects SQL. External side effects need deliberately designed cleanup.

An extra object in storage is usually safer to clean up later than a database reference to a file that no longer exists.