From: Fiona Ebner <f.ebner@proxmox.com>
To: Thomas Lamprecht <t.lamprecht@proxmox.com>, pve-devel@lists.proxmox.com
Subject: Re: [PATCH manager 15/19] vzdump: limit concurrent guest backups cluster-wide
Date: Fri, 6 Nov 2026 15:49:16 +0100 [thread overview]
Message-ID: <cede1743-e943-4d9d-88bc-e0458f4f3e9f@proxmox.com> (raw)
In-Reply-To: <20261101225849.884587-16-t.lamprecht@proxmox.com>
Am 02.11.26 um 12:02 AM schrieb Thomas Lamprecht:
> diff --git a/PVE/Service/pvescheduler.pm b/PVE/Service/pvescheduler.pm
> index f7271fc00..e54f94183 100755
> --- a/PVE/Service/pvescheduler.pm
> +++ b/PVE/Service/pvescheduler.pm
> @@ -7,6 +7,7 @@ use POSIX qw(WNOHANG);
>
> use PVE::Jobs;
> use PVE::SafeSyslog;
> +use PVE::VZDump;
>
> use PVE::API2::Replication;
>
> @@ -114,6 +115,9 @@ sub run {
> $fork->(
> 'jobs',
> sub {
> + eval { PVE::VZDump::cleanup_cluster_slots() };
> + syslog('err', "backup slots: $@") if $@;
> +
> PVE::Jobs::run_jobs($first_run);
> },
> );
> diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm
> index f597f7ed7..525a593c6 100644
> --- a/PVE/VZDump.pm
> +++ b/PVE/VZDump.pm
> @@ -25,6 +25,7 @@ use PVE::Notify;
> use PVE::RPCEnvironment;
> use PVE::Storage;
> use PVE::VZDump::Common;
> +use PVE::VZDump::ClusterSlots;
> use PVE::VZDump::Plugin;
> use PVE::Tools qw(extract_param split_list);
> use PVE::API2Tools;
> @@ -1683,6 +1684,18 @@ my sub try_guest_lock {
> return $fh;
> }
>
> +my sub guest_backup_running {
> + my ($vmid, $upid) = @_;
> +
> + open(my $fh, '<', "$lockdir/guest-$vmid.lock") or return 0;
Should we distinguish ENOENT and different errors?
> + return 0 if flock($fh, LOCK_SH | LOCK_NB);
> +
> + my $holder = <$fh> // '';
> + chomp($holder);
> +
> + return $holder eq $upid;
> +}
> +
> # Slots above a lowered limit stay in use until their backups end, no new one starts before.
> my sub try_slot {
> my ($prefix, $limit) = @_;
> @@ -1771,23 +1784,138 @@ my sub skip_after_error {
> skip_task($_, "not started due to error - $reason") for splice($queue->@*);
> }
>
> +my sub cluster_slot_resources {
> + my ($self) = @_;
> +
> + my $limits = PVE::VZDump::ClusterSlots::limits();
> + return undef if !scalar(keys $limits->%*);
> +
> + if (!PVE::VZDump::ClusterSlots::supported_by_all_nodes()) {
> + debugmsg(
> + 'warn',
> + "cluster-wide backup limits are not enforced - not all online nodes support them",
> + ) if !$self->{'cluster-slots-unsupported'}++;
Style nit: multiline post-if
> + return undef;
> + }
> +
> + return ['cluster'];
> +}
> +
> +my sub release_cluster_slots {
> + my ($key) = @_;
> +
> + return if !defined($key);
> +
> + eval { PVE::VZDump::ClusterSlots::release($key) };
> + if (my $err = $@) {
> + die $err if $err =~ m/interrupted by signal$/; # also if wrapped by the cluster lock
> + debugmsg('warn', "unable to release cluster-wide backup slot - $err");
> + }
> +}
> +
> +sub cleanup_cluster_slots {
> + PVE::VZDump::ClusterSlots::cleanup_local_entries(\&guest_backup_running);
> +}
> +
> +# A claimed guest that needs cluster-wide slots waits for them as pending guest, only one at a time.
> +my sub next_guest_to_start {
> + my ($self, $queue, $schedule) = @_;
> +
> + my $pending = $schedule->{pending};
> + if (!$pending) {
> + my ($task, $locks) = claim_next_guest($self, $queue, $schedule->{deferred}) or return;
> + my $resources = eval { cluster_slot_resources($self) };
> + if (my $err = $@) {
> + unshift $queue->@*, $task;
> + die $err;
> + }
> + return ($task, $locks) if !$resources;
> +
> + $pending = $schedule->{pending} = {
> + task => $task,
> + locks => $locks,
> + resources => $resources,
> + since => time(),
> + refreshed => time(),
> + };
> + }
> +
> + my $task = $pending->{task};
> + my $granted = eval {
> + $pending->{key} //=
> + PVE::VZDump::ClusterSlots::request($self->{upid}, $task->{vmid}, $pending->{resources});
> + if (time() - $pending->{refreshed} >= 30) {
> + PVE::VZDump::ClusterSlots::refresh();
> + $pending->{refreshed} = time();
> + }
> + PVE::VZDump::ClusterSlots::granted($pending->{key});
> + };
> + if (my $err = $@) {
> + die $err if $err =~ m/interrupted by signal$/;
> + chomp($err);
> + debugmsg('warn', "unable to request cluster-wide backup slot - $err")
> + if !defined($pending->{'request-error'});
Should we check if it's the same error as last time and log it if not?
> + $pending->{'request-error'} = $err;
> + } elsif (!defined($granted)) {
> + $pending->{key} = undef; # vanished, for example if released by an admin
> + } elsif ($granted) {
> + delete $schedule->{pending};
> + $schedule->{'cluster-wait'} += time() - $pending->{since};
> + if (my $node = guest_moved_to($task->{vmid})) {
> + release_cluster_slots($pending->{key});
> + skip_task($task, "guest moved to node '$node'");
> + return;
> + }
> + return ($task, $pending->{locks}, $pending->{key});
> + } else {
> + delete $pending->{'request-error'};
> + }
> +
> + my $waited = $schedule->{'cluster-wait'} + time() - $pending->{since};
> + my $maxwait = ($self->{opts}->{lockwait} || 0) * 60;
Same as in patch 12/19:
The default according to the schema would be 3 hours. But getlock()
already relies on the value being initialized by new(), so we could do
the same here.
> + if ($waited >= $maxwait) {
> + release_cluster_slots($pending->{key});
> + delete $schedule->{pending};
> + $schedule->{'cluster-wait'} = $waited;
> + my $reason = 'timeout waiting for cluster-wide backup slot';
> + $reason .= " - $pending->{'request-error'}" if defined($pending->{'request-error'});
> + skip_task($task, $reason);
> + } elsif (!$pending->{logged}++) {
> + debugmsg('info', "VM $task->{vmid} waits for a cluster-wide backup slot");
> + }
> +
> + return;
> +}
> +
---snip 8<---
> diff --git a/PVE/VZDump/ClusterSlots.pm b/PVE/VZDump/ClusterSlots.pm
> new file mode 100644
> index 000000000..6537bdce5
> --- /dev/null
> +++ b/PVE/VZDump/ClusterSlots.pm
> @@ -0,0 +1,245 @@
> +package PVE::VZDump::ClusterSlots;
> +
> +# Cluster-wide limits for guest backups running at the same time.
> +#
> +# Each guest backup that counts against such a limit has an entry in a shared table on pmxcfs. It
> +# starts out as waiter and becomes holder once all limits it counts against have room. Entries of
> +# offline nodes stay, as their guest backups might still be running.
> +
> +use strict;
> +use warnings;
Nit: please use v5.36 and subroutine signatures for new modules
> +
> +use JSON;
> +
> +use PVE::Cluster qw(cfs_read_file);
> +use PVE::INotify;
> +use PVE::QemuServer::Helpers;
> +use PVE::Tools;
> +
> +my $state_file = '/etc/pve/priv/backup-slots.json';
It does not hold any secrets, so is there a special reason why inside priv/?
> +
> +# first release of pve-manager that takes part in cluster-wide limits
> +my $min_version = [9, 2, 26];
> +
> +sub limits {
> + my $limits = {};
> +
> + my $dc_conf = cfs_read_file('datacenter.cfg');
> + if (defined(my $cluster = $dc_conf->{'backup-concurrency'}->{cluster})) {
> + $limits->{cluster} = $cluster;
> + }
> +
> + return $limits;
> +}
> +
> +my sub get_members {
> + my $members = PVE::Cluster::get_members();
> + return $members if $members && scalar(keys $members->%*);
> +
> + # entries of other nodes would get dropped as if these left the cluster
> + die "unable to get cluster members\n" if -e '/etc/pve/corosync.conf';
> +
> + return { PVE::INotify::nodename() => { online => 1 } };
> +}
> +
> +# Offline nodes cannot start guest backups, and their entries stay anyway.
> +sub supported_by_all_nodes {
> + my $members = get_members();
> + my $nodename = PVE::INotify::nodename();
> +
> + for my $node (keys $members->%*) {
> + next if $node eq $nodename || !$members->{$node}->{online};
> + my $version = PVE::QemuServer::Helpers::get_node_pvecfg_version($node);
> + return 0 if !PVE::QemuServer::Helpers::pvecfg_min_version($version, $min_version->@*);
Nit: could use the pvecfg_min_version() from PVE::Tools
Note: conflicts with my patch, which moves the get_node_pvecfg_version()
to PVE::Cluster, so you might want to apply the relevant patch there
first and switch to using the helper from PVE::Cluster:
https://lore.proxmox.com/pve-devel/20261103124831.139399-3-f.ebner@proxmox.com/
> + }
> + return 1;
> +}
> +
> +# Checks a copy, so that numbers do not get written back as strings.
> +my sub is_count {
> + my ($value) = @_;
> + return defined($value) && !ref($value) && $value =~ m/^\d+$/;
> +}
> +
> +my sub valid_entry {
> + my ($entry) = @_;
> +
> + return
> + ref($entry) eq 'HASH'
> + && (grep { !ref($entry->{$_}) && length($entry->{$_} // '') } qw(node upid)) == 2
> + && is_count($entry->{vmid})
> + && is_count($entry->{seq})
> + && ref($entry->{resources}) eq 'ARRAY'
> + && !grep { ref($_) || !length($_ // '') } $entry->{resources}->@*;
> +}
> +
> +sub parse_state {
> + my ($raw) = @_;
> +
> + my $state = eval { decode_json($raw) };
> + die "invalid JSON - $@" if $@;
> + die "not a backup slot table\n"
Maybe log the $raw in the error messages, so it can be checked what the
concrete failure was?
> + if ref($state) ne 'HASH'
> + || (grep { ref($state->{$_}) ne 'HASH' } qw(holders waiters))
> + || !is_count($state->{seq})
> + || grep { !valid_entry($_) } map { values $state->{$_}->%* } qw(holders waiters);
Style nit: multiline post-if
> +
> + return $state;
> +}
> +
> +# Fails on anything but a missing file, as writing back a table that lacks holders would let more
> +# guest backups start than the limits allow.
> +sub read_state {
> + open(my $fh, '<', $state_file) or do {
> + die "unable to open '$state_file' - $!\n" if !$!{ENOENT};
> + return { holders => {}, waiters => {}, seq => 0 };
> + };
> + my $raw = PVE::Tools::safe_read_from($fh, 0, 0, $state_file);
Nit: could use PVE::File
> + my $state = eval { parse_state($raw) };
> + die "unable to parse '$state_file' - $@" if $@;
> +
> + return $state;
> +}
> +
> +my sub used_slots {
> + my ($state) = @_;
> +
> + my $used = {};
> + for my $holder (values $state->{holders}->%*) {
> + $used->{$_}++ for $holder->{resources}->@*;
> + }
> + return $used;
> +}
> +
> +my sub remove_entries {
> + my ($state, $match) = @_;
> +
> + my $count = 0;
> + for my $entries ($state->@{qw(holders waiters)}) {
> + for my $key (keys $entries->%*) {
> + next if !$match->($entries->{$key});
> + delete $entries->{$key};
> + $count++;
> + }
> + }
> + return $count;
> +}
> +
> +# Moves waiters that fit into all limits they count against to the holders, oldest first. Only
> +# resources with a limit matter, the others are tracked for information. Waiters of offline nodes
> +# neither get granted nor hold up others.
> +sub grant_waiters {
> + my ($state, $limits, $members) = @_;
> +
> + remove_entries($state, sub { !$members->{ $_[0]->{node} } });
> +
> + my $used = used_slots($state);
> +
> + my $waiters = $state->{waiters};
> + for my $key (sort { $waiters->{$a}->{seq} <=> $waiters->{$b}->{seq} } keys $waiters->%*) {
> + my $waiter = $waiters->{$key};
> + next if !$members->{ $waiter->{node} }->{online};
> +
> + my $limited = [grep { defined($limits->{$_}) } $waiter->{resources}->@*];
> + next if grep { ($used->{$_} // 0) >= $limits->{$_} } $limited->@*;
> +
> + $used->{$_}++ for $waiter->{resources}->@*;
> + $state->{holders}->{$key} = delete $waiters->{$key};
> + $state->{holders}->{$key}->{granted} = time();
> + }
> +}
> +
> +my sub update_state {
> + my ($code) = @_;
> +
> + my $res = PVE::Cluster::cfs_lock_domain(
> + 'backup-slots',
> + 10,
> + sub {
> + my $json = JSON->new->canonical();
> +
> + my $state = read_state();
> + my $previous = $json->encode($state);
> +
> + my $res = $code->($state);
> + grant_waiters($state, limits(), get_members());
> +
> + my $raw = $json->encode($state);
> + PVE::Tools::file_set_contents($state_file, $raw) if $raw ne $previous;
Nit: could use PVE::File
> +
> + return $res;
> + },
> + );
> + die $@ if $@;
> +
> + return $res;
> +}
> +
> +sub request {
> + my ($upid, $vmid, $resources) = @_;
> +
> + my $node = PVE::INotify::nodename();
> + my $key = "$node/$vmid/$upid";
> +
> + update_state(sub {
> + my ($state) = @_;
> + return if $state->{holders}->{$key} || $state->{waiters}->{$key};
> +
> + $state->{waiters}->{$key} = {
> + node => $node,
> + upid => $upid,
> + vmid => $vmid,
> + resources => $resources,
> + seq => ++$state->{seq},
> + queued => time(),
> + };
> + });
> +
> + return $key;
> +}
> +
> +# Returns 1 if granted, 0 if still waiting and undef if the request vanished.
> +sub granted {
> + my ($key) = @_;
> +
> + my $state = read_state();
> + return 1 if $state->{holders}->{$key};
> + return 0 if $state->{waiters}->{$key};
> + return undef;
> +}
> +
> +# Grants waiters that fit by now, for example after limits got raised.
> +sub refresh {
> + update_state(sub { });
> +}
> +
> +sub release {
> + my ($key) = @_;
> +
> + update_state(sub {
> + my ($state) = @_;
> + delete $state->{$_}->{$key} for qw(holders waiters);
> + });
> +}
> +
> +# Removes the entries of this node whose guest backup is gone, as told by $running->($vmid, $upid).
> +sub cleanup_local_entries {
> + my ($running) = @_;
> +
> + my $node = PVE::INotify::nodename();
> +
> + my $stale = sub {
> + my ($entry) = @_;
> + return $entry->{node} eq $node && !$running->($entry->{vmid}, $entry->{upid});
> + };
> +
> + # avoid the cluster lock if there is nothing to remove
> + return if !remove_entries(read_state(), $stale);
> +
> + update_state(sub {
> + my ($state) = @_;
> + remove_entries($state, $stale);
> + });
> +}
> +
> +1;
> diff --git a/PVE/VZDump/Makefile b/PVE/VZDump/Makefile
> new file mode 100644
> index 000000000..6500eb4d4
> --- /dev/null
> +++ b/PVE/VZDump/Makefile
> @@ -0,0 +1,15 @@
> +include ../../defines.mk
> +
> +PERLSOURCE = \
> + ClusterSlots.pm \
> +
> +all:
> +
> +.PHONY: clean
> +clean:
> + rm -rf *~
> +
> +.PHONY: install
> +install: $(PERLSOURCE)
> + install -d $(PERLLIBDIR)/PVE/VZDump
> + install -m 0644 $(PERLSOURCE) $(PERLLIBDIR)/PVE/VZDump
> diff --git a/test/Makefile b/test/Makefile
> index 1eb94f4e0..d51095978 100644
> --- a/test/Makefile
> +++ b/test/Makefile
> @@ -18,7 +18,7 @@ replication%.t: replication_test%.pl
> ./$<
>
> .PHONY: test-vzdump
> -test-vzdump: test-vzdump-guest-included test-vzdump-new test-vzdump-concurrency
> +test-vzdump: test-vzdump-guest-included test-vzdump-new test-vzdump-concurrency test-vzdump-cluster-slots
>
> .PHONY: test-vzdump-guest-included
> test-vzdump-guest-included:
> @@ -32,6 +32,10 @@ test-vzdump-new:
> test-vzdump-concurrency:
> ./vzdump_concurrency_test.pl
>
> +.PHONY: test-vzdump-cluster-slots
> +test-vzdump-cluster-slots:
> + ./vzdump_cluster_slots_test.pl
> +
> .PHONY: test-osd
> test-osd:
> ./OSD_test.pl
> diff --git a/test/vzdump_cluster_slots_test.pl b/test/vzdump_cluster_slots_test.pl
> new file mode 100755
> index 000000000..85608b5be
> --- /dev/null
> +++ b/test/vzdump_cluster_slots_test.pl
> @@ -0,0 +1,194 @@
> +#!/usr/bin/perl
> +
> +use strict;
> +use warnings;
Nit: could use v5.36
I didn't get around to finish the review of the series, but consider the
patches, where I left no reply or only nits as Reviewed-by me
next prev parent reply other threads:[~2026-11-06 14:49 UTC|newest]
Thread overview: 27+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-11-01 22:58 [PATCH cluster,storage,guest-common,container,manager,docs 00/19] parallel guest backups and backup concurrency limits Thomas Lamprecht
2026-11-01 22:58 ` [PATCH cluster 01/19] datacenter config: add backup concurrency setting Thomas Lamprecht
2026-11-01 22:58 ` [PATCH cluster 02/19] datacenter config: backup concurrency: add cluster-wide limit Thomas Lamprecht
2026-11-01 22:58 ` [PATCH storage 03/19] add backup concurrency option to storages Thomas Lamprecht
2026-11-01 22:58 ` [PATCH guest-common 04/19] vzdump: add options for parallel guest backups and overlapping jobs Thomas Lamprecht
2026-11-01 22:58 ` [PATCH container 05/19] vzdump: honor bandwidth limit for backups to Proxmox Backup Server Thomas Lamprecht
2026-11-06 10:28 ` applied: " Fiona Ebner
2026-11-01 22:58 ` [PATCH manager 06/19] vzdump: notification: capture guest logs right after each backup Thomas Lamprecht
2026-11-06 14:49 ` Fiona Ebner
2026-11-01 22:58 ` [PATCH manager 07/19] vzdump: stop all running backup jobs of the node Thomas Lamprecht
2026-11-06 14:48 ` Fiona Ebner
2026-11-01 22:58 ` [PATCH manager 08/19] vzdump: show the error if a job could not get the global lock Thomas Lamprecht
2026-11-01 22:58 ` [PATCH manager 09/19] vzdump: run job-init hook and set up target once the job may start Thomas Lamprecht
2026-11-06 14:48 ` Fiona Ebner
2026-11-01 22:58 ` [PATCH manager 10/19] vzdump: list guests as skipped if the job could not start Thomas Lamprecht
2026-11-01 22:58 ` [PATCH manager 11/19] vzdump: back up guests of a job in parallel Thomas Lamprecht
2026-11-06 14:49 ` Fiona Ebner
2026-11-01 22:58 ` [PATCH manager 12/19] vzdump: allow jobs to run alongside each other Thomas Lamprecht
2026-11-06 14:49 ` Fiona Ebner
2026-11-01 22:58 ` [PATCH manager 13/19] ui: options: add backup concurrency for datacenter and nodes Thomas Lamprecht
2026-11-01 22:58 ` [PATCH manager 14/19] ui: backup job: add concurrency and overlap options Thomas Lamprecht
2026-11-01 22:58 ` [PATCH manager 15/19] vzdump: limit concurrent guest backups cluster-wide Thomas Lamprecht
2026-11-06 14:49 ` Fiona Ebner [this message]
2026-11-01 22:58 ` [PATCH manager 16/19] vzdump: honor backup concurrency limits of storages Thomas Lamprecht
2026-11-01 22:58 ` [PATCH manager 17/19] api: backup info: add endpoints for cluster-wide backup slots Thomas Lamprecht
2026-11-01 22:58 ` [PATCH manager 18/19] ui: add cluster-wide and storage backup concurrency limits Thomas Lamprecht
2026-11-01 22:58 ` [PATCH docs 19/19] vzdump: document concurrent backups and their limits Thomas Lamprecht
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=cede1743-e943-4d9d-88bc-e0458f4f3e9f@proxmox.com \
--to=f.ebner@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
--cc=t.lamprecht@proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox