public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Ciro Iriarte <cyruspy@gmail.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC PATCH storage] rbd: fix copy-offload flatten status race on fast small copies
Date: Wed, 22 Jul 2026 12:34:28 -0300	[thread overview]
Message-ID: <20260722.rbdflattenracefix.copyoffload@cyruspy.gmail.com> (raw)
In-Reply-To: <20260720.0.copyoffload@cyruspy.gmail.com>

copy_image_status() read the image parent, then looked up the manager flatten
task, and declared the flatten dead ("no longer running and the image is still a
clone") whenever the task was missing. But the flatten task is only a liveness
signal; the image parent link is the completion signal. A fast flatten of a small,
near-empty image can vanish from `rbd task list` between the parent read and the
task lookup, so a copy that actually completed was misreported as a dead flatten
and the whole clone was rolled back.

This surfaced live on a running multi-disk clone (3 offloaded 2 GiB disks): one
disk flattened and was reaped cleanly while another, polled a moment later, hit the
empty-task window and failed the clone.

Re-read the authoritative parent when the task is missing: if the flatten just
finished the parent is now gone (complete); only when the parent persists AND a
second task look-up is still empty is the flatten really dead. A genuinely dead
flatten still fails as before.

Add copy_offload_rbd_status_test.pm, the first unit test on the rbd offload path:
it mocks the rbd/ceph command output to pin the race (empty task + parent cleared
on re-read => complete), the genuinely-dead case (=> dies), and the plain
running/complete states. It fails against the pre-fix code with the exact error.

Found by the freeze-bracket live-validation harness (pve-FCLUPlugin #22).

Generated-By: Claude (https://claude.ai)
Signed-off-by: Ciro Iriarte <ciro.iriarte+software@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
---
Applies on top of the v1 series; fixes the 2/5 rbd copy-offload. Found while
live-validating the freeze bracket on a running multi-disk clone (details in the
commit message). Not a v2 -- the rest of the series is unchanged.

 src/PVE/Storage/RBDPlugin.pm             | 117 +++++++++++++----------
 src/test/copy_offload_rbd_status_test.pm |  98 +++++++++++++++++++
 src/test/run_plugin_tests.pl             |   1 +
 3 files changed, 167 insertions(+), 49 deletions(-)
 create mode 100644 src/test/copy_offload_rbd_status_test.pm

diff --git a/src/PVE/Storage/RBDPlugin.pm b/src/PVE/Storage/RBDPlugin.pm
index 31e9414..7f0dc00 100644
--- a/src/PVE/Storage/RBDPlugin.pm
+++ b/src/PVE/Storage/RBDPlugin.pm
@@ -919,61 +919,80 @@ sub copy_image_status {
 
     my (undef, $name) = $class->parse_volname($volname);
 
-    my $cmd = $rbd_cmd->($scfg, $storeid, 'info', $name, '--format', 'json');
-    my $info = '';
-    run_rbd_command($cmd, errmsg => "rbd info '$name' error", outfunc => sub { $info .= shift });
-
-    my $parent = eval { decode_json($info)->{parent} };
-
-    # No parent: the flatten finished and the image is INDEPENDENT -- which is the
-    # difference that matters. A clone is readable from the moment it exists, but
-    # deleting the source before the flatten completes would destroy it.
-    if (!$parent) {
-        # Drop the snapshot this copy was taken from, on the SOURCE image. Once the
-        # flatten completes the target no longer records where it came from, so this is
-        # the last chance to find it -- and leaving it behind pins space on the source
-        # and blocks deleting that volume later.
-        #
-        # Only ever our own: the name is derived from the target, so a caller-supplied
-        # snapshot can never match and is left untouched.
-        if ($source) {
-            my $own_snap = $rbd_copy_snap->($volname);
-            my (undef, $src_name) = $class->parse_volname($source->{volname});
-            eval {
-                my $c = $rbd_cmd->(
-                    $source->{scfg}, $source->{storeid},
-                    'snap', 'rm', $src_name, '--snap', $own_snap,
-                );
-                run_rbd_command($c, errmsg => "rbd snap rm error", outfunc => sub { });
-            };
-        }
-        # The placeholder copy_image_start() parked. Deliberately not removed there:
-        # 'rbd rm' walks every object and that call can run with a guest frozen. Best
-        # effort -- the copy is already complete and correct, and dying here would make
-        # the caller free it.
-        my $parked = copy_parked_name($name);
-        if (rbd_volume_exists($scfg, $storeid, $parked)) {
-            eval {
-                my $c = $rbd_cmd->($scfg, $storeid, 'rm', $parked);
-                run_rbd_command($c, errmsg => "rbd rm placeholder '$parked' error");
+    # The image's parent link is the source of truth for completion: no parent means the
+    # flatten finished and the image is INDEPENDENT -- the difference that matters. A
+    # clone is readable from the moment it exists, but deleting the source before the
+    # flatten completes would destroy it.
+    my $read_parent = sub {
+        my $cmd = $rbd_cmd->($scfg, $storeid, 'info', $name, '--format', 'json');
+        my $info = '';
+        run_rbd_command($cmd, errmsg => "rbd info '$name' error", outfunc => sub { $info .= shift });
+        return eval { decode_json($info)->{parent} };
+    };
+
+    my $parent = $read_parent->();
+
+    # Still a clone: the flatten is running, has just finished, or died. The manager task
+    # is only a LIVENESS signal, not the completion signal. A fast flatten of a small
+    # image can vanish from 'rbd task list' between the info read above and this task
+    # lookup, so a MISSING task is ambiguous -- re-read the authoritative parent before
+    # concluding the flatten died. Without this, a copy that actually completed is
+    # misreported as a dead flatten, the race that surfaces on concurrent multi-disk
+    # clones of small images.
+    if ($parent) {
+        my $task = $rbd_flatten_task->($scfg, $name);
+        if ($task) {
+            my $progress = $task->{progress};
+            return {
+                state => 'pending',
+                (defined($progress) ? (progress => int($progress * 100)) : ()),
             };
-            warn $@ if $@;
         }
 
-        return { state => 'complete' };
+        # No task: re-read the parent. If the flatten just finished it is now gone.
+        $parent = $read_parent->();
+        if ($parent) {
+            # Parent persists with no task. One more task look-up guards a transient
+            # empty 'rbd task list' while the flatten is genuinely still running; only
+            # when that is also empty is the flatten really dead.
+            die "flatten of '$volname' is no longer running and the image is still a clone\n"
+                if !$rbd_flatten_task->($scfg, $name);
+            return { state => 'pending' };
+        }
+        # Parent cleared -- the flatten completed; fall through to the completion cleanup.
     }
 
-    # Still parented. Distinguish a running flatten from one that died: without this a
-    # dead flatten leaves the image parented forever and the caller polls indefinitely.
-    my $task = $rbd_flatten_task->($scfg, $name);
-    die "flatten of '$volname' is no longer running and the image is still a clone\n"
-        if !$task;
+    # INDEPENDENT. Drop the snapshot this copy was taken from, on the SOURCE image. Once
+    # the flatten completes the target no longer records where it came from, so this is
+    # the last chance to find it -- and leaving it behind pins space on the source and
+    # blocks deleting that volume later.
+    #
+    # Only ever our own: the name is derived from the target, so a caller-supplied
+    # snapshot can never match and is left untouched.
+    if ($source) {
+        my $own_snap = $rbd_copy_snap->($volname);
+        my (undef, $src_name) = $class->parse_volname($source->{volname});
+        eval {
+            my $c = $rbd_cmd->(
+                $source->{scfg}, $source->{storeid},
+                'snap', 'rm', $src_name, '--snap', $own_snap,
+            );
+            run_rbd_command($c, errmsg => "rbd snap rm error", outfunc => sub { });
+        };
+    }
+    # The placeholder copy_image_start() parked. Deliberately not removed there: 'rbd rm'
+    # walks every object and that call can run with a guest frozen. Best effort -- the
+    # copy is already complete and correct, and dying here would make the caller free it.
+    my $parked = copy_parked_name($name);
+    if (rbd_volume_exists($scfg, $storeid, $parked)) {
+        eval {
+            my $c = $rbd_cmd->($scfg, $storeid, 'rm', $parked);
+            run_rbd_command($c, errmsg => "rbd rm placeholder '$parked' error");
+        };
+        warn $@ if $@;
+    }
 
-    my $progress = $task->{progress};
-    return {
-        state => 'pending',
-        (defined($progress) ? (progress => int($progress * 100)) : ()),
-    };
+    return { state => 'complete' };
 }
 
 sub alloc_image {
diff --git a/src/test/copy_offload_rbd_status_test.pm b/src/test/copy_offload_rbd_status_test.pm
new file mode 100644
index 0000000..d895f63
--- /dev/null
+++ b/src/test/copy_offload_rbd_status_test.pm
@@ -0,0 +1,98 @@
+package PVE::Storage::TestCopyOffloadRbdStatus;
+
+use strict;
+use warnings;
+
+use lib qw(..);
+
+use PVE::Storage::RBDPlugin;
+use PVE::CephConfig;
+use Test::More;
+
+# copy_image_status() must treat the image PARENT LINK -- not the manager flatten task --
+# as the completion signal. A fast flatten of a small image can vanish from
+# `rbd task list` between the parent read and the task look-up, so a missing task while
+# the image still shows a parent is AMBIGUOUS: re-read the parent before declaring the
+# flatten dead. This pins that race (a copy that finished must not be reported as a dead
+# flatten -- the failure seen live on a concurrent multi-disk clone) plus the
+# unambiguous states around it.
+
+my $scfg = { type => 'rbd', pool => 'testpool' };
+my $storeid = 'rbd-a';
+my $volname = 'vm-100-disk-0';
+
+# Scripted command output, consumed in call order.
+my @info_json;      # what each 'rbd info' returns (parent present or not)
+my @task_json;      # what each 'ceph rbd task list' returns
+
+{
+    no warnings 'redefine';
+
+    # Keep $rbd_cmd from touching the ceph config / filesystem.
+    *PVE::CephConfig::ceph_connect_option = sub { return (); };
+
+    # Every rbd subcommand copy_image_status touches goes through run_rbd_command:
+    #   info  -> the scripted parent state
+    #   ls    -> '[]'  (rbd_volume_exists: the parked placeholder never exists here)
+    #   other -> succeed silently (snap rm / rm are best-effort cleanup)
+    *PVE::Storage::RBDPlugin::run_rbd_command = sub {
+        my ($cmd, %param) = @_;
+        my $op = join(' ', @$cmd);
+        my $out = $param{outfunc} // sub { };
+        if ($op =~ /\binfo\b/) {
+            $out->(shift(@info_json) // '{}');
+        } elsif ($op =~ /\bls\b/) {
+            $out->('[]');
+        }
+        return 0;
+    };
+
+    # $rbd_flatten_task runs `ceph rbd task list` through run_command.
+    *PVE::Storage::RBDPlugin::run_command = sub {
+        my ($cmd, %param) = @_;
+        my $out = $param{outfunc} // sub { };
+        $out->(shift(@task_json) // '[]');
+        return 0;
+    };
+}
+
+my $PARENT = '{"parent":{"pool":"testpool","image":"src","snapshot":"__copy_vm-100-disk-0"}}';
+my $NOPARENT = '{}';
+my $A_TASK =
+    '[{"refs":{"action":"flatten","image_name":"vm-100-disk-0","pool_name":"testpool"},"progress":0.5}]';
+my $NO_TASK = '[]';
+
+sub status {
+    return PVE::Storage::RBDPlugin->copy_image_status($scfg, $storeid, $volname, undef);
+}
+
+# 1. Independent on the first poll: no parent -> complete.
+@info_json = ($NOPARENT);
+@task_json = ();
+is(status()->{state}, 'complete', 'no parent on the first poll => complete');
+
+# 2. Flatten still running: parent + a live task -> pending (with progress).
+@info_json = ($PARENT);
+@task_json = ($A_TASK);
+my $st = status();
+is($st->{state}, 'pending', 'parent + a running flatten task => pending');
+is($st->{progress}, 50, '  progress surfaced from the task');
+
+# 3. THE RACE: parent seen, task list momentarily empty, re-read shows the flatten just
+#    finished. Must be complete, NOT a fatal "flatten no longer running".
+@info_json = ($PARENT, $NOPARENT);   # info#1 parent, info#2 (re-read) parent gone
+@task_json = ($NO_TASK);             # task look-up empty
+is(status()->{state}, 'complete',
+    'empty task + parent-cleared-on-reread => complete (the fixed race)');
+
+# 4. Genuinely dead flatten: parent persists across the re-read and no task on either
+#    look-up -> still fails, as before.
+@info_json = ($PARENT, $PARENT);
+@task_json = ($NO_TASK, $NO_TASK);   # first lookup + the guard lookup both empty
+my $died = !eval { status(); 1 };
+ok($died, 'parent persists + no task on both look-ups => dies (real failure still fails)');
+like($@, qr/no longer running and the image is still a clone/, '  with the flatten-dead error');
+
+done_testing();
+
+1;
diff --git a/src/test/run_plugin_tests.pl b/src/test/run_plugin_tests.pl
index 84995fe..0c55832 100755
--- a/src/test/run_plugin_tests.pl
+++ b/src/test/run_plugin_tests.pl
@@ -20,6 +20,7 @@ my $res = $harness->runtests(
     "copy_offload_test.pm",
     "copy_offload_naming_test.pm",
     "copy_offload_feature_test.pm",
+    "copy_offload_rbd_status_test.pm",
 );
 
 exit -1 if !$res || $res->{failed} || $res->{parse_errors};
-- 
2.54.0




      parent reply	other threads:[~2026-07-22 16:22 UTC|newest]

Thread overview: 7+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-07-20 14:29 [RFC PATCH storage, qemu-server 0/5] offload full clone to the storage backend Ciro Iriarte
2026-07-20 14:29 ` [RFC PATCH storage 1/5] storage: add asynchronous copy-offload hook for full copies Ciro Iriarte
2026-07-20 14:29 ` [RFC PATCH storage 2/5] rbd: implement copy-offload via snapshot + clone + flatten Ciro Iriarte
2026-07-20 14:30 ` [RFC PATCH storage 3/5] dir: implement copy-offload via reflink (FICLONE) Ciro Iriarte
2026-07-20 14:30 ` [RFC PATCH storage 4/5] btrfs, lvmthin: implement copy-offload (atomic class) Ciro Iriarte
2026-07-20 14:30 ` [RFC PATCH qemu-server 5/5] use storage copy-offload for full clone Ciro Iriarte
2026-07-22 15:34 ` Ciro Iriarte [this message]

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260722.rbdflattenracefix.copyoffload@cyruspy.gmail.com \
    --to=cyruspy@gmail.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal