Skip to main content
RunBook Academy

Proxmox VEXX · CLI & AutomationAPI and automation

Tasks, UPIDs and asynchronous operations

Advanced⏱ ~28 minpveshjq

What you'll learn

  • Decode a UPID into its component fields and say what each one is for
  • Poll a task to completion and distinguish "finished" from "succeeded"
  • Retrieve and read a task log, including for a task that has already ended
  • Write a shell wrapper that turns an asynchronous API call into a synchronous one
  • Recognise the race conditions that arise from treating a returned UPID as a result

Prerequisites

Verified against Proxmox VE 9.2.4 · Proxmox Backup Server 4.2.5 · Ceph Squid / Tentacle · Debian 13 (Trixie) · Linux kernel 7.0 (PVE 9.2 default) · 2026-08-12

Not yet marked complete on this device.

Ask the Proxmox API to start a virtual machine and it does not tell you whether the machine started. It tells you that it has begun trying:

Read-only / Safethe API's answer to 'start this VM'
# pvesh create /nodes/pve-01/qemu/100/status/start
UPID:pve-01:00006A2F:04C1B2E1:6A7C83E0:qmstart:100:root@pam:

Illustrative output

Every state-changing operation in Proxmox works this way. Starting a VM, taking a backup, migrating a guest, creating a container, moving a disk: each one forks a worker process, records it as a task, and returns its identifier immediately. The API viewer confirms it — POST to /nodes/{node}/qemu/{vmid}/status/start has a return type of string, and that string is the UPID.

At an interactive shell this is easy to miss, because qm start usually finishes so fast that the next thing you type happens after the VM is up anyway. In a script it is the single most common source of races, and the failures it produces are intermittent, timing-dependent, and worse on a busy cluster — which is to say, worst in production.

What a UPID encodes

A UPID is a colon-delimited string with a trailing colon, and every field is there for a reason.

UPID:pve-01:00006A2F:04C1B2E1:6A7C83E0:qmstart:100:root@pam:
     |      |        |        |        |       |   |
     |      |        |        |        |       |   +-- user@realm
     |      |        |        |        |       +------ task id (usually VMID)
     |      |        |        |        +-------------- task type
     |      |        |        +----------------------- start time, hex Unix epoch
     |      |        +-------------------------------- process start, hex jiffies
     |      +----------------------------------------- worker PID, hexadecimal
     +------------------------------------------------ node that owns the task

Three of those are immediately useful.

The node. A UPID belongs to the node that ran the task. You cannot query it from a different node’s /nodes/{other}/tasks/ path — the node name in the UPID and the node in the API path have to match. Scripts that migrate a guest and then poll the wrong node are a real category of bug.

The start time. 6A7C83E0 in hexadecimal is 1786545120, a Unix timestamp. That makes a UPID self-dating without any lookup, which is useful when you find one in a log six months later.

The type and id. qmstart:100 says what was attempted and to what. vzdump:141, qmigrate:102, imgcopy:, hastart:vm:100 — the type vocabulary is small and readable, and it is what you filter on when searching the task history.

Read-only / Safedecode a UPID's timestamp
UPID='UPID:pve-01:00006A2F:04C1B2E1:6A7C83E0:qmstart:100:root@pam:'

HEXTIME=$(echo "$UPID" | cut -d: -f5)
date -d "@$(( 16#$HEXTIME ))"

Polling a task correctly

Two things have to be checked, and checking only one is the classic mistake.

Read-only / Safethe two fields that matter
NODE=pve-01
UPID='UPID:pve-01:00006A2F:04C1B2E1:6A7C83E0:qmstart:100:root@pam:'

pvesh get "/nodes/$NODE/tasks/$UPID/status" --output-format json | jq .
Read-only / Safea task that finished, and failed
# pvesh get /nodes/pve-01/tasks/UPID.../status --output-format json
{
"exitstatus": "unable to create VM 900 - VM 900 already exists on node 'pve-02'",
"id": "900",
"node": "pve-01",
"pid": 27183,
"pstart": 79822817,
"starttime": 1786545120,
"status": "stopped",
"type": "qmcreate",
"upid": "UPID:pve-01:00006A2F:04C1B2E1:6A7C83E0:qmcreate:900:root@pam:",
"user": "root@pam"
}

Illustrative output

Reading a task log

The task log is the same output the GUI shows in its task viewer, and it survives the task by a long way — Proxmox keeps completed task logs on disk, so a backup that failed last week can still be read in full.

Read-only / Safethe full log of a task, running or finished
NODE=pve-01
UPID='UPID:pve-01:00006C88:04C41A02:6A7BD3A3:vzdump:141:root@pam:'

pvesh get "/nodes/$NODE/tasks/$UPID/log" --limit 0 --output-format json \
| jq -r '.[].t'

The pvenode wrapper is shorter for interactive use and does the same thing:

Read-only / Safethe interactive route
pvenode task list --limit 20 --errors 1

UPID='UPID:pve-01:00006C88:04C41A02:6A7BD3A3:vzdump:141:root@pam:'
pvenode task status "$UPID"
pvenode task log "$UPID"
Read-only / Safea backup task log with the real cause in it
# pvenode task log UPID:pve-01:00006C88:...:vzdump:141:root@pam:
INFO: starting new backup job: vzdump 141 --storage backup-pbs --mode snapshot
INFO: Starting Backup of VM 141 (qemu)
INFO: Backup started at 2026-08-12 02:00:03
INFO: status = running
INFO: creating Proxmox Backup Server archive 'vm/141/2026-08-12T02:00:03Z'
INFO: started backup task 'a9f21c44-2b1e-4c8d-9f10-REDACTED'
INFO:   0% (1.1 GiB of 200.0 GiB) in 3s, read: 383.4 MiB/s
ERROR: job failed with err -5 - Input/output error
INFO: aborting backup job
ERROR: Backup of VM 141 failed - job failed with err -5 - Input/output error
INFO: Failed at 2026-08-12 02:00:41

Illustrative output

Finding tasks you do not have the UPID for

This is the ordinary morning-after case: something failed, nobody recorded the UPID, and you need to find it.

Read-only / Safefilter the node's task history
NODE=pve-01

# every failed task in the last 24 hours
SINCE=$(date -d '24 hours ago' +%s)
pvesh get "/nodes/$NODE/tasks" --errors 1 --since "$SINCE" \
--output-format json | jq -r '.[] | [.starttime, .type, .id, .status] | @tsv'

# everything that touched one guest
pvesh get "/nodes/$NODE/tasks" --vmid 141 --limit 20 \
--output-format json | jq -r '.[] | [.type, .status] | @tsv'

For a cluster-wide view there is /cluster/tasks, which aggregates across nodes — useful precisely when you do not yet know which node the work happened on.

Read-only / Saferecent tasks across the whole cluster
pvesh get /cluster/tasks --output-format json \
| jq -r '.[] | [.node, .type, .id, .status] | @tsv' | head -20

Making it synchronous in a script

Almost every script wants “start this VM and tell me when it is running, or fail”. That is a wrapper, and it is worth writing once properly.

Configuration changea task-waiting wrapper
#!/usr/bin/env bash
set -euo pipefail

wait_for_task() {
local node="$1" upid="$2" timeout="${3:-600}"
local deadline=$(( $(date +%s) + timeout ))
local st exitstatus

while :; do
  st=$(pvesh get "/nodes/$node/tasks/$upid/status" --output-format json)
  if [ "$(echo "$st" | jq -r .status)" != "running" ]; then
    exitstatus=$(echo "$st" | jq -r '.exitstatus // "no exitstatus"')
    if [ "$exitstatus" = "OK" ]; then
      return 0
    fi
    echo "task failed: $exitstatus" >&2
    pvesh get "/nodes/$node/tasks/$upid/log" --limit 0 \
      --output-format json | jq -r '.[].t' | tail -20 >&2
    return 1
  fi
  if [ "$(date +%s)" -ge "$deadline" ]; then
    echo "timed out after ${timeout}s waiting for $upid" >&2
    return 2
  fi
  sleep 2
done
}

NODE=pve-01
VMID=100
UPID=$(pvesh create "/nodes/$NODE/qemu/$VMID/status/start")
wait_for_task "$NODE" "$UPID" 120

Four details in that wrapper are not decoration.

  • It polls status, then reads exitstatus. Both, in that order, because exitstatus is absent while the task is running.
  • It has a timeout, and a distinct exit code for it. A caller needs to distinguish “the operation failed” from “we stopped waiting”, since the second means the operation may still be in progress.
  • It dumps the tail of the task log on failure. The exitstatus string is often a summary; the log has the detail. Capturing it at the moment of failure is far more reliable than expecting someone to go looking later.
  • It sleeps two seconds rather than spinning. A tight poll loop against pvedaemon across a fleet is a self-inflicted load problem.

Stopping a running task

Service impact possibleask a running task to stop
NODE=pve-01
UPID='UPID:pve-01:00006C88:04C41A02:6A7BD3A3:vzdump:141:root@pam:'

pvesh delete "/nodes/$NODE/tasks/$UPID"

pvesh get "/nodes/$NODE/tasks/$UPID/status" --output-format json | jq -r .exitstatus

A stopped task reports an exitstatus reflecting the interruption rather than OK, which is correct — it did not do what it was asked. Scripts that treat any completed task as done will read a cancelled backup as a successful one.

Common mistakes

  • Treating the returned UPID as a result. It is a receipt for work that has begun.
  • Polling status and ignoring exitstatus. A task can be stopped and failed simultaneously. Only OK means success.
  • Querying a UPID against the wrong node. The node is in the UPID and must match the API path. After a migration this bites.
  • Discarding the error string. exitstatus frequently names the exact problem; the task log has more.
  • Polling every 100 ms. The load exceeds the work on a cluster of any size. Two seconds for a start, ten to thirty for a backup.
  • No timeout, or a timeout that reports the same failure as an error. A caller needs to distinguish “it failed” from “we stopped watching, and it may still be running”.
  • Treating a cancelled task as complete. Stopping a task produces a non-OK exitstatus for a reason.
  • Relying on task history for an audit trail. It rotates. Capture the log if the record matters.

Key takeaways

  • Every state-changing Proxmox operation returns a UPID immediately and does the work asynchronously.
  • A UPID encodes node, worker PID, process start, start time (hex Unix epoch), task type, task id and user — and is globally unique without any coordination.
  • Poll /nodes/{node}/tasks/{upid}/status; status says whether it is running, exitstatus says whether it worked, and exitstatus exists only once it has stopped.
  • exitstatus must be exactly OK. Anything else is the error.
  • /nodes/{node}/tasks/{upid}/log returns the full output and survives the task; pvenode task list --errors 1 finds failures without a UPID.
  • /cluster/tasks aggregates across nodes — the right endpoint when you do not know where the work ran.
  • DELETE /nodes/{node}/tasks/{upid} requests a stop; the worker unwinds at a safe point and reports a non-OK status.
  • Write one task-waiting wrapper with a timeout and reuse it. Fixed sleeps fail in both directions.

Knowledge check

Knowledge check · 5 questions

  1. Q1. A script polls a task until status is "stopped", then proceeds. What class of bug does this introduce?

  2. Q2. A UPID must be queried against the node named inside it; polling it against a different node in the /nodes/{node}/tasks path will not find it.

  3. Q3. Which of these are legitimate reasons the task log is worth retrieving rather than relying on exitstatus alone? Select all that apply.

  4. Q4. Why does the UPID include the worker process start time (the pstart field) in addition to the PID?

  5. Q5. A provisioning script intermittently fails with configuration lock errors, but only on the production cluster and never in test. It creates a VM and immediately calls qm set to attach a disk. What is happening?

Passing score: 75%. Answers are checked in this browser.