The Latest Gartner® Magic Quadrant™Hyperconverged Infrastructure Software
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
});
}
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;
}
1. Fujitsu Siemens Eternus with a 4x Dell PowerEdge R740 and 6x Fujitsu servers (2 separate clusters with 2 separate volumes)yaroslav (staff) wrote: ↑Thu Oct 15, 2026 3:08 pmAFAIK, 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.
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");
});
}
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);
});
}
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");
});
}
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)
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");
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
}
Would you happen to have an ETA on this?yaroslav (staff) wrote: ↑Tue Oct 20, 2026 12:59 pmThanks for your report.
Wait for the release, please.
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
}
}
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.