Skip to content

Commit eaaadef

Browse files
committed
fix(cache): Apply OFFSET/LIMIT to cache hits and implement COUNT caching
Fix critical pagination bug and implement comprehensive COUNT query caching with metadata tracking for performance monitoring. Bug Fixes: - Fix SEARCH cache hits ignoring OFFSET/LIMIT parameters (search_handler.cpp:48-76) - Previously returned full cached results without pagination - Now applies ResultSorter::SortAndPaginate to cached results - Cache design: Store full results to support different OFFSET/LIMIT combinations - Implement COUNT query caching (search_handler.cpp:298-434) - Add cache lookup/insert for COUNT queries - Same LRU eviction and invalidation as SEARCH queries - Separate cache entries for COUNT vs SEARCH New Features: - Add cache metadata tracking infrastructure - CacheLookupResult structure with query_cost_ms and created_at - QueryCache::LookupWithMetadata() for metadata retrieval - CacheManager::LookupWithMetadata() API - Add cache performance metrics - cache_age_ms: Time since cache entry creation - cache_saved_ms: Query execution time saved by cache hit - Displayed in DEBUG mode for both SEARCH and COUNT - Enhance COUNT debug info (response_formatter.cpp:126-147) - Add cache status to COUNT query debug output - Consistent format with SEARCH queries Thread Safety: - LookupWithMetadata uses correct lock ordering (shared → unique) - Metadata copied under shared_lock before upgrade - created_at verification prevents use-after-free - No race conditions in cache hit path Tests: - Add integration_cache_pagination_test.cpp (12 tests) - Cache miss/hit with OFFSET/LIMIT - Multiple pagination combinations - Edge cases (offset beyond results, limit exceeding) - Cache management (clear, enable/disable) - Pagination consistency and metadata in debug mode - Add integration_cache_count_test.cpp (6 tests) - COUNT cache miss/hit - Different search terms - COUNT and SEARCH coexistence - Cache clear and disable - COUNT metadata in debug mode - All tests passing: 18/18 ✓ Changed Files: - src/server/handlers/search_handler.cpp - src/server/response_formatter.cpp - src/cache/cache_manager.{h,cpp} - src/cache/query_cache.{h,cpp} - tests/server/integration_cache_pagination_test.cpp (new) - tests/server/integration_cache_count_test.cpp (new) - tests/server/CMakeLists.txt
1 parent 3cf2f4c commit eaaadef

File tree

9 files changed

+1147
-12
lines changed

9 files changed

+1147
-12
lines changed

src/cache/cache_manager.cpp

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,40 @@ std::optional<std::vector<DocId>> CacheManager::Lookup(const query::Query& query
6464
return query_cache_->Lookup(key);
6565
}
6666

67+
std::optional<CacheLookupResult> CacheManager::LookupWithMetadata(const query::Query& query) {
68+
if (!enabled_ || !query_cache_) {
69+
return std::nullopt;
70+
}
71+
72+
// Only cache SEARCH and COUNT queries
73+
if (query.type != query::QueryType::SEARCH && query.type != query::QueryType::COUNT) {
74+
return std::nullopt;
75+
}
76+
77+
// Normalize query and generate cache key
78+
const std::string normalized = QueryNormalizer::Normalize(query);
79+
if (normalized.empty()) {
80+
return std::nullopt;
81+
}
82+
83+
const CacheKey key = CacheKeyGenerator::Generate(normalized);
84+
85+
// Lookup in cache with metadata
86+
QueryCache::LookupMetadata metadata;
87+
auto result = query_cache_->LookupWithMetadata(key, metadata);
88+
if (!result.has_value()) {
89+
return std::nullopt;
90+
}
91+
92+
// Package result with metadata
93+
CacheLookupResult lookup_result;
94+
lookup_result.results = std::move(result.value());
95+
lookup_result.query_cost_ms = metadata.query_cost_ms;
96+
lookup_result.created_at = metadata.created_at;
97+
98+
return lookup_result;
99+
}
100+
67101
bool CacheManager::Insert(const query::Query& query, const std::vector<DocId>& result,
68102
const std::set<std::string>& ngrams, double query_cost_ms) {
69103
if (!enabled_ || !query_cache_ || !invalidation_mgr_) {

src/cache/cache_manager.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ struct TableContext;
2323

2424
namespace mygramdb::cache {
2525

26+
/**
27+
* @brief Cache lookup result with metadata
28+
*/
29+
struct CacheLookupResult {
30+
std::vector<DocId> results; ///< Cached search results
31+
double query_cost_ms = 0.0; ///< Original query execution time
32+
std::chrono::steady_clock::time_point created_at; ///< When cache entry was created
33+
};
34+
2635
/**
2736
* @brief Unified cache manager
2837
*
@@ -62,6 +71,13 @@ class CacheManager {
6271
*/
6372
[[nodiscard]] std::optional<std::vector<DocId>> Lookup(const query::Query& query);
6473

74+
/**
75+
* @brief Lookup cached query result with metadata
76+
* @param query Parsed query
77+
* @return Cached result with metadata if found and valid, nullopt otherwise
78+
*/
79+
[[nodiscard]] std::optional<CacheLookupResult> LookupWithMetadata(const query::Query& query);
80+
6581
/**
6682
* @brief Insert query result into cache
6783
* @param query Parsed query

src/cache/query_cache.cpp

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,98 @@ std::optional<std::vector<DocId>> QueryCache::Lookup(const CacheKey& key) {
105105
return result;
106106
}
107107

108+
std::optional<std::vector<DocId>> QueryCache::LookupWithMetadata(const CacheKey& key, LookupMetadata& metadata) {
109+
// Start timing
110+
auto start_time = std::chrono::high_resolution_clock::now();
111+
112+
// Shared lock for read
113+
std::shared_lock lock(mutex_);
114+
115+
stats_.total_queries++;
116+
117+
auto iter = cache_map_.find(key);
118+
if (iter == cache_map_.end()) {
119+
stats_.cache_misses++;
120+
stats_.cache_misses_not_found++;
121+
122+
// Record miss latency
123+
auto end_time = std::chrono::high_resolution_clock::now();
124+
double miss_time_ms = std::chrono::duration<double, std::milli>(end_time - start_time).count();
125+
{
126+
std::lock_guard<std::mutex> timing_lock(stats_.timing_mutex_);
127+
stats_.total_cache_miss_time_ms += miss_time_ms;
128+
}
129+
130+
return std::nullopt;
131+
}
132+
133+
// Check invalidation flag
134+
if (iter->second.first.invalidated.load()) {
135+
stats_.cache_misses++;
136+
stats_.cache_misses_invalidated++;
137+
138+
// Record miss latency
139+
auto end_time = std::chrono::high_resolution_clock::now();
140+
double miss_time_ms = std::chrono::duration<double, std::milli>(end_time - start_time).count();
141+
{
142+
std::lock_guard<std::mutex> timing_lock(stats_.timing_mutex_);
143+
stats_.total_cache_miss_time_ms += miss_time_ms;
144+
}
145+
146+
return std::nullopt;
147+
}
148+
149+
// Cache hit
150+
stats_.cache_hits++;
151+
152+
// Decompress result and copy metadata before releasing lock
153+
const auto& entry = iter->second.first;
154+
std::vector<DocId> result;
155+
try {
156+
result = ResultCompressor::Decompress(entry.compressed, entry.original_size);
157+
} catch (const std::exception& e) {
158+
// Decompression failed, treat as miss
159+
stats_.cache_misses++;
160+
161+
// Record miss latency
162+
auto end_time = std::chrono::high_resolution_clock::now();
163+
double miss_time_ms = std::chrono::duration<double, std::milli>(end_time - start_time).count();
164+
{
165+
std::lock_guard<std::mutex> timing_lock(stats_.timing_mutex_);
166+
stats_.total_cache_miss_time_ms += miss_time_ms;
167+
}
168+
169+
return std::nullopt;
170+
}
171+
172+
// Copy metadata before releasing lock to avoid use-after-free
173+
metadata.query_cost_ms = entry.query_cost_ms;
174+
metadata.created_at = entry.metadata.created_at;
175+
176+
// Update access time (need to upgrade to unique lock)
177+
lock.unlock();
178+
std::unique_lock write_lock(mutex_);
179+
180+
// Re-check existence and verify it's the same entry (not a new entry with same key)
181+
iter = cache_map_.find(key);
182+
if (iter != cache_map_.end() && iter->second.first.metadata.created_at == metadata.created_at) {
183+
Touch(key);
184+
iter->second.first.metadata.last_accessed = std::chrono::steady_clock::now();
185+
iter->second.first.metadata.access_count++;
186+
187+
// Record hit latency and saved time
188+
auto end_time = std::chrono::high_resolution_clock::now();
189+
double hit_time_ms = std::chrono::duration<double, std::milli>(end_time - start_time).count();
190+
{
191+
std::lock_guard<std::mutex> timing_lock(stats_.timing_mutex_);
192+
stats_.total_cache_hit_time_ms += hit_time_ms;
193+
stats_.total_query_saved_time_ms += metadata.query_cost_ms; // Time saved by not re-executing
194+
}
195+
}
196+
197+
return result;
198+
}
199+
108200
bool QueryCache::Insert(const CacheKey& key, const std::vector<DocId>& result, const CacheMetadata& metadata,
109201
double query_cost_ms) {
110202
// Check if query cost meets threshold

src/cache/query_cache.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,29 @@ class QueryCache {
138138
QueryCache(QueryCache&&) = delete;
139139
QueryCache& operator=(QueryCache&&) = delete;
140140

141+
/**
142+
* @brief Cache lookup result with metadata
143+
*/
144+
struct LookupMetadata {
145+
double query_cost_ms = 0.0; ///< Original query execution time
146+
std::chrono::steady_clock::time_point created_at; ///< When cache entry was created
147+
};
148+
141149
/**
142150
* @brief Lookup cache entry
143151
* @param key Cache key
144152
* @return Decompressed result if found and not invalidated, nullopt otherwise
145153
*/
146154
[[nodiscard]] std::optional<std::vector<DocId>> Lookup(const CacheKey& key);
147155

156+
/**
157+
* @brief Lookup cache entry with metadata
158+
* @param key Cache key
159+
* @param[out] metadata Output parameter for cache metadata
160+
* @return Decompressed result if found and not invalidated, nullopt otherwise
161+
*/
162+
[[nodiscard]] std::optional<std::vector<DocId>> LookupWithMetadata(const CacheKey& key, LookupMetadata& metadata);
163+
148164
/**
149165
* @brief Insert cache entry
150166
* @param key Cache key

src/server/handlers/search_handler.cpp

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ std::string SearchHandler::HandleSearch(const query::Query& query, ConnectionCon
3434
// Try cache lookup first
3535
auto cache_lookup_start = std::chrono::high_resolution_clock::now();
3636
if (ctx_.cache_manager != nullptr && ctx_.cache_manager->IsEnabled()) {
37-
auto cached_result = ctx_.cache_manager->Lookup(query);
38-
if (cached_result.has_value()) {
37+
auto cached_lookup = ctx_.cache_manager->LookupWithMetadata(query);
38+
if (cached_lookup.has_value()) {
3939
// Cache hit! Return cached result
4040
storage::DocumentStore* current_doc_store = nullptr;
4141
index::Index* dummy_index = nullptr;
@@ -50,22 +50,29 @@ std::string SearchHandler::HandleSearch(const query::Query& query, ConnectionCon
5050
double cache_lookup_time_ms =
5151
std::chrono::duration<double, std::milli>(cache_lookup_end - cache_lookup_start).count();
5252

53+
// Apply pagination to cached results
54+
// Cache stores full results (before pagination) to allow different OFFSET/LIMIT on same query
55+
auto full_results = cached_lookup.value().results;
56+
size_t total_results = full_results.size();
57+
auto paginated_results = query::ResultSorter::SortAndPaginate(full_results, *current_doc_store, query);
58+
5359
if (conn_ctx.debug_mode) {
5460
query::DebugInfo debug_info;
5561
debug_info.query_time_ms = cache_lookup_time_ms;
56-
debug_info.final_results = cached_result.value().size();
62+
debug_info.final_results = paginated_results.size();
5763

58-
// Cache hit debug info
64+
// Cache hit debug info with actual metadata
65+
auto now = std::chrono::steady_clock::now();
5966
debug_info.cache_info.status = query::CacheDebugInfo::Status::HIT;
60-
debug_info.cache_info.cache_age_ms = 0.0; // TODO: Calculate from cache entry timestamp
61-
debug_info.cache_info.cache_saved_ms = 0.0; // TODO: Calculate from query cost in cache entry
67+
debug_info.cache_info.cache_age_ms =
68+
std::chrono::duration<double, std::milli>(now - cached_lookup.value().created_at).count();
69+
debug_info.cache_info.cache_saved_ms = cached_lookup.value().query_cost_ms;
6270

63-
return ResponseFormatter::FormatSearchResponse(cached_result.value(), cached_result.value().size(),
64-
current_doc_store, &debug_info);
71+
return ResponseFormatter::FormatSearchResponse(paginated_results, total_results, current_doc_store,
72+
&debug_info);
6573
}
6674

67-
return ResponseFormatter::FormatSearchResponse(cached_result.value(), cached_result.value().size(),
68-
current_doc_store);
75+
return ResponseFormatter::FormatSearchResponse(paginated_results, total_results, current_doc_store);
6976
}
7077
}
7178
}
@@ -290,6 +297,35 @@ std::string SearchHandler::HandleCount(const query::Query& query, ConnectionCont
290297
return ResponseFormatter::FormatError("Server is loading, please try again later");
291298
}
292299

300+
// Try cache lookup first
301+
auto cache_lookup_start = std::chrono::high_resolution_clock::now();
302+
if (ctx_.cache_manager != nullptr && ctx_.cache_manager->IsEnabled()) {
303+
auto cached_lookup = ctx_.cache_manager->LookupWithMetadata(query);
304+
if (cached_lookup.has_value()) {
305+
// Cache hit! Return count from cached result
306+
auto cache_lookup_end = std::chrono::high_resolution_clock::now();
307+
double cache_lookup_time_ms =
308+
std::chrono::duration<double, std::milli>(cache_lookup_end - cache_lookup_start).count();
309+
310+
if (conn_ctx.debug_mode) {
311+
query::DebugInfo debug_info;
312+
debug_info.query_time_ms = cache_lookup_time_ms;
313+
debug_info.final_results = cached_lookup.value().results.size();
314+
315+
// Cache hit debug info with actual metadata
316+
auto now = std::chrono::steady_clock::now();
317+
debug_info.cache_info.status = query::CacheDebugInfo::Status::HIT;
318+
debug_info.cache_info.cache_age_ms =
319+
std::chrono::duration<double, std::milli>(now - cached_lookup.value().created_at).count();
320+
debug_info.cache_info.cache_saved_ms = cached_lookup.value().query_cost_ms;
321+
322+
return ResponseFormatter::FormatCountResponse(cached_lookup.value().results.size(), &debug_info);
323+
}
324+
325+
return ResponseFormatter::FormatCountResponse(cached_lookup.value().results.size());
326+
}
327+
}
328+
293329
// Get table context
294330
index::Index* current_index = nullptr;
295331
storage::DocumentStore* current_doc_store = nullptr;
@@ -364,11 +400,37 @@ std::string SearchHandler::HandleCount(const query::Query& query, ConnectionCont
364400
results = ApplyFilters(results, query.filters, current_doc_store);
365401
}
366402

403+
// Calculate query execution time
404+
auto end_time = std::chrono::high_resolution_clock::now();
405+
double query_time_ms = std::chrono::duration<double, std::milli>(end_time - start_time).count();
406+
407+
// Store in cache if enabled
408+
if (ctx_.cache_manager != nullptr && ctx_.cache_manager->IsEnabled()) {
409+
// Collect all ngrams from term_infos
410+
std::set<std::string> all_ngrams;
411+
for (const auto& term_info : term_infos) {
412+
all_ngrams.insert(term_info.ngrams.begin(), term_info.ngrams.end());
413+
}
414+
415+
// Insert result into cache (COUNT caches the full result set like SEARCH)
416+
ctx_.cache_manager->Insert(query, results, all_ngrams, query_time_ms);
417+
}
418+
367419
// Calculate final debug info
368420
if (conn_ctx.debug_mode) {
369-
auto end_time = std::chrono::high_resolution_clock::now();
370-
debug_info.query_time_ms = std::chrono::duration<double, std::milli>(end_time - start_time).count();
421+
debug_info.query_time_ms = query_time_ms;
371422
debug_info.index_time_ms = std::chrono::duration<double, std::milli>(end_time - index_start).count();
423+
424+
// Cache debug info
425+
if (ctx_.cache_manager != nullptr && ctx_.cache_manager->IsEnabled()) {
426+
// Cache was enabled but missed (either not found or invalidated)
427+
debug_info.cache_info.status = query::CacheDebugInfo::Status::MISS_NOT_FOUND;
428+
debug_info.cache_info.query_cost_ms = query_time_ms;
429+
} else {
430+
// Cache disabled
431+
debug_info.cache_info.status = query::CacheDebugInfo::Status::MISS_DISABLED;
432+
}
433+
372434
return ResponseFormatter::FormatCountResponse(results.size(), &debug_info);
373435
}
374436

src/server/response_formatter.cpp

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,29 @@ std::string ResponseFormatter::FormatCountResponse(uint64_t count, const query::
122122
oss << "index_time: " << debug_info->index_time_ms << "ms\r\n";
123123
oss << "terms: " << debug_info->search_terms.size() << "\r\n";
124124
oss << "ngrams: " << debug_info->ngrams_used.size() << "\r\n";
125+
126+
// Cache debug information
127+
switch (debug_info->cache_info.status) {
128+
case query::CacheDebugInfo::Status::HIT:
129+
oss << "cache: hit\r\n";
130+
oss << "cache_age_ms: " << std::fixed << std::setprecision(3) << debug_info->cache_info.cache_age_ms << "\r\n";
131+
oss << "cache_saved_ms: " << std::fixed << std::setprecision(3) << debug_info->cache_info.cache_saved_ms
132+
<< "\r\n";
133+
break;
134+
case query::CacheDebugInfo::Status::MISS_NOT_FOUND:
135+
oss << "cache: miss (not found)\r\n";
136+
oss << "query_cost_ms: " << std::fixed << std::setprecision(3) << debug_info->cache_info.query_cost_ms
137+
<< "\r\n";
138+
break;
139+
case query::CacheDebugInfo::Status::MISS_INVALIDATED:
140+
oss << "cache: miss (invalidated)\r\n";
141+
break;
142+
case query::CacheDebugInfo::Status::MISS_DISABLED:
143+
oss << "cache: disabled\r\n";
144+
break;
145+
default:
146+
break;
147+
}
125148
}
126149

127150
return oss.str();

tests/server/CMakeLists.txt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,37 @@ target_link_libraries(request_dispatcher_test
210210
gtest_discover_tests(request_dispatcher_test
211211
PROPERTIES RESOURCE_LOCK server_port
212212
)
213+
214+
# Integration: Cache Pagination tests
215+
# Tests for SEARCH query caching with OFFSET/LIMIT
216+
217+
add_executable(integration_cache_pagination_test
218+
integration_cache_pagination_test.cpp
219+
)
220+
221+
target_link_libraries(integration_cache_pagination_test
222+
mygramdb_server
223+
GTest::gtest_main
224+
)
225+
226+
# RESOURCE_LOCK: Prevent parallel execution to avoid port binding and cache state conflicts
227+
gtest_discover_tests(integration_cache_pagination_test
228+
PROPERTIES RESOURCE_LOCK server_port
229+
)
230+
231+
# Integration: Cache COUNT tests
232+
# Tests for COUNT query caching
233+
234+
add_executable(integration_cache_count_test
235+
integration_cache_count_test.cpp
236+
)
237+
238+
target_link_libraries(integration_cache_count_test
239+
mygramdb_server
240+
GTest::gtest_main
241+
)
242+
243+
# RESOURCE_LOCK: Prevent parallel execution to avoid port binding and cache state conflicts
244+
gtest_discover_tests(integration_cache_count_test
245+
PROPERTIES RESOURCE_LOCK server_port
246+
)

0 commit comments

Comments
 (0)