Skip to content

Warm: Eager Loading + Tiered Cache#22265

Open
vishwasgarg18 wants to merge 12 commits into
opensearch-project:mainfrom
vishwasgarg18:tiered-block-cache-vishwas
Open

Warm: Eager Loading + Tiered Cache#22265
vishwasgarg18 wants to merge 12 commits into
opensearch-project:mainfrom
vishwasgarg18:tiered-block-cache-vishwas

Conversation

@vishwasgarg18

Copy link
Copy Markdown
Contributor

Description

[Describe what this change achieves]

Related Issues

Resolves #[Issue number to be closed when this PR is merged]

Check List

  • Functionality includes testing.
  • API changes companion pull request created, if applicable.
  • Public documentation issue/PR created, if applicable.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

PR Code Analyzer ❗

AI-powered 'Code-Diff-Analyzer' found issues on commit e5fe9ad.

PathLineSeverityDescription
sandbox/libs/dataformat-native/rust/Cargo.lock2841highDependency lockfile updated with new crates: arrow, arrow-array, arrow-schema, datafusion, parquet, url, bytes added to opensearch-tiered-storage. Registry artifact authenticity cannot be verified from the diff; maintainers must confirm checksums match expected crates.io values.
sandbox/libs/tiered-storage/src/main/rust/Cargo.toml22highNew dev-dependencies added: parquet, arrow, arrow-array, arrow-schema, datafusion, url. Supply chain mandatory flag: dependency additions must be verified regardless of apparent legitimacy.
sandbox/plugins/analytics-backend-datafusion/rust/Cargo.toml28highNew dependencies added: bytes and opensearch-tiered-storage. Mandatory flag for all dependency changes per review policy.
sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/foyer_cache.rs18highNew foyer imports: AdmitAll and StorageFilter added. These control cache eviction policy — AdmitAll reinsertion means entries are never evicted by the disk reclaimer. While documented as intentional for the metadata tier, this could mask data retention of sensitive cached content across restarts.
sandbox/libs/tiered-storage/src/main/rust/src/ffm.rs248mediumNew unsafe FFM function ts_put_metadata accepts raw pointer arrays from Java (data_ptrs, data_lens, ranges_ptr) with no bounds validation beyond null checks. Malformed inputs from the Java side could cause out-of-bounds memory reads in the Rust layer, though this appears to be an internal JNI boundary rather than an externally reachable attack surface.

The table above displays the top 10 most important findings.

Total: 5 | Critical: 0 | High: 4 | Medium: 1 | Low: 0


Pull Requests Author(s): Please update your Pull Request according to the report above.

Repository Maintainer(s): You can bypass diff analyzer by adding label skip-diff-analyzer after reviewing the changes carefully, then re-run failed actions. To re-enable the analyzer, remove the label, then re-run all actions.


⚠️ Note: The Code-Diff-Analyzer helps protect against potentially harmful code patterns. Please ensure you have thoroughly reviewed the changes beforehand.

Thanks.

@vishwasgarg18 vishwasgarg18 changed the title Tiered block cache vishwas Tiered block cache Jun 21, 2026
@vishwasgarg18 vishwasgarg18 force-pushed the tiered-block-cache-vishwas branch from 13612e7 to 43ac44d Compare June 21, 2026 21:46
Bukhtawar and others added 10 commits June 22, 2026 03:17
…instances

Introduces TieredBlockCache: a two-tier SSD block cache that physically
separates Parquet metadata from column data.

Components:
- TieredBlockCache: routes get() to probe metadata Foyer first, then data
  Foyer. put() → data Foyer only. put_metadata() → metadata Foyer with
  configurable max_metadata_entry_size bound (default 10MB, dynamic).
- FoyerCache: added reinsertion_admit_all parameter for AdmitAll filter
  on the metadata instance. Added HybridCache::close() on drop to flush
  partial blocks to SSD (fixes entries < block_size surviving restart).
- BlockCache trait: added put_metadata() with default fallback to put().
- FFM: foyer_create_tiered_cache, foyer_update_max_metadata_entry_size.
- 11 unit tests validating routing, isolation, key alignment.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
Integrates the TieredBlockCache with TieredObjectStore and adds the warmup
path for metadata population:

TieredObjectStore changes:
- get_opts: probes cache via try_serve_from_cache() for range reads.
  Resolves Bounded/Suffix/Offset ranges to absolute offsets.
  Serves hits from metadata Foyer (put there by warmup). No auto-populate.
- get_ranges: unchanged (probe → fetch misses → put to data Foyer).
- put_metadata(path, ranges, data): stores bytes directly into metadata Foyer.
- ts_put_metadata FFM entry point for Java warmup.

Warmup (custom_cache_manager):
- warmup_file_with_store(): footer-only to heap (PageIndexPolicy::Skip),
  computes global page index range (matches parquet crate range_for_page_index),
  fetches index bytes through store, returns for promotion to metadata Foyer.
- compute_page_index_range(): extracted helper, folds ALL columns × ALL RGs.
- df_cache_manager_add_files_with_store FFM for warm-node init.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
…quet + DataFusion

Comprehensive integration tests proving correctness across init, query,
delete, and restart lifecycles:

End-to-end production path:
- production_warmup_then_query_from_cache_only: warmup → SQL → delete file → SQL from cache
- restart_with_file_deleted_query_succeeds_from_foyer_only: cross-session Foyer recovery
- datafusion_query_succeeds_from_cache_after_local_file_deleted: key alignment proof

Cache isolation:
- metadata_routed_to_metadata_cache_data_to_data_cache
- get_opts_probe_does_not_pollute_metadata_foyer
- data_pressure_does_not_evict_metadata

Page index correctness:
- page_index_key_alignment_warmup_matches_query_time: global range serves from cache
- page_index_ranges_match_parquet_crate_computation: fold covers all column ranges

Robustness:
- concurrent_shard_warmup_does_not_corrupt: 5 parallel warmups
- metadata_foyer_capacity_breach_graceful_degradation: no panics under pressure
- suffix_fetch_resolves_and_hits_cache: GetRange::Suffix resolution
- metadata_served_from_ssd_not_local_fs: file deleted, cache serves
- metadata_survives_restart_via_foyer_recovery: byte-for-byte recovery

Shared test helpers: compute_global_page_index_range, setup_df_session,
write_page_indexed_parquet.

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
…ted API

1. FoyerCache::drop(): add 5s timeout to HybridCache::close() to prevent
   indefinite blocking if the flusher is stuck during shutdown.

2. TieredObjectStore::resolve_range(): log debug warning when file_size is
   unavailable for Suffix/Offset resolution (cache bypassed silently before).

3. TieredBlockCache::put_metadata(): detect metadata Foyer at capacity and
   route new entries to data_cache (evictable) as graceful degradation.
   Also respects max_metadata_entry_size bound (10MB default, dynamic).
   New FFM: foyer_update_max_metadata_entry_size.

4. fetch_footer_to_heap(): document panic contract (block_on not async-safe).

5. Integration tests: migrate deprecated API, add two new tests:
   - oversized_metadata_routes_to_data_cache: entry > limit → data cache
   - metadata_cache_full_routes_new_entries_to_data_cache: capacity → data cache

Total: 90 tests passing (22 integration + 68 unit).

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
…ectStore

Adds Java-side FFM bindings for the warm-node metadata warmup path:

1. NativeBridge.cacheManagerAddFilesWithStore(runtime, storePtr, files)
   - Calls df_cache_manager_add_files_with_store FFM
   - Reads metadata through TieredObjectStore (routes to S3 for warm nodes)
   - Footer → heap cache; page indexes → fetched for Foyer promotion

2. TieredStorageBridge.putMetadata(storePtr, path, ranges, dataArrays)
   - Calls ts_put_metadata FFM
   - Stores byte ranges directly into metadata Foyer (never-evict tier)

3. DataFusionService.onFilesAddedWithStore(filePaths, storeBoxPtr)
   - New method for warm-node warmup
   - Uses TieredObjectStore instead of LocalFileSystem

Java warmup sequence for warm nodes:
  long boxPtr = TieredStorageBridge.getObjectStoreBoxPtr(storePtr);
  dataFusionService.onFilesAddedWithStore(files, boxPtr);
  TieredStorageBridge.putMetadata(storePtr, path, ranges, bytes);
  TieredStorageBridge.destroyObjectStoreBoxPtr(boxPtr);

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
Add percentage-based settings for data/metadata cache split and separate
stats reporting for each Foyer cache tier in the node stats REST response.

Settings:
- block_cache.foyer.metadata_cache_ratio (default: 5%)
- block_cache.foyer.metadata_block_size (default: 8mb)

Stats:
- BlockCacheTieredStats record with per-tier counters
- data_cache_stats / metadata_cache_stats in block_cache JSON
- Version-gated wire format (V_3_8_0)

Signed-off-by: Bukhtawar Khan <bukhtawa@amazon.com>
Signed-off-by: Vishwas Garg <vishwasgarg14@gmail.com>
Signed-off-by: Vishwas Garg <vishwasgarg14@gmail.com>
Signed-off-by: Vishwas Garg <vishwasgarg14@gmail.com>
Signed-off-by: Vishwas Garg <vishwasgarg14@gmail.com>
@vishwasgarg18 vishwasgarg18 force-pushed the tiered-block-cache-vishwas branch from 43ac44d to e5fe9ad Compare June 21, 2026 21:47
@vishwasgarg18 vishwasgarg18 changed the title Tiered block cache Warm: Eager Loading + Tiered Cache Jun 22, 2026
@nishchay21 nishchay21 added the skip-diff-analyzer Maintainer to skip code-diff-analyzer check, after reviewing issues in AI analysis. label Jun 22, 2026
@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit f91c4b5)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Possible Issue

In the tiered constructor, diskBytes is set to dataDiskBytes + metaDiskBytes, but the single-cache constructor sets diskBytes directly from the parameter. If foyerStats() is called on a tiered instance, it passes diskBytes (the sum) to snapshotTiered, which then uses dataDiskBytes and metadataDiskBytes for the per-tier capacities. However, the single-cache path passes diskBytes to snapshot() which uses it as the single capacity. This asymmetry could cause confusion if diskBytes is ever used directly in tiered mode instead of the per-tier values.

this.diskBytes = dataDiskBytes + metaDiskBytes;
this.dataDiskBytes = dataDiskBytes;
this.metadataDiskBytes = metaDiskBytes;
this.tiered = true;
Possible Issue

When metadataCacheRatio > 0.0, the code computes metaDiskBytes and dataDiskBytes from diskCapacityBytes. If metaDiskBytes rounds to zero (e.g., very small diskCapacityBytes or very small ratio), dataDiskBytes would equal diskCapacityBytes, and the metadata cache would have zero capacity. The tiered constructor does not validate that metaDiskBytes > 0 when tiered mode is intended, which could lead to a silent failure or unexpected behavior.

if (metadataCacheRatio > 0.0) {
    final long metaDiskBytes = Math.round(diskCapacityBytes * metadataCacheRatio);
    final long dataDiskBytes = diskCapacityBytes - metaDiskBytes;
Possible Issue

In get_opts, after a cache miss, the code attempts to populate the data cache by calling get_result.bytes().await?. If the GetResult payload is a stream that has already been partially consumed or is not rewindable, calling .bytes() may fail or return incomplete data. The code then constructs a new GetResult with the buffered bytes, but if the original stream was large and the buffering fails or times out, the caller receives an error instead of the original stream.

let bytes = get_result.bytes().await?;
Possible Issue

In the Drop implementation, self.inner.close() is called with a 30-second timeout. If the timeout is exceeded, the code logs a warning but does not cancel the close operation. The HybridCache may still be running its close logic in the background, and the subsequent key_index_store::save call could race with ongoing writes, potentially corrupting the key index file.

let close_result = self._runtime.block_on(async {
    tokio::time::timeout(
        std::time::Duration::from_secs(30),
        self.inner.close()
    ).await
});

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to f91c4b5

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Prevent zero metadata capacity

When metadataCacheRatio is very small (e.g., 0.01%), Math.round(diskCapacityBytes *
metadataCacheRatio) may produce 0, causing FoyerBlockCache constructor to throw
IllegalArgumentException("metaDiskBytes must be > 0"). Enforce a minimum metadata
capacity (e.g., 1MB) or validate before construction to prevent startup failures.

sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/BlockCacheFoyerPlugin.java [208-210]

 if (metadataCacheRatio > 0.0) {
-    final long metaDiskBytes = Math.round(diskCapacityBytes * metadataCacheRatio);
+    final long metaDiskBytes = Math.max(1024 * 1024, Math.round(diskCapacityBytes * metadataCacheRatio));
     final long dataDiskBytes = diskCapacityBytes - metaDiskBytes;
     ...
 }
Suggestion importance[1-10]: 8

__

Why: When metadataCacheRatio is extremely small (e.g., 0.01%), Math.round() can produce 0, causing the FoyerBlockCache constructor to throw IllegalArgumentException. This is a real edge case that could cause startup failures. Enforcing a minimum capacity prevents this issue.

Medium
Preserve memory usage in merge

The merge method hardcodes memoryBytesUsed to 0L when merging data and metadata tier
stats. If either tier uses memory caching, this silently drops memory usage data.
Consider summing data.memoryBytesUsed() + meta.memoryBytesUsed() to preserve
accurate memory accounting across tiers.

sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerAggregatedStats.java [206-221]

 private static BlockCacheStats merge(BlockCacheStats data, BlockCacheStats meta) {
     return new BlockCacheStats(
         data.hits() + meta.hits(),
         data.misses() + meta.misses(),
         data.hitBytes() + meta.hitBytes(),
         data.missBytes() + meta.missBytes(),
         data.evictions() + meta.evictions(),
         data.evictionBytes() + meta.evictionBytes(),
         data.removed() + meta.removed(),
         data.removedBytes() + meta.removedBytes(),
-        0L,
+        data.memoryBytesUsed() + meta.memoryBytesUsed(),
         data.diskBytesUsed() + meta.diskBytesUsed(),
         data.totalBytes() + meta.totalBytes(),
         data.activeInBytes() + meta.activeInBytes()
     );
 }
Suggestion importance[1-10]: 7

__

Why: The merge method hardcodes memoryBytesUsed to 0L, which is correct for Foyer's disk-only architecture but could silently drop memory stats if the implementation changes. The suggestion improves future-proofing, though the current code is technically correct for the existing design.

Medium
Guard against invalid footer range

The footer range calculation assumes object_meta.size is always valid and non-zero.
If the file is empty or the metadata is corrupted, this could produce an invalid
range (e.g., 0..0). Add a guard to skip the footer range when object_meta.size is
zero or less than footer_prefetch.

sandbox/plugins/analytics-backend-datafusion/rust/src/custom_cache_manager.rs [495-499]

-let footer_prefetch = 64 * 1024u64; // matches DataFusion's DEFAULT_FOOTER_READ_SIZE
-let footer_start = object_meta.size.saturating_sub(footer_prefetch);
-index_ranges.push(footer_start..object_meta.size);
+let footer_prefetch = 64 * 1024u64;
+if object_meta.size >= footer_prefetch {
+    let footer_start = object_meta.size - footer_prefetch;
+    index_ranges.push(footer_start..object_meta.size);
+}
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a potential edge case where object_meta.size could be less than footer_prefetch, leading to an invalid range. The improved code adds a guard to prevent this issue, improving robustness.

Medium
Detect duplicate format names

The loop registers each EngineReaderManager as both a files listener (keyed by
DataFormat.name()) and a snapshot listener. If multiple data formats share the same
name(), the HashMap.put silently overwrites earlier entries, causing some reader
managers to never receive onFilesAdded callbacks. Use putIfAbsent or validate
uniqueness to prevent silent registration loss.

server/src/main/java/org/opensearch/index/engine/DataFormatAwareReadOnlyEngine.java [193-198]

 Map<String, FilesListener> filesListeners = new HashMap<>();
 List<CatalogSnapshotLifecycleListener> snapshotListeners = new ArrayList<>();
 for (Map.Entry<DataFormat, EngineReaderManager<?>> entry : readerManagersRef.entrySet()) {
-    filesListeners.put(entry.getKey().name(), entry.getValue());
+    String key = entry.getKey().name();
+    if (filesListeners.putIfAbsent(key, entry.getValue()) != null) {
+        throw new IllegalStateException("Duplicate DataFormat name: " + key);
+    }
     snapshotListeners.add(entry.getValue());
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that if multiple DataFormat instances share the same name(), the HashMap.put will silently overwrite entries. Adding putIfAbsent with validation prevents silent registration loss. However, this is a defensive check against a configuration error that may not occur in practice.

Low
Verify wire format field order

The wire format adds tieredStats after 12 existing fields, but the constructor reads
only 12 fields before the version gate. If a V_3_8_0+ node sends to an older node
that later upgrades and reads back, field order mismatches can corrupt
deserialization. Verify the StreamInput constructor matches this write order
exactly.

server/src/main/java/org/opensearch/plugins/BlockCacheStats.java [101-117]

-@Override
-public void writeTo(StreamOutput out) throws IOException {
-    out.writeLong(hits);
-    out.writeLong(misses);
-    out.writeLong(hitBytes);
-    out.writeLong(missBytes);
-    out.writeLong(evictions);
-    out.writeLong(evictionBytes);
-    out.writeLong(removed);
-    out.writeLong(removedBytes);
-    out.writeLong(memoryBytesUsed);
-    out.writeLong(diskBytesUsed);
-    out.writeLong(totalBytes);
-    out.writeLong(activeInBytes);
-    if (out.getVersion().onOrAfter(org.opensearch.Version.V_3_8_0)) {
-        out.writeOptionalWriteable(tieredStats);
-    }
+public BlockCacheStats(StreamInput in) throws IOException {
+    this(
+        in.readLong(),  // hits
+        in.readLong(),  // misses
+        in.readLong(),  // hitBytes
+        in.readLong(),  // missBytes
+        in.readLong(),  // evictions
+        in.readLong(),  // evictionBytes
+        in.readLong(),  // removed
+        in.readLong(),  // removedBytes
+        in.readLong(),  // memoryBytesUsed
+        in.readLong(),  // diskBytesUsed
+        in.readLong(),  // totalBytes
+        in.readLong(),  // activeInBytes
+        in.getVersion().onOrAfter(org.opensearch.Version.V_3_8_0) ? in.readOptionalWriteable(BlockCacheTieredStats::new) : null
+    );
 }
Suggestion importance[1-10]: 3

__

Why: The suggestion asks to verify that the StreamInput constructor matches the writeTo order. Examining the PR diff shows the constructor at lines 101-117 already reads fields in the correct order matching writeTo at lines 130-136, so this is already correct. The suggestion is redundant.

Low
General
Fix timeout duration mismatch

The timeout duration is hardcoded to 30 seconds, but the log message incorrectly
states "5s". This inconsistency could confuse operators debugging shutdown issues.
Either fix the log message to match the actual timeout or make the timeout
configurable.

sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/foyer_cache.rs [233-238]

 let close_result = self._runtime.block_on(async {
     tokio::time::timeout(
         std::time::Duration::from_secs(30),
         self.inner.close()
     ).await
 });
+match close_result {
+    ...
+    Err(_) => {
+        native_bridge_common::log_info!(
+            "[block-cache] HybridCache close TIMED OUT (30s) on shutdown — partial blocks may be lost"
+        );
+    }
+}
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies an inconsistency between the hardcoded timeout (30s) and the log message (5s). Fixing this improves clarity and prevents confusion during debugging.

Low
Avoid unnecessary bytes clone

The code consumes get_result by calling bytes().await, then clones the bytes for
caching. This is inefficient for large ranges. Instead, extract the bytes once and
reuse them for both the cache and the return value, avoiding the clone.

sandbox/libs/tiered-storage/src/main/rust/src/tiered_object_store.rs [602-604]

 let bytes = get_result.bytes().await?;
 let key = range_cache_key(path_str, start, end);
 cache.put(&key, bytes.clone());
+// Return the original bytes without re-cloning
+let file_size = self.registry.get(path_str).map(|g| g.size()).unwrap_or(end);
+let meta = ObjectMeta { ... };
+return Ok(GetResult {
+    payload: object_store::GetResultPayload::Stream(
+        futures::stream::once(async { Ok(bytes) }).boxed(),
+    ),
+    meta,
+    range: start..end,
+    attributes: Default::default(),
+});
Suggestion importance[1-10]: 5

__

Why: The suggestion identifies a potential optimization by avoiding a clone() call. However, the improved_code snippet is incomplete and doesn't show the full refactoring needed. The impact is moderate since the clone is only for cached entries.

Low
Document panic condition for block_on

The function uses block_on inside a synchronous context, which can panic if called
from within an existing async runtime. The docstring warns about this for
fetch_footer_to_heap, but fetch_ranges_via_store has the same issue. Add a similar
panic warning in the docstring or refactor to avoid block_on.

sandbox/plugins/analytics-backend-datafusion/rust/src/custom_cache_manager.rs [561-575]

+/// Fetch byte ranges from the store. Returns the fetched bytes in order.
+///
+/// # Panics
+/// Panics if called from within a tokio async context (uses `block_on`).
+/// Must be called from FFM entry points or a dedicated synchronous thread.
 fn fetch_ranges_via_store(
     store: &Arc<dyn MetadataCachingStore>,
     file_path: &str,
     ranges: &[std::ops::Range<u64>],
     rt_handle: &tokio::runtime::Handle,
 ) -> Result<Vec<bytes::Bytes>, String> {
-    if ranges.is_empty() {
-        return Ok(vec![]);
-    }
-    let path = Path::from(file_path.to_string());
-    rt_handle.block_on(async {
-        store.get_ranges(&path, ranges).await
-            .map_err(|e| format!("Failed to fetch ranges for {}: {}", file_path, e))
-    })
+    ...
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion correctly identifies that fetch_ranges_via_store uses block_on and could panic in async contexts. However, adding documentation is a minor improvement and doesn't address the underlying issue. The score is low because it's primarily a documentation suggestion.

Low

Previous suggestions

Suggestions up to commit 1f3671e
CategorySuggestion                                                                                                                                    Impact
Possible issue
Validate tier capacities are positive

When metadataCacheRatio is very small (e.g., 0.01%), Math.round() may produce
metaDiskBytes = 0, causing the tiered constructor to throw IllegalArgumentException.
Add validation to ensure both dataDiskBytes and metaDiskBytes are positive before
constructing the tiered cache.

sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/BlockCacheFoyerPlugin.java [208-210]

 if (metadataCacheRatio > 0.0) {
     final long metaDiskBytes = Math.round(diskCapacityBytes * metadataCacheRatio);
     final long dataDiskBytes = diskCapacityBytes - metaDiskBytes;
+    if (dataDiskBytes <= 0 || metaDiskBytes <= 0) {
+        throw new SettingsException(
+            "Computed tier capacities must be positive: dataDiskBytes=" + dataDiskBytes
+                + ", metaDiskBytes=" + metaDiskBytes
+                + ". Adjust block_cache.foyer.metadata_cache_ratio or node.search.cache.size."
+        );
+    }
     ...
 }
Suggestion importance[1-10]: 8

__

Why: When metadataCacheRatio is very small, Math.round() could produce metaDiskBytes = 0, causing the tiered constructor to throw IllegalArgumentException at line 163-164. This is a real edge case that should be validated before construction to provide a clearer error message.

Medium
Sum memory usage across tiers

The merge method hardcodes memoryBytesUsed to 0L, which may be incorrect if either
tier uses memory. Consider summing data.memoryBytesUsed() + meta.memoryBytesUsed()
to accurately reflect combined memory usage across both tiers.

sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerAggregatedStats.java [206-221]

 private static BlockCacheStats merge(BlockCacheStats data, BlockCacheStats meta) {
     return new BlockCacheStats(
         data.hits() + meta.hits(),
         data.misses() + meta.misses(),
         data.hitBytes() + meta.hitBytes(),
         data.missBytes() + meta.missBytes(),
         data.evictions() + meta.evictions(),
         data.evictionBytes() + meta.evictionBytes(),
         data.removed() + meta.removed(),
         data.removedBytes() + meta.removedBytes(),
-        0L,
+        data.memoryBytesUsed() + meta.memoryBytesUsed(),
         data.diskBytesUsed() + meta.diskBytesUsed(),
         data.totalBytes() + meta.totalBytes(),
         data.activeInBytes() + meta.activeInBytes()
     );
 }
Suggestion importance[1-10]: 7

__

Why: The merge method hardcodes memoryBytesUsed to 0L, which is inconsistent with how other fields are summed. While Foyer may not use memory caching (hence 0L is correct), the code should be consistent with the pattern of summing tier values for maintainability and future-proofing.

Medium
General
Fix timeout duration mismatch

The timeout duration is hardcoded to 30 seconds in the code but the log message
incorrectly states "5s". This mismatch between code and logging will confuse
operators debugging shutdown issues. Update the log message to reflect the actual
30-second timeout, or extract the duration as a named constant to ensure
consistency.

sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/foyer_cache.rs [233-238]

+const CLOSE_TIMEOUT_SECS: u64 = 30;
 let close_result = self._runtime.block_on(async {
     tokio::time::timeout(
-        std::time::Duration::from_secs(30),
+        std::time::Duration::from_secs(CLOSE_TIMEOUT_SECS),
         self.inner.close()
     ).await
 });
+// ... in the Err(_) branch:
+native_bridge_common::log_info!(
+    "[block-cache] HybridCache close TIMED OUT ({}s) on shutdown — partial blocks may be lost",
+    CLOSE_TIMEOUT_SECS
+);
Suggestion importance[1-10]: 8

__

Why: Correctly identifies a critical inconsistency between the hardcoded 30-second timeout and the "5s" log message. This mismatch will confuse operators during debugging. The suggested constant improves maintainability and fixes the logging error.

Medium
Guard footer range calculation

The footer range calculation assumes object_meta.size is always valid and non-zero.
If object_meta.size is 0 (empty file or metadata error), saturating_sub produces
footer_start = 0, creating a 0..0 range that will be fetched and promoted to
metadata Foyer. This wastes I/O and cache space. Add a guard to skip footer
promotion when object_meta.size is 0 or less than footer_prefetch.

sandbox/plugins/analytics-backend-datafusion/rust/src/custom_cache_manager.rs [495-499]

-let footer_prefetch = 64 * 1024u64; // matches DataFusion's DEFAULT_FOOTER_READ_SIZE
-let footer_start = object_meta.size.saturating_sub(footer_prefetch);
-index_ranges.push(footer_start..object_meta.size);
+let footer_prefetch = 64 * 1024u64;
+if object_meta.size >= footer_prefetch {
+    let footer_start = object_meta.size - footer_prefetch;
+    index_ranges.push(footer_start..object_meta.size);
+}
Suggestion importance[1-10]: 7

__

Why: Valid suggestion to prevent wasteful I/O and cache pollution when object_meta.size is 0 or smaller than footer_prefetch. The guard improves robustness by skipping invalid footer ranges.

Medium
Guard against null tier stats

If foyerStats() returns a snapshot where dataCacheStats() or metadataCacheStats() is
unexpectedly null (e.g., due to a race condition during initialization), the method
will throw NullPointerException. Add null checks to handle this edge case
gracefully.

sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerBlockCache.java [229-260]

 public BlockCacheTieredStats tieredStats() {
     if (tiered == false) {
         return null;
     }
     FoyerAggregatedStats s = foyerStats();
     BlockCacheStats data = s.dataCacheStats();
     BlockCacheStats meta = s.metadataCacheStats();
+    if (data == null || meta == null) {
+        return null;
+    }
     return new BlockCacheTieredStats(
         data.hits(),
         ...
     );
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion adds null checks for dataCacheStats() and metadataCacheStats() to prevent NullPointerException. While the tiered flag should guarantee non-null stats, defensive programming is reasonable here. However, the score is moderate because the existing code already checks tiered == false at line 230, making the race condition unlikely.

Low
Validate suffix range bounds

*The Suffix and Offset branches both call self.registry.get(path_str) which acquires
a lock on the registry. If the registry lookup fails or returns size=0, the function
logs a debug message and returns None, bypassing the cache. However, for Suffix(n)
where n > file_size, the calculation size.saturating_sub(n) could produce an
invalid range (e.g., Suffix(1000) on a 500-byte file yields 0..500). Verify that n
<= file_size before constructing the range to prevent serving incorrect byte ranges
from cache.

sandbox/libs/tiered-storage/src/main/rust/src/tiered_object_store.rs [221-234]

-fn resolve_range(&self, path_str: &str, range: &GetRange) -> Option<(u64, u64)> {
-    match range {
-        GetRange::Bounded(r) => Some((r.start, r.end)),
-        GetRange::Suffix(n) => {
-            let file_size = self.registry.get(path_str).map(|g| g.size()).filter(|&s| s > 0);
-            ...
+GetRange::Suffix(n) => {
+    let file_size = self.registry.get(path_str).map(|g| g.size()).filter(|&s| s > 0);
+    match file_size {
+        Some(size) if *n <= size => Some((size - *n, size)),
+        Some(size) => {
+            native_bridge_common::log_debug!(
+                "TieredObjectStore: Suffix({}) exceeds file_size {} for '{}', cache bypassed",
+                n, size, path_str
+            );
+            None
         }
-        GetRange::Offset(o) => {
-            let file_size = self.registry.get(path_str).map(|g| g.size()).filter(|&s| s > 0);
-            ...
-        }
+        None => { /* existing None branch */ None }
     }
 }
Suggestion importance[1-10]: 6

__

Why: Valid concern about Suffix(n) where n > file_size producing incorrect ranges via saturating_sub. However, object_store implementations typically handle this at the protocol level (HTTP Range headers clamp to file bounds). The suggestion adds defensive validation that improves correctness but may be redundant in practice.

Low
Clarify filesListeners key contract

The filesListeners map uses DataFormat.name() as the key, but the
CatalogSnapshotManager may expect a different key format (e.g., file extension or
format identifier). Verify that the key matches the format used by IndexFileDeleter
when firing onFilesAdded, or document the expected contract.

server/src/main/java/org/opensearch/index/engine/DataFormatAwareReadOnlyEngine.java [193-198]

 Map<String, FilesListener> filesListeners = new HashMap<>();
 List<CatalogSnapshotLifecycleListener> snapshotListeners = new ArrayList<>();
 for (Map.Entry<DataFormat, EngineReaderManager<?>> entry : readerManagersRef.entrySet()) {
-    filesListeners.put(entry.getKey().name(), entry.getValue());
+    // Key must match the format identifier used by IndexFileDeleter
+    String formatKey = entry.getKey().name();
+    filesListeners.put(formatKey, entry.getValue());
     snapshotListeners.add(entry.getValue());
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion asks to verify the key format used in filesListeners.put(entry.getKey().name(), ...). While this is a valid concern about contract clarity, the code appears correct as-is (using DataFormat.name() is a reasonable key). The suggestion adds a comment but doesn't change behavior, making it a minor documentation improvement.

Low
Avoid double-buffering in cache population

After checking range_size > max_size and returning early, the code unconditionally
calls get_result.bytes().await? which consumes the entire stream into memory. For
large ranges that exceed max_size, this early return prevents buffering, but for
ranges under the limit, this buffers the entire response before caching. If the
stream is already consumed elsewhere or the response is large, this could cause
memory pressure or double-buffering. Consider checking if the payload is already
buffered or using a streaming approach.

sandbox/libs/tiered-storage/src/main/rust/src/tiered_object_store.rs [597-602]

 if range_size > max_size {
     return Ok(get_result);
 }
-let bytes = get_result.bytes().await?;
+// Only buffer if not already buffered to avoid double-allocation
+let bytes = match &get_result.payload {
+    object_store::GetResultPayload::Stream(_) => get_result.bytes().await?,
+    object_store::GetResultPayload::File(_, _) => get_result.bytes().await?,
+};
Suggestion importance[1-10]: 3

__

Why: The suggestion misunderstands the code flow. get_result.bytes().await? is the standard way to consume a GetResult stream into memory for caching. The proposed pattern-match adds complexity without clear benefit, as bytes() already handles both payload types correctly.

Low
Suggestions up to commit 46e6290
CategorySuggestion                                                                                                                                    Impact
General
Validate non-zero metadata capacity

When metadataCacheRatio is very small, Math.round() could produce metaDiskBytes = 0,
causing the metadata cache to be non-functional. Add validation to ensure
metaDiskBytes is at least a minimum threshold (e.g., 1MB) when tiered mode is
enabled.

sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/BlockCacheFoyerPlugin.java [208-211]

 if (metadataCacheRatio > 0.0) {
     final long metaDiskBytes = Math.round(diskCapacityBytes * metadataCacheRatio);
+    if (metaDiskBytes == 0) {
+        throw new SettingsException(
+            "Computed metadata cache capacity is 0 bytes. Increase node.search.cache.size or block_cache.foyer.metadata_cache_ratio."
+        );
+    }
     final long dataDiskBytes = diskCapacityBytes - metaDiskBytes;
     ...
 }
Suggestion importance[1-10]: 8

__

Why: When metadataCacheRatio is very small, Math.round() could produce metaDiskBytes = 0, causing the metadata cache to be non-functional. Adding validation to ensure a minimum threshold prevents a silent failure mode where tiered caching appears enabled but the metadata tier has zero capacity.

Medium
Sum memory usage in merge

The merge method hardcodes memoryBytesUsed to 0L, which may not accurately represent
the combined memory usage of both tiers. Consider summing data.memoryBytesUsed() and
meta.memoryBytesUsed() to provide accurate overall memory statistics.

sandbox/plugins/block-cache-foyer/src/main/java/org/opensearch/blockcache/foyer/FoyerAggregatedStats.java [206-221]

 private static BlockCacheStats merge(BlockCacheStats data, BlockCacheStats meta) {
     return new BlockCacheStats(
         data.hits() + meta.hits(),
         data.misses() + meta.misses(),
         data.hitBytes() + meta.hitBytes(),
         data.missBytes() + meta.missBytes(),
         data.evictions() + meta.evictions(),
         data.evictionBytes() + meta.evictionBytes(),
         data.removed() + meta.removed(),
         data.removedBytes() + meta.removedBytes(),
-        0L,
+        data.memoryBytesUsed() + meta.memoryBytesUsed(),
         data.diskBytesUsed() + meta.diskBytesUsed(),
         data.totalBytes() + meta.totalBytes(),
         data.activeInBytes() + meta.activeInBytes()
     );
 }
Suggestion importance[1-10]: 7

__

Why: The merge method hardcodes memoryBytesUsed to 0L instead of summing data.memoryBytesUsed() and meta.memoryBytesUsed(). This could lead to inaccurate overall memory statistics in tiered mode. The suggestion correctly identifies a potential accuracy issue in the merged stats.

Medium
Verify exception indicates closed handle

Catching IllegalStateException by name closed without verifying the exception
message could mask unrelated state errors. Verify the exception indicates a closed
handle or use a more specific exception type to avoid silently ignoring critical
failures.

sandbox/plugins/analytics-backend-datafusion/src/main/java/org/opensearch/be/datafusion/DatafusionReaderManager.java [127-137]

 private static long storePointerOrDefault(NativeStoreHandle handle) {
     if (handle == null) {
         return 0L;
     }
     try {
         return handle.getPointer();
-    } catch (IllegalStateException closed) {
-        return 0L;
+    } catch (IllegalStateException e) {
+        if (e.getMessage() != null && e.getMessage().contains("closed")) {
+            return 0L;
+        }
+        throw e;
     }
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion to verify the exception message before catching IllegalStateException is reasonable for robustness. However, the current code's approach of catching any IllegalStateException and returning 0L is a valid fallback strategy (degrading gracefully to local FS). The suggestion improves specificity but the existing code is not incorrect.

Low
Document tiered stats aggregation behavior

When multiple block caches exist, only the first non-null tieredStats() is captured,
potentially ignoring tiered stats from other caches. Consider aggregating tiered
stats from all caches or documenting that only the first tiered cache's breakdown is
reported.

server/src/main/java/org/opensearch/index/store/remote/filecache/NodeCacheService.java [264-267]

 for (BlockCache bc : blockCaches) {
     BlockCacheStats s = bc.stats();
     ...
     if (tiered == null) {
         tiered = bc.tieredStats();
     }
 }
+// Note: only the first non-null tieredStats is reported when multiple caches exist
Suggestion importance[1-10]: 5

__

Why: The code only captures the first non-null tieredStats() when multiple block caches exist. While the suggestion to document this behavior is valid, the impact is low since the current implementation appears intentional (only one tiered cache is expected in practice). The suggestion is more about code clarity than correctness.

Low
Skip empty range processing

If index_ranges is empty (no page indexes and file size < 64KB),
fetch_ranges_via_store returns an empty vec but put_metadata is still called with
empty slices. While the trait default is a no-op, the TieredObjectStore
implementation iterates ranges.iter().zip(data.iter()) which is safe but wasteful.
Add an early return after computing index_ranges if it's empty to avoid unnecessary
calls.

sandbox/plugins/analytics-backend-datafusion/rust/src/custom_cache_manager.rs [502-506]

+if index_ranges.is_empty() {
+    return Ok(true);
+}
+
 let fetched_bytes = Self::fetch_ranges_via_store(store, file_path, &index_ranges, rt_handle)?;
-
-// Step 5: Promote bytes to metadata Foyer (never-evict tier).
-// No-op when `store` is not a TieredObjectStore (default trait impl is no-op).
 store.put_metadata(file_path, &index_ranges, &fetched_bytes);
Suggestion importance[1-10]: 5

__

Why: The suggestion identifies a minor optimization opportunity. While calling put_metadata with empty slices is safe (the trait default is no-op and TieredObjectStore handles it via zip), adding an early return avoids unnecessary function calls and improves code clarity. Moderate improvement to efficiency and readability.

Low
Timeout duration comment mismatch

The timeout duration is hardcoded to 30 seconds in the code but the comment mentions
"5s". This inconsistency can confuse maintainers. Either update the comment to match
the 30-second timeout or adjust the timeout value to match the documented behavior.

sandbox/plugins/block-cache-foyer/src/main/rust/src/foyer/foyer_cache.rs [233-238]

 let close_result = self._runtime.block_on(async {
     tokio::time::timeout(
         std::time::Duration::from_secs(30),
         self.inner.close()
     ).await
 });
+// ... existing match arms ...
+Err(_) => {
+    native_bridge_common::log_info!(
+        "[block-cache] HybridCache close TIMED OUT (30s) on shutdown — partial blocks may be lost"
+    );
+}
Suggestion importance[1-10]: 3

__

Why: The suggestion correctly identifies an inconsistency between the comment ("5s") and the actual timeout (30 seconds). However, this is a minor documentation issue that doesn't affect functionality. The fix is trivial but has low impact.

Low
Possible issue
Guard against zero-size files

The footer range calculation assumes object_meta.size is always valid and non-zero.
If object_meta.size is 0 (e.g., empty file or metadata fetch failure), footer_start
becomes 0 and the range 0..0 is pushed. This creates an invalid empty range that
will fail during get_ranges. Add a guard to skip footer promotion when
object_meta.size is 0 or less than footer_prefetch.

sandbox/plugins/analytics-backend-datafusion/rust/src/custom_cache_manager.rs [497-499]

-let footer_prefetch = 64 * 1024u64; // matches DataFusion's DEFAULT_FOOTER_READ_SIZE
-let footer_start = object_meta.size.saturating_sub(footer_prefetch);
-index_ranges.push(footer_start..object_meta.size);
+let footer_prefetch = 64 * 1024u64;
+if object_meta.size >= footer_prefetch {
+    let footer_start = object_meta.size - footer_prefetch;
+    index_ranges.push(footer_start..object_meta.size);
+}
Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies a potential issue where empty files or files smaller than footer_prefetch (64KB) would create invalid ranges (e.g., 0..0 or negative start). This could cause failures in get_ranges. The fix properly guards against this edge case.

Medium

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 46e6290: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

Signed-off-by: Vishwas Garg <vishwasgarg14@gmail.com>
@vishwasgarg18 vishwasgarg18 force-pushed the tiered-block-cache-vishwas branch from 46e6290 to 1f3671e Compare June 22, 2026 09:59
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 1f3671e

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for 1f3671e: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit f91c4b5

@github-actions

Copy link
Copy Markdown
Contributor

❌ Gradle check result for f91c4b5: FAILURE

Please examine the workflow log, locate, and copy-paste the failure(s) below, then iterate to green. Is the failure a flaky test unrelated to your change?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-diff-analyzer Maintainer to skip code-diff-analyzer check, after reviewing issues in AI analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants