Big issues with the StarLVM plugin and data corruption after a snapshot rollback

Software-based VM-centric and flash-friendly VM storage + free version
nvanaert
Posts: 12
Joined: Tue Jul 14, 2026 6:39 am

Thu Oct 15, 2026 12:24 pm

Dear sir/miss,

StarLVM plugin (starwind-proxmox-plugin-pve9 1.1.41, PVE 9.2.21): snapshot rollback recreates the disk LVs with lvcreate -kn -n vm-<id>-disk-<n> -s standard/snap_vm-<id>-disk-<n>_<snapname>, which clears ACTIVATION_SKIP. All other plugin operations use -ky. With the shared VG, LVM event autoactivation (vgchange -aay --autoactivation event) on any node that boots then activates these thin LVs and their pool while the VM runs on another node. On 2026-10-05 this happened on two nodes during a rolling reboot, the pool was active on two nodes at once, and the thin pool metadata was corrupted (thin_check status 64 on next activation). We have the LVM archives, journals and the original tmeta (as an image file) available. Please fix the rollback to use -ky, and confirm whether vgchange --setautoactivation n on the shared VG is supported with your plugin.
yaroslav (staff)
Staff
Posts: 4515
Joined: Mon Nov 18, 2019 11:11 am

Thu Oct 15, 2026 1:47 pm

Hi,

Can you please pull the plugin logs from both nodes?
Use /opt/starwind/starwind-proxmox-plugin/collect_pve_logs.sh

Upload them here https://www.starwindsoftware.com/support-form. 1560299 - is your reference
nvanaert
Posts: 12
Joined: Tue Jul 14, 2026 6:39 am

Thu Oct 15, 2026 2:36 pm

Yaroslav,

I've uploaded the logs as requested. I think the code below could solve the issue, be careful, as I might've made mistakes though

Code: Select all

--- StarLvmPlugin.pm.1.1.41.orig	2026-10-15 14:37:49.340779120 +0200
+++ StarLvmPlugin.pm	2026-10-15 14:37:49.361779194 +0200
@@ -661,7 +661,7 @@
         # remove the thin volume (it is a snapshot too)
         run_command(['/sbin/lvremove', '-f', "$vg/$volname"], errmsg => "lvremove '$vg/$volname' error");
         # create a copy of the specified snapshot with the volume's name
-        run_command(['/sbin/lvcreate', '-kn', '-n', $volname, '-s', "$vg/$snapvol"], errmsg => "lvm rollback '$vg/$snapvol' error");
+        run_command(['/sbin/lvcreate', '-ky', '-an', '-n', $volname, '-s', "$vg/$snapvol"], errmsg => "lvm rollback '$vg/$snapvol' error"); # local patch: -ky -an (was -kn), keep ACTIVATION_SKIP on shared VG
     });
 }
nvanaert
Posts: 12
Joined: Tue Jul 14, 2026 6:39 am

Thu Oct 15, 2026 2:42 pm

Yaroslav,

Another issue came up when searching through the logs (partially Claude assisted, but found myself in dmesg).

The LVM commands keep running after the cluster storage lock expires
These operations run their LVM commands inside cluster_lock_storage(), so that only one node changes the shared VG at a time:
- volume_resize (line 621)
- volume_snapshot (line 640)
- volume_snapshot_rollback (line 660)
- volume_snapshot_delete (line 676)
- rename_snapshot (line 690)
- StarLvmMonitor.pl (line 116)

PVE's cfs_lock sets alarm(60) around the locked code (pve-cluster, src/PVE/Cluster.pm: "fixed command timeout: cfs locks have a timeout of 120"). run_command (pve-common, src/PVE/Cmd.pm) only installs its own SIGALRM handler and kills the child when it is given a "timeout" parameter. The plugin never passes one. When the 60 s alarm fires, the Perl code dies and cfs_lock releases the lock, but the lvcreate, lvremove or lvextend child keeps running. From then on, other nodes can take the storage lock and write VG metadata at the same time as the still-running command. Without lvmlockd, this can corrupt the metadata of the whole VG, not just one pool.

Thin pool operations easily take longer than 60 s under load, because each thin volume and the pool are suspended in turn.

Evidence
On 2026-10-10 on idkstbabshv05, StarLvmMonitor.pl started "lvextend --use-policies standard/lvmth-1033". It stayed in D state in thin_presuspend/thin_postsuspend for about 30 minutes. By the code above, the lock was released after 60 s, so the extend ran unlocked for about 29 minutes.

Expected
The lock is held until the LVM command has finished, or the operation fails without leaving a running LVM command behind. Alternatively, document and support lvmlockd for the shared VG.

Claude's suggested fix for this issue is to wrap every LVM command that runs under the lock in a helper. The 60 second abort is replaced by a timer that renews the lock every 30 seconds and only would returns when the command is really finished.

Code: Select all

  # Run an LVM command inside cluster_lock_storage() and keep the cfs lock
  # alive until the command has exited, instead of losing it after 60 s.
  sub run_lvm_locked {
      my ($storeid, $cmd, %param) = @_;

      my $lockfile = "/etc/pve/priv/lock/storage-$storeid";
      local $SIG{ALRM} = sub {
          # same lock renewal the HA manager uses (PVE::HA::Env::PVE2)
          utime(0, time(), $lockfile)
              or warn "cannot renew cfs lock 'storage-$storeid': $!\n";
          alarm(30);
      };
      alarm(30);

      my $res = eval { run_command($cmd, %param) };
      my $err = $@;
      alarm(0);
      die $err if $err;
      return $res;
  }
yaroslav (staff)
Staff
Posts: 4515
Joined: Mon Nov 18, 2019 11:11 am

Thu Oct 15, 2026 3:08 pm

AFAIK, a timeout extension for the plugin is already on the to-do list. Please wait for the next build.
Please tell me more about the system
1. storage provider
2. protocol over which the storage is connected.
3. Proxmox version.
nvanaert
Posts: 12
Joined: Tue Jul 14, 2026 6:39 am

Fri Oct 16, 2026 9:57 am

yaroslav (staff) wrote: ↑
Thu Oct 15, 2026 3:08 pm
AFAIK, a timeout extension for the plugin is already on the to-do list. Please wait for the next build.
Please tell me more about the system
1. storage provider
2. protocol over which the storage is connected.
3. Proxmox version.
1. Fujitsu Siemens Eternus with a 4x Dell PowerEdge R740 and 6x Fujitsu servers (2 separate clusters with 2 separate volumes)
2. We're using iSCSI 10Gbps with Multipath to 2x Cisco Nexus 9300 series switches.
3. Proxmox version on all hosts is 9.2.21

Another few issues that I've encountered based on the logs
- During live migration, the thin pool is active on two nodes
- LVM commands keep running after the cluster storage lock expires
- StarLvmMonitor.pl reports success when the extend did not run
- Pools left active on other nodes are not detected, and their space is reused

Evidence of these bugs can be found in the logs
- We hit the same pattern with a longer window. On 2026-10-05, shv02 activated lvmth-1005 at boot (autoactivation, 11:24:35) while VM 1005 was running on shv05. VM 1005 was live-migrated onto shv02 at 11:29-11:30. That VM had had a snapshot since 2026-09-17, so most of its writes were copy-on-write. Days later, the pool failed thin_check with status 64.

- After lvmth-1005 was removed and recreated on shv05 (2026-10-15), shv03 still had live dm mappings of the old lvmth-1005 pool and disks. The VM was not running there. We removed them by hand. An earlier case on shv02 left orphaned dm mappings of a deleted pool (lvmth-1001), with "Failed to suspend ... with queued messages" when the VMID was reused.

- On 2026-10-10 on shv05, StarLvmMonitor.pl started "lvextend --use-policies standard/lvmth-1033". It stayed in D state in thin_presuspend/thin_postsuspend for about 30 minutes, during a heavy initial write into a new VM. By the code above, the lock was released after 60 s, so the extend ran unlocked for about 29 minutes.

I've also put these details in the ticket via an e-mail update.
nvanaert
Posts: 12
Joined: Tue Jul 14, 2026 6:39 am

Mon Oct 19, 2026 9:33 am

After doing some digging myself (assisted by Claude), I had Claude write a few patches (which I reviewed) for the current StarLVM version (untested in Production, but they compile and passed testing and seem to work), I'll see if I can test them here on an older test cluster:

Of course use these patches at your own risk and check / review them first (as I'm familiar with Perl, but certainly no expert).

0001-rollback-keep-activation-skip.patch

Code: Select all

--- a/usr/share/perl5/PVE/Storage/Custom/StarLvmPlugin.pm
+++ b/usr/share/perl5/PVE/Storage/Custom/StarLvmPlugin.pm
@@ -661,7 +661,9 @@
         # remove the thin volume (it is a snapshot too)
         run_command(['/sbin/lvremove', '-f', "$vg/$volname"], errmsg => "lvremove '$vg/$volname' error");
         # create a copy of the specified snapshot with the volume's name
-        run_command(['/sbin/lvcreate', '-kn', '-n', $volname, '-s', "$vg/$snapvol"], errmsg => "lvm rollback '$vg/$snapvol' error");
+        # keep activation skip and leave it inactive like every other volume on the shared vg,
+        # '-kn' let boot autoactivation on other nodes activate the thin pool
+        run_command(['/sbin/lvcreate', '-ky', '-an', '-n', $volname, '-s', "$vg/$snapvol"], errmsg => "lvm rollback '$vg/$snapvol' error");
     });
 }
0002-disable-lv-autoactivation.patch

Code: Select all

--- a/usr/share/perl5/PVE/Storage/Custom/StarLvmPlugin.pm
+++ b/usr/share/perl5/PVE/Storage/Custom/StarLvmPlugin.pm
@@ -111,6 +111,35 @@
     }
 };
 
+# Boot or iSCSI-login autoactivation must never activate a volume of the shared vg on
+# another node. Like PVE 9 LvmThinPlugin, disable it per LV in addition to activation
+# skip. lvcreate on trixie does not accept --setautoactivation for thin LVs, so use lvchange.
+sub set_lv_autoactivation_off {
+    my ($vg, $lv) = @_;
+
+    eval { run_command(['/sbin/lvchange', '--setautoactivation', 'n', "$vg/$lv"]); };
+    warn "could not disable autoactivation for '$vg/$lv': $@" if $@;
+}
+
+my $vg_autoactivation_checked = {};
+
+sub warn_vg_autoactivation {
+    my ($vg) = @_;
+
+    # activate_storage runs often, check once an hour per process
+    return if time() - ($vg_autoactivation_checked->{$vg} // 0) < 3600;
+    $vg_autoactivation_checked->{$vg} = time();
+
+    my $autoactivation = '';
+    eval {
+        run_command(['/sbin/vgs', '--noheadings', '-o', 'autoactivation', $vg],
+            outfunc => sub { $autoactivation = trim(shift); },
+            errfunc => $ignore_no_medium_warnings);
+    };
+    print "WARNING: autoactivation is enabled on shared vg '$vg', disable it with 'vgchange --setautoactivation n $vg'\n"
+        if $autoactivation eq 'enabled';
+}
+
 sub lvm_vg_pv_dm_name {
     my ($vg) = @_;
 
@@ -278,6 +307,7 @@
 	    # create new thin pool
         $cmd = ['/sbin/lvcreate', '--type', 'thin-pool', '-an', '-ky', '-Zy', '-n', $pool, '-L', "${psize}k", '--addtag', $sidtag, "$vg"];
         run_command($cmd, errmsg => "creation of thin pool '$pool' in $vg error");
+        set_lv_autoactivation_off($vg, $pool);
     }
 
     my $msize = $size / 1024;
@@ -285,6 +315,7 @@
 
     $cmd = ['/sbin/lvcreate', '--type', 'thin', '-an', '-ky', '-V', "${size}k", '-n', $name, '--addtag', "pve-vm-$vmid", "$vg/$pool"];
     run_command($cmd, errmsg => "lvcreate thin lv '$vg/$name' error");
+    set_lv_autoactivation_off($vg, $name);
 
     my $cache = {};
     activate_volume($class, $storeid, $scfg, $name, undef, $cache);
@@ -399,6 +430,7 @@
 
     my $vg = $scfg->{vgname};
     print "unsupported storage of vg '$vg'\n" unless chk_stor($vg);
+    warn_vg_autoactivation($vg);
 
     $class->SUPER::activate_storage($storeid, $scfg, $cache);
 }
@@ -536,6 +568,7 @@
         push @$cmd, '--addtag', "pve-sid-$storeid";
         push @$cmd, "$vg";
         run_command($cmd, errmsg => "creation of pool '$pool' in $vg error");
+        set_lv_autoactivation_off($vg, $pool);
     }
 
     if ($lvs->{$vg}->{$lv} && $lvs->{$vg}->{$lv}->{lv_state} eq 'a') {
@@ -546,6 +579,7 @@
     my $cmd = ['/sbin/lvcreate', '-s', '-an', '-ky', '-n', $name, '-prw', '--thinpool', $pool,
             '--addtag', "pve-vm-$vmid", "$vg/$volname"];
     run_command($cmd, errmsg => "clone image '$vg/$volname' into $name with pool $pool error");
+    set_lv_autoactivation_off($vg, $name);
 
     return $name;
 }
@@ -664,6 +698,8 @@
         # keep activation skip and leave it inactive like every other volume on the shared vg,
         # '-kn' let boot autoactivation on other nodes activate the thin pool
         run_command(['/sbin/lvcreate', '-ky', '-an', '-n', $volname, '-s', "$vg/$snapvol"], errmsg => "lvm rollback '$vg/$snapvol' error");
+        # PVE 9 LvmThinPlugin does the same after its rollback
+        set_lv_autoactivation_off($vg, $volname);
     });
 }
0003-plugin-renew-cfs-lock.patch

Code: Select all

--- a/usr/share/perl5/PVE/Storage/Custom/StarLvmPlugin.pm
+++ b/usr/share/perl5/PVE/Storage/Custom/StarLvmPlugin.pm
@@ -121,6 +121,32 @@
     warn "could not disable autoactivation for '$vg/$lv': $@" if $@;
 }
 
+# PVE cfs_lock() aborts the locked code after 60 s and releases the lock, but a running
+# LVM command is not killed and goes on changing the shared vg without the lock (a thin
+# pool suspend can take minutes under load). Renew the lock every 30 s while the command
+# runs, like PVE::HA::Env::PVE2 does, so it is held until the command has exited.
+# Only for commands that run while the storage lock is held.
+sub run_command_locked {
+    my ($storeid, $scfg, $cmd, %param) = @_;
+
+    # not shared: PVE uses a local flock, which does not expire
+    return run_command($cmd, %param) if !$scfg->{shared};
+
+    my $lockid = "storage-$storeid";
+    local $SIG{ALRM} = sub {
+        utime(0, time(), "/etc/pve/priv/lock/$lockid")
+            or print STDERR "WARNING: can't renew cfs lock '$lockid': $!\n";
+        alarm(30);
+    };
+    alarm(30);
+    my $res = eval { run_command($cmd, %param) };
+    my $err = $@;
+    alarm(0);
+    die $err if $err;
+
+    return $res;
+}
+
 my $vg_autoactivation_checked = {};
 
 sub warn_vg_autoactivation {
@@ -295,18 +321,18 @@
         if ($free < $psize) {
             $psize = $psize - $free;
             $cmd = ['/sbin/lvresize', '-L', "+${psize}k", "$vg/$pool"];
-            run_command($cmd, errmsg => "lvresize thin pool '$vg/$pool' to ${psize}k error");
+            run_command_locked($storeid, $scfg, $cmd, errmsg => "lvresize thin pool '$vg/$pool' to ${psize}k error");
     	}
 
 	    if (!$lvs->{$vg}->{$pool}->{tags}) {
             # add our tag with storeid to use it in monitor's callbacks for proper vg locking
             $cmd = ['/sbin/lvchange', '--addtag', $sidtag, "$vg/$pool"];
-            run_command($cmd, errmsg => "add tag '$sidtag' for thin pool '$vg/$pool' failed");
+            run_command_locked($storeid, $scfg, $cmd, errmsg => "add tag '$sidtag' for thin pool '$vg/$pool' failed");
         }
     } else {
 	    # create new thin pool
         $cmd = ['/sbin/lvcreate', '--type', 'thin-pool', '-an', '-ky', '-Zy', '-n', $pool, '-L', "${psize}k", '--addtag', $sidtag, "$vg"];
-        run_command($cmd, errmsg => "creation of thin pool '$pool' in $vg error");
+        run_command_locked($storeid, $scfg, $cmd, errmsg => "creation of thin pool '$pool' in $vg error");
         set_lv_autoactivation_off($vg, $pool);
     }
 
@@ -314,7 +340,7 @@
     print " creating lv $name (size ${msize}M) from pool $vg/$pool\n";
 
     $cmd = ['/sbin/lvcreate', '--type', 'thin', '-an', '-ky', '-V', "${size}k", '-n', $name, '--addtag', "pve-vm-$vmid", "$vg/$pool"];
-    run_command($cmd, errmsg => "lvcreate thin lv '$vg/$name' error");
+    run_command_locked($storeid, $scfg, $cmd, errmsg => "lvcreate thin lv '$vg/$name' error");
     set_lv_autoactivation_off($vg, $name);
 
     my $cache = {};
@@ -350,13 +376,13 @@
         foreach my $lv (keys %$dat) {
             next if $lv !~ m/^snap_${volname}_${PVE::JSONSchema::CONFIGID_RE}$/;
 
-            run_command(['/sbin/lvremove', '--yes', "$vg/$lv"], errmsg => "lvremove snapshot '$vg/$lv' error");
+            run_command_locked($storeid, $scfg, ['/sbin/lvremove', '--yes', "$vg/$lv"], errmsg => "lvremove snapshot '$vg/$lv' error");
             undef $dat->{$lv};
         }
 
         # remove lv itself (if exists)
         if ($dat->{$volname}) {
-            run_command(['/sbin/lvremove', '--yes', "$vg/$volname"], errmsg => "lvremove '$vg/$volname' error");
+            run_command_locked($storeid, $scfg, ['/sbin/lvremove', '--yes', "$vg/$volname"], errmsg => "lvremove '$vg/$volname' error");
             undef $dat->{$volname};
         }
 
@@ -378,7 +404,7 @@
                 my $p = "$vg/$1";
             print "path $p tainted!\n" if Scalar::Util::tainted($p);
 
-            run_command(['/sbin/lvremove', '--yes', "$p"], errmsg => "lvremove '$p' error");
+            run_command_locked($storeid, $scfg, ['/sbin/lvremove', '--yes', "$p"], errmsg => "lvremove '$p' error");
         } else {
             #print "$vg/$pool still has $num ref(s)\n";
         }
@@ -567,18 +593,18 @@
         my $cmd = ['/sbin/lvcreate', '--type', 'thin-pool', '-an', '-ky', '-Zy', '-n', $pool, '-L', '100M'];
         push @$cmd, '--addtag', "pve-sid-$storeid";
         push @$cmd, "$vg";
-        run_command($cmd, errmsg => "creation of pool '$pool' in $vg error");
+        run_command_locked($storeid, $scfg, $cmd, errmsg => "creation of pool '$pool' in $vg error");
         set_lv_autoactivation_off($vg, $pool);
     }
 
     if ($lvs->{$vg}->{$lv} && $lvs->{$vg}->{$lv}->{lv_state} eq 'a') {
-        run_command(['/sbin/lvchange', '-an', "$vg/$lv"], errmsg => "deactivate_volume '$vg/$lv' error");
+        run_command_locked($storeid, $scfg, ['/sbin/lvchange', '-an', "$vg/$lv"], errmsg => "deactivate_volume '$vg/$lv' error");
     }
 
     # create a new snapshot LV with external readonly base LV
     my $cmd = ['/sbin/lvcreate', '-s', '-an', '-ky', '-n', $name, '-prw', '--thinpool', $pool,
             '--addtag', "pve-vm-$vmid", "$vg/$volname"];
-    run_command($cmd, errmsg => "clone image '$vg/$volname' into $name with pool $pool error");
+    run_command_locked($storeid, $scfg, $cmd, errmsg => "clone image '$vg/$volname' into $name with pool $pool error");
     set_lv_autoactivation_off($vg, $name);
 
     return $name;
@@ -612,7 +638,7 @@
     }
 
     if ($lvs->{$vg}->{$pool} && $lvs->{$vg}->{$pool}->{lv_state} eq 'a') {
-        run_command(['/sbin/lvchange', '--monitor', 'n', "$vg/$pool"], errmsg => "monitor=n for thin pool '$pool' failed");
+        run_command_locked($storeid, $scfg, ['/sbin/lvchange', '--monitor', 'n', "$vg/$pool"], errmsg => "monitor=n for thin pool '$pool' failed");
     }
 
     if ($lvs->{$vg}->{$volname} && $lvs->{$vg}->{$volname}->{lv_state} eq 'a') {
@@ -622,11 +648,11 @@
     my $newname = $name;
     $newname =~ s/^vm-/base-/;
     my $cmd = ['/sbin/lvrename', $vg, $volname, $newname];
-    run_command($cmd, errmsg => "lvrename '$vg/$volname' => '$vg/$newname' error");
+    run_command_locked($storeid, $scfg, $cmd, errmsg => "lvrename '$vg/$volname' => '$vg/$newname' error");
 
     # set read-only flag for the thin lv
     $cmd = ['/sbin/lvchange', '-pr', "$vg/$newname"];
-    eval { run_command($cmd); };
+    eval { run_command_locked($storeid, $scfg, $cmd); };
     warn $@ if $@;
 
     return $newname;
@@ -654,7 +680,7 @@
 
     $class->cluster_lock_storage($storeid, $scfg->{shared}, undef, sub {
         # Resize only the LV, not its thin pool. Thin pools are auto extended with dmeventd
-    	run_command(['/sbin/lvextend', '-L', $size, $p], errmsg => "error resizing volume '$p'");
+    	run_command_locked($storeid, $scfg, ['/sbin/lvextend', '-L', $size, $p], errmsg => "error resizing volume '$p'");
     	#run_command(['/sbin/lvextend', '-L', $size, $pname], errmsg => "error resizing thin pool '$pname'");
     });
 
@@ -672,7 +698,7 @@
     my $cmd = ['/sbin/lvcreate', '-s', '-an', '-n', $snapvol, '-ky', '-pr', "$vg/$volname"];
 
     $class->cluster_lock_storage($storeid, $scfg->{shared}, undef, sub {
-        run_command($cmd, errmsg => "lvcreate snapshot '$vg/$snapvol' error");
+        run_command_locked($storeid, $scfg, $cmd, errmsg => "lvcreate snapshot '$vg/$snapvol' error");
     });
 }
 
@@ -693,11 +719,11 @@
 
     $class->cluster_lock_storage($storeid, $scfg->{shared}, undef, sub {
         # remove the thin volume (it is a snapshot too)
-        run_command(['/sbin/lvremove', '-f', "$vg/$volname"], errmsg => "lvremove '$vg/$volname' error");
+        run_command_locked($storeid, $scfg, ['/sbin/lvremove', '-f', "$vg/$volname"], errmsg => "lvremove '$vg/$volname' error");
         # create a copy of the specified snapshot with the volume's name
         # keep activation skip and leave it inactive like every other volume on the shared vg,
         # '-kn' let boot autoactivation on other nodes activate the thin pool
-        run_command(['/sbin/lvcreate', '-ky', '-an', '-n', $volname, '-s', "$vg/$snapvol"], errmsg => "lvm rollback '$vg/$snapvol' error");
+        run_command_locked($storeid, $scfg, ['/sbin/lvcreate', '-ky', '-an', '-n', $volname, '-s', "$vg/$snapvol"], errmsg => "lvm rollback '$vg/$snapvol' error");
         # PVE 9 LvmThinPlugin does the same after its rollback
         set_lv_autoactivation_off($vg, $volname);
     });
@@ -712,7 +738,7 @@
     print " deleting snapshot $snapvol of $vg/$volname...\n";
 
     $class->cluster_lock_storage($storeid, $scfg->{shared}, undef, sub {
-        run_command(['/sbin/lvremove', '-f', "$vg/$snapvol"], errmsg => "lvremove snapshot '$vg/$snapvol' error");
+        run_command_locked($storeid, $scfg, ['/sbin/lvremove', '-f', "$vg/$snapvol"], errmsg => "lvremove snapshot '$vg/$snapvol' error");
     });
 }
 
@@ -727,7 +753,7 @@
 
     $class->cluster_lock_storage($storeid, $scfg->{shared}, undef, sub {
         my $cmd = ['/sbin/lvrename', $vg, $src_snapvol, $tgt_snapvol];
-        run_command($cmd, errmsg => "lvrename '$vg/$src_snapvol' => '$vg/$tgt_snapvol' error");
+        run_command_locked($storeid, $scfg, $cmd, errmsg => "lvrename '$vg/$src_snapvol' => '$vg/$tgt_snapvol' error");
     });
 }
0004-monitor-report-failures-renew-lock.patch

Code: Select all

--- a/usr/share/perl5/PVE/Storage/Custom/StarLvmMonitor.pl
+++ b/usr/share/perl5/PVE/Storage/Custom/StarLvmMonitor.pl
@@ -76,13 +76,42 @@
     return $lvs;
 }
 
+# Same lock renewal as run_command_locked() in StarLvmPlugin.pm: cfs_lock() gives the
+# code 60 s and then releases the lock while lvextend keeps running. Renew it every
+# 30 s until lvextend has exited.
+sub run_command_locked {
+    my ($storeid, $cmd, %param) = @_;
+
+    my $lockid = "storage-$storeid";
+    local $SIG{ALRM} = sub {
+        utime(0, time(), "/etc/pve/priv/lock/$lockid")
+            or print STDERR "WARNING: can't renew cfs lock '$lockid': $!\n";
+        alarm(30);
+    };
+    alarm(30);
+    my $res = eval { run_command($cmd, %param) };
+    my $err = $@;
+    alarm(0);
+    die $err if $err;
+
+    return $res;
+}
+
 # main code to extend thin pools if needed
 
-my $percent = 80; #FIXME: we use the hardcoded 80% now, but it should be read from lvmlocal.conf thin_pool_autoextend_threshold
+# the threshold from lvm.conf/lvmlocal.conf (the package installs 80), 100 disables autoextend
+my $percent = 80;
+eval {
+    run_command(['/sbin/lvmconfig', '--typeconfig', 'full', 'activation/thin_pool_autoextend_threshold'],
+        outfunc => sub { $percent = $1 if $_[0] =~ m/=\s*(\d+)/; },
+        errfunc => $ignore_no_medium_warnings);
+};
+print STDERR "can't read thin_pool_autoextend_threshold, using $percent: $@" if $@;
 
 my $vol = $ARGV[0] || "";
-my $datap = $ENV{DMEVENTD_THIN_POOL_DATA} || '?';
-my $metap = $ENV{DMEVENTD_THIN_POOL_METADATA} || '?';
+# an unknown usage counts as 100, lvextend --use-policies checks the real usage itself
+my $datap = ($ENV{DMEVENTD_THIN_POOL_DATA} // '') =~ m/^(\d+(?:\.\d+)?)/ ? $1 : 100;
+my $metap = ($ENV{DMEVENTD_THIN_POOL_METADATA} // '') =~ m/^(\d+(?:\.\d+)?)/ ? $1 : 100;
 
 die "volume name not specified.\n" if !$vol;
 
@@ -98,6 +127,8 @@
 if ($pool =~ m/^lvmth-\d+$/) {
     # it's our thin pool, we have to use the cluster lock to extend it
 
+    exit 0 if $percent >= 100;
+
     if ($percent > $datap && $percent > $metap) {
         print STDERR "called for vol '$vg/$pool' ($percent > $datap:$metap)\n";
         exit 0;
@@ -113,9 +144,16 @@
 
     print STDERR " calling 'lvextend --use-policies $vol' with cluster lock '$storeid' ($datap:$metap)\n";
 
-    $res = PVE::Cluster::cfs_lock_storage($storeid, 10, sub {
-        return run_command(['/sbin/lvextend', '--use-policies', $vol], errmsg => "storeid $storeid - locked extending of vol '$vol' failed");
+    # wait up to 60 s, a snapshot or another extend may hold the lock for a while
+    $res = PVE::Cluster::cfs_lock_storage($storeid, 60, sub {
+        return run_command_locked($storeid, ['/sbin/lvextend', '--use-policies', $vol], errmsg => "storeid $storeid - locked extending of vol '$vol' failed");
     });
+    # cfs_lock_storage() does not die, it returns undef and sets $@ (no lock, no quorum,
+    # lvextend failed). Exit non-zero so dmeventd logs it and calls us again.
+    if (my $err = $@) {
+        print STDERR "extending '$vol' failed: $err";
+        exit 1;
+    }
 } else {
     # it's some other thin pool, it should be safe to extend it directly (default behaviour for LVM) 
0005-guard-pools-active-elsewhere.patch

Code: Select all

--- a/usr/share/perl5/PVE/Storage/Custom/StarLvmPlugin.pm
+++ b/usr/share/perl5/PVE/Storage/Custom/StarLvmPlugin.pm
@@ -12,7 +12,10 @@
 use IO::File;
 use Scalar::Util;
 
+use PVE::Cluster;
+use PVE::INotify;
 use PVE::JSONSchema qw(get_standard_option);
+use PVE::SSHInfo;
 use PVE::Tools qw(run_command trim);
 use PVE::Storage::Plugin;
 use PVE::Storage::LVMPlugin;
@@ -166,6 +169,52 @@
         if $autoactivation eq 'enabled';
 }
 
+# Without lvmlockd a node only knows its own activations. A thin pool that is still
+# active on another node keeps device-mapper mappings to extents that LVM hands out
+# again once the LVs are removed, and that node later writes its stale metadata there.
+# Ask every other online node before removing or recreating LVs of the pool.
+sub check_pool_inactive_elsewhere {
+    my ($vg, $pool, $action) = @_;
+
+    my $members = PVE::Cluster::get_members() // {};
+    my $nodename = PVE::INotify::nodename();
+
+    # device-mapper names double the '-' inside vg and lv names
+    my $dmname = join('-', map { (my $n = $_) =~ s/-/--/g; $n } ($vg, $pool));
+    my $re = qr/^\Q$dmname\E(?:[-_]|$)/;
+
+    my (@active, @failed);
+    foreach my $node (sort keys %$members) {
+        next if $node eq $nodename;
+
+        if (!$members->{$node}->{online}) {
+            print "WARNING: node '$node' is offline, can't check it for active thin pool '$vg/$pool'\n";
+            next;
+        }
+
+        # free_image runs under the storage lock (120 s lifetime): keep the worst case short
+        my $ssh = PVE::SSHInfo::ssh_info_to_command(PVE::SSHInfo::get_ssh_info($node), '-o', 'ConnectTimeout=5');
+        my @devs;
+        eval {
+            run_command([@$ssh, '/sbin/dmsetup', 'ls'], timeout => 10, outfunc => sub {
+                my ($dev) = split(/\s+/, trim(shift));
+                push @devs, $dev if $dev && $dev =~ $re;
+            });
+        };
+        if (my $err = $@) {
+            chomp $err;
+            push @failed, "$node: $err";
+            next;
+        }
+        push @active, "$node: " . join(' ', @devs) if @devs;
+    }
+
+    die "thin pool '$vg/$pool' is active on other node(s), refusing to $action\n  "
+        . join("\n  ", @active) . "\n" if @active;
+    die "can't check other node(s) for thin pool '$vg/$pool', refusing to $action\n  "
+        . join("\n  ", @failed) . "\n" if @failed;
+}
+
 sub lvm_vg_pv_dm_name {
     my ($vg) = @_;
 
@@ -362,6 +411,8 @@
     my $pool = $lvs->{$vg}->{$volname}->{pool_lv} ? $lvs->{$vg}->{$volname}->{pool_lv} : "lvmth-$vmid";
     die "no such thin pool $vg/$pool\n" if !$lvs->{$vg}->{$pool};
 
+    check_pool_inactive_elsewhere($vg, $pool, "delete $volname");
+
     print " deleting $volname from $vg/$pool\n";
 
     if (my $dat = $lvs->{$vg}) {
@@ -717,6 +768,9 @@
 
     print " rolling back snapshot $snapvol of $vg/$volname...\n";
 
+    die "Invalid volname $volname" if $volname !~ m/^(vm|base)-(\d+)-/;
+    check_pool_inactive_elsewhere($vg, "lvmth-$2", "roll back $volname");
+
     $class->cluster_lock_storage($storeid, $scfg->{shared}, undef, sub {
         # remove the thin volume (it is a snapshot too)
         run_command_locked($storeid, $scfg, ['/sbin/lvremove', '-f', "$vg/$volname"], errmsg => "lvremove '$vg/$volname' error");
0006-optional-exclusive-activation.patch

Code: Select all

--- a/usr/share/perl5/PVE/Storage/Custom/StarLvmPlugin.pm
+++ b/usr/share/perl5/PVE/Storage/Custom/StarLvmPlugin.pm
@@ -548,6 +548,11 @@
         }
     } else {
         if ($v->{$pool}->{lv_state} ne 'a') {
+            # Opt-in: a pool activated here while another node still has it active works with
+            # stale metadata. This also refuses online migration (the target activates the pool
+            # while the source still runs), so migrate offline while the file exists.
+            check_pool_inactive_elsewhere($vg, $pool, "activate it on this node")
+                if -e '/etc/pve/starlvm-exclusive-activation';
             run_command(['/sbin/lvchange', '-aly', '-K', '--monitor', 'y', $vol], errmsg => "activating thin pool '$vol' failed");
             $v->{$pool}->{lv_state} = 'a'; # update cached state
         }
Side Notes:
- Patch 0005 can still miss two cases:
- Another node activates the pool in the short window between the check and the delete.
- An offline node that isn't fenced (like hv01 on its test bench) still has stale mappings. Offline nodes only get a warning.
- Without quorum the lock can't be renewed. The bug race is then possible again, and wouldn't know how this could be prevented (can this be prevented?).
- Patch 0003 doesn't cover the activation step inside disk creation.
- Patch 0006 only avoids it by refusing live migration, and whether the bug happens at all still depends on a migration test.
yaroslav (staff)
Staff
Posts: 4515
Joined: Mon Nov 18, 2019 11:11 am

Mon Oct 19, 2026 9:48 am

Hi,

Thanks again for your active collaboration!
climbrbry
Posts: 1
Joined: Mon Sep 21, 2026 1:55 pm

Tue Oct 20, 2026 11:38 am

We appear to have an independent occurrence of the same failure class with StarLVM 1.1.41 on PVE 9.2.10/9.2.11, using StarWind VSAN multipath iSCSI rather than an Eternus array. We did not perform a snapshot rollback, although Veeam creates and deletes temporary Proxmox snapshots.

We observed a successfully migrated VM pool remaining active through device-mapper on the former node, with zero open child volumes and no owning process. On another VM, the pool activated successfully, ran for approximately three minutes, then reported a metadata-integrity failure during source-side deactivation. Migration subsequently reported success, but destination activation failed with thin_check status 64.

We found no corresponding iSCSI, multipath, kernel I/O, target, or replication fault. Multiple per-VM pools are now affected. Autoactivation is enabled on the shared VG/LVs even though activation-skip is set.

We have retained task logs, journal excerpts and copied _tmeta images. Does StarWind consider disabling VG autoactivation supported with 1.1.41, and is there a build that addresses stale cross-node mappings and lock renewal?
yaroslav (staff)
Staff
Posts: 4515
Joined: Mon Nov 18, 2019 11:11 am

Tue Oct 20, 2026 12:59 pm

Thanks for your report.
Wait for the release, please.
nvanaert
Posts: 12
Joined: Tue Jul 14, 2026 6:39 am

Thu Oct 22, 2026 1:31 pm

yaroslav (staff) wrote: ↑
Tue Oct 20, 2026 12:59 pm
Thanks for your report.
Wait for the release, please.
Would you happen to have an ETA on this?
yaroslav (staff)
Staff
Posts: 4515
Joined: Mon Nov 18, 2019 11:11 am

Thu Oct 22, 2026 2:18 pm

Sadly I have no ETA.
nvanaert
Posts: 12
Joined: Tue Jul 14, 2026 6:39 am

Mon Oct 26, 2026 1:11 pm

Unfortunately we had data corruption again, this time while live migrating virtual machines.

What happened
A rolling update (node evacuation by live migration, then a reboot) moved
VM 2025 (Windows Server, 4 GiB RAM, pool legacy/lvmth-2025, no snapshots)
twice:
- 06:39:15 lhv02 -> lhv03 (evacuating lhv02)
- 06:53:06 lhv03 -> lhv02 (evacuating lhv03)

Second migration, all times CEST on 2026-10-23. Sources: the qmigrate task log
on lhv03, the journal on lhv03, and the kernel log on lhv02.

06:53:06 lhv03 qmigrate 2025 starts
06:53:08 lhv03 "starting VM 2025 on remote node 'idkstbablhv02'"
06:53:11 lhv02 lvmth-2025 activated (tdata dm-46, tpool 252:47),
so the target loads the pool metadata here
06:53:15 lhv03 RAM transfer starts; the guest keeps running and writing
on lhv03 for the next 55 seconds (6.4 GiB sent for
4.0 GiB RAM, xbzrle active, downtime auto-raised to
200 ms: a busy guest)
06:54:10 lhv03 "migration completed", downtime 140 ms
-> VM 2025 resumes on lhv02
06:54:10 lhv03 " deactivating vm-2025-disk-2/1/0..."
06:54:13 lhv03 dmeventd: "No longer monitoring thin pool
legacy-lvmth--2025-tpool." (pool released on source)
06:55:41 lhv02 kernel:
device-mapper: space map common: metadata block out of bounds
device-mapper: thin: 252:47: metadata operation
'dm_thin_insert_block' failed: error = -22
device-mapper: thin: 252:47: aborting current metadata transaction
device-mapper: thin: 252:47: switching pool to read-only mode
07:11 lhv02 Buffer I/O errors on dm-49, "lost async page write"

The guest ended in Windows Automatic Repair. On 2026-10-26, "lvchange -an"
reported "Integrity check of metadata for pool legacy/lvmth-2025 failed". We
removed the pool and restored the VM from backup.

What we ruled out
- Space: the VG had 2.95 TiB free, the pool was at 63.69% data and 29.49%
metadata. (The restore attempt also printed "free space in thin pool
reached threshold"; with the pool at 64% we read that as LVM refusing a
pool it could not write to, not a real space problem.)
- Snapshots: VM 2025 had none.
- LVM autoactivation (the lvmth-1005 path): on all 4 nodes, every early pool
activation on 2026-10-23 matches a migration or startall to the second,
and every boot activated 0 LVs.
- A third node: lvmth-2025 was active only on lhv02 afterwards.
- No other dm-thin errors on any node. Every other active pool is rw, has a
matching kernel and LVM transaction id, and no needs_check.

About 31 other VMs made the same migrations that morning without a visible
error.

Root cause
This is exactly Bug 1 from our earlier report, now with timing.

The target loaded the pool metadata at 06:53:11. The source kept allocating
and committing for another 59 seconds. The VM then resumed on the target with
an in-kernel copy of the metadata that was about a minute old. The source
released the pool 3 seconds after the resume. The target's next metadata
operation ran into blocks the source had changed, and the pool went
read-only.

Three corrections to the earlier report
- No snapshot is needed. VM 2025 had none, so writes to blocks that were
never provisioned are enough. The pool had about 36% of its data space
unallocated.
- The trigger here was the live migration itself, not boot autoactivation.
The lvmth-1005 case (autoactivation after a rollback stripped
ACTIVATION_SKIP, Bug 1 option B and the -kn rollback issue) is a second
path to the same condition: a pool active on two nodes.
- Damage that doesn't show up as a metadata error is silent: lost mappings
for writes made during the migration window, or blocks handed out twice.
We cannot rule this out for the other VMs that were live-migrated, and we
will thin_check their pools at their next planned restart.

Why the plugin can't fix this without help from Proxmox
The only safe moment to move the pool is after the source has paused and
flushed, and before the target resumes. qemu-server (9.2.11 to 9.2.13)
calls no storage plugin in that window:
- target: vm_start_nolock sets $ENV{PVE_MIGRATED_FROM} and calls
activate_volumes BEFORE the RAM transfer (QemuServer.pm, around line 5632)
- resume: phase3_cleanup sends "resume" to the target (QemuMigrate.pm,
around line 1749); vm_resume (QemuServer/RunState.pm) makes no storage call
- source: deactivate_volumes only runs in vm_stop_cleanup, AFTER the resume
(QemuMigrate.pm, around line 1872)

So the plugin can either refuse the dual activation (this rules out live
migration, our option B) or accept the race. A safe handover needs a hook in
qemu-server. This is option A from our earlier report, which we have now
implemented.

Proposed patch
Note: This patch is made with the assistance of Claude AI, so be careful and check it thoroughly.

Code: Select all

--- a/usr/share/perl5/PVE/Storage/Custom/StarLvmPlugin.pm
+++ b/usr/share/perl5/PVE/Storage/Custom/StarLvmPlugin.pm
@@ -16,9 +16,19 @@
 use PVE::Tools qw(run_command trim);
 use PVE::Storage::Plugin;
 use PVE::Storage::LVMPlugin;
+use PVE::INotify;
+use PVE::SSHInfo;
 
 my $PLUGIN_VERSION = '1.6';
 
+# Live migration handover, opt-in. A dm-thin pool loads its metadata once, when it is activated.
+# Live migration activates the pool on the target while the source still writes, so the target
+# would resume with stale metadata. With handover the target keeps the pool read-only until the
+# source has released it, then reloads it if the source changed anything meanwhile.
+# Needs qemu-server with storage_migration_handover() on every node.
+our $HANDOVER_FLAG = '/etc/pve/starlvm-live-handover';
+our $HANDOVER_STATE_DIR = '/run/starlvm-handover';
+
 use base qw(PVE::Storage::LVMPlugin);
 
 sub api {
@@ -403,6 +413,336 @@
     $class->SUPER::activate_storage($storeid, $scfg, $cache);
 }
 
+sub handover_enabled {
+    return 0 if !-e $HANDOVER_FLAG;
+    # without the qemu-server hook nobody would switch the pool back to read-write
+    return eval {
+        require PVE::QemuMigrate;
+        PVE::QemuMigrate->can('storage_migration_handover');
+    } ? 1 : 0;
+}
+
+my sub handover_state_file {
+    my ($vg, $pool) = @_;
+    return "$HANDOVER_STATE_DIR/$vg-$pool";
+}
+
+# device-mapper name of an LV, e.g. legacy/lvmth-2025_tmeta -> legacy-lvmth--2025_tmeta
+my sub dm_name {
+    my ($vg, $lv) = @_;
+    return join('-', map { s/-/--/gr } ($vg, $lv));
+}
+
+my sub dm_exists {
+    my ($name) = @_;
+    my $found = 0;
+    eval {
+        run_command(['/sbin/dmsetup', 'info', '-c', '--noheadings', '-o', 'name', $name],
+            outfunc => sub { $found = 1 if trim(shift) eq $name }, errfunc => sub {});
+    };
+    return $found;
+}
+
+my sub dm_table {
+    my ($name) = @_;
+    my @lines;
+    run_command(['/sbin/dmsetup', 'table', $name],
+        outfunc => sub { push @lines, trim(shift) },
+        errmsg => "dmsetup table '$name' failed");
+    die "expected one table line for '$name', got " . scalar(@lines) . "\n" if @lines != 1;
+    return $lines[0];
+}
+
+# load a new table into an existing device, whoever has it open keeps using the same device
+my sub dm_swap_table {
+    my ($name, $table) = @_;
+    run_command(['/sbin/dmsetup', 'load', $name, '--table', $table], errmsg => "dmsetup load '$name' failed");
+    eval {
+        run_command(['/sbin/dmsetup', 'suspend', $name], timeout => 60, errmsg => "dmsetup suspend '$name' failed");
+    };
+    if (my $err = $@) {
+        eval { run_command(['/sbin/dmsetup', 'clear', $name]) };
+        die $err;
+    }
+    run_command(['/sbin/dmsetup', 'resume', $name], errmsg => "dmsetup resume '$name' failed");
+}
+
+# point a thin LV at the error target, so it no longer holds a reference on its pool
+my sub thin_lv_detach {
+    my ($vg, $lv) = @_;
+    my $name = dm_name($vg, $lv);
+    return if !dm_exists($name);
+    my $table = dm_table($name);
+    return if $table =~ m/^0 \d+ error$/;
+    die "unexpected table for '$name': $table\n" if $table !~ m/^0 (\d+) thin /;
+    dm_swap_table($name, "0 $1 error");
+}
+
+my sub pool_set_read_only {
+    my ($vg, $pool, $read_only) = @_;
+    my $name = dm_name($vg, $pool) . '-tpool';
+    my $table = dm_table($name);
+    # 0 <len> thin-pool <metadata dev> <data dev> <block size> <low water mark> <#features> [<feature>...]
+    my @f = split(/\s+/, $table);
+    die "unexpected table for '$name': $table\n"
+        if @f < 8 || $f[2] ne 'thin-pool' || $f[7] !~ m/^\d+$/ || @f != 8 + $f[7];
+    my @features = grep { $_ ne 'read_only' } @f[8 .. $#f];
+    push @features, 'read_only' if $read_only;
+    my $new = join(' ', @f[0 .. 6], scalar(@features), @features);
+    dm_swap_table($name, $new) if $new ne $table;
+}
+
+my sub pool_check_healthy {
+    my ($vg, $pool) = @_;
+    my $name = dm_name($vg, $pool) . '-tpool';
+    my $status = '';
+    # --noflush: a plain status commits the pool metadata
+    run_command(['/sbin/dmsetup', 'status', '--noflush', $name],
+        outfunc => sub { $status = trim(shift) },
+        errmsg => "dmsetup status '$name' failed");
+    # 0 <len> thin-pool <transaction id> <meta used/total> <data used/total> <held root> <mode> ...
+    my @f = split(/\s+/, $status);
+    die "unexpected status for '$name': $status\n" if @f < 8 || $f[2] ne 'thin-pool';
+    die "thin pool $vg/$pool is in mode '$f[7]', expected rw\n" if $f[7] ne 'rw';
+    die "thin pool $vg/$pool needs a check\n" if grep { $_ eq 'needs_check' } @f;
+}
+
+# Fingerprint of the on-disk superblock of a thin pool, it changes with every metadata commit.
+# Read straight from the PV, so it also works while the pool is inactive on this node.
+my sub pool_superblock_digest {
+    my ($vg, $pool) = @_;
+
+    my ($pv, $pe, $extent_size);
+    run_command(['/sbin/lvs', '-a', '--segments', '--noheadings', '--units', 'b', '--nosuffix',
+                 '--separator', '|', '-o', 'seg_start_pe,seg_pe_ranges,vg_extent_size',
+                 '-S', "lv_name=${pool}_tmeta", $vg],
+        outfunc => sub {
+            my ($start, $ranges, $size) = split(/\|/, trim(shift));
+            return if !defined($start) || $start ne '0'; # the superblock is block 0 of the LV
+            ($pv, $pe) = $ranges =~ m|^(/dev/[-/\w.]+):(\d+)-\d+$|;
+            $extent_size = $size;
+        },
+        errmsg => "lvs for $vg/${pool}_tmeta failed");
+    die "can't locate the metadata of $vg/$pool\n" if !$pv || !defined($pe) || !$extent_size;
+
+    my $pe_start;
+    run_command(['/sbin/pvs', '--noheadings', '--units', 'b', '--nosuffix', '-o', 'pe_start', $pv],
+        outfunc => sub { $pe_start = trim(shift) },
+        errmsg => "pvs for $pv failed");
+    die "can't read pe_start of $pv\n" if !defined($pe_start) || $pe_start !~ m/^\d+$/;
+
+    my $offset = $pe_start + $pe * $extent_size;
+    my $digest;
+    run_command([['/bin/dd', "if=$pv", 'bs=4096', 'count=1', "skip=$offset",
+                  'iflag=direct,skip_bytes', 'status=none'], ['/usr/bin/sha256sum']],
+        outfunc => sub { $digest = $1 if $_[0] =~ m/^([0-9a-f]{64})\b/ },
+        errmsg => "reading the superblock of $vg/$pool failed");
+    die "no superblock digest for $vg/$pool\n" if !$digest;
+    return $digest;
+}
+
+# take a pool off this node: thin LVs stay open for QEMU but point at the error target,
+# removing the pool stack suspends the pool, which commits its metadata a last time
+my sub handover_release {
+    my ($vg, $pool, $volumes) = @_;
+    run_command(['/sbin/lvchange', '--monitor', 'n', "$vg/$pool"], errmsg => "monitor=no for pool '$vg/$pool' error");
+    thin_lv_detach($vg, $_) for @$volumes;
+    my $base = dm_name($vg, $pool);
+    for my $name ($base, "$base-tpool", dm_name($vg, "${pool}_tdata"), dm_name($vg, "${pool}_tmeta")) {
+        next if !dm_exists($name);
+        run_command(['/sbin/dmsetup', 'remove', $name], timeout => 60, errmsg => "dmsetup remove '$name' failed");
+    }
+}
+
+# activate a pool fresh (LVM runs thin_check) and map the thin LVs onto it again
+my sub handover_reattach {
+    my ($vg, $pool, $volumes) = @_;
+    my $vol = "$vg/$pool";
+    run_command(['/sbin/lvchange', '-aly', '-K', '--monitor', 'y', $vol], errmsg => "activating thin pool '$vol' failed");
+    for my $lv (@$volumes) {
+        run_command(['/sbin/lvchange', '--refresh', "$vg/$lv"], errmsg => "can't refresh LV '$vg/$lv'");
+        my $table = dm_table(dm_name($vg, $lv));
+        die "$vg/$lv is not mapped to its pool after the refresh: $table\n" if $table !~ m/^0 \d+ thin /;
+    }
+    pool_check_healthy($vg, $pool);
+}
+
+my sub ssh_node_cmd {
+    my ($node, $cmd) = @_;
+    my $ssh = PVE::SSHInfo::ssh_info_to_command(PVE::SSHInfo::get_ssh_info($node));
+    return [@$ssh, '--', PVE::Tools::cmd2string($cmd)];
+}
+
+# the source must run the qemu-server hook, otherwise the pool would stay read-only here
+my sub handover_source_ready {
+    my ($source) = @_;
+    my $check = ['/usr/bin/perl', '-MPVE::QemuMigrate', '-e',
+                 'exit(PVE::QemuMigrate->can(q{storage_migration_handover}) ? 0 : 1)'];
+    eval { run_command(ssh_node_cmd($source, $check), timeout => 30, outfunc => sub {}, errfunc => sub {}) };
+    die "source node '$source' has no qemu-server with storage_migration_handover(), refusing\n" if $@;
+}
+
+# incoming live migration: load the pool read-only and remember the superblock fingerprint
+my sub handover_prepare_pool {
+    my ($class, $storeid, $vg, $pool, $v, $source) = @_;
+    my $vol = "$vg/$pool";
+    my $state_file = handover_state_file($vg, $pool);
+
+    if ($v->{$pool}->{lv_state} eq 'a') {
+        my $state = -e $state_file ? PVE::Tools::file_get_contents($state_file) : '';
+        return if $state =~ m/^source=\Q$source\E$/m; # an earlier volume of this migration
+        # active here since before this migration, so its metadata is of unknown age
+        die "thin pool '$vol' is already active on this node, refusing the incoming live migration\n";
+    }
+
+    handover_source_ready($source);
+
+    # the storage lock keeps the auto-extend monitor from resizing the pool in between
+    $class->cluster_lock_storage($storeid, 1, 60, sub {
+        # fingerprint first: when the source commits before the kernel loads the metadata, the
+        # handover sees a change and reloads, instead of trusting a fingerprint that is too new
+        my $digest = pool_superblock_digest($vg, $pool);
+        run_command(['/sbin/lvchange', '-aly', '-K', '--monitor', 'n', $vol], errmsg => "activating thin pool '$vol' failed");
+        $v->{$pool}->{lv_state} = 'a'; # update cached state
+        pool_set_read_only($vg, $pool, 1);
+        mkdir($HANDOVER_STATE_DIR, 0700);
+        PVE::Tools::file_set_contents($state_file, "source=$source\ndigest=$digest\n");
+    });
+}
+
+# target side, called by migration_handover() over SSH once the source has released the pool
+my sub handover_attach {
+    my ($vg, $pool, $source, $volumes) = @_;
+
+    my $state_file = handover_state_file($vg, $pool);
+    my $state = -e $state_file ? PVE::Tools::file_get_contents($state_file) : '';
+    my ($from) = $state =~ m/^source=(\S+)$/m;
+    my ($digest) = $state =~ m/^digest=([0-9a-f]{64})$/m;
+    die "no handover state for $vg/$pool on this node\n" if !$from || !$digest;
+    die "handover state of $vg/$pool belongs to '$from', not '$source'\n" if $from ne $source;
+
+    my $mode;
+    if (pool_superblock_digest($vg, $pool) eq $digest) {
+        # the source changed nothing after this node loaded the metadata
+        pool_set_read_only($vg, $pool, 0);
+        run_command(['/sbin/lvchange', '--monitor', 'y', "$vg/$pool"], errmsg => "monitor=yes for pool '$vg/$pool' error");
+        for my $lv (@$volumes) {
+            my $table = dm_table(dm_name($vg, $lv));
+            die "$vg/$lv is not mapped to its pool: $table\n" if $table !~ m/^0 \d+ thin /;
+        }
+        pool_check_healthy($vg, $pool);
+        $mode = 'unchanged';
+    } else {
+        # the source committed after this node loaded the metadata, so load it again
+        handover_release($vg, $pool, $volumes);
+        handover_reattach($vg, $pool, $volumes);
+        $mode = 'reloaded';
+    }
+
+    unlink($state_file);
+    return $mode;
+}
+
+sub handover_attach_cli {
+    my ($vg, $pool, $source, @volumes) = @_;
+
+    my $mode = eval { handover_attach($vg, $pool, $source, \@volumes) };
+    if (my $err = $@) {
+        chomp $err;
+        # take the pool off this node again, so the source can safely use it
+        eval {
+            handover_release($vg, $pool, \@volumes);
+            unlink(handover_state_file($vg, $pool));
+        };
+        if (my $err2 = $@) {
+            chomp $err2;
+            print "STARLVM-HANDOVER UNKNOWN $err - releasing failed too: $err2\n";
+            exit(3);
+        }
+        print "STARLVM-HANDOVER RELEASED $err\n";
+        exit(2);
+    }
+    print "STARLVM-HANDOVER OK $mode\n";
+    exit(0);
+}
+
+my sub handover_call_target {
+    my ($vg, $pool, $volumes, $target) = @_;
+
+    my $source = PVE::INotify::nodename();
+    my $cmd = ['/usr/bin/perl', '-MPVE::Storage', '-e',
+               'PVE::Storage::Custom::StarLvmPlugin::handover_attach_cli(@ARGV)',
+               $vg, $pool, $source, @$volumes];
+
+    my ($status, $msg) = ('UNKNOWN', 'no answer from the target');
+    eval {
+        run_command(ssh_node_cmd($target, $cmd), timeout => 300, noerr => 1,
+            outfunc => sub {
+                my $line = shift;
+                if ($line =~ m/^STARLVM-HANDOVER (OK|RELEASED|UNKNOWN)\s*(.*)$/) {
+                    ($status, $msg) = ($1, $2);
+                } else {
+                    print "[$target] $line\n";
+                }
+            },
+            errfunc => sub { print STDERR "[$target] $_[0]\n" });
+    };
+    $msg = "$@" if $@ && $status eq 'UNKNOWN';
+    chomp $msg;
+    return ($status, $msg);
+}
+
+# Called by qemu-server (storage_migration_handover) on the source node, after the live
+# migration converged and before the target resumes. The source VM is paused and flushed.
+sub migration_handover {
+    my ($class, $storeid, $scfg, $vmid, $volnames, $target) = @_;
+
+    return if !-e $HANDOVER_FLAG; # without the flag the target didn't load the pool read-only
+
+    my $vg = $scfg->{vgname};
+    my $lvs = lvm_list_volumes($vg)->{$vg} or die "no such vg $vg\n";
+
+    my $pools = {};
+    for my $volname (@$volnames) {
+        my $info = $lvs->{$volname} or die "no such volume $vg/$volname\n";
+        my $pool = $info->{pool_lv} or die "$vg/$volname is not a thin volume\n";
+        push $pools->{$pool}->@*, $volname;
+    }
+    return if !%$pools;
+    # with one pool per VM this doesn't happen; refusing here still lets the source resume
+    die "volumes in more than one thin pool can't be handed over\n" if scalar(keys %$pools) > 1;
+    my ($pool) = keys %$pools;
+    my $volumes = $pools->{$pool};
+
+    # the pool can only leave this node when nothing else here still uses it
+    my %moving = map { $_ => 1 } @$volumes;
+    for my $lv (sort keys %$lvs) {
+        next if ($lvs->{$lv}->{pool_lv} // '') ne $pool || $lvs->{$lv}->{lv_state} ne 'a';
+        die "$vg/$lv in pool $pool is active here but not part of this migration\n" if !$moving{$lv};
+    }
+
+    eval { handover_release($vg, $pool, $volumes) };
+    if (my $err = $@) {
+        eval { handover_reattach($vg, $pool, $volumes) };
+        die { msg => "releasing $vg/$pool failed ($err) and re-attaching it failed too: $@", keep_paused => 1 }
+            if $@;
+        die "releasing $vg/$pool failed: $err";
+    }
+
+    my ($status, $msg) = handover_call_target($vg, $pool, $volumes, $target);
+    if ($status eq 'OK') {
+        print " handed over $vg/$pool to $target ($msg)\n";
+        return;
+    }
+    if ($status eq 'RELEASED') {
+        eval { handover_reattach($vg, $pool, $volumes) };
+        die { msg => "$target refused ($msg) and re-attaching $vg/$pool here failed: $@", keep_paused => 1 }
+            if $@;
+        die "$target refused the handover of $vg/$pool: $msg\n";
+    }
+    die { msg => "state of $vg/$pool on $target is unknown: $msg", keep_paused => 1 };
+}
+
 sub activate_volume {
     my ($class, $storeid, $scfg, $volname, $snapname, $cache) = @_;
 
@@ -427,9 +767,15 @@
 
     no locale;
     die "Invalid pool name $pool" if $pool !~ /^(\w[-\w]*\w)$/;
-    my $vol = "$vg/$1";
+    my $pool_name = $1;
+    my $vol = "$vg/$pool_name";
     print "path $vol tainted!\n" if Scalar::Util::tainted($vol);
 
+    # target of a live migration: the source still writes to the pool until the handover
+    my ($source) = ($ENV{PVE_MIGRATED_FROM} // '') =~ m/^([-\w.]+)$/;
+    my $incoming = !$isBase && !$snapname && $source && handover_enabled();
+    handover_prepare_pool($class, $storeid, $vg, $pool_name, $v, $source) if $incoming;
+
     return undef if $v->{$lv}->{lv_state} eq 'a';
 
     if ($isBase) {
@@ -437,6 +783,8 @@
             run_command(['/sbin/lvchange', '-an', '-K', '--monitor', 'n', $vol], errmsg => "deactivating thin pool '$vol' failed");
             $v->{$pool}->{lv_state} = '-'; # update cached state
         }
+    } elsif ($incoming) {
+        # handover_prepare_pool() activated the pool read-only, a refresh here would undo that
     } else {
         if ($v->{$pool}->{lv_state} ne 'a') {
             run_command(['/sbin/lvchange', '-aly', '-K', '--monitor', 'y', $vol], errmsg => "activating thin pool '$vol' failed");
@@ -451,10 +799,14 @@
     }
 
     my $p = "$vg/$lv";
-    run_command(['/sbin/lvchange', '-aly', '-K', $p], errmsg => "activating LV '$p' failed");
+    my @nomonitor = $incoming ? ('--config', 'activation/monitoring=0') : ();
+    run_command(['/sbin/lvchange', '-aly', '-K', @nomonitor, $p], errmsg => "activating LV '$p' failed");
     $v->{$lv}->{lv_state} = 'a'; # update cached state
 
-    run_command(['/sbin/lvchange', '--refresh', $p], errmsg => "can't refresh LV '$p' for activation");
+    run_command(['/sbin/lvchange', '--refresh', @nomonitor, $p], errmsg => "can't refresh LV '$p' for activation");
+
+    # the refresh can reload the pool table, so make sure the pool is still read-only
+    pool_set_read_only($vg, $pool_name, 1) if $incoming;
 }
 
 sub deactivate_volume {
@@ -509,6 +861,7 @@
         run_command(['/sbin/lvchange', '-an', $p], errmsg => "deactivate_volume pool '$p' error");
         $v->{$pool}->{lv_state} = '-'; # update cached state
         run_command(['/sbin/lvchange', '--refresh', $p], errmsg => "can't refresh LV pool '$p' for deactivation");
+        unlink(handover_state_file($vg, $pool)); # an incoming migration that was aborted
     }
 }
 

Status
It compiles and passes 33 behaviour tests against a simulated dmsetup/LVM (tests/handover.). It is NOT tested on hardware yet, and shouldn't be used in production, but as a reference.

I've made a post on the Proxmox forums about this too: https://forum.proxmox.com/threads/live- ... er.187272/
yaroslav (staff)
Staff
Posts: 4515
Joined: Mon Nov 18, 2019 11:11 am

Mon Oct 26, 2026 2:13 pm

Thanks for all the effort you put into it!
nvanaert
Posts: 12
Joined: Tue Jul 14, 2026 6:39 am

Mon Oct 26, 2026 2:29 pm

yaroslav (staff) wrote: ↑
Mon Oct 26, 2026 2:13 pm
Thanks for all the effort you put into it!
Would you happen have any update on this on the how and when? As this is getting pretty serious unfortunately, as this is the 2nd virtual machine that faced data corruption due to this issue.
Post Reply