diff --git a/src/common-tests/CMakeLists.txt b/src/common-tests/CMakeLists.txt
index ebd07e84d..fbd05bda7 100644
--- a/src/common-tests/CMakeLists.txt
+++ b/src/common-tests/CMakeLists.txt
@@ -12,6 +12,7 @@ add_executable(common-tests
gsvector_yuvtorgb_test.cpp
hash_tests.cpp
heap_array_tests.cpp
+ lru_cache_tests.cpp
misc_tests.cpp
path_tests.cpp
rectangle_tests.cpp
diff --git a/src/common-tests/common-tests.vcxproj b/src/common-tests/common-tests.vcxproj
index b1d22082e..4fd9de3a0 100644
--- a/src/common-tests/common-tests.vcxproj
+++ b/src/common-tests/common-tests.vcxproj
@@ -8,6 +8,7 @@
+
@@ -39,4 +40,4 @@
-
\ No newline at end of file
+
diff --git a/src/common-tests/common-tests.vcxproj.filters b/src/common-tests/common-tests.vcxproj.filters
index 9db1e8e85..074a6d511 100644
--- a/src/common-tests/common-tests.vcxproj.filters
+++ b/src/common-tests/common-tests.vcxproj.filters
@@ -13,7 +13,8 @@
+
-
\ No newline at end of file
+
diff --git a/src/common-tests/lru_cache_tests.cpp b/src/common-tests/lru_cache_tests.cpp
new file mode 100644
index 000000000..720c308ca
--- /dev/null
+++ b/src/common-tests/lru_cache_tests.cpp
@@ -0,0 +1,252 @@
+// SPDX-FileCopyrightText: 2019-2026 Connor McLaughlin
+// SPDX-License-Identifier: CC-BY-NC-ND-4.0
+
+#include "common/lru_cache.h"
+
+#include
+
+#include
+#include
+#include
+#include
+
+namespace {
+struct TrackedValue
+{
+ TrackedValue(int value_, int* destruction_count_) : value(value_), destruction_count(destruction_count_) {}
+ ~TrackedValue() { (*destruction_count)++; }
+
+ int value;
+ int* destruction_count;
+};
+
+struct PoolDeleter
+{
+ void operator()(std::unique_ptr&& value) const { pool->push_back(std::move(value)); }
+
+ std::vector>* pool;
+};
+} // namespace
+
+TEST(LRUCache, InsertLookupClearAndCapacityAccessors)
+{
+ LRUCache cache(2);
+ EXPECT_EQ(cache.GetSize(), 0u);
+ EXPECT_EQ(cache.GetMaxCapacity(), 2u);
+ EXPECT_EQ(cache.Lookup(1), nullptr);
+
+ int* value = cache.Insert(1, 10);
+ ASSERT_NE(value, nullptr);
+ EXPECT_EQ(*value, 10);
+ EXPECT_EQ(cache.GetSize(), 1u);
+
+ value = cache.Lookup(1);
+ ASSERT_NE(value, nullptr);
+ EXPECT_EQ(*value, 10);
+
+ cache.Clear();
+ EXPECT_EQ(cache.GetSize(), 0u);
+ EXPECT_EQ(cache.Lookup(1), nullptr);
+}
+
+TEST(LRUCache, LookupUpdatesLeastRecentlyUsedOrder)
+{
+ LRUCache cache(2);
+ cache.Insert(1, 10);
+ cache.Insert(2, 20);
+ ASSERT_NE(cache.Lookup(1), nullptr);
+
+ cache.Insert(3, 30);
+ EXPECT_NE(cache.Lookup(1), nullptr);
+ EXPECT_EQ(cache.Lookup(2), nullptr);
+ EXPECT_NE(cache.Lookup(3), nullptr);
+}
+
+TEST(LRUCache, LookupDistinguishesMissingRawPointerFromCachedNullPointer)
+{
+ LRUCache cache(1);
+ EXPECT_EQ(cache.Lookup(1), nullptr);
+
+ int** inserted_value = cache.Insert(1, nullptr);
+ ASSERT_NE(inserted_value, nullptr);
+ EXPECT_EQ(*inserted_value, nullptr);
+
+ int** cached_value = cache.Lookup(1);
+ ASSERT_NE(cached_value, nullptr);
+ EXPECT_EQ(*cached_value, nullptr);
+}
+
+TEST(LRUCache, SetMaxCapacityEvictsLeastRecentlyUsedItems)
+{
+ LRUCache cache(3);
+ cache.Insert(1, 10);
+ cache.Insert(2, 20);
+ cache.Insert(3, 30);
+ ASSERT_NE(cache.Lookup(1), nullptr);
+
+ cache.SetMaxCapacity(2);
+ EXPECT_EQ(cache.GetMaxCapacity(), 2u);
+ EXPECT_EQ(cache.GetSize(), 2u);
+ EXPECT_NE(cache.Lookup(1), nullptr);
+ EXPECT_EQ(cache.Lookup(2), nullptr);
+ EXPECT_NE(cache.Lookup(3), nullptr);
+
+ cache.SetMaxCapacity(0);
+ EXPECT_EQ(cache.GetMaxCapacity(), 0u);
+ EXPECT_EQ(cache.GetSize(), 0u);
+}
+
+TEST(LRUCache, EvictRemovesRequestedNumberOfItems)
+{
+ LRUCache cache(4);
+ cache.Insert(1, 10);
+ cache.Insert(2, 20);
+ cache.Insert(3, 30);
+ cache.Insert(4, 40);
+ ASSERT_NE(cache.Lookup(1), nullptr);
+
+ cache.Evict(0);
+ EXPECT_EQ(cache.GetSize(), 4u);
+
+ cache.Evict(2);
+ EXPECT_EQ(cache.GetSize(), 2u);
+ EXPECT_NE(cache.Lookup(1), nullptr);
+ EXPECT_EQ(cache.Lookup(2), nullptr);
+ EXPECT_EQ(cache.Lookup(3), nullptr);
+ EXPECT_NE(cache.Lookup(4), nullptr);
+
+ cache.Evict(10);
+ EXPECT_EQ(cache.GetSize(), 0u);
+ cache.Evict();
+ EXPECT_EQ(cache.GetSize(), 0u);
+}
+
+TEST(LRUCache, RemoveAndRemoveMatchingItems)
+{
+ LRUCache cache(5);
+ for (int i = 1; i <= 5; i++)
+ cache.Insert(i, i * 10);
+
+ EXPECT_FALSE(cache.Remove(6));
+ EXPECT_TRUE(cache.Remove(1));
+ EXPECT_FALSE(cache.Remove(1));
+ EXPECT_EQ(cache.RemoveMatchingItems([](int key) { return (key % 2) == 0; }), 2u);
+ EXPECT_EQ(cache.RemoveMatchingItems([](int) { return false; }), 0u);
+
+ EXPECT_EQ(cache.GetSize(), 2u);
+ EXPECT_NE(cache.Lookup(3), nullptr);
+ EXPECT_NE(cache.Lookup(5), nullptr);
+}
+
+TEST(LRUCache, ManualEvictionCanTemporarilyExceedCapacity)
+{
+ LRUCache cache(2, true);
+ cache.Insert(1, 10);
+ cache.Insert(2, 20);
+ cache.Insert(3, 30);
+ EXPECT_EQ(cache.GetSize(), 3u);
+
+ ASSERT_NE(cache.Lookup(1), nullptr);
+ cache.ManualEvict();
+ EXPECT_EQ(cache.GetSize(), 2u);
+ EXPECT_NE(cache.Lookup(1), nullptr);
+ EXPECT_EQ(cache.Lookup(2), nullptr);
+ EXPECT_NE(cache.Lookup(3), nullptr);
+
+ cache.SetManualEvict(true);
+ cache.Insert(4, 40);
+ EXPECT_EQ(cache.GetSize(), 3u);
+ ASSERT_NE(cache.Lookup(1), nullptr);
+ cache.SetManualEvict(false);
+ EXPECT_EQ(cache.GetSize(), 2u);
+ EXPECT_NE(cache.Lookup(1), nullptr);
+ EXPECT_EQ(cache.Lookup(3), nullptr);
+ EXPECT_NE(cache.Lookup(4), nullptr);
+}
+
+TEST(LRUCache, ApplyVisitsAndCanModifyEveryItem)
+{
+ LRUCache cache(3);
+ cache.Insert(3, 30);
+ cache.Insert(1, 10);
+ cache.Insert(2, 20);
+
+ std::vector visited_keys;
+ cache.Apply([&visited_keys](const int& key, int& value) {
+ visited_keys.push_back(key);
+ value += key;
+ });
+
+ EXPECT_EQ(visited_keys, (std::vector{1, 2, 3}));
+ EXPECT_EQ(*cache.Lookup(1), 11);
+ EXPECT_EQ(*cache.Lookup(2), 22);
+ EXPECT_EQ(*cache.Lookup(3), 33);
+}
+
+TEST(LRUCache, StringKeysSupportHeterogeneousLookupAndRemoval)
+{
+ LRUCache cache(2);
+ cache.Insert("first", 1);
+ cache.Insert("second", 2);
+
+ const std::string_view first_key = "first";
+ ASSERT_NE(cache.Lookup(first_key), nullptr);
+ EXPECT_EQ(*cache.Lookup(first_key), 1);
+ EXPECT_TRUE(cache.Remove(first_key));
+ EXPECT_EQ(cache.Lookup(first_key), nullptr);
+ EXPECT_EQ(cache.GetSize(), 1u);
+}
+
+TEST(LRUCache, DefaultDeleterDeletesRawPointers)
+{
+ int destruction_count = 0;
+ {
+ LRUCache cache(1);
+ cache.Insert(1, new TrackedValue(1, &destruction_count));
+ cache.Insert(2, new TrackedValue(2, &destruction_count));
+ EXPECT_EQ(destruction_count, 1);
+
+ EXPECT_TRUE(cache.Remove(2));
+ EXPECT_EQ(destruction_count, 2);
+ }
+ EXPECT_EQ(destruction_count, 2);
+}
+
+TEST(LRUCache, DefaultDeleterAllowsUniquePointersToDestroyNormally)
+{
+ int destruction_count = 0;
+ {
+ LRUCache> cache(1);
+ cache.Insert(1, std::make_unique(1, &destruction_count));
+ cache.Insert(1, std::make_unique(2, &destruction_count));
+
+ EXPECT_EQ(cache.GetSize(), 1u);
+ EXPECT_EQ(destruction_count, 1);
+ }
+ EXPECT_EQ(destruction_count, 2);
+}
+
+TEST(LRUCache, CustomDeleterCanPoolUniquePointers)
+{
+ int destruction_count = 0;
+ std::vector> pool;
+ {
+ LRUCache, PoolDeleter> cache(2, false, PoolDeleter{&pool});
+ cache.Insert(1, std::make_unique(1, &destruction_count));
+ cache.Insert(2, std::make_unique(2, &destruction_count));
+ cache.Insert(1, std::make_unique(3, &destruction_count));
+
+ ASSERT_EQ(pool.size(), 1u);
+ EXPECT_EQ(pool.front()->value, 1);
+ EXPECT_NE(cache.Lookup(2), nullptr);
+ EXPECT_EQ(destruction_count, 0);
+
+ cache.Evict();
+ cache.Clear();
+ EXPECT_EQ(pool.size(), 3u);
+ EXPECT_EQ(destruction_count, 0);
+ }
+
+ pool.clear();
+ EXPECT_EQ(destruction_count, 3);
+}
diff --git a/src/common/lru_cache.h b/src/common/lru_cache.h
index addc05e75..6e8332a6c 100644
--- a/src/common/lru_cache.h
+++ b/src/common/lru_cache.h
@@ -3,10 +3,24 @@
#pragma once
#include "heterogeneous_containers.h"
+#include "types.h"
#include
#include