Skip to content

fix: don't leave or roll back into the revision of a failed older upload - #884

Draft
pbleser-oc wants to merge 3 commits into
opencloud-eu:mainfrom
pbleser-oc:fix/883-overlapping-upload-versions
Draft

pbleser-oc wants to merge 3 commits into
opencloud-eu:mainfrom
pbleser-oc:fix/883-overlapping-upload-versions

Conversation

@pbleser-oc

@pbleser-oc pbleser-oc commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Fixes #883.

Builds on #839, which is in another fork: until #839 is merged, this PR's diff contains #839's commits (4c0efbd4d, a0ba3b373) as well. The change of this PR is the last commit, 80daa79e9. I'll rebase it onto main once #839 is merged. Draft until then.

Reworked on top of a0ba3b373, which hands the node back to the older upload when the newer one fails (versionProcessingID) and marks an aborted session as failed: this PR no longer does that itself.

What it fixes

When a newer upload of a file is received while an older one is still in postprocessing, the file's metadata at that moment, the older upload's, is kept as a revision, into which the older upload writes its blob if it succeeds (DecomposedFsSession.Finalize). That revision only becomes valid if the older upload succeeds. With #839, two cases still go wrong (see #883 for the details and all orders):

  • The older upload fails after the newer one replaced the node: the revision is left behind without its content. With decomposed it points to a blob that was never written (downloads fail with HTTP 500); with posix it holds the previous content's bytes under the older upload's metadata.
  • The newer upload fails after that: it rolls back to that revision, so the node points to the older upload's content, which doesn't exist. This also happens when the older upload was aborted.

Changes

pkg/storage/pkg/decomposedfs/upload/upload.go, DecomposedFsSession.Cleanup, after a failed postprocessing:

  • The failing upload doesn't own the node anymore (the case fix(posix): restore revisions atomically and guard upload rollback #839 keeps): deleteRevisionCreatedForUpload deletes the revision a newer upload created for its content, i.e. the revision named after the session's mtime, if it still belongs to this upload (its blob id is the session id). This is the FIXME in Cleanup, now also handled in the branch for new files.
  • The failing upload owns the node, and the revision to roll back to is gone (the older upload failed and deleted it): the node is rolled back to its latest remaining revision instead (Node.DeleteRevision(ctx, "")), the content the older upload had replaced.
  • Otherwise, the roll back and fix(posix): restore revisions atomically and guard upload rollback #839's hand-back are unchanged.

Tests

pkg/storage/pkg/decomposedfs/upload_async_test.go:

  • "two uploads overwrite an existing file in parallel" (added in fix(posix): restore revisions atomically and guard upload rollback #839): the FIXME expectations are enabled (only the original version is left), the pending "restores the original file when both fail, the older one first" is enabled, "does not hand the node back to an older upload that was aborted" also checks that the original file is restored without versions, and two tests check the versions when both uploads succeed, in either order;
  • "Two uploads are processed in parallel" (a new file): the expectations that were commented out with FIXME we need to delete the revision are enabled, and "the first can fail after the second fails" expects the file to be deleted once both uploads failed, which resolves the FIXME there.

Disabling either part of the fix makes tests fail. go test -race of pkg/storage/pkg/decomposedfs/... and pkg/storage/fs/posix/... passes, golangci-lint reports no issues.

End-to-end check

With an OpenCloud server, for posix and decomposed, this PR merged onto current main (it merges cleanly): every order in which the two uploads can finish, each on a file of its own. Postprocessing was held with POSTPROCESSING_DELAY=1h, and the PostprocessingFinished events were published in the order of each case (as described in my comment in #838). After each step, the script checked the file's content (or that it is still processing), downloaded every remaining version, and with posix compared the bytes of the file and of each version with their user.oc.blobsize and user.oc.cs.sha1 xattrs.

order main #839 this PR
A fails after B succeeded B lost (decomposed), file corrupted (posix), broken version broken version left ok
A fails before B succeeds same broken version left ok
both fail, A first file broken (HTTP 500 / download error) same ok
both fail, B first file broken while A is still processing ok ok
A succeeds after B failed same ok ok
both succeed, B first ok (decomposed), version metadata wrong (posix) same same
both succeed, A first same same same
A aborted, then B fails not checked file broken (HTTP 500 / download error) ok

So every case this PR changes is fixed for both drivers, and posix stays consistent: in these cases, none of the uploads that failed had replaced the file's bytes.

The two cases where both uploads succeed show a separate posix bug, unchanged by this PR and by #839, and also on main: when the older upload succeeds after the newer one was received, its version gets the older upload's bytes, but the newer upload's size (measured: 21 bytes instead of 19), and, as all user.oc.* xattrs are copied, its blob id and checksums. See #887.

Not addressed: the size propagation of overlapping failed uploads (owncloud/ocis#8848).

RestoreRevision in the posix tree truncated the live file and copied the
revision into it in place. Concurrent readers could see an empty or
partially restored file, and a failed copy left the file truncated
while its xattrs still described the previous content.

The posix RestoreRevision now copies the revision into a temp file in
the space's .oc-tmp directory, fsyncs it, carries over the target's oc
xattrs, mode and owner, applies the revision's checksum, blob id, blob
size and mtime, and renames it over the target, like the blobstore does
for uploads.

Separately, rolling back an aborted upload (Cleanup with a versionID)
doesn't check that the session still owns the node. An older upload
whose postprocessing fails after a newer upload has finished therefore
reverts the node to the old version, replacing the newer content.
Cleanup now locks the node, checks that it is still processing this
session, and only then calls RevertUpload, under the same lock (the
metadata lock is re-entrant for the node object). Otherwise it keeps
the node and leaves the revision as an ordinary version.

Assisted-by: Claude:claude-opus-5-5
RestoreRevision only carried the user.oc.* xattrs over to the restored
file, so other attributes set on the file (Samba's user.DOSATTRIB,
macOS com.apple.*, custom xattrs, ACLs in system.posix_acl_access) were
lost with every restore. It now copies all xattrs of the target; a
failure on a non-oc attribute (e.g. security.* without privileges) is
logged as a warning. The setuid, setgid and sticky bits are kept too,
with the chown done before the chmod since it clears them.

When two uploads replace a file and the newer one fails first, its
rollback restored the version it had created for the older upload, which
was still in postprocessing, and cleared the processing status. The node
got the metadata of an upload that never finished, and the older
upload's own rollback, which now checks that it owns the node, left it
like that. The newer upload now records the older upload's id when it
creates the version, and if that upload is still processing when it
rolls back, it marks the node as processing for it again, so that the
older upload's outcome decides. Cleanup marks a kept session as failed
when it rolls back, so that an aborted upload is not handed the node.

Assisted-by: Claude:claude-opus-5-5
When a newer upload of a file is received while an older one is still in
postprocessing, the file's metadata at that moment, the older upload's, is
kept as a revision, into which the older upload writes its blob if it
succeeds (see Finalize). That revision only becomes valid if the older
upload succeeds. With opencloud-eu#839, two cases still went wrong (opencloud-eu#883):

- When the older upload failed after the newer one had replaced the node,
  the revision was left behind without its content: with decomposed, it
  pointed to a blob that was never written (downloads failed), with posix
  it held the previous content's bytes under the older upload's metadata.
  Cleanup now deletes it, if it still belongs to the failed upload (its
  blob id is the session id). That's the FIXME in Cleanup, for existing
  and for new files.
- When the newer upload failed after that, it rolled back to that deleted
  revision. It now rolls back to the latest remaining revision instead,
  the content the older upload had replaced. This also covers an older
  upload that was aborted.

Tests: the expectations that were commented out with FIXMEs are enabled,
the pending test of both uploads failing, the older one first, is
enabled, and the cases where both succeed and where the aborted older
upload is followed by a failing newer one check the versions. "the first
can fail after the second fails" for a new file now expects the file to
be deleted once both uploads failed.

Builds on opencloud-eu#839. The size propagation of overlapping failed uploads
(owncloud/ocis#8848) is unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Overlapping uploads: a failed older upload leaves a broken version behind, and a failing newer upload can roll back into it

2 participants