Repository navigation
fix(posix): restore revisions atomically and guard upload rollback - #839
Mortimer-RR wants to merge 2 commits into
Conversation
3c8a465 to
e21625a
Compare
|
Resolved the merge conflict |
|
I approved the CI run. |
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
e21625a to
4c0efbd
Compare
|
Thanks for this, the guard in What I tested
1. Both uploads fail, the newer one first: a regressionWhen B fails first, B is the latest upload, so it rolls back to the version recorded in its session. But that version is the one that was created for A's content when B's bytes were received, and A hasn't succeeded (and later fails): the node gets A's metadata, without A's content, and B's roll back removes the "Both fail, A first" ends the same way, on The root cause is the same as for the version that is left behind (see 3.): a newer upload's "previous version" can be the version of an older upload that never succeeded. Fixing that properly is probably a follow-up of its own, but since this PR changes the outcome of "both fail, B first" for the worse, I think it should at least be covered by a test here, e.g. with the expected outcome marked as a known issue, or B's roll back should skip a version whose upload hasn't succeeded. 2.
|
| after the restore | main (in place) |
this PR |
|---|---|---|
| content | restored | restored |
user.custom (a non-OpenCloud xattr) |
kept | lost |
ACL (setfacl -m u:nobody:r) |
kept | lost |
| inode | unchanged | new |
Since posixfs is meant to share its files with other tools (Samba keeps user.DOSATTRIB, macOS com.apple.*, admins set ACLs), I think the restore should copy all the xattrs of the target, including system.posix_acl_access (ACLs are xattrs as well, so xattr.List/Set covers them), not only user.oc.*. security.* (e.g. an SELinux label) may need privileges; maybe best effort with a warning, like the Chown. The new inode also breaks hard links to the file; that's probably an acceptable trade-off for an atomic restore, but worth a note in the code.
Related, minor: Chmod(fi.Mode().Perm()) (L318) drops setuid/setgid/sticky; for regular files probably irrelevant.
3. The version created for the failed older upload stays (follow-up)
When the older upload fails after the newer one created a version for it, that version is kept "as an ordinary version". It's broken, though: with decomposed it points to A's blob, which was never written (downloading it fails with HTTP 500; confirmed end to end with this PR); with posix it contains the original's bytes under A's metadata (it downloads as the original content, while claiming A's size and checksum). This is the known FIXME in Cleanup, so I don't think it has to be part of this PR: I'll open an issue and a PR on top of this one for it, which can also take care of 1.
Restore with watch_fs and enable_fs_revisions: fine
Since the rename into place is a new file for the watcher (IN_MOVED_TO), I checked that it doesn't trigger anything unexpected, end to end on posix with the inotifywait watcher, comparing this PR with main:
watch_fson, versions from uploads: restoring a version through the API (COPY /remote.php/dav/meta/<fileid>/v/<version>) restores the content, keeps the file id, leaves the size and checksum xattrs consistent with the bytes on disk, and leaves exactly one version (the replaced content). The watcher only logs "skip already known item".watch_fsandenable_fs_revisionson, versions from changes on disk (uploads fail in that setup, according to posix: with enable_fs_revisions every upload fails at finalize #840): same result after the restore, the.CURRENTcopy holds the restored content, and the next change on disk creates a version with the restored content, as expected.
The behaviour was the same as on main in all of these. (Only the inotifywait watcher; not tested with the GPFS or CephFS watchers.)
A remark
.oc-tmpis in the space root: if a folder within the space is on another file system (e.g. a bind mount), the rename fails withEXDEV. That's consistent with uploads, whose final rename from.oc-tmp(blobstore.Upload) has the same limitation, so just mentioning it.
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
|
@pbleser-oc Thanks a lot for the thorough testing, and for the table, that made the cases very easy to follow. I pushed a commit on top that addresses 1. and 2. 1. Both fail, the newer one firstRather than skipping the version, B's roll back now leaves the decision to the upload it belongs to. B records, when it creates the version, whether the node was still in postprocessing for an older upload ( "Still processing" is checked through A's session (it exists and its status is I added your orders as tests in
"Both fail, A first" and the leftover version stay for your follow-up; the FIXMEs point at #838. This changes one existing expectation: "the first can fail after the second fails" (two uploads creating a new file) expected the status to be cleared after the second upload fails, while the first one is still in postprocessing. That's the status removal you described, so the test now expects 2.
|
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.
|
Thanks for
End to end with an OpenCloud server, posix and decomposed, every order in which two overlapping uploads A (older) and B (newer) of an existing file can finish (same setup as in my comment in #838):
The remaining failures are all about the version created for the older upload, which is #883. I've rebased #884 onto From my side, this PR is good to go; #884 follows on top of it. |
Fixes #838
Changes
pkg/storage/fs/posix/tree/revisions.goRestoreRevision: copies the revision into atemp file in the space's
.oc-tmpdirectory, fsyncs it, carries over the target'suser.oc.*xattrs, mode and owner (a failed chown is logged, not fatal), applies therevision's checksum, blob id, blob size and type attributes and the mtime, and
rename()s it over the target, likeblobstore.Uploaddoes for uploads. The mtime is thenset through the metadata backend so its cache picks up the new attributes. The "current"
copy for
EnableFSRevisionsis unchanged.pkg/storage/pkg/decomposedfs/upload/upload.goCleanup(versionIDbranch): takes thenode's metadata lock and re-reads the status through the metadata backend (bypassing the
node's attribute cache). It only restores when the node is still
processing:<this session>. Otherwise it logs, keeps the node, and leaves the revision as an ordinaryversion. The lock is released before the rest of
Cleanup, whoseUnmarkProcessinglocks again through a different node object.
Tests
pkg/storage/fs/posix/tree/revisions_test.go:main: target truncated to 0 bytes);main: 3831 of3841 reads saw partial content).
pkg/storage/pkg/decomposedfs/upload_async_test.go, "two uploads overwrite an existingfile in parallel": the older upload fails after the newer one succeeded; the newer content
and the original version must survive (fails on
main: the file is rolled back from 20to 10 bytes).
go test -raceclean onpkg/storage/fs/posix/tree,pkg/storage/pkg/decomposedfsandits
uploadpackage.pkg/storage/...passes.(upstream: 50 of 50 bad); aborting the older upload keeps the newer content (upstream:
rolled back).
Note: downloads still don't take the node lock; with the atomic rename they don't need it
for consistent bytes (an open file descriptor keeps the old inode).
🤖 Generated with Claude Code