The recovery point objective is the time since the last successful send

The recovery point objective is the age of the last snapshot that arrived on the replica, counted from that snapshot's creation time. A 15-minute schedule, a local snapshot, and a send that exited 0 on the source are not that age.

RPO is the time since the last successful send

The recovery point objective is the time since the last successful send. On this stack that time is the age of the newest snapshot the replica has, counted from the snapshot’s creation time to now. A 15-minute schedule is the timer. It is not that age. A snapshot that exists only on the source is not that age either.

The recovery point is the snapshot. The recovery point is a snapshot of the disk’s dataset. This page is how old that snapshot is on the copy you would actually use.

What is a recovery point objective?

You write it down as a duration, before you set the timer. Fifteen minutes means you can lose fifteen minutes of writes and still do the job.

Keeping points for 14 days does not make the recovery point 14 days. That is retention: how far back you can still go, if those points arrived. Choose backup retention for virtual machines.

A snapshot schedule is a series of points. It does not record every write between them. If the objective is a few seconds, this job cannot meet it. A timer shorter than the transfer leaves you on the last snapshot that arrived.

What is the difference between RTO and RPO?

RPO is the data you can lose, in minutes. RTO is how long the guest stays down.

The last snapshot on the replica was created at 09:00. The pool dies at 09:40. You have lost 40 minutes of writes. That is the recovery point. If receiving that snapshot onto other storage and booting the guest takes two hours, the recovery time is those two hours, plus whatever the application needs before it answers. A send that finished at 09:18 did not bring the guest up.

Time a restore when you need the recovery time. Test a virtual machine restore.

What is an example of an RPO?

The objective for tank/vm-db is 15 minutes. Snapshots are taken at 09:00, 09:15, and 09:30. It is 09:40.

The replica has tank/vm-db@2026-11-06-0900. Creation is 09:00. The guid matches the source snapshot you sent. The achieved recovery point is 40 minutes. The objective is missed.

The 09:15 and 09:30 snapshots are still on the source. zfs send for 09:15 exited 0 there. The replica does not have that guid. The recovery point stays 09:00. Exit 0 means the sender finished writing the stream. The receive may not have committed.

A bookmark named tank/vm-db#2026-11-06-0900 is not a point you can boot. Bookmarks let you delete dailies without breaking the next send.

How do you read the last successful send?

On the replica:

zfs list -t snapshot -o name,creation,guid -s creation tank/vm-db

zfs get -H -p -o value creation tank/vm-db@2026-11-06-0900 is creation in seconds. Age is now minus that number. Creation is the sender’s clock at zfs snapshot. Run date -u +%s on both hosts before you report a number under an hour. If they disagree by minutes, the age is off by that much.

A snapshot created at 09:00 and received at 09:18 is a 09:00 point. At 09:20 the data loss is 20 minutes, not 2. Two minutes is how late the transfer was.

Match the guid to the source snapshot you sent. The last name in that list is the wrong row if someone snapshotted the replica dataset after the receive. That local snapshot is newer, and it was not in the stream.

One guest should be one dataset, so this age belongs to that guest. One dataset per VM is the job, the quota, and the key. A disk dataset from 09:15 and an EFI dataset from yesterday is not a 09:15 VM. Take the older creation of the pair that arrived together.

Several VMs that are one application need one frozen moment. An application of several VMs shares one frozen moment. The set’s recovery point is the oldest creation in the set. Do not average them.

The off-site age is the last copy that arrived off site, not the replica in this building. Keep an off-site virtual machine backup. On VMware, Hyper-V, and Proxmox the same rule holds: use the time in the last copy that arrived on the store you would restore from, not the job schedule. Back up VMware virtual machines. Back up Hyper-V virtual machines. Back up Proxmox virtual machines.

What does not count as the recovery point?

Skip the schedule, a cron exit of 0, and a source zfs send that exited 0 when the replica lacks the snapshot. Also skip the local snapshot when the production pool is what failed, plus a bookmark, the receive timestamp, a snapshot created on the replica, and a 30-day keep.

A local mirror covers a dead disk in the rack. It does not move the remote age to zero. A local mirror is the synchronous tier; remote send stays asynchronous.

What fails?

The 09:00 send arrived. The next three did not. At 10:00 the last exit code is still 0, and the recovery point is 60 minutes. Alert on the age.

The sender’s clock is fast, so creation is ahead of the replica and the age comes out negative or too small. Fix the time source, then snapshot again. Do not set the clock back to make the objective pass.

The newest name on the replica can be a local snapshot whose guid you never sent.

The name is there and the key is not, so you cannot open the dataset. A raw send replicates ciphertext and leaves the key at home. A name you cannot read is not a recovery point.

The database will not open, or it spends the boot replaying a torn write. The bytes are from that creation time. The usable point may be the last freeze that finished. Make application-consistent virtual machine backups.

zfs receive refuses the stream because the replica still has a snapshot the source no longer sends. The timer still fires, and the age still grows. The replica keeps snapshots the source is no longer allowed to expire.

How do you prove that point restores?

zfs list shows the name, the creation time, and the guid. It does not start the guest.

Receive or clone that snapshot onto storage that is not the production pool. New name, new UUID, NIC on a bridge with no uplink. Boot it. A record written before the creation time has to be there. A record written after it has to be absent. If the later record is there, you booted production, or a newer point than the one you measured. A cleanroom is a clone on a bridge with no uplink.

Write the dataset, the snapshot name, the guid, the creation time, the clock you subtracted, the age, and whether that age was inside the objective. “RPO met”, with none of that, is not a record.

Check the age, then boot the guest

These scripts build fixtures under a temporary directory. They do not run zfs. In the first fixture, now is 36000 and the newest received creation is 32400, so the age is 3600 against an objective of 900. A source-only send, a replica-local snapshot, a receive timestamp, a bookmark, and the schedule are in the fixture and are not the age.

#!/usr/bin/env bash
set -euo pipefail

root="$(mktemp -d)"
trap 'rm -rf "$root"' EXIT

field() {
  local file="$1"
  local key="$2"
  local line
  while IFS= read -r line || [[ -n "$line" ]]; do
    if [[ "$line" == "${key}="* ]]; then
      printf '%s\n' "${line#"${key}="}"
      return 0
    fi
  done < "$file"
  printf 'missing %s in %s\n' "$key" "$file" >&2
  return 1
}

write_snap() {
  local file="$1"
  local guid="$2"
  local creation="$3"
  mkdir -p "$(dirname "$file")"
  printf 'guid=%s\ncreation=%s\n' "$guid" "$creation" > "$file"
}

seed_late() {
  local base="$1"
  mkdir -p "${base}/source" "${base}/replica/received" "${base}/replica/local" "${base}/source/bookmarks"
  printf 'now=36000\nobjective=900\nschedule=900\n' > "${base}/meta"
  write_snap "${base}/source/99" 99 30000
  write_snap "${base}/replica/received/0800" 99 30000
  write_snap "${base}/source/100" 100 32400
  write_snap "${base}/replica/received/0900" 100 32400
  printf 'receive_completed=34200\n' >> "${base}/replica/received/0900"
  write_snap "${base}/source/101" 101 35100
  printf 'send_exit=0\n' >> "${base}/source/101"
  write_snap "${base}/replica/local/local-1" 199 35500
  write_snap "${base}/replica/received/unknown" 50 35000
  printf 'guid=100\ncreation=32400\n' > "${base}/source/bookmarks/0900"
}

achieved_age() {
  local base="$1"
  local now best_creation guid path source_file creation found
  now="$(field "${base}/meta" now)"
  best_creation="-1"
  found=0
  shopt -s nullglob
  for path in "${base}/replica/received"/*; do
    [[ -f "$path" ]] || continue
    guid="$(field "$path" guid)"
    creation="$(field "$path" creation)"
    source_file="${base}/source/${guid}"
    [[ -f "$source_file" ]] || continue
    [[ "$(field "$source_file" guid)" == "$guid" ]] || continue
    if [[ "$creation" -gt "$now" ]]; then
      return 1
    fi
    if [[ "$creation" -gt "$best_creation" ]]; then
      best_creation="$creation"
      found=1
    fi
  done
  shopt -u nullglob
  [[ "$found" -eq 1 ]] || return 1
  printf '%s\n' $((now - best_creation))
}

within_objective() {
  local base="$1"
  local age objective
  age="$(achieved_age "$base")"
  objective="$(field "${base}/meta" objective)"
  [[ "$age" -le "$objective" ]]
}

must_fail() {
  local label="$1"
  shift
  if "$@"; then
    printf 'checker accepted %s\n' "$label" >&2
    exit 1
  fi
}

late="${root}/late"
seed_late "$late"
age="$(achieved_age "$late")"
[[ "$age" == "3600" ]] || {
  printf 'expected 3600, got %s\n' "$age" >&2
  exit 1
}
must_fail "a 3600-second age against a 900-second objective" within_objective "$late"

schedule="$(field "${late}/meta" schedule)"
[[ "$schedule" != "$age" ]] || {
  printf 'schedule was equal to the age\n' >&2
  exit 1
}
recv="$(field "${late}/replica/received/0900" receive_completed)"
now="$(field "${late}/meta" now)"
lag="$((now - recv))"
[[ "$lag" != "$age" ]] || {
  printf 'receive time was used as creation\n' >&2
  exit 1
}

empty="${root}/empty"
mkdir -p "${empty}/source" "${empty}/replica/received" "${empty}/source/bookmarks"
printf 'now=36000\nobjective=900\nschedule=900\n' > "${empty}/meta"
write_snap "${empty}/source/101" 101 35100
printf 'send_exit=0\n' >> "${empty}/source/101"
printf 'guid=101\ncreation=35100\n' > "${empty}/source/bookmarks/0915"
must_fail "a source send exit 0 and a bookmark, with nothing received" achieved_age "$empty"

future="${root}/future"
mkdir -p "${future}/source" "${future}/replica/received"
printf 'now=36000\nobjective=900\nschedule=900\n' > "${future}/meta"
write_snap "${future}/source/100" 100 40000
write_snap "${future}/replica/received/future" 100 40000
must_fail "a creation time after now" achieved_age "$future"

printf 'RPO age check passed.\n'
printf 'Proves: the age is now minus the creation of the newest received guid that still exists on the source. A schedule, a receive timestamp, a source-only send, a bookmark, a replica-local snapshot, an unmatched guid, and a future creation are not that age.\n'
printf 'Does not prove: zfs send ran, a replica received a stream, or a guest boots from the snapshot.\n'

The second script reads a restore note. It accepts the note only when the record is older than creation, a later record is absent, the network is isolated with no uplink, and the age comes from creation. A list-only note fails. The note is a fixture. Running the script does not boot a guest.

#!/usr/bin/env bash
set -euo pipefail

root="$(mktemp -d)"
trap 'rm -rf "$root"' EXIT

field() {
  local file="$1"
  local key="$2"
  local line
  while IFS= read -r line || [[ -n "$line" ]]; do
    if [[ "$line" == "${key}="* ]]; then
      printf '%s\n' "${line#"${key}="}"
      return 0
    fi
  done < "$file"
  printf 'missing %s in %s\n' "$key" "$file" >&2
  return 1
}

write_note() {
  local file="$1"
  shift
  mkdir -p "$(dirname "$file")"
  printf '%s\n' "$@" > "$file"
}

proves_restore() {
  local file="$1"
  local creation record later network uplink booted listed age_basis
  creation="$(field "$file" creation)"
  record="$(field "$file" record_time)"
  later="$(field "$file" later_record)"
  network="$(field "$file" network)"
  uplink="$(field "$file" uplink)"
  booted="$(field "$file" booted)"
  listed="$(field "$file" zfs_list_only)"
  age_basis="$(field "$file" age_basis)"
  [[ "$booted" == "yes" ]] || return 1
  [[ "$listed" == "no" ]] || return 1
  [[ "$network" == "isolated" ]] || return 1
  [[ "$uplink" == "no" ]] || return 1
  [[ "$age_basis" == "creation" ]] || return 1
  [[ "$later" == "absent" ]] || return 1
  [[ "$record" -lt "$creation" ]] || return 1
}

must_fail() {
  local label="$1"
  shift
  if "$@"; then
    printf 'checker accepted %s\n' "$label" >&2
    exit 1
  fi
}

good="${root}/good"
write_note "$good" \
  "creation=32400" \
  "record_time=32000" \
  "later_record=absent" \
  "network=isolated" \
  "uplink=no" \
  "booted=yes" \
  "zfs_list_only=no" \
  "age_basis=creation"
proves_restore "$good"

write_note "${root}/list-only" \
  "creation=32400" \
  "record_time=32000" \
  "later_record=absent" \
  "network=isolated" \
  "uplink=no" \
  "booted=no" \
  "zfs_list_only=yes" \
  "age_basis=creation"
must_fail "a list line with no boot" proves_restore "${root}/list-only"

write_note "${root}/new-record" \
  "creation=32400" \
  "record_time=33000" \
  "later_record=absent" \
  "network=isolated" \
  "uplink=no" \
  "booted=yes" \
  "zfs_list_only=no" \
  "age_basis=creation"
must_fail "a record written after the snapshot" proves_restore "${root}/new-record"

write_note "${root}/later-present" \
  "creation=32400" \
  "record_time=32000" \
  "later_record=present" \
  "network=isolated" \
  "uplink=no" \
  "booted=yes" \
  "zfs_list_only=no" \
  "age_basis=creation"
must_fail "a later record still present" proves_restore "${root}/later-present"

write_note "${root}/receive-clock" \
  "creation=32400" \
  "record_time=32000" \
  "later_record=absent" \
  "network=isolated" \
  "uplink=no" \
  "booted=yes" \
  "zfs_list_only=no" \
  "age_basis=receive"
must_fail "an age taken from receive time" proves_restore "${root}/receive-clock"

write_note "${root}/production-net" \
  "creation=32400" \
  "record_time=32000" \
  "later_record=absent" \
  "network=production" \
  "uplink=yes" \
  "booted=yes" \
  "zfs_list_only=no" \
  "age_basis=creation"
must_fail "a production network with an uplink" proves_restore "${root}/production-net"

printf 'Restore-note check passed.\n'
printf 'Proves: on this fixture, a boot note counts only when the record is older than creation, a later record is absent, the network is isolated with no uplink, and the age is taken from creation.\n'
printf 'Does not prove: a guest booted, a hypervisor was contacted, or the snapshot named in the note exists on a pool.\n'

Questions

What is the recovery point objective of a ZFS snapshot schedule?

The age of the newest snapshot the replica has, from that snapshot's creation time to now, when the guid matches a snapshot you sent. The schedule is the timer. It is not the age. A send that exited 0 on the source does not move the age until that snapshot is on the replica.

Is RPO the same as RTO?

No. RPO is how much data you lose, measured as time since the last snapshot the replica has. RTO is how long until that guest answers after you start the restore. A send that finished does not start the guest.

Does a successful zfs send on the source set the recovery point?

No. Read the replica. The snapshot has to be there, and its guid has to match the source snapshot you sent. Exit 0 on the source can happen before the receive commits. A bookmark of that snapshot is not a point you can boot.

Can I report the local snapshot age as the RPO?

Only for a failure that leaves the production pool intact. If the pool is what failed, the recovery point is the replica's snapshot. Do not report the local age as the off-site recovery point. Retention, how long you keep older points, is a different number.