all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Christian Ebner <c.ebner@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [pbs-devel] [PATCH proxmox-backup 3/4] datastore: introduce reverse chunk digest lookup table
Date: Mon, 19 Jan 2026 14:27:06 +0100	[thread overview]
Message-ID: <20260119132707.686523-4-c.ebner@proxmox.com> (raw)
In-Reply-To: <20260119132707.686523-1-c.ebner@proxmox.com>

Store the [digest]->[namespace]->[group]->[snapshot] relation
by inserting the items in the respective indices, generating their
key and store the referencing keys in a hashmap, allowing for
easy lookup. This will be used to track chunk references of snapshot
index files during phase 1 of garbage collection and accumulate
statistics after filling relevant raw chunk size information during
phase 2 of garbage collection.

The actual hierarchy is only reconstructed for each chunk digest on
accumulation, allowing to identify unique chunks and perform
chunk reference counting.

Signed-off-by: Christian Ebner <c.ebner@proxmox.com>
---
 pbs-datastore/src/reverse_digest_map.rs | 245 ++++++++++++++++++++++++
 1 file changed, 245 insertions(+)

diff --git a/pbs-datastore/src/reverse_digest_map.rs b/pbs-datastore/src/reverse_digest_map.rs
index 323e7315d..1c3efa8a8 100644
--- a/pbs-datastore/src/reverse_digest_map.rs
+++ b/pbs-datastore/src/reverse_digest_map.rs
@@ -1,7 +1,10 @@
 use std::collections::{BTreeMap, HashMap};
 use std::hash::BuildHasher;
 
+use tracing::info;
+
 use pbs_api_types::{BackupDir, BackupGroup, BackupNamespace};
+use proxmox_human_byte::HumanByte;
 
 #[derive(Copy, Clone, Eq, Hash, PartialEq, PartialOrd, Ord)]
 /// Backup snapshot index key
@@ -102,3 +105,245 @@ impl NamespaceIndex {
         self.index.get(key)
     }
 }
+
+#[derive(Default)]
+/// Store metadata associated with given digest such as raw chunk size and
+/// reference counted list of snapshot keys referencing given chunk.
+struct DigestMetadata {
+    raw_size: u64,
+    logical_size: u64,
+    refcount: BTreeMap<SKey, u32>,
+}
+
+impl DigestMetadata {
+    fn from_skey_with_logical_size(skey: SKey, logical_size: u64) -> Self {
+        let mut refcount = BTreeMap::new();
+        refcount.insert(skey, 1);
+        Self {
+            raw_size: 0,
+            logical_size,
+            refcount,
+        }
+    }
+}
+
+#[derive(Default)]
+pub(super) struct ReverseDigestMap {
+    // Map associating digest to snapshot index with reference count (digest metadata)
+    digests: HashMap<[u8; 32], DigestMetadata>,
+    // indexes storing the actual namespace, group and snapshot data
+    namespaces: NamespaceIndex,
+    groups: GroupIndex,
+    snapshots: SnapshotIndex,
+}
+
+impl ReverseDigestMap {
+    /// Associate digest or update reference count for backup snapshot of given namespace.
+    ///
+    /// Returns [`true`] if the digest key was not yet present.
+    pub(super) fn insert(
+        &mut self,
+        digest: &[u8; 32],
+        namespace: &BackupNamespace,
+        snapshot: &BackupDir,
+        logical_size: u64,
+    ) -> bool {
+        let mut is_new = true;
+        let nkey = self.namespaces.insert(namespace.clone());
+        let gkey = self.groups.insert(snapshot.group.clone());
+        let skey = self
+            .snapshots
+            .insert(BackupTime::from(snapshot.time), nkey, gkey);
+
+        self.digests
+            .entry(*digest)
+            .and_modify(|digest_info| {
+                is_new = false;
+                digest_info
+                    .refcount
+                    .entry(skey)
+                    .and_modify(|count| *count += 1)
+                    .or_insert(1);
+                digest_info.logical_size = logical_size;
+            })
+            .or_insert(DigestMetadata::from_skey_with_logical_size(
+                skey,
+                logical_size,
+            ));
+
+        is_new
+    }
+
+    /// Set the raw chunk size metadata information for associated digest.
+    ///
+    /// Inserts the digest without snapshots associated if not present.
+    pub(super) fn set_raw_chunk_size(&mut self, digest: &[u8; 32], raw_size: u64) {
+        self.digests
+            .entry(*digest)
+            .and_modify(|digest_info| digest_info.raw_size = raw_size);
+    }
+}
+
+#[derive(Debug, Default)]
+pub(super) struct AccumulatedStats {
+    raw_size: u64,
+    logical_size: u64,
+    unique_raw_size: u64,
+    refcount: u64,
+    uniquecount: u64,
+    dedupcount: u64,
+}
+
+// Hierarchy for snapshots on individual digest: [NKey] -> [GKey] -> [(SKey, count)]
+type DigestHierarchy = HashMap<NKey, HashMap<GKey, Vec<(SKey, u32)>>>;
+
+fn format_namespace(namespace: &BackupNamespace) -> String {
+    if namespace.is_root() {
+        "[root]".to_string()
+    } else {
+        namespace.display_as_path().to_string()
+    }
+}
+
+#[derive(Default)]
+pub(super) struct DigestStatAccumulator {
+    list: HashMap<String, AccumulatedStats>,
+}
+
+impl DigestStatAccumulator {
+    // Accumulate statistics by consuming the digest map
+    pub(super) fn accumulate_and_list(mut self, digest_map: ReverseDigestMap) {
+        let mut datastore_stats = AccumulatedStats::default();
+
+        for digest_info in digest_map.digests.into_values() {
+            if digest_info.refcount.is_empty() {
+                continue;
+            }
+
+            // Generate [namespaces] -> [groups] -> [snapshots] reference hierarchy for given digest
+            // to account for statistics. The hierarchy is then used to account for chunk uniqueness
+            // and raw size.
+            let mut hierarchy: DigestHierarchy = HashMap::new();
+            for (skey, count) in digest_info.refcount {
+                if let Some((nkey, gkey, _snapshot_time)) = digest_map.snapshots.lookup(&skey) {
+                    hierarchy
+                        .entry(*nkey)
+                        .or_default()
+                        .entry(*gkey)
+                        .or_default()
+                        .push((skey, count));
+                }
+            }
+
+            let mut unique = hierarchy.len() <= 1;
+            let raw_size = digest_info.raw_size;
+            let logical_size = digest_info.logical_size;
+
+            for (nkey, gkey_map) in hierarchy {
+                if gkey_map.len() > 1 {
+                    unique = false;
+                }
+
+                let namespace = digest_map.namespaces.lookup(&nkey).unwrap();
+                let mut logical_namespace_sum = 0;
+
+                for (gkey, skey_list) in gkey_map {
+                    if skey_list.len() > 1 {
+                        unique = false;
+                    }
+
+                    let group = digest_map.groups.lookup(&gkey).unwrap();
+                    let mut logical_group_sum = 0;
+
+                    for (skey, duplicates) in skey_list {
+                        if duplicates > 1 {
+                            unique = false;
+                        }
+
+                        let (_nkey, _gkey, timestamp) = digest_map.snapshots.lookup(&skey).unwrap();
+                        let snapshot =
+                            BackupDir::from((group.clone(), Into::<i64>::into(*timestamp)));
+
+                        let snapshot = format!("{}/{snapshot}", format_namespace(namespace));
+                        let logical_snapshot_sum = duplicates as u64 * logical_size;
+                        self.account(snapshot, raw_size, logical_snapshot_sum, unique);
+                        logical_group_sum += logical_snapshot_sum;
+                    }
+
+                    let group = format!("{}/{group}", format_namespace(namespace));
+                    self.account(group, raw_size, logical_group_sum, unique);
+                    logical_namespace_sum += logical_group_sum;
+                }
+
+                let namespace = format_namespace(namespace);
+                self.account(namespace, raw_size, logical_namespace_sum, unique);
+            }
+
+            datastore_stats.raw_size += raw_size;
+            datastore_stats.refcount += 1;
+            if unique {
+                datastore_stats.uniquecount += 1;
+                datastore_stats.unique_raw_size += raw_size;
+            } else {
+                datastore_stats.dedupcount += 1;
+            }
+        }
+
+        let mut list: Vec<(String, AccumulatedStats)> = self.list.into_iter().collect();
+        list.sort_unstable_by(|a, b| a.0.cmp(&b.0));
+
+        for (name, stats) in list {
+            info!(
+                "{name}: {} ({} referenced / {} unique ({}) / {} deduped chunks), deduplication factor: {:.2}",
+                HumanByte::from(stats.raw_size),
+                stats.refcount,
+                stats.uniquecount,
+                HumanByte::from(stats.unique_raw_size),
+                stats.dedupcount,
+                Self::dedup_factor(stats.raw_size, stats.logical_size),
+            );
+        }
+
+        info!("===");
+        info!(
+            "Datastore: {} ({} referenced / {} unique / {} deduped chunks)",
+            HumanByte::from(datastore_stats.raw_size),
+            datastore_stats.refcount,
+            datastore_stats.uniquecount,
+            datastore_stats.dedupcount,
+        );
+        info!("===");
+    }
+
+    /// Account for statistics on given key, inserted if not yet present
+    fn account(&mut self, key: String, raw_size: u64, logical_size: u64, unique: bool) {
+        let (uniquecount, dedupcount, unique_raw_size) =
+            if unique { (1, 0, raw_size) } else { (0, 1, 0) };
+        self.list
+            .entry(key)
+            .and_modify(|stats| {
+                stats.raw_size += raw_size;
+                stats.unique_raw_size += unique_raw_size;
+                stats.logical_size += logical_size;
+                stats.refcount += 1;
+                stats.uniquecount += uniquecount;
+                stats.dedupcount += dedupcount;
+            })
+            .or_insert(AccumulatedStats {
+                raw_size,
+                unique_raw_size,
+                logical_size,
+                refcount: 1,
+                uniquecount,
+                dedupcount,
+            });
+    }
+
+    fn dedup_factor(raw_size: u64, logical_size: u64) -> f64 {
+        if raw_size > 0 {
+            logical_size as f64 / raw_size as f64
+        } else {
+            1.0
+        }
+    }
+}
-- 
2.47.3



_______________________________________________
pbs-devel mailing list
pbs-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel


  parent reply	other threads:[~2026-01-19 13:27 UTC|newest]

Thread overview: 5+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-01-19 13:27 [pbs-devel] [RFC proxmox-backup 0/4] fix #5799: Gather per-namespace/group/snapshot storage usage stats Christian Ebner
2026-01-19 13:27 ` [pbs-devel] [PATCH proxmox-backup 1/4] chunk store: restrict chunk sweep helper method to module parent Christian Ebner
2026-01-19 13:27 ` [pbs-devel] [PATCH proxmox-backup 2/4] datastore: add namespace/group/snapshot indices for reverse lookups Christian Ebner
2026-01-19 13:27 ` Christian Ebner [this message]
2026-01-19 13:27 ` [pbs-devel] [PATCH proxmox-backup 4/4] fix #5799: GC: track chunk digests and accumulate statistics Christian Ebner

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=20260119132707.686523-4-c.ebner@proxmox.com \
    --to=c.ebner@proxmox.com \
    --cc=pbs-devel@lists.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