A Two-Phase Commit for File Moves, Without a Database Transaction

Photo Sweep marks a verdict 'applied' only after the file has actually moved — never before. A crash between the move and the write leaves the file gone but the row still pending, and retrying handles that correctly.

A Two-Phase Commit for File Moves, Without a Database Transaction

There is no database transaction that can wrap a filesystem move. Nextcloud's storage layer might be local disk, might be S3, might be an SMB share with its own latency and its own ways of failing halfway. CleanupService::apply() has to carry out potentially thousands of individual file moves or deletions, and it has to do so in a way that survives being killed at any point without either losing a verdict or repeating one.

Move the file first; mark applied only after; a crash in between is safe to retry
Move the file first; mark applied only after; a crash in between is safe to retry

Crash between step 1 and step 3: the file already moved but the row still says pending. Retrying finds the file gone, counts it as already-succeeded, and marks it applied.

The two rules

The class comment states both of them plainly:

Two rules hold throughout. A verdict is marked applied only after the file has actually moved, so an interrupted run leaves work pending and safe to retry rather than claiming something it did not do. And each file is handled on its own, so one permission error costs one photo instead of the whole batch.

The ordering is the whole trick. Every other design would either risk claiming a deletion that never happened (mark first, delete second — a crash in between loses a file's worth of state entirely) or risk deleting a file twice (no bookkeeping at all). Doing the risky, irreversible half first and recording success only afterward means the worst a crash can do is leave a few files moved with their rows still pending — which is exactly the state a fresh call to apply() already knows how to continue from.

What a retry sees

foreach ($pending as $decision) {
    try {
        $file = $this->findFile($userFolder, $decision->getFileId());
        if ($file === null) {
            // Already gone — someone deleted it elsewhere. The verdict has
            // effectively been carried out, so record it rather than
            // nagging about it every time the review screen opens.
            $succeededIds[] = $decision->getFileId();
            continue;
        }
        // ... $file->delete() or $file->move(...)
        $succeededIds[] = $decision->getFileId();
    } catch (NotPermittedException $e) {
        $failures[$decision->getFileId()] = '...';
    }
    // ...
}

if ($succeededIds !== []) {
    $this->decisionMapper->markApplied($userId, $succeededIds, time(), $mode);
    $this->mediaMapper->removeByFileIds($userId, $succeededIds);
}

A file that's already gone — because a previous, interrupted run already moved it, and the row just never got marked — is treated as a success, not an error. The mark-applied step is a single batched write covering every file that either moved this time or was already found missing. There is no retry loop that could re-delete an already-deleted file, because deletion is idempotent from this code's point of view: gone is gone, regardless of which run made it so.

Per-file isolation is the other half

The second rule — one permission error costs one photo — matters for a different reason: real libraries have real edge cases, and a shared file a user doesn't own, or a file on a temporarily unreachable external storage, shouldn't stop nine hundred other successful deletions:

} catch (NotPermittedException $e) {
    $failures[$decision->getFileId()] = 'You do not have permission to change this file';
} catch (\Throwable $e) {
    $this->logger->warning(...);
    $failures[$decision->getFileId()] = $e->getMessage();
}

The result type, ApplyResult, reflects this directly: it's not a boolean success/failure, it's a count of successes plus a list of per-file failures with reasons. The comment on that class puts it well: "a run where nine hundred files moved and three did not is a success that needs a footnote, not an error."

The lesson generalizes

Any operation that mutates external, non-transactional state — a file move, an API call to a third-party service, a message published to a queue — has the same shape of problem. The fix is never a bigger transaction, because there usually isn't one available. It's choosing an order of operations where the state after a crash, at every single point, is a state your own retry logic already knows how to interpret correctly.

Series: Photo Sweep.