Skip to content

Commit cf070d3

Browse files
committed
refactor(scan): simplify snapshot cache eviction
1 parent 953ec59 commit cf070d3

9 files changed

Lines changed: 147 additions & 175 deletions

File tree

‎docs/source/api/scan.rst‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,17 @@ progress, executors and scan metrics remain independent. ``SetTableSchema()`` ke
8181
behavior: on main it bypasses the shared schema cache; on other branches it is ignored.
8282

8383
Successfully loaded snapshots are cached by their full file paths, with an LRU limit of 20 entries
84-
per resource object. Entries expire after 10 minutes without access or 30 minutes since their last
85-
cache write, whichever comes first. Repeated reads do not extend the write deadline. Expiration is
86-
checked during cache operations, without a background refresh thread. Snapshot caching is enabled
87-
by these resources; ordinary snapshot managers have no cache unless one is explicitly supplied.
84+
per resource object. The whole snapshot cache is replaced on the first access after it reaches
85+
30 minutes of age. Reads and writes do not extend this deadline; recently added entries are
86+
discarded along with older ones. There is no per-entry TTL or background refresh thread.
87+
Snapshot caching is enabled by these resources; ordinary snapshot managers have no cache unless
88+
one is explicitly supplied.
8889
Latest and earliest snapshot discovery and existence checks still use the catalog or file system
8990
as appropriate. Read or parse failures are not cached.
9091

91-
Snapshot deletion through a manager invalidates the corresponding entry in its injected cache.
92+
Snapshot deletion through a manager replaces its entire injected snapshot cache before and after
93+
the deletion attempt. Explicit invalidation also discards the whole cache. In-flight loads can
94+
finish against the old cache but cannot populate the replacement used by subsequent callers.
9295
Historical commit lookup, timestamp searches, and retained-snapshot publication checks read files
9396
directly so cached metadata cannot hide missing or replaced files. Other clients' deletions do not
9497
immediately invalidate this process's cache. A cached snapshot can remain available after its

‎include/paimon/table/source/table_scan_resources.h‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,15 @@ class TableScanResourcesAccess;
3636
/// the latest schema ID and chooses its own snapshot. Schema versions and schema-derived resources
3737
/// are retained for this object's lifetime without an entry limit. Statistics evolutions are also
3838
/// retained without an entry limit; each caches up to 64 dense-field mappings. Snapshots use a
39-
/// separate 20-entry LRU cache, expiring after 10 minutes without access or 30 minutes since the
40-
/// last cache write. Eviction releases cache references; active scans retain the metadata they
41-
/// use. Snapshot and dense-field cache limits count entries, not bytes. Latest snapshot discovery
42-
/// and existence checks still query the file system. A cached snapshot does not pin its metadata or
43-
/// data files. The caller must recreate this object after fast-forward, dropping and recreating a
44-
/// table or branch, or changing the file system's access configuration. Fast-forward can replace
45-
/// schema and snapshot contents under existing IDs; there is no immediate cross-client
46-
/// invalidation. Use the new resources for subsequent scans.
39+
/// separate 20-entry LRU cache. On access, the whole snapshot cache is replaced once it is
40+
/// 30 minutes old; reads and writes do not extend that deadline. Eviction releases cache
41+
/// references; active scans retain the metadata they use. Snapshot and dense-field cache limits
42+
/// count entries, not bytes. Latest snapshot discovery and existence checks still query the file
43+
/// system. A cached snapshot does not pin its metadata or data files. The caller must recreate this
44+
/// object after fast-forward, dropping and recreating a table or branch, or changing the file
45+
/// system's access configuration. Fast-forward can replace schema and snapshot contents under
46+
/// existing IDs; there is no immediate cross-client invalidation. Use the new resources for
47+
/// subsequent scans.
4748
///
4849
/// Concurrent scans may share these resources. The supplied file system and metadata memory pool
4950
/// must support concurrent use. Filters, executors, scan progress and scan metrics are not shared.

‎src/paimon/common/utils/generic_lru_cache.h‎

Lines changed: 24 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,9 @@ namespace paimon {
3838
/// A generic LRU cache with support for weight-based eviction, time-based expiration,
3939
/// and removal callbacks.
4040
///
41-
/// Uses std::list + unordered_map for O(1) lookup and LRU updates:
41+
/// Uses std::list + unordered_map for O(1) get/put/evict:
4242
/// - list stores entries in LRU order (most recently used at front)
4343
/// - map stores key -> list::iterator for O(1) lookup
44-
/// When write expiration is enabled, insertion and replacement also scan for expired entries.
4544
///
4645
/// @tparam K Key type
4746
/// @tparam V Value type
@@ -57,13 +56,12 @@ class GenericLruCache {
5756
enum class RemovalCause {
5857
EXPLICIT, // Removed by Invalidate() or InvalidateAll()
5958
SIZE, // Evicted because total weight exceeded max_weight
60-
EXPIRED, // Evicted because the entry expired
59+
EXPIRED, // Evicted because the entry expired (expireAfterAccess)
6160
REPLACED // Replaced by a new value for the same key via Put()
6261
};
6362

6463
using WeighFunc = std::function<int64_t(const K&, const V&)>;
6564
using RemovalCallback = std::function<void(const K&, const V&, RemovalCause)>;
66-
using Clock = std::function<std::chrono::steady_clock::time_point()>;
6765

6866
/// Configuration options for the cache.
6967
struct Options {
@@ -80,17 +78,9 @@ class GenericLruCache {
8078
/// Callback invoked when an entry is removed (evicted, invalidated, or replaced).
8179
/// If nullptr, no callback is invoked.
8280
RemovalCallback removal_callback = nullptr;
83-
84-
/// Time in milliseconds after insertion or Put() before an entry expires.
85-
/// Reads do not extend this deadline. A value < 0 disables expiration.
86-
int64_t expire_after_write_ms = -1;
8781
};
8882

89-
explicit GenericLruCache(Options options)
90-
: GenericLruCache(std::move(options), std::chrono::steady_clock::now) {}
91-
92-
GenericLruCache(Options options, Clock clock)
93-
: options_(std::move(options)), clock_(std::move(clock)) {}
83+
explicit GenericLruCache(Options options) : options_(std::move(options)) {}
9484

9585
/// Look up a key in the cache. On hit, promotes the entry to the front (most recently
9686
/// used) and updates its access time. Returns std::nullopt on miss or if the entry
@@ -102,17 +92,13 @@ class GenericLruCache {
10292

10393
/// Look up a key. On miss, load via the supplier, insert into cache, and return.
10494
/// If the supplier returns an error, the error is propagated and nothing is cached.
105-
/// Invalidation during a load prevents its result from being cached, but the result is
106-
/// still returned to the caller whose load was already in progress.
10795
Result<V> Get(const K& key, std::function<Result<V>(const K&)> supplier) {
108-
uint64_t generation;
10996
{
11097
std::unique_lock<std::shared_mutex> lock(mutex_);
11198
auto cached = FindPromoteOrExpire(key);
11299
if (cached.has_value()) {
113100
return std::move(cached.value());
114101
}
115-
generation = generation_;
116102
}
117103

118104
// Cache miss: load via supplier outside the lock
@@ -123,9 +109,6 @@ class GenericLruCache {
123109
}
124110

125111
std::unique_lock<std::shared_mutex> lock(mutex_);
126-
if (generation != generation_) {
127-
return value;
128-
}
129112
// Double-check: another thread may have inserted while we were loading
130113
auto cached = FindPromoteOrExpire(key);
131114
if (cached.has_value()) {
@@ -152,7 +135,6 @@ class GenericLruCache {
152135
auto it = lru_map_.find(key);
153136
if (it != lru_map_.end()) {
154137
if (ValuesEqual(it->second->value, value)) {
155-
it->second->last_write_time = clock_();
156138
Promote(it->second);
157139
return Status::OK();
158140
}
@@ -168,7 +150,6 @@ class GenericLruCache {
168150
/// Remove a specific entry. Invokes the EXPLICIT removal callback if the key exists.
169151
void Invalidate(const K& key) {
170152
std::unique_lock<std::shared_mutex> lock(mutex_);
171-
++generation_;
172153
auto it = lru_map_.find(key);
173154
if (it != lru_map_.end()) {
174155
RemoveEntry(it->second, RemovalCause::EXPLICIT);
@@ -178,7 +159,6 @@ class GenericLruCache {
178159
/// Remove all entries. Each entry's EXPLICIT removal callback is invoked.
179160
void InvalidateAll() {
180161
std::unique_lock<std::shared_mutex> lock(mutex_);
181-
++generation_;
182162
while (!lru_list_.empty()) {
183163
RemoveEntry(std::prev(lru_list_.end()), RemovalCause::EXPLICIT);
184164
}
@@ -208,7 +188,6 @@ class GenericLruCache {
208188
V value;
209189
int64_t weight;
210190
std::chrono::steady_clock::time_point last_access_time;
211-
std::chrono::steady_clock::time_point last_write_time;
212191
};
213192

214193
using EntryList = std::list<CacheEntry>;
@@ -223,7 +202,7 @@ class GenericLruCache {
223202
return std::nullopt;
224203
}
225204
auto list_it = it->second;
226-
if (IsExpired(*list_it, clock_())) {
205+
if (IsExpired(list_it->last_access_time)) {
227206
RemoveEntry(list_it, RemovalCause::EXPIRED);
228207
return std::nullopt;
229208
}
@@ -233,14 +212,14 @@ class GenericLruCache {
233212

234213
/// Move an entry to the front of the LRU list and update its access time.
235214
void Promote(typename EntryList::iterator list_it) {
236-
list_it->last_access_time = clock_();
215+
list_it->last_access_time = std::chrono::steady_clock::now();
237216
lru_list_.splice(lru_list_.begin(), lru_list_, list_it);
238217
}
239218

240219
/// Insert a new entry at the front of the LRU list.
241220
void InsertEntry(const K& key, V value, int64_t weight) {
242-
auto now = clock_();
243-
lru_list_.emplace_front(CacheEntry{key, std::move(value), weight, now, now});
221+
lru_list_.emplace_front(
222+
CacheEntry{key, std::move(value), weight, std::chrono::steady_clock::now()});
244223
lru_map_[key] = lru_list_.begin();
245224
current_weight_ += weight;
246225
}
@@ -271,8 +250,7 @@ class GenericLruCache {
271250
V old_value = std::move(list_it->value);
272251
list_it->value = std::move(new_value);
273252
list_it->weight = new_weight;
274-
list_it->last_access_time = clock_();
275-
list_it->last_write_time = list_it->last_access_time;
253+
list_it->last_access_time = std::chrono::steady_clock::now();
276254
current_weight_ += new_weight;
277255
lru_list_.splice(lru_list_.begin(), lru_list_, list_it);
278256

@@ -289,33 +267,25 @@ class GenericLruCache {
289267
InvokeCallback(key, value, cause);
290268
}
291269

292-
/// Evict expired entries, then evict by weight if still over capacity.
270+
/// Evict expired entries from the tail, then evict by weight if still over capacity.
293271
void EvictIfNeeded() {
294272
EvictExpired();
295273
while (current_weight_ > options_.max_weight && !lru_list_.empty()) {
296274
RemoveEntry(std::prev(lru_list_.end()), RemovalCause::SIZE);
297275
}
298276
}
299277

300-
/// Write deadlines are not ordered by LRU position, so write expiration requires a scan.
301-
/// With only access expiration, the oldest access time is at the tail.
278+
/// Evict expired entries from the tail of the LRU list.
279+
/// Since the tail has the oldest access time, we can stop as soon as we find
280+
/// a non-expired entry.
302281
void EvictExpired() {
303-
auto now = clock_();
304-
if (options_.expire_after_write_ms >= 0) {
305-
for (auto it = lru_list_.begin(); it != lru_list_.end();) {
306-
auto current = it++;
307-
if (IsExpired(*current, now)) {
308-
RemoveEntry(current, RemovalCause::EXPIRED);
309-
}
310-
}
311-
return;
312-
}
313282
if (options_.expire_after_access_ms < 0) {
314283
return;
315284
}
285+
auto now = std::chrono::steady_clock::now();
316286
while (!lru_list_.empty()) {
317287
auto it = std::prev(lru_list_.end());
318-
if (!IsExpired(*it, now)) {
288+
if (!IsExpired(it->last_access_time, now)) {
319289
break;
320290
}
321291
RemoveEntry(it, RemovalCause::EXPIRED);
@@ -330,16 +300,16 @@ class GenericLruCache {
330300
return 1;
331301
}
332302

333-
bool IsExpired(const CacheEntry& entry,
334-
const std::chrono::steady_clock::time_point& now) const {
335-
auto access_elapsed =
336-
std::chrono::duration_cast<std::chrono::milliseconds>(now - entry.last_access_time);
337-
auto write_elapsed =
338-
std::chrono::duration_cast<std::chrono::milliseconds>(now - entry.last_write_time);
339-
return (options_.expire_after_access_ms >= 0 &&
340-
access_elapsed.count() >= options_.expire_after_access_ms) ||
341-
(options_.expire_after_write_ms >= 0 &&
342-
write_elapsed.count() >= options_.expire_after_write_ms);
303+
/// Check if an entry has expired based on its last access time.
304+
bool IsExpired(
305+
const std::chrono::steady_clock::time_point& last_access_time,
306+
const std::chrono::steady_clock::time_point& now = std::chrono::steady_clock::now()) const {
307+
if (options_.expire_after_access_ms < 0) {
308+
return false;
309+
}
310+
auto elapsed =
311+
std::chrono::duration_cast<std::chrono::milliseconds>(now - last_access_time);
312+
return elapsed.count() >= options_.expire_after_access_ms;
343313
}
344314

345315
/// Invoke the removal callback if one is configured.
@@ -350,9 +320,6 @@ class GenericLruCache {
350320
}
351321

352322
Options options_;
353-
Clock clock_;
354-
// A single generation avoids retaining per-key state for loads that have not completed.
355-
uint64_t generation_ = 0;
356323
int64_t current_weight_ = 0;
357324
EntryList lru_list_;
358325
EntryMap lru_map_;

‎src/paimon/common/utils/generic_lru_cache_test.cpp‎

Lines changed: 0 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -68,80 +68,6 @@ TEST_F(GenericLruCacheTest, ConstructorAndDefaults) {
6868

6969
// ==================== GetIfPresent ====================
7070

71-
TEST_F(GenericLruCacheTest, WriteDeadlineSurvivesRepeatedAccess) {
72-
std::chrono::steady_clock::time_point now{};
73-
IntIntCache::Options options;
74-
options.expire_after_access_ms = 10;
75-
options.expire_after_write_ms = 30;
76-
IntIntCache cache(options, [&now]() { return now; });
77-
ASSERT_OK(cache.Put(1, 10));
78-
for (int32_t i = 0; i < 5; ++i) {
79-
now += std::chrono::milliseconds(5);
80-
ASSERT_EQ(cache.GetIfPresent(1), 10);
81-
}
82-
now += std::chrono::milliseconds(5);
83-
ASSERT_FALSE(cache.GetIfPresent(1));
84-
ASSERT_OK_AND_ASSIGN(int32_t reloaded,
85-
cache.Get(1, [](const int32_t&) -> Result<int32_t> { return 20; }));
86-
ASSERT_EQ(reloaded, 20);
87-
now += std::chrono::milliseconds(10);
88-
ASSERT_FALSE(cache.GetIfPresent(1));
89-
}
90-
91-
TEST_F(GenericLruCacheTest, PutRefreshesWriteDeadlineForEqualAndDifferentValues) {
92-
for (int32_t replacement : {10, 20}) {
93-
std::chrono::steady_clock::time_point now{};
94-
IntIntCache::Options options;
95-
options.expire_after_write_ms = 30;
96-
IntIntCache cache(options, [&now]() { return now; });
97-
ASSERT_OK(cache.Put(1, 10));
98-
now += std::chrono::milliseconds(20);
99-
ASSERT_OK(cache.Put(1, replacement));
100-
now += std::chrono::milliseconds(20);
101-
ASSERT_EQ(cache.GetIfPresent(1), replacement);
102-
now += std::chrono::milliseconds(10);
103-
ASSERT_FALSE(cache.GetIfPresent(1));
104-
}
105-
}
106-
107-
TEST_F(GenericLruCacheTest, WriteExpiredEntryIsEvictedEvenWhenMostRecentlyUsed) {
108-
std::chrono::steady_clock::time_point now{};
109-
IntIntCache::Options options;
110-
options.max_weight = 2;
111-
options.expire_after_write_ms = 30;
112-
IntIntCache cache(options, [&now]() { return now; });
113-
ASSERT_OK(cache.Put(1, 10));
114-
now += std::chrono::milliseconds(20);
115-
ASSERT_OK(cache.Put(2, 20));
116-
ASSERT_EQ(cache.GetIfPresent(1), 10);
117-
now += std::chrono::milliseconds(10);
118-
ASSERT_OK(cache.Put(3, 30));
119-
ASSERT_FALSE(cache.GetIfPresent(1));
120-
ASSERT_EQ(cache.GetIfPresent(2), 20);
121-
ASSERT_EQ(cache.GetIfPresent(3), 30);
122-
}
123-
124-
TEST_F(GenericLruCacheTest, InvalidationDuringLoadPreventsRefill) {
125-
for (bool invalidate_all : {false, true}) {
126-
IntIntCache cache(IntIntCache::Options{});
127-
ASSERT_OK_AND_ASSIGN(int32_t loaded,
128-
cache.Get(1, [&](const int32_t& key) -> Result<int32_t> {
129-
if (invalidate_all) {
130-
cache.InvalidateAll();
131-
} else {
132-
cache.Invalidate(key);
133-
}
134-
return 10;
135-
}));
136-
ASSERT_EQ(loaded, 10);
137-
ASSERT_FALSE(cache.GetIfPresent(1));
138-
ASSERT_OK_AND_ASSIGN(int32_t reloaded,
139-
cache.Get(1, [](const int32_t&) -> Result<int32_t> { return 20; }));
140-
ASSERT_EQ(reloaded, 20);
141-
ASSERT_EQ(cache.GetIfPresent(1), 20);
142-
}
143-
}
144-
14571
TEST_F(GenericLruCacheTest, GetIfPresentMissAndHit) {
14672
StringIntCache::Options options;
14773
StringIntCache cache(options);

‎src/paimon/core/operation/expire_snapshots_test.cpp‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,7 @@ TEST_F(ExpireSnapshotsTest, TestInvalidInput) {
230230
}
231231

232232
TEST_F(ExpireSnapshotsTest, CachedRetainedSnapshotDoesNotProvePublication) {
233-
auto cache = std::make_shared<SnapshotManager::SnapshotCache>(
234-
SnapshotManager::SnapshotCache::Options{/*max_weight=*/20});
233+
auto cache = std::make_shared<SnapshotManager::SnapshotCache>();
235234
auto manager = std::make_shared<SnapshotManager>(fs_, test_data_path_, "main", cache);
236235
ASSERT_OK(fs_->Mkdirs(manager->SnapshotDirectory()));
237236
for (int64_t id : {1, 2, 3}) {

‎src/paimon/core/table/source/table_scan_resources.cpp‎

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,6 @@
2828
#include "paimon/core/utils/field_mapping.h"
2929

3030
namespace paimon {
31-
namespace {
32-
33-
std::shared_ptr<SnapshotManager::SnapshotCache> CreateSnapshotCache() {
34-
SnapshotManager::SnapshotCache::Options options;
35-
options.max_weight = 20;
36-
options.expire_after_access_ms = 10 * 60 * 1000;
37-
options.expire_after_write_ms = 30 * 60 * 1000;
38-
return std::make_shared<SnapshotManager::SnapshotCache>(options);
39-
}
40-
41-
} // namespace
42-
4331
Result<std::shared_ptr<TableScanResources>> TableScanResources::Create(
4432
const std::string& table_path, const std::shared_ptr<FileSystem>& file_system,
4533
const std::string& branch, const std::shared_ptr<MemoryPool>& memory_pool) {
@@ -72,8 +60,8 @@ TableScanResources::Impl::Impl(const std::string& path, const std::string& branc
7260
file_system_(file_system),
7361
memory_pool_(memory_pool),
7462
schema_manager_(std::make_shared<SchemaManager>(file_system, path, branch)),
75-
snapshot_manager_(
76-
std::make_shared<SnapshotManager>(file_system, path, branch, CreateSnapshotCache())) {}
63+
snapshot_manager_(std::make_shared<SnapshotManager>(
64+
file_system, path, branch, std::make_shared<SnapshotManager::SnapshotCache>())) {}
7765

7866
Status TableScanResources::Impl::Validate(
7967
const std::string& scan_path, const std::string& scan_branch,

0 commit comments

Comments
 (0)