all lists on lists.proxmox.com
 help / color / mirror / Atom feed
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




  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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal