You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ga...@apache.org on 2026/08/21 08:44:42 UTC

(iceberg-cpp) branch main updated: feat: add lru cache (#891)

This is an automated email from the ASF dual-hosted git repository.

wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new 53f6a3e3 feat: add lru cache (#891)
53f6a3e3 is described below

commit 53f6a3e31b4ef71ec9483d62cb9e129e9ee6dcef
Author: Zehua Zou <ze...@gmail.com>
AuthorDate: Fri Aug 21 16:44:37 2026 +0800

    feat: add lru cache (#891)
---
 LICENSE                                |   1 +
 src/iceberg/delete_file_index.cc       |  98 +++++++---------
 src/iceberg/manifest/manifest_group.cc |  58 ++++------
 src/iceberg/test/CMakeLists.txt        |   1 +
 src/iceberg/test/cache_test.cc         | 113 ++++++++++++++++++
 src/iceberg/test/lazy_test.cc          |   8 ++
 src/iceberg/test/meson.build           |   1 +
 src/iceberg/util/cache_internal.h      | 201 +++++++++++++++++++++++++++++++++
 src/iceberg/util/lazy.h                |   6 +-
 9 files changed, 385 insertions(+), 102 deletions(-)

diff --git a/LICENSE b/LICENSE
index 8d8d5ff4..d0429e24 100644
--- a/LICENSE
+++ b/LICENSE
@@ -215,6 +215,7 @@ License: Public Domain
 This product includes code from Apache Arrow.
 
 * Core utilities:
+  * LRU cache and memoization utilities in src/iceberg/util/cache_internal.h.
   * checked_cast utility in src/iceberg/util/checked_cast.h.
   * FnOnce utility in src/iceberg/util/functional.h.
   * visit_type utility in src/iceberg/util/visit_type.h.
diff --git a/src/iceberg/delete_file_index.cc b/src/iceberg/delete_file_index.cc
index 634ab63b..a8c4ef12 100644
--- a/src/iceberg/delete_file_index.cc
+++ b/src/iceberg/delete_file_index.cc
@@ -22,9 +22,7 @@
 #include <algorithm>
 #include <cstdint>
 #include <iterator>
-#include <mutex>
 #include <ranges>
-#include <shared_mutex>
 #include <vector>
 
 #include "iceberg/expression/expression.h"
@@ -38,6 +36,7 @@
 #include "iceberg/metrics/scan_report.h"
 #include "iceberg/partition_spec.h"
 #include "iceberg/schema.h"
+#include "iceberg/util/cache_internal.h"
 #include "iceberg/util/checked_cast.h"
 #include "iceberg/util/content_file_util.h"
 #include "iceberg/util/executor_util_internal.h"
@@ -543,12 +542,6 @@ DeleteFileIndex::Builder& DeleteFileIndex::Builder::WithScanMetrics(
 }
 
 Result<std::vector<ManifestEntry>> DeleteFileIndex::Builder::LoadDeleteFiles() {
-  // TODO(zehua): Replace with a thread-safe LRU cache.
-  std::shared_mutex projected_expr_cache_mutex;
-  std::unordered_map<int32_t, std::shared_ptr<Expression>> projected_expr_cache;
-  std::shared_mutex eval_cache_mutex;
-  std::unordered_map<int32_t, std::unique_ptr<ManifestEvaluator>> eval_cache;
-
   auto data_filter = ignore_residuals_ ? True::Instance() : data_filter_;
 
   auto and_filters =
@@ -560,59 +553,47 @@ Result<std::vector<ManifestEntry>> DeleteFileIndex::Builder::LoadDeleteFiles() {
     return right ? std::move(right) : std::move(left);
   };
 
-  auto get_projected_expr = [&](int32_t spec_id,
-                                const std::shared_ptr<PartitionSpec>& spec)
-      -> Result<std::shared_ptr<Expression>> {
-    if (!data_filter_) {
-      return std::shared_ptr<Expression>();
-    }
-
-    {
-      std::shared_lock lock(projected_expr_cache_mutex);
-      auto iter = projected_expr_cache.find(spec_id);
-      if (iter != projected_expr_cache.end()) {
-        return iter->second;
-      }
-    }
+  const auto cache_capacity = static_cast<int32_t>(specs_by_id_.size());
 
-    std::lock_guard lock(projected_expr_cache_mutex);
-    auto iter = projected_expr_cache.find(spec_id);
-    if (iter != projected_expr_cache.end()) {
-      return iter->second;
-    }
+  auto get_projected_expr = internal::MemoizeLru(
+      [this](int32_t spec_id) -> Result<std::shared_ptr<Expression>> {
+        if (!data_filter_) {
+          return std::shared_ptr<Expression>();
+        }
 
-    auto projector = Projections::Inclusive(*spec, *schema_, case_sensitive_);
-    ICEBERG_ASSIGN_OR_RAISE(auto projected, projector->Project(data_filter_));
-    auto [inserted_iter, _] = projected_expr_cache.emplace(spec_id, std::move(projected));
-    return inserted_iter->second;
-  };
+        auto spec_iter = specs_by_id_.find(spec_id);
+        ICEBERG_CHECK(spec_iter != specs_by_id_.cend(),
+                      "Partition spec ID {} not found when projecting data filter",
+                      spec_id);
 
-  auto get_manifest_evaluator =
-      [&](int32_t spec_id, const std::shared_ptr<PartitionSpec>& spec,
-          const std::shared_ptr<Expression>& filter) -> Result<ManifestEvaluator*> {
-    if (!filter) {
-      return nullptr;
-    }
+        auto projector =
+            Projections::Inclusive(*spec_iter->second, *schema_, case_sensitive_);
+        ICEBERG_ASSIGN_OR_RAISE(auto projected, projector->Project(data_filter_));
+        return projected;
+      },
+      cache_capacity);
 
-    {
-      std::shared_lock lock(eval_cache_mutex);
-      auto iter = eval_cache.find(spec_id);
-      if (iter != eval_cache.end()) {
-        return iter->second.get();
-      }
-    }
+  auto get_manifest_evaluator = internal::MemoizeLru(
+      [this, &and_filters, &get_projected_expr](
+          int32_t spec_id) -> Result<std::shared_ptr<ManifestEvaluator>> {
+        auto spec_iter = specs_by_id_.find(spec_id);
+        ICEBERG_CHECK(spec_iter != specs_by_id_.cend(),
+                      "Partition spec ID {} not found when creating manifest evaluator",
+                      spec_id);
 
-    std::lock_guard lock(eval_cache_mutex);
-    auto iter = eval_cache.find(spec_id);
-    if (iter != eval_cache.end()) {
-      return iter->second.get();
-    }
+        ICEBERG_ASSIGN_OR_RAISE(auto projected_data_filter, get_projected_expr(spec_id));
+        ICEBERG_ASSIGN_OR_RAISE(auto filter,
+                                and_filters(partition_filter_, projected_data_filter));
+        if (!filter) {
+          return std::shared_ptr<ManifestEvaluator>();
+        }
 
-    ICEBERG_ASSIGN_OR_RAISE(auto evaluator, ManifestEvaluator::MakePartitionFilter(
-                                                filter, spec, *schema_, case_sensitive_));
-    auto [inserted_iter, _] = eval_cache.emplace(spec_id, std::move(evaluator));
-    return inserted_iter->second.get();
-  };
+        ICEBERG_ASSIGN_OR_RAISE(
+            auto evaluator, ManifestEvaluator::MakePartitionFilter(
+                                filter, spec_iter->second, *schema_, case_sensitive_));
+        return std::shared_ptr<ManifestEvaluator>(std::move(evaluator));
+      },
+      cache_capacity);
 
   return ParallelCollect(
       executor_, delete_manifests_,
@@ -634,13 +615,10 @@ Result<std::vector<ManifestEntry>> DeleteFileIndex::Builder::LoadDeleteFiles() {
 
         const auto& spec = spec_iter->second;
 
-        ICEBERG_ASSIGN_OR_RAISE(auto projected_data_filter,
-                                get_projected_expr(spec_id, spec));
+        ICEBERG_ASSIGN_OR_RAISE(auto projected_data_filter, get_projected_expr(spec_id));
         ICEBERG_ASSIGN_OR_RAISE(auto delete_partition_filter,
                                 and_filters(partition_filter_, projected_data_filter));
-        ICEBERG_ASSIGN_OR_RAISE(
-            auto manifest_evaluator,
-            get_manifest_evaluator(spec_id, spec, delete_partition_filter));
+        ICEBERG_ASSIGN_OR_RAISE(auto manifest_evaluator, get_manifest_evaluator(spec_id));
         if (manifest_evaluator != nullptr) {
           ICEBERG_ASSIGN_OR_RAISE(auto should_match,
                                   manifest_evaluator->Evaluate(manifest));
diff --git a/src/iceberg/manifest/manifest_group.cc b/src/iceberg/manifest/manifest_group.cc
index 3db0a1df..02a51b11 100644
--- a/src/iceberg/manifest/manifest_group.cc
+++ b/src/iceberg/manifest/manifest_group.cc
@@ -21,8 +21,6 @@
 
 #include <algorithm>
 #include <memory>
-#include <mutex>
-#include <shared_mutex>
 #include <string>
 #include <unordered_set>
 #include <utility>
@@ -42,6 +40,7 @@
 #include "iceberg/schema.h"
 #include "iceberg/table_scan.h"
 #include "iceberg/type.h"
+#include "iceberg/util/cache_internal.h"
 #include "iceberg/util/checked_cast.h"
 #include "iceberg/util/content_file_util.h"
 #include "iceberg/util/executor_util_internal.h"
@@ -376,42 +375,25 @@ Result<std::unique_ptr<ManifestReader>> ManifestGroup::MakeReader(
 
 Result<std::unordered_map<int32_t, std::vector<ManifestEntry>>>
 ManifestGroup::ReadEntries() {
-  // TODO(zehua): Replace with a thread-safe LRU cache.
-  std::shared_mutex eval_cache_mutex;
-  std::unordered_map<int32_t, std::unique_ptr<ManifestEvaluator>> eval_cache;
-
-  auto get_manifest_evaluator = [&](int32_t spec_id) -> Result<ManifestEvaluator*> {
-    {
-      std::shared_lock lock(eval_cache_mutex);
-      auto iter = eval_cache.find(spec_id);
-      if (iter != eval_cache.end()) {
-        return iter->second.get();
-      }
-    }
-
-    std::lock_guard lock(eval_cache_mutex);
-    auto iter = eval_cache.find(spec_id);
-    if (iter != eval_cache.end()) {
-      return iter->second.get();
-    }
-
-    auto spec_iter = specs_by_id_.find(spec_id);
-    ICEBERG_CHECK(spec_iter != specs_by_id_.cend(),
-                  "Cannot find partition spec for ID {}", spec_id);
-
-    const auto& spec = spec_iter->second;
-    auto projector = Projections::Inclusive(*spec, *schema_, case_sensitive_);
-    ICEBERG_ASSIGN_OR_RAISE(auto partition_filter, projector->Project(data_filter_));
-    ICEBERG_ASSIGN_OR_RAISE(partition_filter,
-                            And::Make(partition_filter, partition_filter_));
-    ICEBERG_ASSIGN_OR_RAISE(
-        auto manifest_evaluator,
-        ManifestEvaluator::MakePartitionFilter(std::move(partition_filter), spec,
-                                               *schema_, case_sensitive_));
-    eval_cache[spec_id] = std::move(manifest_evaluator);
-
-    return eval_cache[spec_id].get();
-  };
+  const auto cache_capacity = static_cast<int32_t>(specs_by_id_.size());
+  auto get_manifest_evaluator = internal::MemoizeLru(
+      [this](int32_t spec_id) -> Result<std::shared_ptr<ManifestEvaluator>> {
+        auto spec_iter = specs_by_id_.find(spec_id);
+        ICEBERG_CHECK(spec_iter != specs_by_id_.cend(),
+                      "Cannot find partition spec for ID {}", spec_id);
+
+        auto projector =
+            Projections::Inclusive(*spec_iter->second, *schema_, case_sensitive_);
+        ICEBERG_ASSIGN_OR_RAISE(auto partition_filter, projector->Project(data_filter_));
+        ICEBERG_ASSIGN_OR_RAISE(partition_filter,
+                                And::Make(partition_filter, partition_filter_));
+        ICEBERG_ASSIGN_OR_RAISE(
+            auto evaluator, ManifestEvaluator::MakePartitionFilter(
+                                std::move(partition_filter), spec_iter->second, *schema_,
+                                case_sensitive_));
+        return std::shared_ptr<ManifestEvaluator>(std::move(evaluator));
+      },
+      cache_capacity);
 
   const bool has_file_filter =
       file_filter_ && file_filter_->op() != Expression::Operation::kTrue;
diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt
index b3e1bdd5..8c1d43cf 100644
--- a/src/iceberg/test/CMakeLists.txt
+++ b/src/iceberg/test/CMakeLists.txt
@@ -126,6 +126,7 @@ add_iceberg_test(util_test
                  SOURCES
                  base64_test.cc
                  bucket_util_test.cc
+                 cache_test.cc
                  config_test.cc
                  content_file_util_test.cc
                  data_file_set_test.cc
diff --git a/src/iceberg/test/cache_test.cc b/src/iceberg/test/cache_test.cc
new file mode 100644
index 00000000..fbc29ab9
--- /dev/null
+++ b/src/iceberg/test/cache_test.cc
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#include <atomic>
+#include <functional>
+#include <memory>
+#include <string>
+
+#include <gtest/gtest.h>
+
+#include "iceberg/test/executor.h"
+#include "iceberg/util/cache_internal.h"
+#include "iceberg/util/task_group.h"
+
+namespace iceberg::internal {
+
+TEST(LruCacheTest, Eviction) {
+  LruCache<int32_t, std::string> cache(2);
+
+  EXPECT_TRUE(cache.Replace(1, "one").first);
+  EXPECT_TRUE(cache.Replace(2, "two").first);
+  ASSERT_NE(cache.Find(1), nullptr);
+
+  EXPECT_TRUE(cache.Replace(3, "three").first);
+  EXPECT_EQ(cache.Find(2), nullptr);
+  ASSERT_NE(cache.Find(1), nullptr);
+  EXPECT_EQ(*cache.Find(1), "one");
+  ASSERT_NE(cache.Find(3), nullptr);
+  EXPECT_EQ(*cache.Find(3), "three");
+}
+
+TEST(MemoizeLruTest, Caches) {
+  std::atomic<int> calls{0};
+  auto memoized = MemoizeLru(
+      [&calls](int32_t key) {
+        ++calls;
+        return std::to_string(key);
+      },
+      2);
+
+  EXPECT_EQ(memoized(1), "1");
+  EXPECT_EQ(memoized(2), "2");
+  EXPECT_EQ(memoized(1), "1");
+  EXPECT_EQ(calls, 2);
+
+  EXPECT_EQ(memoized(3), "3");
+  EXPECT_EQ(memoized(2), "2");
+  EXPECT_EQ(calls, 4);
+}
+
+TEST(MemoizeLruTest, MovesKey) {
+  auto memoized = MemoizeLru([](const std::unique_ptr<int32_t>& key) { return *key; }, 2);
+  EXPECT_EQ(memoized(std::make_unique<int32_t>(1)), 1);
+
+  auto memoized_thread_unsafe =
+      MemoizeLruThreadUnsafe([](const std::unique_ptr<int32_t>& key) { return *key; }, 2);
+  EXPECT_EQ(memoized_thread_unsafe(std::make_unique<int32_t>(2)), 2);
+}
+
+TEST(MemoizeLruTest, ThreadUnsafe) {
+  int32_t calls = 0;
+  auto memoized = MemoizeLruThreadUnsafe(
+      [&calls](int32_t key) {
+        ++calls;
+        return std::to_string(key);
+      },
+      2);
+
+  EXPECT_EQ(memoized(1), "1");
+  EXPECT_EQ(memoized(1), "1");
+  EXPECT_EQ(calls, 1);
+}
+
+TEST(MemoizeLruTest, Threads) {
+  auto memoized = MemoizeLru([](int32_t key) { return key * 2; }, 4);
+  std::atomic<bool> mismatch{false};
+  test::ThreadExecutor executor;
+  TaskGroup group;
+  group.SetExecutor(std::ref(executor));
+
+  for (int32_t thread_id = 0; thread_id < 4; ++thread_id) {
+    group.Submit([&] -> Status {
+      for (int32_t i = 0; i < 100; ++i) {
+        const int32_t key = i % 8;
+        if (memoized(key) != key * 2) {
+          mismatch.store(true, std::memory_order_relaxed);
+        }
+      }
+      return {};
+    });
+  }
+
+  ASSERT_TRUE(std::move(group).Run().has_value());
+  EXPECT_FALSE(mismatch.load(std::memory_order_relaxed));
+}
+
+}  // namespace iceberg::internal
diff --git a/src/iceberg/test/lazy_test.cc b/src/iceberg/test/lazy_test.cc
index 04eac680..59f711ee 100644
--- a/src/iceberg/test/lazy_test.cc
+++ b/src/iceberg/test/lazy_test.cc
@@ -66,3 +66,11 @@ TEST(LazyTest, ReusesInitializationError) {
   EXPECT_THAT(second, iceberg::IsError(iceberg::ErrorKind::kInvalid));
   EXPECT_THAT(second, iceberg::HasErrorMessage("init failed"));
 }
+
+TEST(LazyTest, SupportsLambda) {
+  const iceberg::Lazy<[](int value) -> iceberg::Result<int> { return value; }> lazy;
+
+  auto result = lazy.Get(42);
+  ASSERT_THAT(result, iceberg::IsOk());
+  EXPECT_EQ(result->get(), 42);
+}
diff --git a/src/iceberg/test/meson.build b/src/iceberg/test/meson.build
index 6dde45ee..87b31790 100644
--- a/src/iceberg/test/meson.build
+++ b/src/iceberg/test/meson.build
@@ -100,6 +100,7 @@ iceberg_tests = {
         'sources': files(
             'base64_test.cc',
             'bucket_util_test.cc',
+            'cache_test.cc',
             'config_test.cc',
             'content_file_util_test.cc',
             'data_file_set_test.cc',
diff --git a/src/iceberg/util/cache_internal.h b/src/iceberg/util/cache_internal.h
new file mode 100644
index 00000000..d82a93b8
--- /dev/null
+++ b/src/iceberg/util/cache_internal.h
@@ -0,0 +1,201 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Borrowed the file from Apache Arrow:
+// https://github.com/apache/arrow/blob/main/cpp/src/arrow/util/cache_internal.h
+
+#pragma once
+
+/// \file iceberg/util/cache_internal.h
+/// \brief Provide internal LRU cache and memoization helpers.
+
+#include <cstdint>
+#include <functional>
+#include <list>
+#include <mutex>
+#include <type_traits>
+#include <unordered_map>
+#include <utility>
+
+#include "iceberg/util/macros.h"
+
+namespace iceberg::internal {
+
+// A LRU (Least recently used) replacement cache
+template <typename Key, typename Value>
+class LruCache {
+ public:
+  explicit LruCache(int32_t capacity) : capacity_(capacity) {
+    // The map size can temporarily exceed the cache capacity, see Replace()
+    map_.reserve(capacity_ + 1);
+  }
+
+  LruCache(const LruCache&) = delete;
+  LruCache& operator=(const LruCache&) = delete;
+  LruCache(LruCache&&) noexcept = default;
+  LruCache& operator=(LruCache&&) noexcept = default;
+
+  void Clear() {
+    items_.clear();
+    map_.clear();
+    // The C++ spec doesn't tell whether map_.clear() will shrink the map capacity
+    map_.reserve(capacity_ + 1);
+  }
+
+  int32_t size() const {
+    ICEBERG_DCHECK(items_.size() == map_.size(), "Cache item and map sizes differ");
+    return static_cast<int32_t>(items_.size());
+  }
+
+  template <typename K>
+  Value* Find(K&& key) {
+    const auto it = map_.find(key);
+    if (it == map_.end()) {
+      return nullptr;
+    }
+
+    // Found => move item at front of the list
+    auto list_it = it->second;
+    items_.splice(items_.begin(), items_, list_it);
+    return &list_it->value;
+  }
+
+  template <typename K, typename V>
+  std::pair<bool, Value*> Replace(K&& key, V&& value) {
+    // Try to insert temporary iterator
+    auto [it, inserted] = map_.emplace(std::forward<K>(key), ListIt{});
+    if (inserted) {
+      // Inserted => push item at front of the list, and update iterator
+      items_.emplace_front(&it->first, std::forward<V>(value));
+      it->second = items_.begin();
+      // Did we exceed the cache capacity?  If so, remove least recently used item
+      if (static_cast<int32_t>(items_.size()) > capacity_) {
+        const bool erased = map_.erase(*items_.back().key);
+        ICEBERG_DCHECK(erased, "Failed to erase least recently used cache item");
+        static_cast<void>(erased);
+        items_.pop_back();
+      }
+      return {true, &it->second->value};
+    }
+
+    // Already exists => move item at front of the list, and update value
+    auto list_it = it->second;
+    items_.splice(items_.begin(), items_, list_it);
+    list_it->value = std::forward<V>(value);
+    return {false, &list_it->value};
+  }
+
+ private:
+  struct Item {
+    // Pointer to the key inside the unordered_map
+    const Key* key;
+    Value value;
+  };
+  using List = std::list<Item>;
+  using ListIt = typename List::iterator;
+
+  const int32_t capacity_;
+  // In most to least recently used order
+  std::list<Item> items_;
+  std::unordered_map<Key, ListIt> map_;
+};
+
+template <typename Key, typename Value, typename Cache, typename Func>
+struct ThreadSafeMemoizer {
+  using RetType = Value;
+
+  template <typename F>
+  ThreadSafeMemoizer(F&& func, int32_t cache_capacity)
+      : func_(std::forward<F>(func)), cache_(cache_capacity) {}
+
+  // The memoizer can't return a pointer to the cached value, because
+  // the cache entry may be evicted by another thread.
+  template <typename K>
+  RetType operator()(K&& key) {
+    std::unique_lock<std::mutex> lock(mutex_);
+    if (const Value* value_ptr = cache_.Find(key); value_ptr != nullptr) {
+      return *value_ptr;
+    }
+    lock.unlock();
+    Value value = func_(key);
+    lock.lock();
+    return *cache_.Replace(std::forward<K>(key), std::move(value)).second;
+  }
+
+ private:
+  std::mutex mutex_;
+  Func func_;
+  Cache cache_;
+};
+
+template <typename Key, typename Value, typename Cache, typename Func>
+struct ThreadUnsafeMemoizer {
+  using RetType = const Value&;
+
+  template <typename F>
+  ThreadUnsafeMemoizer(F&& func, int32_t cache_capacity)
+      : func_(std::forward<F>(func)), cache_(cache_capacity) {}
+
+  template <typename K>
+  RetType operator()(K&& key) {
+    if (const Value* value_ptr = cache_.Find(key); value_ptr != nullptr) {
+      return *value_ptr;
+    }
+    Value value = func_(key);
+    return *cache_.Replace(std::forward<K>(key), std::move(value)).second;
+  }
+
+ private:
+  Func func_;
+  Cache cache_;
+};
+
+template <typename T>
+struct unary_traits;
+
+template <typename R, typename Arg>
+struct unary_traits<std::function<R(Arg)>> {
+  using arg = Arg;
+  using return_type = R;
+};
+
+template <template <typename...> class MemoizerType, typename Func>
+auto Memoize(Func&& func, int32_t cache_capacity) {
+  using Function = decltype(std::function{std::forward<Func>(func)});
+  using Key = std::decay_t<typename unary_traits<Function>::arg>;
+  using Value = std::decay_t<std::invoke_result_t<Func, const Key&>>;
+  using Memoizer = MemoizerType<Key, Value, LruCache<Key, Value>, Func>;
+  return Memoizer(std::forward<Func>(func), cache_capacity);
+}
+
+// Apply a LRU memoization cache to a callable.
+template <typename Func>
+auto MemoizeLru(Func&& func, int32_t cache_capacity) {
+  return Memoize<ThreadSafeMemoizer>(std::forward<Func>(func), cache_capacity);
+}
+
+// Like MemoizeLru, but not thread-safe.  This version allows for much faster
+// lookups (more than 2x faster), but you'll have to manage thread safety yourself.
+// A recommended usage is to declare per-thread caches using `thread_local`.
+template <typename Func>
+auto MemoizeLruThreadUnsafe(Func&& func, int32_t cache_capacity) {
+  return Memoize<ThreadUnsafeMemoizer>(std::forward<Func>(func), cache_capacity);
+}
+
+}  // namespace iceberg::internal
diff --git a/src/iceberg/util/lazy.h b/src/iceberg/util/lazy.h
index b31cbc78..0dad2f7e 100644
--- a/src/iceberg/util/lazy.h
+++ b/src/iceberg/util/lazy.h
@@ -34,10 +34,8 @@ namespace iceberg {
 
 template <auto InitFunc>
 class Lazy {
-  template <typename R, typename... Args>
-  static R ExtractReturnType(R (*)(Args...));  // only declaration, never defined
-
-  using T = ResultValueT<decltype(ExtractReturnType(InitFunc))>;
+  using InitResult = typename decltype(std::function{InitFunc})::result_type;
+  using T = ResultValueT<InitResult>;
 
  public:
   template <typename... Args>