docs/design-docs/design_docs/20260602-expression-result-cache.md
Expression Result Cache is a QueryNode-local cache for scalar filter expression bitmaps. It stores the full active-segment result bitmap produced by an expression and reuses it when the same expression is evaluated again on the same segment snapshot.
The cache is implemented in segcore through ExprResCacheManager, with two storage modes:
pread/pwrite, frequency and latency admission, per-segment Clock eviction, and global segment-file eviction.The feature is controlled by refreshable queryNode.exprCache parameters. It is disabled by default and does not change query semantics when disabled.
QueryNode can repeatedly evaluate the same scalar filter expression on the same segment. This happens in repeated search/query workloads, two-stage retrieval, text match filters, JSON path filters, and scalar index/statistics based predicates.
Some expressions are expensive because they need to:
Recomputing the same bitmap wastes CPU and increases tail latency. Caching the full-segment bitmap avoids this work when the effective expression and segment snapshot are unchanged.
queryNode:
exprCache:
enabled: false
mode: disk
minEvalDurationUs: 1000
admissionThreshold: 2
memory:
maxBytes: 268435456
compressionEnabled: true
disk:
maxBytes: 10737418240
maxFileSizeBytes: 268435456
| Key | Default | Hot-reload | Description |
|---|---|---|---|
queryNode.exprCache.enabled | false | yes | Enable expression result cache. |
queryNode.exprCache.mode | disk | yes | Cache backend: disk or memory. |
queryNode.exprCache.minEvalDurationUs | 1000 | yes | Skip caching expressions that evaluate faster than this threshold. 0 disables latency admission. |
queryNode.exprCache.admissionThreshold | 2 | yes | Frequency admission threshold shared by memory and disk modes. 1 disables frequency admission. |
queryNode.exprCache.memory.maxBytes | 268435456 | yes | Maximum memory budget for memory mode. |
queryNode.exprCache.memory.compressionEnabled | true | yes | Enable adaptive bitmap compression in memory mode. |
queryNode.exprCache.disk.maxBytes | 10737418240 | yes | Maximum logical used-slot disk budget for disk mode. |
queryNode.exprCache.disk.maxFileSizeBytes | 268435456 | yes | Maximum cache file size per sealed segment in disk mode. |
Go-side paramtable refresh propagates config into C++ through:
void SetExprResCacheEnable(bool val);
void SetExprResCacheConfig(const char* mode,
const char* disk_base_path,
int64_t mem_max_bytes,
bool compression_enabled,
int32_t admission_threshold,
int64_t mem_min_eval_duration_us,
int64_t disk_max_bytes,
int64_t disk_max_file_size,
int64_t disk_min_eval_duration_us);
ExprResCacheManager exposes a mode-independent API:
struct Key {
int64_t segment_id;
std::string signature;
};
struct Value {
std::shared_ptr<TargetBitmap> result;
std::shared_ptr<TargetBitmap> valid_result;
int64_t active_count;
size_t bytes;
int64_t eval_duration_us;
};
bool Get(const Key& key, Value& out_value);
void Put(const Key& key, const Value& value);
void Clear();
size_t EraseSegment(int64_t segment_id);
bool SetConfig(const CacheConfig& config);
Get requires the caller to set out_value.active_count before calling. The cache uses it to reject stale entries.
Expression execution
|
| ExprCacheHelper::GetOrCompute
| SegmentExpr::TryCacheGet / CachePut
| FilterBitsNode whole-filter cache
v
ExprResCacheManager
|
+-- Memory mode -> EntryPool
| +-- adaptive compression
| +-- latency and frequency admission
| +-- Clock eviction
|
+-- Disk mode -> DiskSlotFile per sealed segment
+-- fixed-size raw bitmap slots
+-- pread / pwrite
+-- per-file Clock eviction
+-- global segment-file Clock eviction
ExprResCacheManager owns mode selection, frequency admission, dynamic config rebuild, segment erasure, and usage metrics. Backend implementations focus on storage-specific behavior.
The external cache key is (segment_id, expression_signature).
The expression signature is normally expr->ToString() or this->ToString(). It must include every parameter that can affect the result:
The cached value stores:
Correctness depends on:
active_count;active_count.If active_count mismatches, the entry is treated as stale and the request falls back to normal expression evaluation.
Memory mode uses EntryPool.
It supports both sealed and growing segments. Internally, EntryPool keys entries by:
This isolates growing-segment snapshots with different active row counts.
Memory mode stores payloads in heap memory. CacheCompressor chooses the encoding:
| Bitmap pattern | Encoding |
|---|---|
| Sparse result bitmap | Roaring |
| Very dense result bitmap | Inverted Roaring |
| Medium-density bitmap | Raw bytes |
| Compression disabled | Raw bytes |
When the validity bitmap is all ones, memory mode records that state as metadata and avoids storing a separate validity payload.
Eviction uses Clock. Get takes a shared lock and updates an atomic usage counter; Put takes an exclusive lock and may evict entries until the memory budget is satisfied.
Disk mode uses DiskSlotFile. It is sealed-segment only because slot size is derived from the segment row count at file creation time.
File layout:
[FileHeader 64B][slot_0][slot_1]...[slot_N-1]
Slot layout:
[SlotHeader 17B][raw result bitmap][raw valid bitmap]
Each segment owns one cache file:
<localStorage.path>/cache/<nodeID>/expr_cache/seg_<segment_id>.cache
Disk files are temporary process-local cache files. Signature-to-slot metadata is kept in memory, so old .cache files are removed when disk config is applied or rebuilt.
If the same segment later appears with a different row count, the fixed slot file no longer matches the bitmap shape. The manager removes the file and marks the segment ineligible for disk caching until the segment or config is reset.
Disk mode has two size limits:
queryNode.exprCache.disk.maxFileSizeBytes limits one sealed segment file and determines how many fixed slots the segment file can hold.queryNode.exprCache.disk.maxBytes limits total logical used-slot bytes across disk cache files. The budget counts FileHeader + used_slots * slot_size, not the full preallocated file capacity.Within one DiskSlotFile, slot eviction uses Clock. Across segment files, Get hits and Put writes touch a segment-level Clock usage counter. After a disk Put, the manager checks total used-slot bytes. If usage exceeds disk.maxBytes, it scans segment files with a second-chance Clock policy and evicts whole segment files whose usage counter has decayed to zero, skipping the segment that was just written.
Two admission policies are applied before writing a new entry:
queryNode.exprCache.minEvalDurationUs.queryNode.exprCache.admissionThreshold times.Frequency admission is mode-independent and is owned by ExprResCacheManager, not by EntryPool, so memory and disk use the same policy.
Existing same-signature entries can be updated without re-running frequency admission. This allows refreshed snapshots for the same expression to update the cache after the expression has already been admitted.
All queryNode.exprCache parameters are refreshable.
Refresh behavior is conservative:
enabled=false disables future cache get/put operations.enabled=true first applies the current config, then enables cache access.SetConfig.SetConfig rebuilds the backend and clears existing cache entries.SetConfig removes old .cache files in the target cache directory.When thresholds such as admissionThreshold or minEvalDurationUs change, existing entries are not reinterpreted. They are dropped, and future evaluations repopulate the cache under the new policy.
The common integration path is ExprCacheHelper::GetOrCompute:
(segment_id, signature) key.Get.Put.For Volcano-style batched expressions, BatchedCachedMixin loads or computes the full-segment bitmap once, then slices it on later Eval() calls.
Current integration points include:
BinaryRangeExprTermExprJsonContainsExprExistsExprUnaryExpr TextMatch / PhraseMatchSegmentExpr::TryCacheGet and SegmentExpr::CachePutFilterBitsNodeIntegration rules:
Segment release can call:
EraseSegmentCache(segment_id)
In memory mode, this removes all entries for the segment from EntryPool.
In disk mode, this closes and deletes the segment cache file, clears the segment's ineligible marker, and removes the segment from global disk Clock metadata.
The cache reports usage through existing cachinglayer gauges:
| Metric | Meaning |
|---|---|
cache_loaded_bytes{cell_data_type="OTHER", storage_type="MEMORY"} | Current expression cache memory usage. |
cache_loaded_bytes{cell_data_type="OTHER", storage_type="DISK"} | Current expression cache disk used-slot logical bytes. |
ExprResCacheManager::SyncUsageMetrics tracks the last reported memory/disk bytes and updates gauges by delta. This avoids double counting.
Usage is synchronized after:
ExprResCacheManager uses:
state_mutex_ for config and active backend state;disk_files_mutex_ for the disk segment-file map;disk_clock_mutex_ for segment-level disk Clock metadata;enabled_;Get and Put re-check IsEnabled() after acquiring state_mutex_, preventing requests that entered before a refresh from using a backend after the cache has been disabled.
Backend concurrency:
EntryPool uses shared/exclusive locking around its entry index.DiskSlotFile uses a shared mutex around slot metadata and file operations.DiskSlotFile metadata under its shared mutex.The feature is disabled by default.
When disabled, cache manager operations return before building cache keys or taking backend locks. Most expression integrations therefore add only an atomic enabled check and a branch.
Integration code should avoid doing cache-only work when disabled. In particular:
The cache is best-effort and must not fail user queries.
Failure behavior:
Expression result cache and FilterBitsNode cache are different cache layers.
FilterBitsNode cache key: the whole filter expression plus dynamic filter context such as entity TTL physical time.Both can reuse ExprResCacheManager.
For two-stage search, QueryContext can allow whole-filter cache reads and writes while disabling sub-expression cache writes. This prevents caching both a full-filter bitmap and duplicate child-expression bitmaps for the same request path.
active_count.The feature is controlled by queryNode.exprCache.enabled, defaulting to false.
All config values are refreshable. Operators can enable, disable, or tune the cache without restarting QueryNode.
The feature does not change client-facing API or query semantics.
ExprResCacheManager basic put/get..cache file cleanup.SetConfig with get/put.FilterBitsNode tests to verify outer filter cache does not duplicate sub-expression entries.Current limitations:
Potential follow-ups:
ToString().