]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
osd: Add ECOmapJournal class and relocate OmapUpdateType enum class
authorMatty Williams <Matty.Williams@ibm.com>
Fri, 12 Dec 2025 10:13:11 +0000 (10:13 +0000)
committerMatty Williams <Matty.Williams@ibm.com>
Wed, 3 Jun 2026 09:30:51 +0000 (10:30 +0100)
The ECOmapJournal will be used to store omap updates (in ec pools with optimisations enabled) which have not yet been committed to the object store.
Added unit tests for this class.
Promoted OmapUpdateType to osd_types.h so that it can be shared to multiple files without circular dependencies.

Signed-off-by: Matty Williams <Matty.Williams@ibm.com>
src/crimson/osd/ec_backend.cc
src/osd/CMakeLists.txt
src/osd/ECBackend.cc
src/osd/ECCommon.h
src/osd/ECOmapJournal.cc [new file with mode: 0644]
src/osd/ECOmapJournal.h [new file with mode: 0644]
src/osd/PGTransaction.h
src/osd/ReplicatedBackend.cc
src/osd/osd_types.h
src/test/osd/CMakeLists.txt
src/test/osd/test_ec_omap_journal.cc [new file with mode: 0644]

index 2c01eb113045598cb99c2011b2509be5257b62ec..532ecd446a4da0837d40dee97883bbf577c5f07e 100644 (file)
@@ -45,6 +45,7 @@ ECBackend::ECBackend(pg_shard_t whoami,
                     DoutPrefixProvider &dpp,
                     ECListener &eclistener)
   : PGBackend{whoami, coll, shard_services, store_index, dpp},
+    ECCommon(dpp),
     ec_impl{create_ec_impl(ec_profile)},
     sinfo(ec_impl, &(eclistener.get_pool()), stripe_width),
     fast_read{fast_read},
index 1d0cc271127349641a109d3d1d81636592e6d454..c19441285e77e205cac5ae99477fd96055b9137b 100644 (file)
@@ -53,6 +53,8 @@ set(osd_srcs
   ECInject.cc
   ECInject.h
   Coroutines.h
+  ECOmapJournal.cc
+  ECOmapJournal.h
   ${CMAKE_SOURCE_DIR}/src/common/TrackedOp.cc
   ${CMAKE_SOURCE_DIR}/src/mgr/OSDPerfMetricTypes.cc
   ${osd_cyg_functions_src}
index 588494d7f74d3714f58fc972c99d98e4cbe70bd6..3d7725c4bf9967df4971d5356ee9d942b49b4fae 100644 (file)
@@ -75,7 +75,8 @@ ECBackend::ECBackend(
   uint64_t stripe_width,
   ECSwitch *s,
   ECExtentCache::LRU &ec_extent_cache_lru)
-  : parent(pg), cct(cct), switcher(s),
+  : ECCommon(*pg->get_dpp()),
+    parent(pg), cct(cct), switcher(s),
 #ifdef WITH_CRIMSON
     read_pipeline(cct, ec_impl, this->sinfo, get_parent()->get_eclistener(), *this),
 #else
index cdf3f9f1cc43e7e54212c7a98d7ad81da61fe434..370cce17d2910d3eee9618f05178e7c725a853f7 100644 (file)
@@ -51,6 +51,10 @@ struct PGLog;
 struct RecoveryMessages;
 
 struct ECCommon {
+  ECOmapJournal ec_omap_journal;
+
+  explicit ECCommon(const DoutPrefixProvider& dpp) : ec_omap_journal(dpp) {}
+
   struct ec_extent_t {
     int err;
     extent_map emap;
diff --git a/src/osd/ECOmapJournal.cc b/src/osd/ECOmapJournal.cc
new file mode 100644 (file)
index 0000000..c33af5e
--- /dev/null
@@ -0,0 +1,580 @@
+// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*- 
+// vim: ts=8 sw=2 sts=2 expandtab
+
+/*
+ * Ceph - scalable distributed file system
+ *
+ * Copyright (C) 2025 IBM
+ *
+ * This is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License version 2.1, as published by the Free Software
+ * Foundation.  See file COPYING.
+ */
+
+#include "ECOmapJournal.h"
+
+#include <utility>
+
+ECOmapJournalEntry::ECOmapJournalEntry(
+  const eversion_t version, const bool clear_omap, std::optional<ceph::buffer::list> omap_header,
+  std::vector<std::pair<OmapUpdateType, ceph::buffer::list>> omap_updates)
+  : version(version), clear_omap(clear_omap),
+    omap_header(std::move(omap_header)), omap_updates(std::move(omap_updates)) {}
+
+void ECOmapValue::update_value(const eversion_t new_version, std::optional<ceph::buffer::list> new_value) {
+  this->version = new_version;
+  this->value = std::move(new_value);
+}
+
+void ECOmapRemovedRanges::add_range(const std::string& start, const std::optional<std::string>& end) {
+  std::string new_start = start;
+  std::optional<std::string> new_end = end;
+  auto it = ranges.begin();
+  bool inserted = false;
+  while (it != ranges.end()) {
+    // Current range is to the left of new range
+    if (it->second && *it->second < new_start) {
+      ++it;
+      continue;
+    }
+    // Current range is to the right of new range
+    if (new_end && *new_end < it->first) {
+      ranges.insert(it, {new_start, new_end});
+      inserted = true;
+      break;
+    }
+    // Ranges overlap, merge them
+    if (it->first < new_start) {
+      new_start = it->first;
+    }
+    if (!it->second) {
+      new_end = std::nullopt;
+    } else if (new_end && *it->second > *new_end) {
+      new_end = it->second;
+    }
+    it = ranges.erase(it);
+  }
+  if (!inserted) {
+    ranges.emplace_back(new_start, new_end);
+  }
+}
+
+void ECOmapRemovedRanges::clear_omap() {
+  ranges.clear();
+  ranges.emplace_back("", std::nullopt);
+}
+
+void ECOmapHeader::update_header(const eversion_t new_version,
+  std::optional<ceph::buffer::list> new_header) {
+  this->version = new_version;
+  this->header = std::move(new_header);
+}
+
+
+void ECOmapJournal::add_entry(const hobject_t &hoid, const ECOmapJournalEntry &entry) {
+  ldpp_dout(&dpp, 20) << __func__ << ": hoid=" << hoid
+                      << " version=" << entry.version
+                      << " clear_omap=" << entry.clear_omap
+                      << " header_size=" << (entry.omap_header ? entry.omap_header->length() : 0)
+                      << dendl;
+  entries[hoid].push_back(entry);
+}
+
+bool ECOmapJournal::remove_entry(const hobject_t &hoid, const ECOmapJournalEntry &entry) {
+  // Attempt to remove entry from unprocessed entries
+  if (const auto it_map = entries.find(hoid);
+    it_map != entries.end()) {
+    auto &entry_list = it_map->second;
+    for (auto it = entry_list.begin(); it != entry_list.end(); ++it) {
+      if (it->version == entry.version) {
+        ldpp_dout(&dpp, 20) << __func__ << ": hoid=" << hoid
+                            << " version=" << entry.version << " found_unprocessed=true" << dendl;
+        entry_list.erase(it);
+        if (const auto header_it = header_map.find(hoid);
+          header_it != header_map.end() &&
+            header_it->second.version == entry.version) {
+          header_map.erase(header_it);
+        }
+        return true;
+      }
+    }
+  }
+  ldpp_dout(&dpp, 20) << __func__ << ": hoid=" << hoid
+                      << " version=" << entry.version << " found_unprocessed=false" << dendl;
+
+  // Attempt to remove entry from processed entries
+  return remove_processed_entry(hoid, entry);
+}
+
+bool ECOmapJournal::remove_entry_by_version(const hobject_t &hoid, const eversion_t version) {
+  // Attempt to remove entry from unprocessed entries
+  if (const auto it_map = entries.find(hoid);
+    it_map != entries.end()) {
+    auto &entry_list = it_map->second;
+    for (auto it = entry_list.begin(); it != entry_list.end(); ++it) {
+      if (it->version == version) {
+        entry_list.erase(it);
+        if (const auto header_it = header_map.find(hoid);
+          header_it != header_map.end() &&
+            header_it->second.version == version) {
+          header_map.erase(header_it);
+        }
+        return true;
+      }
+    }
+  }
+
+  // Attempt to remove entry from processed entries
+  return remove_processed_entry_by_version(hoid, version);
+}
+
+void ECOmapJournal::clear(const hobject_t &hoid) {
+  entries.erase(hoid);
+  key_map.erase(hoid);
+  removed_ranges_map.erase(hoid);
+  header_map.erase(hoid);
+  object_state_map.erase(hoid);
+}
+
+void ECOmapJournal::clear_all() {
+  entries.clear();
+  key_map.clear();
+  removed_ranges_map.clear();
+  header_map.clear();
+  object_state_map.clear();
+}
+
+std::size_t ECOmapJournal::entries_size(const hobject_t &hoid) const {
+  if (const auto entries_it = entries.find(hoid);
+    entries_it != entries.end()) {
+    return entries_it->second.size();
+  }
+  return 0u;
+}
+
+bool ECOmapJournal::has_unprocessed_entries(const hobject_t &hoid) const {
+  return entries.contains(hoid);
+}
+
+bool ECOmapJournal::has_omap_updates(const hobject_t &hoid) const {
+  // Check unprocessed entries
+  if (const auto entries_it = entries.find(hoid);
+    entries_it != entries.end() && !entries_it->second.empty()) {
+    return true;
+  }
+  
+  // Check processed key map
+  if (const auto key_it = key_map.find(hoid);
+    key_it != key_map.end() && !key_it->second.empty()) {
+    return true;
+  }
+  
+  // Check removed ranges
+  if (const auto ranges_it = removed_ranges_map.find(hoid);
+    ranges_it != removed_ranges_map.end() && !ranges_it->second.empty()) {
+    return true;
+  }
+  
+  // Check header updates
+  if (header_map.find(hoid) != header_map.end()) {
+    return true;
+  }
+  
+  return false;
+}
+
+// Function to get specific object's entries, if not present, creates an empty list
+std::list<ECOmapJournalEntry>& ECOmapJournal::get_entries(const hobject_t &hoid) {
+  return entries[hoid];
+}
+
+std::list<ECOmapJournalEntry> ECOmapJournal::snapshot_entries(const hobject_t &hoid) const {
+  if (const auto it = entries.find(hoid);
+    it != entries.end()) {
+    return it->second;
+  }
+  return {};
+}
+
+ECOmapJournal::const_iterator ECOmapJournal::begin_entries(const hobject_t &hoid) const {
+  return entries.at(hoid).begin();
+}
+
+ECOmapJournal::const_iterator ECOmapJournal::end_entries(const hobject_t &hoid) const {
+  return entries.at(hoid).end();
+}
+
+std::optional<ceph::buffer::list> ECOmapJournal::get_updated_header(const hobject_t &hoid) {
+  process_entries(hoid);
+  if (!header_map.contains(hoid)) {
+    return std::nullopt;
+  }
+  return header_map[hoid].header;
+}
+
+std::tuple<ECOmapJournal::UpdateMapType, ECOmapJournal::RangeMapType>
+ECOmapJournal::get_value_updates(const hobject_t &hoid) {
+  process_entries(hoid);
+  return {get_key_map(hoid), get_removed_ranges(hoid)};
+}
+
+void ECOmapJournal::process_entries(const hobject_t &hoid) {
+  auto entry_list = get_entries(hoid);
+  ldpp_dout(&dpp, 20) << __func__ << ": hoid=" << hoid
+                      << " processing " << entry_list.size() << " entries" << dendl;
+  if (!has_unprocessed_entries(hoid)) {
+    return;
+  }
+  
+  for (auto entry_iter = begin_entries(hoid);
+        entry_iter != end_entries(hoid); ++entry_iter) {
+    ECOmapRemovedRanges removed_ranges(entry_iter->version);
+
+    // Clear omap if specified
+    if (entry_iter->clear_omap) {
+      // Mark all keys as removed
+      for (auto [_, value] : key_map[hoid]) {
+        value.update_value(entry_iter->version, std::nullopt);
+      }
+
+      // Mark entire range as removed
+      removed_ranges.clear_omap();
+
+      if (!entry_iter->omap_header) {
+        // Set the header to an empty bufferlist
+        bufferlist bl;
+        if (!header_map.contains(hoid)) {
+          header_map[hoid] = ECOmapHeader(entry_iter->version, bl);
+        } else {
+          header_map[hoid].update_header(entry_iter->version, bl);
+        }
+      }
+    }
+
+    // Update header if present
+    if (entry_iter->omap_header) {
+      if (!header_map.contains(hoid)) {
+        header_map[hoid] = ECOmapHeader(entry_iter->version, entry_iter->omap_header);
+      } else {
+        header_map[hoid].update_header(entry_iter->version, entry_iter->omap_header);
+      }
+    }
+
+    // Process key updates
+    auto &obj_map = key_map[hoid];
+    for (const auto & [type, update] : entry_iter->omap_updates) {
+      auto iter = update.cbegin();
+      switch (type) {
+        case OmapUpdateType::Insert: {
+          std::map<std::string, ceph::buffer::list> vals;
+          decode(vals, iter);
+          // Insert key value pairs into update_map
+          for (auto it = vals.begin(); it != vals.end(); ++it) {
+            const auto &key = it->first;
+            const auto &val = it->second;
+            
+            // Check if key already exists in key map
+            auto entry_it = obj_map.find(key);
+            if (entry_it != obj_map.end()) {
+              // Update existing value
+              entry_it->second.update_value(entry_iter->version, val);
+            } else {
+              // Insert new value
+              obj_map.emplace(key, ECOmapValue(entry_iter->version, val));
+            }
+          }
+          break;
+        }
+        case OmapUpdateType::Remove: {
+          std::set<std::string> keys;
+          decode(keys, iter);
+          // Mark keys in key_map as removed
+          for (const auto &key : keys) {
+            // Check if key already exists in key map
+            if (auto entry_it = obj_map.find(key);
+              entry_it != obj_map.end()) {
+              // Update existing value to null
+              entry_it->second.update_value(entry_iter->version, std::nullopt);
+            } else {
+              // Insert new null value
+              obj_map.emplace(key, ECOmapValue(entry_iter->version, std::nullopt));
+            }
+          }
+          break;
+        }
+        case OmapUpdateType::RemoveRange: {
+          std::string key_begin, key_end;
+          decode(key_begin, iter);
+          decode(key_end, iter);
+          
+          // Add removed range
+          std::string start = key_begin;
+          std::optional<std::string> end = key_end;
+          removed_ranges.add_range(start, end);
+
+          // Mark keys in key_map as removed that fall within the removed range
+          auto map_it = obj_map.lower_bound(key_begin);
+          while (map_it != obj_map.end()) {
+            if (map_it->first >= key_end) {
+              break;
+            }
+            map_it->second.update_value(entry_iter->version, std::nullopt);
+            ++map_it;
+          }
+          break;
+        }
+        default:
+          ceph_abort_msg("Unknown OmapUpdateType");
+      }
+    }
+    if (!removed_ranges.ranges.empty()) {
+      removed_ranges_map[hoid].emplace_back(removed_ranges);
+    }
+  }
+  entries.erase(hoid);
+}
+
+bool ECOmapJournal::remove_processed_entry(const hobject_t &hoid, const ECOmapJournalEntry &entry) {
+  // Remove the header if version matches
+  if (const auto header_it = header_map.find(hoid);
+    header_it != header_map.end() && header_it->second.version == entry.version) {
+      header_map.erase(header_it);
+  }
+
+  // Remove key updates if version matches
+  auto &obj_map = key_map[hoid];
+  for (const auto & [type, update] : entry.omap_updates) {
+    auto iter = update.cbegin();
+    switch (type) {
+      case OmapUpdateType::Insert: {
+        std::map<std::string, ceph::buffer::list> vals;
+        decode(vals, iter);
+        for (auto val_it = vals.begin(); val_it != vals.end(); ++val_it) {
+          const auto &key = val_it->first;
+          auto key_it = obj_map.find(key);
+          if (key_it != obj_map.end() && 
+            key_it->second.version == entry.version) {
+            obj_map.erase(key);
+          }
+        }
+        break;
+      }
+      case OmapUpdateType::Remove: {
+        std::set<std::string> keys;
+        decode(keys, iter);
+        for (const auto &key : keys) {
+          if (auto it = obj_map.find(key);
+            it != obj_map.end() &&
+              it->second.version == entry.version) {
+            obj_map.erase(key);
+          }
+        }
+        break;
+      }
+      case OmapUpdateType::RemoveRange: {
+        std::string key_begin, key_end;
+        decode(key_begin, iter);
+        decode(key_end, iter);
+        auto map_it = obj_map.lower_bound(key_begin);
+        while (map_it != obj_map.end()) {
+          if (map_it->first >= key_end) {
+            break;
+          }
+          if (map_it->second.version == entry.version) {
+            map_it = obj_map.erase(map_it);
+          } else {
+            ++map_it;
+          }
+        }
+        break;
+      }
+      default: {
+        return false;
+      }
+    }
+  }
+
+  // Remove removed ranges if version matches
+  if (const auto removed_ranges_it = removed_ranges_map.find(hoid);
+    removed_ranges_it != removed_ranges_map.end()) {
+    auto &removed_ranges_list = removed_ranges_it->second;
+    for (auto rr_it = removed_ranges_list.begin(); rr_it != removed_ranges_list.end(); ++rr_it) {
+      if (rr_it->version == entry.version) {
+        removed_ranges_list.erase(rr_it);
+        break;
+      }
+    }
+  } else {
+    return false;
+  }
+
+  return true;
+}
+
+bool ECOmapJournal::remove_processed_entry_by_version(const hobject_t &hoid, const eversion_t version) {
+  // Remove the header if version matches
+  if (const auto header_it = header_map.find(hoid);
+    header_it != header_map.end() && header_it->second.version == version) {
+    header_map.erase(header_it);
+  }
+
+  // Remove key updates if version matches
+  auto key_map_it = key_map.find(hoid);
+  if (key_map_it != key_map.end()) {
+   for (auto it = key_map_it->second.begin(); it != key_map_it->second.end(); ) {
+      if (it->second.version == version) {
+        it = key_map_it->second.erase(it);
+      } else {
+        ++it;
+      }
+    }
+  }
+
+  // Remove removed ranges if version matches
+  auto removed_ranges_it = removed_ranges_map.find(hoid);
+  if (removed_ranges_it != removed_ranges_map.end()) {
+    auto &removed_ranges_list = removed_ranges_it->second;
+    for (auto rr_it = removed_ranges_list.begin(); rr_it != removed_ranges_list.end(); ++rr_it) {
+      if (rr_it->version == version) {
+        removed_ranges_list.erase(rr_it);
+        break;
+      }
+    }
+  } else {
+    return false;
+  }
+
+  return true;
+}
+
+ECOmapJournal::UpdateMapType ECOmapJournal::get_key_map(const hobject_t &hoid) const {
+  if (const auto it = key_map.find(hoid); it != key_map.end()) {
+    return it->second;
+  }
+  return {};
+}
+
+ECOmapJournal::RangeMapType ECOmapJournal::get_removed_ranges(const hobject_t &hoid) const {
+  // Merge all removed ranges for the object
+  RangeMapType merged_ranges;
+  if (const auto it = removed_ranges_map.find(hoid);
+    it != removed_ranges_map.end()) {
+    for (const auto &rr : it->second) {
+      for (const auto & [range_first, range_second] : rr.ranges) {
+        // Add range to merged_ranges, merging overlapping ranges
+        std::string start = range_first;
+        std::optional<std::string> end = range_second;
+
+        // Find the range that starts after the current start
+        auto map_it = merged_ranges.upper_bound(start);
+        if (map_it != merged_ranges.begin()) {
+          // Merge range to the left, if they overlap
+          if (const auto prev = std::prev(map_it);
+            !prev->second || *prev->second >= start) {
+            start = prev->first;
+            if (!end) {
+              // end is already open ended so cannot be extended
+            } else if (!prev->second) {
+              end = std::nullopt;
+            } else if (*prev->second > *end) {
+              end = *prev->second;
+            }
+            merged_ranges.erase(prev);
+          }
+        }
+        // Merge ranges to the right, if they overlap
+        while (map_it != merged_ranges.end() &&
+               (!end || map_it->first <= *end)) {
+          if (!end) {
+            // end is already open ended so cannot be extended
+          } else if (!map_it->second) {
+            end = std::nullopt;
+          } else if (*map_it->second > *end) {
+            end = map_it->second;
+          }
+          map_it = merged_ranges.erase(map_it);
+        }
+        merged_ranges.emplace_hint(map_it, start, end);
+      }
+    }
+  }
+  return merged_ranges;
+}
+
+void ECOmapJournal::append_delete(
+  const hobject_t &hoid,
+  const version_t version,
+  const bool lost_delete) {
+  entries.erase(hoid);
+  key_map.erase(hoid);
+  removed_ranges_map.erase(hoid);
+  header_map.erase(hoid);
+  
+  auto [it, inserted] = object_state_map.try_emplace(hoid, std::map<version_t, bool>{});
+  it->second.insert({version, lost_delete});
+  
+  size_t total_versions = it->second.size();
+  
+  ldpp_dout(&dpp, 20) << __func__ << ": hoid=" << hoid
+                      << " version=" << version
+                      << " whiteout=" << lost_delete
+                      << " total_versions=" << total_versions
+                      << dendl;
+}
+
+void ECOmapJournal::append_create(const hobject_t &hoid) {
+  entries.erase(hoid);
+  key_map.erase(hoid);
+  removed_ranges_map.erase(hoid);
+  header_map.erase(hoid);
+}
+
+void ECOmapJournal::append_whiteout(const hobject_t &hoid) {
+  entries.erase(hoid);
+  key_map.erase(hoid);
+  removed_ranges_map.erase(hoid);
+  header_map.erase(hoid);
+}
+
+void ECOmapJournal::trim_delete(const hobject_t &hoid, const version_t version) {
+  // Capture whiteout value before erasing
+  bool whiteout = false;
+  if (const auto it = object_state_map.find(hoid); it != object_state_map.end()) {
+    if (const auto it2 = it->second.find(version); it2 != it->second.end()) {
+      whiteout = it2->second;
+    }
+  }
+  
+  if (const auto it = object_state_map.find(hoid); it != object_state_map.end()) {
+    std::map<version_t,bool>& versions = it->second;
+    if (const auto it2 = versions.find(version); it2 != versions.end()) {
+      versions.erase(it2);
+    }
+    if (versions.empty()) {
+      object_state_map.erase(it);
+    }
+  }
+  
+  // Get the total number of versions for this object after the operation
+  size_t total_versions = 0;
+  if (const auto it = object_state_map.find(hoid); it != object_state_map.end()) {
+    total_versions = it->second.size();
+  }
+  
+  ldpp_dout(&dpp, 20) << __func__ << ": hoid=" << hoid
+                      << " version=" << version
+                      << " whiteout=" << whiteout
+                      << " total_versions=" << total_versions
+                      << dendl;
+}
+
+std::pair<gen_t, bool> ECOmapJournal::get_generation(const hobject_t &hoid) const {
+  if (const auto it = object_state_map.find(hoid); it != object_state_map.end()) {
+    if (const auto& versions = it->second; !versions.empty()) {
+      const auto& [gen, lost] = *versions.begin();
+      return {gen, lost};
+    }
+  }
+  return {static_cast<gen_t>(ghobject_t::NO_GEN), false};
+}
diff --git a/src/osd/ECOmapJournal.h b/src/osd/ECOmapJournal.h
new file mode 100644 (file)
index 0000000..50dcfbb
--- /dev/null
@@ -0,0 +1,156 @@
+// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*- 
+// vim: ts=8 sw=2 sts=2 expandtab
+
+/*
+ * Ceph - scalable distributed file system
+ *
+ * Copyright (C) 2025 IBM
+ *
+ * This is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License version 2.1, as published by the Free Software
+ * Foundation.  See file COPYING.
+ */
+
+/*
+ * ECOmapJournal
+ *
+ * An in-memory journal to maintain OMAP consistency for Erasure Coded pools.
+ *
+ * Background:
+ * Unlike Replicated pools which use a "roll-forward" recovery model, EC pools rely on a
+ * "roll-back" mechanism for interrupted updates. To support efficient rollback without
+ * incurring expensive read-before-write operations to generate rollback info, EC OMAP
+ * updates are initially recorded only in the PG Log. They are applied to the underlying
+ * ObjectStore only after the transaction is fully committed on all shards.
+ *
+ * The Problem:
+ * This deferred application creates a latency window where the authoritative state exists
+ * in the PG Log but the ObjectStore contains stale data. Reads served during this window
+ * would return incorrect results.
+ *
+ * The Solution:
+ * An ECOmapJournal tracks these "log-only" updates in memory. When an OMAP read occurs,
+ * the backend fetches the base state from the ObjectStore and supplements it with the
+ * updates stored in this journal. This ensures clients always receive the most up-to-date
+ * result, merging the persistent state with the in-flight log state.
+ */
+
+#pragma once
+
+#include <map>
+#include <optional>
+#include <utility>
+#include <vector>
+
+#include "include/buffer.h"
+
+#include "osd_types.h"
+
+struct eversion_t;
+
+class ECOmapJournalEntry {
+ public:
+  eversion_t version;
+  bool clear_omap;
+  std::optional<ceph::buffer::list> omap_header;
+  std::vector<std::pair<OmapUpdateType, ceph::buffer::list>> omap_updates;
+
+  ECOmapJournalEntry(
+    eversion_t version, 
+    bool clear_omap, 
+    std::optional<ceph::buffer::list> omap_header,
+    std::vector<std::pair<OmapUpdateType, ceph::buffer::list>> omap_updates);
+};
+
+class ECOmapValue {
+ public:
+  eversion_t version;
+  std::optional<ceph::buffer::list> value;
+
+  ECOmapValue(
+    const eversion_t version,
+    const std::optional<ceph::buffer::list> &value)
+    : version(version), value(value) {}
+
+  void update_value(eversion_t new_version, std::optional<ceph::buffer::list> new_value);
+};
+
+class ECOmapRemovedRanges {
+ public:
+  eversion_t version;
+  std::list<std::pair<std::string, std::optional<std::string>>> ranges;
+
+  explicit ECOmapRemovedRanges(const eversion_t version) : version(version) {}
+  ECOmapRemovedRanges(
+    const eversion_t version, std::list<std::pair<std::string,
+    std::optional<std::string>>> ranges)
+    : version(version), ranges(std::move(ranges)) {}
+
+  void add_range(const std::string& start, const std::optional<std::string>& end);
+  void clear_omap();
+};
+
+class ECOmapHeader {
+ public:
+  eversion_t version = eversion_t();
+  std::optional<ceph::buffer::list> header = std::nullopt;
+
+  ECOmapHeader(const eversion_t version, std::optional<ceph::buffer::list> header)
+    : version(version), header(std::move(header)) {}
+  ECOmapHeader() = default;
+
+  void update_header(eversion_t new_version, std::optional<ceph::buffer::list> new_header);
+};
+
+class ECOmapJournal {
+  using UpdateMapType = std::map<std::string, ECOmapValue>;
+  using RangeMapType = std::map<std::string, std::optional<std::string>>;
+  using const_iterator = std::list<ECOmapJournalEntry>::const_iterator;
+ private:
+  // Unprocessed journal entries 
+  std::map<hobject_t, std::list<ECOmapJournalEntry>> entries;
+
+  // Processed journal entries
+  std::map<hobject_t, std::map<std::string, ECOmapValue>> key_map;
+  std::map<hobject_t, std::list<ECOmapRemovedRanges>> removed_ranges_map;
+  std::map<hobject_t, ECOmapHeader> header_map;
+
+  // Contains the set of versions and lost object booleans corresponding to
+  // outstanding deletes for that ob
+  std::map<hobject_t, std::map<version_t, bool>> object_state_map;
+
+  const DoutPrefixProvider& dpp;
+
+  // Function to get specific object's unprocessed entries
+  std::list<ECOmapJournalEntry>& get_entries(const hobject_t &hoid);
+  std::list<ECOmapJournalEntry> snapshot_entries(const hobject_t &hoid) const;
+
+  void process_entries(const hobject_t &hoid);
+  bool remove_processed_entry(const hobject_t &hoid, const ECOmapJournalEntry &entry);
+  bool remove_processed_entry_by_version(const hobject_t &hoid, const eversion_t version);
+  UpdateMapType get_key_map(const hobject_t &hoid) const;
+  RangeMapType get_removed_ranges(const hobject_t &hoid) const;
+
+ public:
+  explicit ECOmapJournal(const DoutPrefixProvider& dpp_) : dpp(dpp_) {}
+
+  void add_entry(const hobject_t &hoid, const ECOmapJournalEntry &entry);
+  bool remove_entry(const hobject_t &hoid, const ECOmapJournalEntry &entry);
+  bool remove_entry_by_version(const hobject_t &hoid, const eversion_t version);
+  void clear(const hobject_t &hoid);
+  void clear_all();
+  [[nodiscard]] std::size_t entries_size(const hobject_t &hoid) const;
+  [[nodiscard]] bool has_unprocessed_entries(const hobject_t &hoid) const;
+  [[nodiscard]] bool has_omap_updates(const hobject_t &hoid) const;
+  std::tuple<UpdateMapType, RangeMapType> get_value_updates(const hobject_t &hoid);
+  std::optional<ceph::buffer::list> get_updated_header(const hobject_t &hoid);
+  void append_delete(const hobject_t &hoid, const version_t version, const bool lost_delete);
+  void append_create(const hobject_t &hoid);
+  void append_whiteout(const hobject_t &hoid);
+  void trim_delete(const hobject_t &hoid, const version_t version);
+  std::pair<gen_t, bool> get_generation(const hobject_t &hoid) const;
+
+  [[nodiscard]] const_iterator begin_entries(const hobject_t &hoid) const;
+  [[nodiscard]] const_iterator end_entries(const hobject_t &hoid) const;
+};
index 898ca6f957374bfb2233a2aa2db76813e6291c33..1e227b058ad94c590482fd701621bc303dfda11e 100644 (file)
@@ -137,7 +137,6 @@ public:
 
     std::map<std::string, std::optional<ceph::buffer::list> > attr_updates;
 
-    enum class OmapUpdateType {Remove, Insert, RemoveRange};
     std::vector<std::pair<OmapUpdateType, ceph::buffer::list> > omap_updates;
 
     std::optional<ceph::buffer::list> omap_header;
@@ -455,7 +454,7 @@ public:
     auto &op = get_object_op_for_modify(hoid);
     op.omap_updates.emplace_back(
       std::make_pair(
-       ObjectOperation::OmapUpdateType::Insert,
+       OmapUpdateType::Insert,
        keys_bl));
   }
   void omap_setkeys(
@@ -474,7 +473,7 @@ public:
     auto &op = get_object_op_for_modify(hoid);
     op.omap_updates.emplace_back(
       std::make_pair(
-       ObjectOperation::OmapUpdateType::Remove,
+       OmapUpdateType::Remove,
        keys_bl));
   }
   void omap_rmkeys(
@@ -493,7 +492,7 @@ public:
     auto &op = get_object_op_for_modify(hoid);
     op.omap_updates.emplace_back(
       std::make_pair(
-       ObjectOperation::OmapUpdateType::RemoveRange,
+       OmapUpdateType::RemoveRange,
        range_bl));
   }
   void omap_rmkeyrange(
index 06bde7f24186c06970ce076898465d074f09c422..5ecae182604a60c4b71389b32567a665313ff1ce 100644 (file)
@@ -456,15 +456,14 @@ void generate_transaction(
        t->omap_setheader(coll, goid, *(op.omap_header));
 
       for (auto &&up: op.omap_updates) {
-       using UpdateType = PGTransaction::ObjectOperation::OmapUpdateType;
        switch (up.first) {
-       case UpdateType::Remove:
+       case OmapUpdateType::Remove:
          t->omap_rmkeys(coll, goid, up.second);
          break;
-       case UpdateType::Insert:
+       case OmapUpdateType::Insert:
          t->omap_setkeys(coll, goid, up.second);
          break;
-       case UpdateType::RemoveRange:
+       case OmapUpdateType::RemoveRange:
          t->omap_rmkeyrange(coll, goid, up.second);
          break;
        }
index 846ffc6edf481967ce11253bc60fa4be15a34e22..eed7eaf23a03438f07aa6b191684b0d3ceff09ac 100644 (file)
@@ -145,6 +145,8 @@ typedef interval_set<
 using shard_id_set = bitset_set<128, shard_id_t>;
 WRITE_CLASS_DENC(shard_id_set)
 
+enum class OmapUpdateType : uint8_t {Remove, Insert, RemoveRange};
+
 /**
  * osd request identifier
  *
index 94a57342c017a0969d88d5d6f656a3de9410ec0e..d878cf5631e165ad99d7086bb2dfc582a2d35087 100644 (file)
@@ -215,10 +215,18 @@ target_link_libraries(unittest_mclock_scheduler
   global osd dmclock os
 )
 
+# unittest ECOmapJournal
+add_executable(unittest_ec_omap_journal
+  test_ec_omap_journal.cc
+)
+add_ceph_unittest(unittest_ec_omap_journal)
+target_link_libraries(unittest_ec_omap_journal osd global ${BLKID_LIBRARIES})
+
 # osd_unittests: custom target that builds and runs all OSD unit tests
 # Not including unittest_osdmap, as it is slow. It is tested elsewhere.
 set(OSD_UNITTESTS
   unittest_backend_basics
+  unittest_ec_omap_journal
   unittest_ec_transaction
   unittest_ec_transaction_l
   unittest_ecbackend
diff --git a/src/test/osd/test_ec_omap_journal.cc b/src/test/osd/test_ec_omap_journal.cc
new file mode 100644 (file)
index 0000000..431acae
--- /dev/null
@@ -0,0 +1,1427 @@
+// -*- mode:C++; tab-width:8; c-basic-offset:2; indent-tabs-mode:nil -*-
+// vim: ts=8 sw=2 sts=2 expandtab
+
+/*
+ * Ceph - scalable distributed file system
+ *
+ * Copyright (C) 2026 IBM
+ *
+ * This is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License version 2.1, as published by the Free Software
+ * Foundation.  See file COPYING.
+ *
+ */
+
+#include <gtest/gtest.h>
+#include "test/unit.cc"
+
+#include "osd/ECOmapJournal.h"
+#include "common/dout.h"
+
+class MockDoutPrefixProvider : public DoutPrefixProvider {
+public:
+  std::ostream& gen_prefix(std::ostream& out) const override {
+    return out << "test: ";
+  }
+  CephContext *get_cct() const override {
+    return g_ceph_context;
+  }
+  unsigned get_subsys() const override {
+    return ceph_subsys_osd;
+  }
+};
+
+TEST(ecomapjournalentry, attributes_retained)
+{
+  eversion_t version(1, 1);
+  bool clear_omap = true;
+  ceph::buffer::list omap_val_bl, omap_header_bl, omap_map_bl, keys_to_remove_bl;
+  const std::string omap_header = "this is the header";
+  encode(omap_header, omap_header_bl);
+  const std::string omap_key_1 = "omap_key_1_palomino";
+  const std::string omap_key_2 = "omap_key_2_chestnut";
+  const std::string omap_key_3 = "omap_key_3_bay";
+  const std::string omap_value = "omap_value_1_horse";
+  encode(omap_value, omap_val_bl);
+  std::map<std::string, bufferlist> omap_map = {
+    {omap_key_1.c_str(), omap_val_bl},
+    {omap_key_2.c_str(), omap_val_bl},
+    {omap_key_3.c_str(), omap_val_bl}
+  };
+  encode(omap_map, omap_map_bl);
+  std::set<std::string> keys_to_remove = {omap_key_2};
+  encode(keys_to_remove, keys_to_remove_bl);
+  std::vector<std::pair<OmapUpdateType, ceph::buffer::list>> omap_updates = {
+    {OmapUpdateType::Insert, omap_map_bl},
+    {OmapUpdateType::Remove, keys_to_remove_bl}
+  };
+
+  ECOmapJournalEntry entry(version, clear_omap, omap_header_bl, omap_updates);
+
+  // Attributes should be retained correctly
+  ASSERT_TRUE(entry.version == version);
+  ASSERT_TRUE(entry.clear_omap == clear_omap);
+  ASSERT_TRUE(*entry.omap_header == omap_header_bl);
+  ASSERT_TRUE(entry.omap_updates == omap_updates);
+}
+
+TEST(ecomapjournal, new_journal_starts_empty)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_key", CEPH_NOSNAP, 1, 0, "test_namespace");
+
+  // A new journal should start empty
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+}
+
+TEST(ecomapjournal, add_entry)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_key2", CEPH_NOSNAP, 1, 0, "test_namespace");
+
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  journal.add_entry(test_hoid, entry1);
+
+  // The journal should contain the added entry
+  ASSERT_EQ(1u, journal.entries_size(test_hoid));
+  ASSERT_TRUE(journal.begin_entries(test_hoid)->version == entry1.version);
+}
+
+TEST(ecomapjournal, remove_entry)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_key3", CEPH_NOSNAP, 1, 0, "test_namespace");
+
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  ECOmapJournalEntry entry2(eversion_t(1, 2), false, std::nullopt, {});
+  ECOmapJournalEntry entry3(eversion_t(1, 3), false, std::nullopt, {});
+  journal.add_entry(test_hoid, entry1);
+  journal.add_entry(test_hoid, entry2);
+  journal.add_entry(test_hoid, entry3);
+  bool res = journal.remove_entry(test_hoid, entry1);
+  ASSERT_TRUE(res);
+
+  // The journal should have 2 entries in it after removal
+  ASSERT_EQ(2u, journal.entries_size(test_hoid));
+  ASSERT_TRUE(journal.begin_entries(test_hoid)->version == entry2.version);
+  ASSERT_TRUE((++journal.begin_entries(test_hoid))->version == entry3.version);
+}
+
+TEST(ecomapjournal, remove_entry_by_version)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_key4", CEPH_NOSNAP, 1, 0, "test_namespace");
+
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  ECOmapJournalEntry entry2(eversion_t(1, 2), false, std::nullopt, {});
+  ECOmapJournalEntry entry3(eversion_t(1, 3), false, std::nullopt, {});
+  journal.add_entry(test_hoid, entry1);
+  journal.add_entry(test_hoid, entry2);
+  journal.add_entry(test_hoid, entry3);
+  bool res = journal.remove_entry_by_version(test_hoid, entry2.version);
+  ASSERT_TRUE(res);
+
+  // The journal should have 2 entries in it after removal
+  ASSERT_EQ(2u, journal.entries_size(test_hoid));
+  ASSERT_TRUE(journal.begin_entries(test_hoid)->version == entry1.version);
+  ASSERT_TRUE((++journal.begin_entries(test_hoid))->version == entry3.version);
+}
+
+TEST(ecomapjournal, clear_one_journal)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_key5", CEPH_NOSNAP, 1, 0, "test_namespace");
+
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  ECOmapJournalEntry entry2(eversion_t(1, 2), false, std::nullopt, {});
+  journal.add_entry(test_hoid, entry1);
+  journal.add_entry(test_hoid, entry2);
+  journal.clear(test_hoid);
+
+  // The journal should be empty after clearing
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+}
+
+TEST(ecomapjournal, clear_all_journals)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid1("test_key6", CEPH_NOSNAP, 1, 0, "test_namespace");
+  const hobject_t test_hoid2("test_key7", CEPH_NOSNAP, 1, 0, "test_namespace");
+
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  ECOmapJournalEntry entry2(eversion_t(1, 2), false, std::nullopt, {});
+  journal.add_entry(test_hoid1, entry1);
+  journal.add_entry(test_hoid2, entry2);
+  journal.clear_all();
+
+  // Both journals should be empty after clearing all
+  ASSERT_EQ(0u, journal.entries_size(test_hoid1));
+  ASSERT_EQ(0u, journal.entries_size(test_hoid2));
+}
+
+TEST(ecomapjournal, remove_bad_entry)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_key6", CEPH_NOSNAP, 1, 0, "test_namespace");
+
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  ECOmapJournalEntry entry2(eversion_t(1, 2), false, std::nullopt, {});
+  journal.add_entry(test_hoid, entry1);
+
+  // Attempting to remove an entry not in the journal should fail
+  bool res = journal.remove_entry(test_hoid, entry2);
+  ASSERT_FALSE(res);
+
+  // The journal should still have 1 entry in it after failed removal
+  ASSERT_EQ(1u, journal.entries_size(test_hoid));
+  ASSERT_TRUE(journal.begin_entries(test_hoid)->version == entry1.version);
+}
+
+TEST(ecomapjournal, remove_bad_entry_by_version)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_key7", CEPH_NOSNAP, 1, 0, "test_namespace");
+
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  ECOmapJournalEntry entry2(eversion_t(1, 2), false, std::nullopt, {});
+  journal.add_entry(test_hoid, entry1);
+
+  // Attempting to remove an entry not in the journal should fail
+  bool res = journal.remove_entry_by_version(test_hoid, entry2.version);
+  ASSERT_FALSE(res);
+
+  // The journal should still have 1 entry in it after failed removal
+  ASSERT_EQ(1u, journal.entries_size(test_hoid));
+  ASSERT_TRUE(journal.begin_entries(test_hoid)->version == entry1.version);
+}
+
+TEST(ecomapjournal, get_value_updates_no_updates)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_key7", CEPH_NOSNAP, 1, 0, "test_namespace");
+
+  // The journal should return empty updates for an object with no entries
+  auto [update_map, removed_ranges] = journal.get_value_updates(test_hoid);
+  ASSERT_TRUE(update_map.empty());
+  ASSERT_TRUE(removed_ranges.empty());
+}
+
+TEST(ecomapjournal, get_value_updates_multiple_updates)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_key7", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  std::map<std::string, ceph::buffer::list> key_value;
+  ceph::buffer::list val_bl;
+  encode("my_value", val_bl);
+  key_value["key_1"] = val_bl;
+  ceph::buffer::list map_bl;
+  encode(key_value, map_bl);
+
+  std::string range_begin = "key_1";
+  std::string range_end = "key_2";
+  
+  ceph::buffer::list range_bl;
+  encode(range_begin, range_bl);
+  encode(range_end, range_bl);
+  
+  ECOmapJournalEntry entry1(
+    eversion_t(1, 1), false, std::nullopt,
+    {std::pair(OmapUpdateType::RemoveRange, range_bl)}
+  );
+  ECOmapJournalEntry entry2(
+    eversion_t(1, 2), true, std::nullopt, 
+    {std::pair(OmapUpdateType::Insert, map_bl)}
+  );
+  journal.add_entry(test_hoid, entry1);
+  journal.add_entry(test_hoid, entry2);
+
+  // The journal should return the combined updates for the object
+  auto [update_map, removed_ranges] = journal.get_value_updates(test_hoid);
+
+  ASSERT_TRUE(!update_map.empty());
+  ASSERT_TRUE(!removed_ranges.empty());
+
+  // Removed ranges should contain the clear from entry2
+  ASSERT_TRUE(removed_ranges.contains(""));
+  ASSERT_TRUE(!removed_ranges[""].has_value());
+
+  // Update map should contain key1 inserted in entry2
+  auto it = update_map.find("key_1");
+  ASSERT_TRUE(it != update_map.end());
+  ASSERT_TRUE(it->second.value.has_value());
+}
+
+TEST(ecomapjournal, get_updated_header_no_entries)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_key7", CEPH_NOSNAP, 1, 0, "test_namespace");
+
+  // The journal should return no updated header for an object with no entries
+  std::optional<ceph::buffer::list> header = journal.get_updated_header(test_hoid);
+  ASSERT_TRUE(!header.has_value());
+}
+
+TEST(ecomapjournal, get_updated_header_single_entry)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_key7", CEPH_NOSNAP, 1, 0, "test_namespace");
+  const std::string omap_header_str = "this is the header";
+  ceph::buffer::list omap_header_bl;
+  encode(omap_header_str, omap_header_bl);
+
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, omap_header_bl, {});
+  journal.add_entry(test_hoid, entry1);
+
+  // The journal should return the updated header for the object
+  std::optional<ceph::buffer::list> header = journal.get_updated_header(test_hoid);
+  ASSERT_TRUE(header.has_value());
+  ASSERT_TRUE(*header == omap_header_bl);
+}
+
+TEST(ecomapjournal, get_updated_header_multiple_entries)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_key7", CEPH_NOSNAP, 1, 0, "test_namespace");
+  const std::string omap_header_str_1 = "this is the header";
+  const std::string omap_header_str_2 = "this is the new header";
+  ceph::buffer::list omap_header_bl_1, omap_header_bl_2;
+  encode(omap_header_str_1, omap_header_bl_1);
+  encode(omap_header_str_2, omap_header_bl_2);
+
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, omap_header_bl_1, {});
+  ECOmapJournalEntry entry2(eversion_t(1, 2), false, omap_header_bl_2, {});
+  journal.add_entry(test_hoid, entry1);
+  journal.add_entry(test_hoid, entry2);
+
+  // The journal should return the second updated header for the object
+  std::optional<ceph::buffer::list> header = journal.get_updated_header(test_hoid);
+  ASSERT_TRUE(header.has_value());
+  ASSERT_TRUE(*header == omap_header_bl_2);
+}
+
+std::string make_key(int i) {
+  std::stringstream ss;
+  ss << "key_" << std::setw(3) << std::setfill('0') << i;
+  return ss.str();
+}
+
+// Encodes a map of keys for OmapUpdateType::Insert
+ceph::buffer::list encode_map(const std::map<std::string, ceph::buffer::list>& keys) {
+  ceph::buffer::list bl;
+  encode(keys, bl);
+  return bl;
+}
+
+// Encodes start/end strings for OmapUpdateType::RemoveRange
+ceph::buffer::list encode_range(const std::string& start, const std::string& end) {
+  ceph::buffer::list bl;
+  encode(start, bl);
+  encode(end, bl);
+  return bl;
+}
+
+// Create an insert entry
+ECOmapJournalEntry create_insert_entry(const eversion_t v, const int start_key, const int end_key) {
+  std::map<std::string, ceph::buffer::list> key_map;
+  for (int i = start_key; i <= end_key; ++i) {
+    ceph::buffer::list val_bl;
+    val_bl.append("val");
+    key_map[make_key(i)] = val_bl;
+  }
+
+  std::vector<std::pair<OmapUpdateType, ceph::buffer::list>> updates;
+  updates.push_back({OmapUpdateType::Insert, encode_map(key_map)});
+
+  return ECOmapJournalEntry(v, false, std::nullopt, updates);
+}
+
+TEST(ecomapjournal, RemoveOneRange) {
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t hoid("obj_rm_range", CEPH_NOSNAP, 1, 0, "nspace");
+
+  journal.add_entry(hoid, create_insert_entry(eversion_t(1, 1), 1, 100));
+
+  std::vector<std::pair<OmapUpdateType, ceph::buffer::list>> rm_updates;
+  rm_updates.push_back({
+    OmapUpdateType::RemoveRange,
+    encode_range(make_key(20), make_key(41))
+  });
+  journal.add_entry(hoid, ECOmapJournalEntry(eversion_t(1, 2), false, std::nullopt, rm_updates));
+
+  auto [updates, ranges] = journal.get_value_updates(hoid);
+  ASSERT_EQ(1u, ranges.size());
+  auto it = ranges.begin();
+  EXPECT_EQ(make_key(20), it->first);
+  ASSERT_TRUE(it->second.has_value());
+  EXPECT_EQ(make_key(41), *it->second);
+}
+
+TEST(ecomapjournal, RemoveTwoRanges) {
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t hoid("obj_rm_two_ranges", CEPH_NOSNAP, 1, 0, "nspace");
+
+  journal.add_entry(hoid, create_insert_entry(eversion_t(1, 1), 1, 100));
+
+  std::vector<std::pair<OmapUpdateType, ceph::buffer::list>> rm_updates;
+  rm_updates.push_back({OmapUpdateType::RemoveRange, encode_range(make_key(20), make_key(41))});
+  rm_updates.push_back({OmapUpdateType::RemoveRange, encode_range(make_key(60), make_key(81))});
+  journal.add_entry(hoid, ECOmapJournalEntry(eversion_t(1, 2), false, std::nullopt, rm_updates));
+
+  auto [updates, ranges] = journal.get_value_updates(hoid);
+  ASSERT_EQ(2u, ranges.size());
+  auto it = ranges.begin();
+  EXPECT_EQ(make_key(20), it->first);
+  EXPECT_EQ(make_key(41), *it->second);
+  ++it;
+  EXPECT_EQ(make_key(60), it->first);
+  EXPECT_EQ(make_key(81), *it->second);
+}
+
+TEST(ecomapjournal, OverlappingRanges) {
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t hoid("obj_overlap", CEPH_NOSNAP, 1, 0, "nspace");
+
+  journal.add_entry(hoid, create_insert_entry(eversion_t(1, 1), 1, 100));
+
+  std::vector<std::pair<OmapUpdateType, ceph::buffer::list>> rm_updates;
+  rm_updates.push_back({OmapUpdateType::RemoveRange, encode_range(make_key(10), make_key(21))});
+  rm_updates.push_back({OmapUpdateType::RemoveRange, encode_range(make_key(15), make_key(31))});
+  journal.add_entry(hoid, ECOmapJournalEntry(eversion_t(1, 2), false, std::nullopt, rm_updates));
+
+  auto [updates, ranges] = journal.get_value_updates(hoid);
+  ASSERT_EQ(1u, ranges.size());
+  EXPECT_EQ(make_key(10), ranges.begin()->first);
+  EXPECT_EQ(make_key(31), *ranges.begin()->second);
+}
+
+TEST(ecomapjournal, RemoveWithNullStart) {
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t hoid("obj_null_start", CEPH_NOSNAP, 1, 0, "nspace");
+
+  journal.add_entry(hoid, create_insert_entry(eversion_t(1, 1), 1, 100));
+
+  std::vector<std::pair<OmapUpdateType, ceph::buffer::list>> rm_updates;
+  rm_updates.push_back({OmapUpdateType::RemoveRange, encode_range("", make_key(21))});
+  journal.add_entry(hoid, ECOmapJournalEntry(eversion_t(1, 2), false, std::nullopt, rm_updates));
+
+  auto [updates, ranges] = journal.get_value_updates(hoid);
+  ASSERT_EQ(1u, ranges.size());
+  EXPECT_EQ("", ranges.begin()->first);
+  EXPECT_EQ(make_key(21), *ranges.begin()->second);
+}
+
+TEST(ecomapjournal, RemoveRangeThenInsert) {
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t hoid("obj_rm_ins", CEPH_NOSNAP, 1, 0, "nspace");
+
+  journal.add_entry(hoid, create_insert_entry(eversion_t(1, 1), 1, 100));
+
+  std::vector<std::pair<OmapUpdateType, ceph::buffer::list>> ops;
+  ops.push_back({OmapUpdateType::RemoveRange, encode_range(make_key(30), make_key(61))});
+  journal.add_entry(hoid, ECOmapJournalEntry(eversion_t(1, 2), false, std::nullopt, ops));
+
+  journal.add_entry(hoid, create_insert_entry(eversion_t(1, 3), 45, 45));
+
+  auto [updates, ranges] = journal.get_value_updates(hoid);
+  ASSERT_EQ(1u, ranges.size());
+  EXPECT_EQ(make_key(30), ranges.begin()->first);
+  EXPECT_EQ(make_key(61), *ranges.begin()->second);
+  ASSERT_TRUE(updates.count(make_key(45)));
+  EXPECT_TRUE(updates.at(make_key(45)).value.has_value());
+}
+
+TEST(ecomapjournal, RemoveInsertRemove) {
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t hoid("obj_rm_ins_rm", CEPH_NOSNAP, 1, 0, "nspace");
+
+  journal.add_entry(hoid, create_insert_entry(eversion_t(1, 1), 1, 100));
+
+  std::vector<std::pair<OmapUpdateType, ceph::buffer::list>> ops1;
+  ops1.push_back({OmapUpdateType::RemoveRange, encode_range(make_key(30), make_key(61))});
+  journal.add_entry(hoid, ECOmapJournalEntry(eversion_t(1, 2), false, std::nullopt, ops1));
+
+  journal.add_entry(hoid, create_insert_entry(eversion_t(1, 3), 45, 45));
+
+  std::vector<std::pair<OmapUpdateType, ceph::buffer::list>> ops2;
+  ops2.push_back({OmapUpdateType::RemoveRange, encode_range(make_key(30), make_key(61))});
+  journal.add_entry(hoid, ECOmapJournalEntry(eversion_t(1, 4), false, std::nullopt, ops2));
+
+  auto [updates, ranges] = journal.get_value_updates(hoid);
+  ASSERT_EQ(1u, ranges.size());
+  EXPECT_EQ(make_key(30), ranges.begin()->first);
+  EXPECT_EQ(make_key(61), *ranges.begin()->second);
+  ASSERT_TRUE(updates.count(make_key(45)));
+  EXPECT_FALSE(updates.at(make_key(45)).value.has_value());
+}
+
+TEST(ecomapjournal, AdjacentRanges) {
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t hoid("obj_adjacent", CEPH_NOSNAP, 1, 0, "nspace");
+
+  journal.add_entry(hoid, create_insert_entry(eversion_t(1, 1), 1, 100));
+
+  std::vector<std::pair<OmapUpdateType, ceph::buffer::list>> rm_updates;
+  rm_updates.push_back({OmapUpdateType::RemoveRange, encode_range(make_key(10), make_key(20))});
+  rm_updates.push_back({OmapUpdateType::RemoveRange, encode_range(make_key(20), make_key(30))});
+  journal.add_entry(hoid, ECOmapJournalEntry(eversion_t(1, 2), false, std::nullopt, rm_updates));
+
+  auto [updates, ranges] = journal.get_value_updates(hoid);
+  EXPECT_EQ(make_key(10), ranges.begin()->first);
+  EXPECT_EQ(make_key(30), *ranges.begin()->second);
+}
+
+TEST(ecomapjournal, ClearOmap) {
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t hoid("obj_clear", CEPH_NOSNAP, 1, 0, "nspace");
+
+  ceph::buffer::list header_bl;
+  header_bl.append("header_v1");
+  auto entry_ins = create_insert_entry(eversion_t(1, 1), 1, 100);
+  entry_ins.omap_header = header_bl;
+  journal.add_entry(hoid, entry_ins);
+
+  ECOmapJournalEntry entry_clear(eversion_t(1, 2), true, std::nullopt, {});
+  journal.add_entry(hoid, entry_clear);
+
+  journal.add_entry(hoid, create_insert_entry(eversion_t(1, 3), 50, 50));
+
+  auto [updates, ranges] = journal.get_value_updates(hoid);
+  auto header = journal.get_updated_header(hoid);
+  ASSERT_TRUE(updates.contains(make_key(50)));
+  EXPECT_TRUE(updates.at(make_key(50)).value.has_value());
+  ASSERT_TRUE(header.has_value());
+  EXPECT_EQ(0u, header->length());
+  ASSERT_EQ(1u, ranges.size());
+  EXPECT_TRUE(ranges.contains(""));
+  EXPECT_TRUE(ranges.at("") == std::nullopt);
+}
+
+TEST(ecomapjournal, append_delete_clears_maps)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_delete", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Add some journal entries
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  ECOmapJournalEntry entry2(eversion_t(1, 2), false, std::nullopt, {});
+  journal.add_entry(test_hoid, entry1);
+  journal.add_entry(test_hoid, entry2);
+  
+  ASSERT_EQ(2u, journal.entries_size(test_hoid));
+  
+  // Delete the object
+  journal.append_delete(test_hoid, 5, false);
+  
+  // All entries should be cleared
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+}
+
+TEST(ecomapjournal, append_delete_adds_to_object_state_map)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_delete2", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Delete the object at version 5
+  journal.append_delete(test_hoid, 5, false);
+  
+  // Generation should be the delete version
+  auto [gen, lost] = journal.get_generation(test_hoid);
+  ASSERT_EQ(5u, gen);
+  ASSERT_FALSE(lost);
+}
+
+TEST(ecomapjournal, append_delete_multiple_versions)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_delete3", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Delete the object at multiple versions
+  journal.append_delete(test_hoid, 5, false);
+  journal.append_delete(test_hoid, 8, false);
+  journal.append_delete(test_hoid, 3, false);
+  
+  // Generation should be the lowest delete version
+  auto [gen, lost] = journal.get_generation(test_hoid);
+  ASSERT_EQ(3u, gen);
+  ASSERT_FALSE(lost);
+}
+
+TEST(ecomapjournal, append_create_clears_entries)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_create", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Add some journal entries
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  ECOmapJournalEntry entry2(eversion_t(1, 2), false, std::nullopt, {});
+  journal.add_entry(test_hoid, entry1);
+  journal.add_entry(test_hoid, entry2);
+  
+  ASSERT_EQ(2u, journal.entries_size(test_hoid));
+  
+  // Create the object
+  journal.append_create(test_hoid);
+  
+  // All entries should be cleared
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+}
+
+TEST(ecomapjournal, trim_delete_removes_version)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_trim_delete", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Delete the object at multiple versions
+  journal.append_delete(test_hoid, 5, false);
+  journal.append_delete(test_hoid, 8, false);
+  journal.append_delete(test_hoid, 3, false);
+  
+  auto [gen1, lost1] = journal.get_generation(test_hoid);
+  ASSERT_EQ(3u, gen1);
+  ASSERT_FALSE(lost1);
+  
+  // Trim the lowest version
+  journal.trim_delete(test_hoid, 3);
+  
+  // Generation should now be the next lowest version
+  auto [gen2, lost2] = journal.get_generation(test_hoid);
+  ASSERT_EQ(5u, gen2);
+  ASSERT_FALSE(lost2);
+}
+
+TEST(ecomapjournal, trim_delete_last_version)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_trim_last", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Delete the object at one version
+  journal.append_delete(test_hoid, 5, false);
+  
+  // Trim the only version
+  journal.trim_delete(test_hoid, 5);
+  
+  // Generation should be NO_GEN
+  auto [gen, lost] = journal.get_generation(test_hoid);
+  ASSERT_EQ(UINT64_MAX, gen);
+  ASSERT_FALSE(lost);
+}
+
+TEST(ecomapjournal, get_generation_no_deletes)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_no_delete", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Generation should be NO_GEN
+  auto [gen, lost] = journal.get_generation(test_hoid);
+  ASSERT_EQ(UINT64_MAX, gen);
+  ASSERT_FALSE(lost);
+}
+
+TEST(ecomapjournal, append_delete_with_lost_flag)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_lost", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Delete with lost_delete = true
+  journal.append_delete(test_hoid, 5, true);
+  auto [gen1, lost1] = journal.get_generation(test_hoid);
+  ASSERT_EQ(5u, gen1);
+  ASSERT_TRUE(lost1);
+  
+  // Delete with lost_delete = false
+  journal.append_delete(test_hoid, 8, false);
+  auto [gen2, lost2] = journal.get_generation(test_hoid);
+  ASSERT_EQ(5u, gen2);
+  ASSERT_TRUE(lost2);
+}
+
+TEST(ecomapjournal, delete_then_create_workflow)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_delete_create", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Add entries
+  journal.add_entry(test_hoid, create_insert_entry(eversion_t(1, 1), 1, 10));
+  ASSERT_EQ(1u, journal.entries_size(test_hoid));
+  
+  // Delete object
+  journal.append_delete(test_hoid, 5, false);
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+  
+  // Add more entries after delete
+  journal.add_entry(test_hoid, create_insert_entry(eversion_t(1, 6), 20, 30));
+  ASSERT_EQ(1u, journal.entries_size(test_hoid));
+  
+  // Create object (simulating recreation)
+  journal.append_create(test_hoid);
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+  
+  // Object should still have the delete version tracked
+  auto [gen, lost] = journal.get_generation(test_hoid);
+  ASSERT_EQ(5u, gen);
+  ASSERT_FALSE(lost);
+}
+
+TEST(ecomapjournal, multiple_objects_state_tracking)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t hoid1("obj1", CEPH_NOSNAP, 1, 0, "ns");
+  const hobject_t hoid2("obj2", CEPH_NOSNAP, 1, 0, "ns");
+  const hobject_t hoid3("obj3", CEPH_NOSNAP, 1, 0, "ns");
+  
+  // Delete different objects at different versions
+  journal.append_delete(hoid1, 10, false);
+  journal.append_delete(hoid2, 5, false);
+  journal.append_delete(hoid3, 15, false);
+  
+  auto [gen1, lost1] = journal.get_generation(hoid1);
+  ASSERT_EQ(10u, gen1);
+  ASSERT_FALSE(lost1);
+  auto [gen2, lost2] = journal.get_generation(hoid2);
+  ASSERT_EQ(5u, gen2);
+  ASSERT_FALSE(lost2);
+  auto [gen3, lost3] = journal.get_generation(hoid3);
+  ASSERT_EQ(15u, gen3);
+  ASSERT_FALSE(lost3);
+  
+  // Trim one object's delete
+  journal.trim_delete(hoid2, 5);
+  
+  // Only hoid2 should have NO_GEN
+  auto [gen1b, lost1b] = journal.get_generation(hoid1);
+  ASSERT_EQ(10u, gen1b);
+  ASSERT_FALSE(lost1b);
+  auto [gen2b, lost2b] = journal.get_generation(hoid2);
+  ASSERT_EQ(UINT64_MAX, gen2b);
+  ASSERT_FALSE(lost2b);
+  auto [gen3b, lost3b] = journal.get_generation(hoid3);
+  ASSERT_EQ(15u, gen3b);
+  ASSERT_FALSE(lost3b);
+}
+
+TEST(ecomapjournal, append_delete_clears_processed_entries)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_processed", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Add entries and process them
+  journal.add_entry(test_hoid, create_insert_entry(eversion_t(1, 1), 1, 10));
+  auto [updates, ranges] = journal.get_value_updates(test_hoid);
+  
+  // Verify entries were processed (entries_size should be 0 after processing)
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+  ASSERT_FALSE(updates.empty());
+  
+  // Delete the object - should clear processed entries too
+  journal.append_delete(test_hoid, 5, false);
+  
+  // Get updates again - should be empty
+  auto [updates2, ranges2] = journal.get_value_updates(test_hoid);
+  ASSERT_TRUE(updates2.empty());
+  ASSERT_TRUE(ranges2.empty());
+}
+
+TEST(ecomapjournal, generation_with_whiteout)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_whiteout", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Simulate whiteout by deleting
+  journal.append_delete(test_hoid, 10, false);
+  
+  auto [gen1, lost1] = journal.get_generation(test_hoid);
+  ASSERT_EQ(10u, gen1);
+  ASSERT_FALSE(lost1);
+  
+  // Add another delete (could represent another whiteout)
+  journal.append_delete(test_hoid, 12, false);
+  
+  // Generation should still be the lowest
+  auto [gen2, lost2] = journal.get_generation(test_hoid);
+  ASSERT_EQ(10u, gen2);
+  ASSERT_FALSE(lost2);
+}
+
+TEST(ecomapjournal, append_whiteout_clears_entries)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_whiteout_entries", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Add some journal entries
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  ECOmapJournalEntry entry2(eversion_t(1, 2), false, std::nullopt, {});
+  journal.add_entry(test_hoid, entry1);
+  journal.add_entry(test_hoid, entry2);
+  
+  ASSERT_EQ(2u, journal.entries_size(test_hoid));
+  
+  // Whiteout the object
+  journal.append_whiteout(test_hoid);
+  
+  // All entries should be cleared
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+}
+
+TEST(ecomapjournal, append_whiteout_clears_processed_entries)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_whiteout_processed", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Add entries and process them
+  journal.add_entry(test_hoid, create_insert_entry(eversion_t(1, 1), 1, 10));
+  auto [updates, ranges] = journal.get_value_updates(test_hoid);
+  
+  // Verify entries were processed
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+  ASSERT_FALSE(updates.empty());
+  
+  // Whiteout the object - should clear processed entries too
+  journal.append_whiteout(test_hoid);
+  
+  // Get updates again - should be empty
+  auto [updates2, ranges2] = journal.get_value_updates(test_hoid);
+  ASSERT_TRUE(updates2.empty());
+  ASSERT_TRUE(ranges2.empty());
+}
+
+TEST(ecomapjournal, append_whiteout_clears_header)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_whiteout_header", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  const std::string omap_header_str = "test header";
+  ceph::buffer::list omap_header_bl;
+  encode(omap_header_str, omap_header_bl);
+  
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, omap_header_bl, {});
+  journal.add_entry(test_hoid, entry1);
+  
+  // Verify header exists
+  std::optional<ceph::buffer::list> header = journal.get_updated_header(test_hoid);
+  ASSERT_TRUE(header.has_value());
+  
+  // Whiteout the object
+  journal.append_whiteout(test_hoid);
+  
+  // Header should be cleared
+  std::optional<ceph::buffer::list> header2 = journal.get_updated_header(test_hoid);
+  ASSERT_FALSE(header2.has_value());
+}
+
+TEST(ecomapjournal, append_whiteout_on_nonexistent_object)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_whiteout_nonexist", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Whiteout on non-existent object should not crash
+  journal.append_whiteout(test_hoid);
+  
+  // Should still be empty
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+}
+
+TEST(ecomapjournal, append_whiteout_followed_by_new_entries)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_whiteout_new", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Add entries
+  journal.add_entry(test_hoid, create_insert_entry(eversion_t(1, 1), 1, 10));
+  ASSERT_EQ(1u, journal.entries_size(test_hoid));
+  
+  // Whiteout
+  journal.append_whiteout(test_hoid);
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+  
+  // Add new entries after whiteout
+  journal.add_entry(test_hoid, create_insert_entry(eversion_t(1, 2), 20, 30));
+  ASSERT_EQ(1u, journal.entries_size(test_hoid));
+  
+  // New entries should be present
+  auto [updates, ranges] = journal.get_value_updates(test_hoid);
+  ASSERT_FALSE(updates.empty());
+  ASSERT_TRUE(updates.contains(make_key(20)));
+}
+
+TEST(ecomapjournal, append_whiteout_does_not_affect_generation)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_whiteout_gen", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Delete object to set generation
+  journal.append_delete(test_hoid, 5, false);
+  auto [gen1, lost1] = journal.get_generation(test_hoid);
+  ASSERT_EQ(5u, gen1);
+  
+  // Add entries
+  journal.add_entry(test_hoid, create_insert_entry(eversion_t(1, 6), 1, 10));
+  
+  // Whiteout should clear entries but not generation
+  journal.append_whiteout(test_hoid);
+  
+  auto [gen2, lost2] = journal.get_generation(test_hoid);
+  ASSERT_EQ(5u, gen2);
+  ASSERT_FALSE(lost2);
+}
+
+TEST(ecomapjournal, whiteout_create_delete_workflow)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_whiteout_workflow", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Add entries
+  journal.add_entry(test_hoid, create_insert_entry(eversion_t(1, 1), 1, 10));
+  ASSERT_EQ(1u, journal.entries_size(test_hoid));
+  
+  // Whiteout
+  journal.append_whiteout(test_hoid);
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+  
+  // Create
+  journal.append_create(test_hoid);
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+  
+  // Add more entries
+  journal.add_entry(test_hoid, create_insert_entry(eversion_t(1, 2), 20, 30));
+  ASSERT_EQ(1u, journal.entries_size(test_hoid));
+  
+  // Delete
+  journal.append_delete(test_hoid, 10, false);
+  ASSERT_EQ(0u, journal.entries_size(test_hoid));
+  
+  auto [gen, lost] = journal.get_generation(test_hoid);
+  ASSERT_EQ(10u, gen);
+}
+
+TEST(ecomapvalue, update_value_with_data)
+{
+  eversion_t v1(1, 1);
+  ceph::buffer::list bl1;
+  bl1.append("value1");
+  
+  ECOmapValue value(v1, bl1);
+  ASSERT_EQ(v1, value.version);
+  ASSERT_TRUE(value.value.has_value());
+  
+  eversion_t v2(1, 2);
+  ceph::buffer::list bl2;
+  bl2.append("value2");
+  
+  value.update_value(v2, bl2);
+  ASSERT_EQ(v2, value.version);
+  ASSERT_TRUE(value.value.has_value());
+  ASSERT_EQ(bl2, *value.value);
+}
+
+TEST(ecomapvalue, update_value_to_nullopt)
+{
+  eversion_t v1(1, 1);
+  ceph::buffer::list bl1;
+  bl1.append("value1");
+  
+  ECOmapValue value(v1, bl1);
+  ASSERT_TRUE(value.value.has_value());
+  
+  eversion_t v2(1, 2);
+  value.update_value(v2, std::nullopt);
+  
+  ASSERT_EQ(v2, value.version);
+  ASSERT_FALSE(value.value.has_value());
+}
+
+TEST(ecomapvalue, multiple_updates)
+{
+  eversion_t v1(1, 1);
+  ECOmapValue value(v1, std::nullopt);
+  
+  for (int i = 2; i <= 5; ++i) {
+    eversion_t v(1, i);
+    ceph::buffer::list bl;
+    bl.append(std::to_string(i));
+    value.update_value(v, bl);
+    ASSERT_EQ(v, value.version);
+  }
+  
+  ASSERT_EQ(eversion_t(1, 5), value.version);
+}
+
+TEST(ecomapremovedranges, add_range_non_overlapping)
+{
+  ECOmapRemovedRanges ranges(eversion_t(1, 1));
+  
+  ranges.add_range("key_010", "key_020");
+  ranges.add_range("key_030", "key_040");
+  
+  ASSERT_EQ(2u, ranges.ranges.size());
+}
+
+TEST(ecomapremovedranges, add_range_overlapping)
+{
+  ECOmapRemovedRanges ranges(eversion_t(1, 1));
+  
+  ranges.add_range("key_010", "key_030");
+  ranges.add_range("key_020", "key_040");
+  
+  // Should merge into single range
+  ASSERT_EQ(1u, ranges.ranges.size());
+  EXPECT_EQ("key_010", ranges.ranges.front().first);
+  EXPECT_EQ("key_040", *ranges.ranges.front().second);
+}
+
+TEST(ecomapremovedranges, add_range_adjacent)
+{
+  ECOmapRemovedRanges ranges(eversion_t(1, 1));
+  
+  ranges.add_range("key_010", "key_020");
+  ranges.add_range("key_020", "key_030");
+  
+  // Should merge into single range
+  ASSERT_EQ(1u, ranges.ranges.size());
+  EXPECT_EQ("key_010", ranges.ranges.front().first);
+  EXPECT_EQ("key_030", *ranges.ranges.front().second);
+}
+
+TEST(ecomapremovedranges, add_range_with_nullopt_end)
+{
+  ECOmapRemovedRanges ranges(eversion_t(1, 1));
+  
+  ranges.add_range("key_010", "key_020");
+  ranges.add_range("key_015", std::nullopt);
+  
+  // Should merge and extend to end
+  ASSERT_EQ(1u, ranges.ranges.size());
+  EXPECT_EQ("key_010", ranges.ranges.front().first);
+  EXPECT_FALSE(ranges.ranges.front().second.has_value());
+}
+
+TEST(ecomapremovedranges, clear_omap_test)
+{
+  ECOmapRemovedRanges ranges(eversion_t(1, 1));
+  
+  ranges.add_range("key_010", "key_020");
+  ranges.add_range("key_030", "key_040");
+  ASSERT_EQ(2u, ranges.ranges.size());
+  
+  ranges.clear_omap();
+  
+  // Should replace all ranges with single range from "" to nullopt
+  ASSERT_EQ(1u, ranges.ranges.size());
+  EXPECT_EQ("", ranges.ranges.front().first);
+  EXPECT_FALSE(ranges.ranges.front().second.has_value());
+}
+
+TEST(ecomapheader, update_header_with_data)
+{
+  eversion_t v1(1, 1);
+  ceph::buffer::list bl1;
+  bl1.append("header1");
+  
+  ECOmapHeader header(v1, bl1);
+  ASSERT_EQ(v1, header.version);
+  ASSERT_TRUE(header.header.has_value());
+  
+  eversion_t v2(1, 2);
+  ceph::buffer::list bl2;
+  bl2.append("header2");
+  
+  header.update_header(v2, bl2);
+  ASSERT_EQ(v2, header.version);
+  ASSERT_TRUE(header.header.has_value());
+  ASSERT_EQ(bl2, *header.header);
+}
+
+TEST(ecomapheader, update_header_to_nullopt)
+{
+  eversion_t v1(1, 1);
+  ceph::buffer::list bl1;
+  bl1.append("header1");
+  
+  ECOmapHeader header(v1, bl1);
+  ASSERT_TRUE(header.header.has_value());
+  
+  eversion_t v2(1, 2);
+  header.update_header(v2, std::nullopt);
+  
+  ASSERT_EQ(v2, header.version);
+  ASSERT_FALSE(header.header.has_value());
+}
+
+TEST(ecomapheader, default_constructor)
+{
+  ECOmapHeader header;
+  ASSERT_EQ(eversion_t(), header.version);
+  ASSERT_FALSE(header.header.has_value());
+}
+
+TEST(ecomapjournal, remove_entry_on_empty_journal)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_empty", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  ECOmapJournalEntry entry(eversion_t(1, 1), false, std::nullopt, {});
+  bool res = journal.remove_entry(test_hoid, entry);
+  
+  ASSERT_FALSE(res);
+}
+
+TEST(ecomapjournal, remove_entry_by_version_on_empty_journal)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_empty2", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  bool res = journal.remove_entry_by_version(test_hoid, eversion_t(1, 1));
+  
+  ASSERT_FALSE(res);
+}
+
+TEST(ecomapjournal, get_value_updates_after_clear)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_clear_updates", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  journal.add_entry(test_hoid, create_insert_entry(eversion_t(1, 1), 1, 10));
+  journal.clear(test_hoid);
+  
+  auto [updates, ranges] = journal.get_value_updates(test_hoid);
+  ASSERT_TRUE(updates.empty());
+  ASSERT_TRUE(ranges.empty());
+}
+
+TEST(ecomapjournal, get_updated_header_after_clear)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_clear_header", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  ceph::buffer::list header_bl;
+  header_bl.append("header");
+  ECOmapJournalEntry entry(eversion_t(1, 1), false, header_bl, {});
+  journal.add_entry(test_hoid, entry);
+  
+  journal.clear(test_hoid);
+  
+  std::optional<ceph::buffer::list> header = journal.get_updated_header(test_hoid);
+  ASSERT_FALSE(header.has_value());
+}
+
+TEST(ecomapjournal, entries_with_empty_omap_updates)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_empty_updates", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  ECOmapJournalEntry entry(eversion_t(1, 1), false, std::nullopt, {});
+  journal.add_entry(test_hoid, entry);
+  
+  auto [updates, ranges] = journal.get_value_updates(test_hoid);
+  ASSERT_TRUE(updates.empty());
+  ASSERT_TRUE(ranges.empty());
+}
+
+TEST(ecomapjournal, trim_delete_nonexistent_version)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_trim_nonexist", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  journal.append_delete(test_hoid, 5, false);
+  
+  // Trim non-existent version should not crash
+  journal.trim_delete(test_hoid, 10);
+  
+  auto [gen, lost] = journal.get_generation(test_hoid);
+  ASSERT_EQ(5u, gen);
+}
+
+TEST(ecomapjournal, get_generation_after_clear_all)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_gen_clear_all", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  journal.append_delete(test_hoid, 5, false);
+  journal.clear_all();
+  
+  auto [gen, lost] = journal.get_generation(test_hoid);
+  ASSERT_EQ(UINT64_MAX, gen);
+  ASSERT_FALSE(lost);
+}
+
+TEST(ecomapjournal, multiple_objects_interleaved_operations)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t hoid1("obj1", CEPH_NOSNAP, 1, 0, "ns");
+  const hobject_t hoid2("obj2", CEPH_NOSNAP, 1, 0, "ns");
+  const hobject_t hoid3("obj3", CEPH_NOSNAP, 1, 0, "ns");
+  
+  // Interleave operations on multiple objects
+  journal.add_entry(hoid1, create_insert_entry(eversion_t(1, 1), 1, 10));
+  journal.add_entry(hoid2, create_insert_entry(eversion_t(1, 1), 20, 30));
+  journal.append_delete(hoid3, 5, false);
+  journal.add_entry(hoid1, create_insert_entry(eversion_t(1, 2), 11, 20));
+  journal.append_whiteout(hoid2);
+  
+  // Verify each object's state
+  ASSERT_EQ(2u, journal.entries_size(hoid1));
+  ASSERT_EQ(0u, journal.entries_size(hoid2));
+  ASSERT_EQ(0u, journal.entries_size(hoid3));
+  
+  auto [gen3, lost3] = journal.get_generation(hoid3);
+  ASSERT_EQ(5u, gen3);
+}
+
+TEST(ecomapjournal, iterator_over_empty_entries)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_iter_empty", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Check that hoid doesn't have unprocessed entries in the journal
+  ASSERT_FALSE(journal.has_unprocessed_entries(test_hoid));
+}
+
+TEST(ecomapjournal, iterator_over_single_entry)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_iter_single", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  ECOmapJournalEntry entry(eversion_t(1, 1), false, std::nullopt, {});
+  journal.add_entry(test_hoid, entry);
+  
+  auto begin = journal.begin_entries(test_hoid);
+  auto end = journal.end_entries(test_hoid);
+  
+  ASSERT_NE(begin, end);
+  ASSERT_EQ(entry.version, begin->version);
+  ASSERT_EQ(end, ++begin);
+}
+
+TEST(ecomapjournal, iterator_over_multiple_entries)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_iter_multiple", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  ECOmapJournalEntry entry2(eversion_t(1, 2), false, std::nullopt, {});
+  ECOmapJournalEntry entry3(eversion_t(1, 3), false, std::nullopt, {});
+  
+  journal.add_entry(test_hoid, entry1);
+  journal.add_entry(test_hoid, entry2);
+  journal.add_entry(test_hoid, entry3);
+  
+  int count = 0;
+  for (auto it = journal.begin_entries(test_hoid); it != journal.end_entries(test_hoid); ++it) {
+    count++;
+  }
+  
+  ASSERT_EQ(3, count);
+}
+
+TEST(ecomapjournal, has_omap_updates_empty_journal)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_has_omap_1", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Empty journal should return false
+  ASSERT_FALSE(journal.has_omap_updates(test_hoid));
+}
+
+TEST(ecomapjournal, has_omap_updates_with_unprocessed_entries)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_has_omap_2", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  journal.add_entry(test_hoid, entry1);
+  
+  // Journal with unprocessed entries should return true
+  ASSERT_TRUE(journal.has_omap_updates(test_hoid));
+}
+
+TEST(ecomapjournal, has_omap_updates_with_processed_key_map)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_has_omap_3", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Create an entry with key-value updates
+  std::map<std::string, ceph::buffer::list> key_value;
+  ceph::buffer::list val_bl;
+  encode("test_value", val_bl);
+  key_value["test_key"] = val_bl;
+  ceph::buffer::list map_bl;
+  encode(key_value, map_bl);
+  
+  ECOmapJournalEntry entry1(
+    eversion_t(1, 1), false, std::nullopt,
+    {std::pair(OmapUpdateType::Insert, map_bl)}
+  );
+  journal.add_entry(test_hoid, entry1);
+  
+  // Process entries by calling get_value_updates
+  auto [updates, ranges] = journal.get_value_updates(test_hoid);
+  
+  // Journal with processed key map should return true
+  ASSERT_TRUE(journal.has_omap_updates(test_hoid));
+  ASSERT_FALSE(updates.empty());
+}
+
+TEST(ecomapjournal, has_omap_updates_with_removed_ranges)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_has_omap_4", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Create an entry with range removal
+  std::string range_begin = "key_a";
+  std::string range_end = "key_z";
+  ceph::buffer::list range_bl;
+  encode(range_begin, range_bl);
+  encode(range_end, range_bl);
+  
+  ECOmapJournalEntry entry1(
+    eversion_t(1, 1), false, std::nullopt,
+    {std::pair(OmapUpdateType::RemoveRange, range_bl)}
+  );
+  journal.add_entry(test_hoid, entry1);
+  
+  // Process entries by calling get_value_updates
+  auto [updates, ranges] = journal.get_value_updates(test_hoid);
+  
+  // Journal with removed ranges should return true
+  ASSERT_TRUE(journal.has_omap_updates(test_hoid));
+  ASSERT_FALSE(ranges.empty());
+}
+
+TEST(ecomapjournal, has_omap_updates_with_header)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_has_omap_5", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  // Create an entry with header update
+  ceph::buffer::list header_bl;
+  encode("test_header", header_bl);
+  
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, header_bl, {});
+  journal.add_entry(test_hoid, entry1);
+  
+  // Process entries by calling get_updated_header
+  std::optional<ceph::buffer::list> header = journal.get_updated_header(test_hoid);
+  
+  // Journal with header update should return true
+  ASSERT_TRUE(journal.has_omap_updates(test_hoid));
+  ASSERT_TRUE(header.has_value());
+}
+
+TEST(ecomapjournal, has_omap_updates_after_clear)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_has_omap_6", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  journal.add_entry(test_hoid, entry1);
+  
+  // Should have updates before clear
+  ASSERT_TRUE(journal.has_omap_updates(test_hoid));
+  
+  journal.clear(test_hoid);
+  
+  // Should not have updates after clear
+  ASSERT_FALSE(journal.has_omap_updates(test_hoid));
+}
+
+TEST(ecomapjournal, has_omap_updates_different_objects)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid1("test_has_omap_7a", CEPH_NOSNAP, 1, 0, "test_namespace");
+  const hobject_t test_hoid2("test_has_omap_7b", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  journal.add_entry(test_hoid1, entry1);
+  
+  // Object with entry should return true
+  ASSERT_TRUE(journal.has_omap_updates(test_hoid1));
+  
+  // Different object without entry should return false
+  ASSERT_FALSE(journal.has_omap_updates(test_hoid2));
+}
+
+TEST(ecomapjournal, has_omap_updates_after_remove_entry)
+{
+  MockDoutPrefixProvider dpp;
+  ECOmapJournal journal(dpp);
+  const hobject_t test_hoid("test_has_omap_8", CEPH_NOSNAP, 1, 0, "test_namespace");
+  
+  ECOmapJournalEntry entry1(eversion_t(1, 1), false, std::nullopt, {});
+  ECOmapJournalEntry entry2(eversion_t(1, 2), false, std::nullopt, {});
+  
+  journal.add_entry(test_hoid, entry1);
+  journal.add_entry(test_hoid, entry2);
+  
+  ASSERT_TRUE(journal.has_omap_updates(test_hoid));
+  
+  // Remove one entry
+  journal.remove_entry(test_hoid, entry1);
+  
+  // Should still have updates (entry2 remains)
+  ASSERT_TRUE(journal.has_omap_updates(test_hoid));
+  
+  // Remove last entry
+  journal.remove_entry(test_hoid, entry2);
+  
+  // Should not have updates after removing all entries
+  ASSERT_FALSE(journal.has_omap_updates(test_hoid));
+}
\ No newline at end of file