Repository: awslabs/analytics-accelerator-s3
Pull request #257: Added stats to record memory usage and cache hit/miss
Author: login: rajdchak
Created: 2025-04-17T09:02:07Z | Merged: 2025-04-25T11:52:49Z

## Description of change
Added stats to record memory usage and cache hit/miss

#### Relevant issues
<!-- Please add issue numbers. -->
<!-- Please also link them to this PR. -->

#### Does this contribution introduce any breaking changes to the existing APIs or behaviors?
<!-- Please explain why this was necessary. -->

#### Does this contribution introduce any new public APIs or behaviors?
<!-- Please describe them and explain what scenarios they target.  -->

#### How was the contribution tested?
<!-- Please describe how this contribution was tested. -->

#### Does this contribution need a changelog entry?
- [ ] I have updated the CHANGELOG or README if appropriate

---

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license and I agree to the terms of the [Developer Certificate of Origin (DCO)](https://developercertificate.org/).


Diff:
diff --git a/common/src/main/java/software/amazon/s3/analyticsaccelerator/common/Metrics.java b/common/src/main/java/software/amazon/s3/analyticsaccelerator/common/Metrics.java
new file mode 100644
index 00000000..e3ad9c33
--- /dev/null
+++ b/common/src/main/java/software/amazon/s3/analyticsaccelerator/common/Metrics.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.common;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+import software.amazon.s3.analyticsaccelerator.util.MetricKey;
+
+/**
+ * A thread-safe metrics collection class that maintains counters for different types of metrics.
+ * Each metric is identified by a {@link MetricKey} and stored as an {@link AtomicLong} value.
+ */
+public class Metrics {
+
+  /**
+   * Thread-safe map storing metric values. Keys are {@link MetricKey} enums and values are {@link
+   * AtomicLong} counters for each metric.
+   */
+  private final Map<MetricKey, AtomicLong> metrics = new ConcurrentHashMap<>();
+
+  /**
+   * Adds the specified delta to the metric identified by the given key. If the metric doesn't
+   * exist, it will be created with an initial value of 0 before adding the delta.
+   *
+   * @param key the metric key to identify which metric to update
+   * @param delta the value to add to the metric (can be negative for decrements)
+   */
+  public void add(MetricKey key, long delta) {
+    getOrCreate(key).addAndGet(delta);
+  }
+
+  /**
+   * Reduces the specified delta from the metric identified by the given key. If the metric doesn't
+   * exist, it will be created with an initial value of 0 before adding the delta.
+   *
+   * @param key the metric key to identify which metric to update
+   * @param delta the value to add to the metric (can be negative for decrements)
+   */
+  public void reduce(MetricKey key, long delta) {
+    getOrCreate(key).addAndGet(-delta);
+  }
+
+  /**
+   * Retrieves the current value of the specified metric. If the metric doesn't exist, it will be
+   * created with an initial value of 0.
+   *
+   * @param key the metric key whose value should be retrieved
+   * @return the current value of the metric
+   */
+  public long get(MetricKey key) {
+    return getOrCreate(key).get();
+  }
+
+  /**
+   * Helper method to get or create an {@link AtomicLong} for a given metric key. If the metric
+   * doesn't exist, it creates a new {@link AtomicLong} initialized to 0.
+   *
+   * @param key the metric key to look up or create
+   * @return the existing or newly created {@link AtomicLong} for the metric
+   */
+  private AtomicLong getOrCreate(MetricKey key) {
+    return metrics.computeIfAbsent(key, k -> new AtomicLong(0));
+  }
+}
diff --git a/common/src/main/java/software/amazon/s3/analyticsaccelerator/util/MetricComputationUtils.java b/common/src/main/java/software/amazon/s3/analyticsaccelerator/util/MetricComputationUtils.java
new file mode 100644
index 00000000..ebe55cf9
--- /dev/null
+++ b/common/src/main/java/software/amazon/s3/analyticsaccelerator/util/MetricComputationUtils.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.util;
+
+/** Utility class for computing various metrics related to cache performance. */
+public final class MetricComputationUtils {
+
+  /** Private constructor to prevent instantiation of utility class. */
+  private MetricComputationUtils() {
+    throw new AssertionError("No instances of MetricComputationUtils should be created");
+  }
+
+  /**
+   * Computes the cache hit rate as a percentage based on the number of cache hits and misses. The
+   * hit rate is calculated as (cache hits / (cache hits + cache misses)) * 100. If there are no
+   * hits and misses (total is zero), returns 0.
+   *
+   * @param cacheHits The number of cache hits
+   * @param cacheMiss The number of cache misses
+   * @return The cache hit rate as a percentage (0-100)
+   */
+  public static double computeCacheHitRate(long cacheHits, long cacheMiss) {
+    long hitsAndMiss = cacheHits + cacheMiss;
+    double hitRate = hitsAndMiss == 0 ? 0 : (double) cacheHits / hitsAndMiss;
+    return hitRate * 100;
+  }
+}
diff --git a/common/src/main/java/software/amazon/s3/analyticsaccelerator/util/MetricKey.java b/common/src/main/java/software/amazon/s3/analyticsaccelerator/util/MetricKey.java
new file mode 100644
index 00000000..41910498
--- /dev/null
+++ b/common/src/main/java/software/amazon/s3/analyticsaccelerator/util/MetricKey.java
@@ -0,0 +1,60 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.util;
+
+/**
+ * Enum representing different types of metrics that can be tracked in the system. Each metric is
+ * identified by a unique name and can be used to track various performance and operational aspects.
+ */
+public enum MetricKey {
+  /**
+   * Tracks the memory usage in bytes. Used to monitor the amount of memory being consumed by the
+   * blobstore.
+   */
+  MEMORY_USAGE("MemoryUsage"),
+
+  /**
+   * Tracks the number of successful cache hits. Incremented when requested block is found in the
+   * cache.
+   */
+  CACHE_HIT("CacheHit"),
+
+  /**
+   * Tracks the number of cache misses. Incremented when requested block is not found in the cache
+   */
+  CACHE_MISS("CacheMiss");
+
+  /** The string name representation of the metric. */
+  private final String name;
+
+  /**
+   * Constructs a new MetricKey with the specified name.
+   *
+   * @param name The string identifier for the metric
+   */
+  MetricKey(String name) {
+    this.name = name;
+  }
+
+  /**
+   * Returns the string name of the metric.
+   *
+   * @return The name of the metric
+   */
+  public String getName() {
+    return name;
+  }
+}
diff --git a/common/src/test/java/software/amazon/s3/analyticsaccelerator/common/MetricKeyTest.java b/common/src/test/java/software/amazon/s3/analyticsaccelerator/common/MetricKeyTest.java
new file mode 100644
index 00000000..5b7a46fd
--- /dev/null
+++ b/common/src/test/java/software/amazon/s3/analyticsaccelerator/common/MetricKeyTest.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.common;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+import software.amazon.s3.analyticsaccelerator.util.MetricKey;
+
+public class MetricKeyTest {
+
+  @Test
+  public void testMetricKeyNames() {
+    assertEquals("MemoryUsage", MetricKey.MEMORY_USAGE.getName());
+    assertEquals("CacheHit", MetricKey.CACHE_HIT.getName());
+    assertEquals("CacheMiss", MetricKey.CACHE_MISS.getName());
+  }
+
+  @Test
+  public void testEnumValues() {
+    MetricKey[] values = MetricKey.values();
+    assertEquals(3, values.length);
+    assertEquals(MetricKey.MEMORY_USAGE, values[0]);
+    assertEquals(MetricKey.CACHE_HIT, values[1]);
+    assertEquals(MetricKey.CACHE_MISS, values[2]);
+  }
+}
diff --git a/common/src/test/java/software/amazon/s3/analyticsaccelerator/request/ObjectMetadataTest.java b/common/src/test/java/software/amazon/s3/analyticsaccelerator/request/ObjectMetadataTest.java
new file mode 100644
index 00000000..da0a9046
--- /dev/null
+++ b/common/src/test/java/software/amazon/s3/analyticsaccelerator/request/ObjectMetadataTest.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.request;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+@SuppressFBWarnings(
+    value = "NP_NONNULL_PARAM_VIOLATION",
+    justification = "Null values are intentionally tested")
+class ObjectMetadataTest {
+
+  @Test
+  void testValidConstruction() {
+    ObjectMetadata metadata = ObjectMetadata.builder().contentLength(100).etag("test-etag").build();
+
+    assertEquals(100, metadata.getContentLength());
+    assertEquals("test-etag", metadata.getEtag());
+  }
+
+  @Test
+  void testZeroContentLength() {
+    ObjectMetadata metadata = ObjectMetadata.builder().contentLength(0).etag("test-etag").build();
+
+    assertEquals(0, metadata.getContentLength());
+  }
+
+  @Test
+  @SuppressWarnings("ConstantConditions")
+  void testNullEtagThrowsException() {
+    assertThrows(
+        NullPointerException.class,
+        () -> ObjectMetadata.builder().contentLength(100).etag(null).build());
+  }
+
+  @ParameterizedTest
+  @ValueSource(longs = {-1L, -100L, Long.MIN_VALUE})
+  void testNegativeContentLengthThrowsException(long negativeLength) {
+    IllegalArgumentException exception =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> ObjectMetadata.builder().contentLength(negativeLength).etag("test-etag").build());
+
+    assertEquals("content length must be non-negative", exception.getMessage());
+  }
+
+  @Test
+  void testMaxContentLength() {
+    ObjectMetadata metadata =
+        ObjectMetadata.builder().contentLength(Long.MAX_VALUE).etag("test-etag").build();
+
+    assertEquals(Long.MAX_VALUE, metadata.getContentLength());
+  }
+
+  @Test
+  void testEquality() {
+    ObjectMetadata metadata1 =
+        ObjectMetadata.builder().contentLength(100).etag("test-etag").build();
+
+    ObjectMetadata metadata2 =
+        ObjectMetadata.builder().contentLength(100).etag("test-etag").build();
+
+    ObjectMetadata differentLength =
+        ObjectMetadata.builder().contentLength(200).etag("test-etag").build();
+
+    ObjectMetadata differentEtag =
+        ObjectMetadata.builder().contentLength(100).etag("different-etag").build();
+
+    // Test equals
+    assertEquals(metadata1, metadata2);
+    assertNotEquals(metadata1, differentLength);
+    assertNotEquals(metadata1, differentEtag);
+    assertNotEquals(metadata1, null);
+    assertNotEquals(metadata1, new Object());
+
+    // Test hashCode
+    assertEquals(metadata1.hashCode(), metadata2.hashCode());
+  }
+
+  @Test
+  void testToString() {
+    ObjectMetadata metadata = ObjectMetadata.builder().contentLength(100).etag("test-etag").build();
+
+    String toString = metadata.toString();
+    assertTrue(toString.contains("contentLength=100"));
+    assertTrue(toString.contains("etag=test-etag"));
+  }
+
+  @Test
+  void testBuilder() {
+    ObjectMetadata.ObjectMetadataBuilder builder = ObjectMetadata.builder();
+    assertNotNull(builder);
+
+    ObjectMetadata metadata = builder.contentLength(100).etag("test-etag").build();
+
+    assertNotNull(metadata);
+  }
+
+  @Test
+  void testSetters() {
+    ObjectMetadata metadata = ObjectMetadata.builder().contentLength(100).etag("test-etag").build();
+
+    metadata.setContentLength(200);
+    assertEquals(200, metadata.getContentLength());
+
+    metadata.setEtag("new-etag");
+    assertEquals("new-etag", metadata.getEtag());
+  }
+
+  @Test
+  void testCopyConstructor() {
+    ObjectMetadata original = ObjectMetadata.builder().contentLength(100).etag("test-etag").build();
+
+    ObjectMetadata copy =
+        ObjectMetadata.builder()
+            .contentLength(original.getContentLength())
+            .etag(original.getEtag())
+            .build();
+
+    assertEquals(original, copy);
+    assertNotSame(original, copy);
+  }
+}
diff --git a/common/src/test/java/software/amazon/s3/analyticsaccelerator/util/LogHelperTest.java b/common/src/test/java/software/amazon/s3/analyticsaccelerator/util/LogHelperTest.java
new file mode 100644
index 00000000..4554838a
--- /dev/null
+++ b/common/src/test/java/software/amazon/s3/analyticsaccelerator/util/LogHelperTest.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.util;
+
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import java.util.Optional;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.slf4j.Logger;
+import org.slf4j.event.Level;
+
+class LogHelperTest {
+
+  @Mock private Logger logger;
+
+  @BeforeEach
+  void setUp() {
+    MockitoAnnotations.openMocks(this);
+  }
+
+  @Test
+  void testLogErrorWithException() {
+    String message = "Error message";
+    Exception exception = new RuntimeException("Test exception");
+
+    LogHelper.logAtLevel(logger, Level.ERROR, message, Optional.of(exception));
+
+    verify(logger).error(message, exception);
+    verifyNoMoreInteractions(logger);
+  }
+
+  @Test
+  void testLogErrorWithoutException() {
+    String message = "Error message";
+
+    LogHelper.logAtLevel(logger, Level.ERROR, message, Optional.empty());
+
+    verify(logger).error(message);
+    verifyNoMoreInteractions(logger);
+  }
+
+  @ParameterizedTest
+  @EnumSource(
+      value = Level.class,
+      names = {"WARN", "INFO", "DEBUG", "TRACE"})
+  void testNonErrorLevels(Level level) {
+    String message = "Test message";
+
+    LogHelper.logAtLevel(logger, level, message, Optional.empty());
+
+    if (level == Level.WARN) {
+      verify(logger).warn(message);
+    } else if (level == Level.INFO) {
+      verify(logger).info(message);
+    } else if (level == Level.DEBUG) {
+      verify(logger).debug(message);
+    } else if (level == Level.TRACE) {
+      verify(logger).trace(message);
+    } else {
+      fail("Unexpected log level: " + level);
+    }
+
+    verifyNoMoreInteractions(logger);
+  }
+
+  @Test
+  void testNonErrorLevelsWithException() {
+    String message = "Test message";
+    Exception exception = new RuntimeException("Test exception");
+
+    // Exception should be ignored for non-ERROR levels
+    LogHelper.logAtLevel(logger, Level.WARN, message, Optional.of(exception));
+    LogHelper.logAtLevel(logger, Level.INFO, message, Optional.of(exception));
+    LogHelper.logAtLevel(logger, Level.DEBUG, message, Optional.of(exception));
+    LogHelper.logAtLevel(logger, Level.TRACE, message, Optional.of(exception));
+
+    verify(logger).warn(message);
+    verify(logger).info(message);
+    verify(logger).debug(message);
+    verify(logger).trace(message);
+    verifyNoMoreInteractions(logger);
+  }
+
+  @Test
+  void testNullMessage() {
+    LogHelper.logAtLevel(logger, Level.INFO, null, Optional.empty());
+
+    verify(logger).info(null);
+    verifyNoMoreInteractions(logger);
+  }
+
+  @Test
+  void testEmptyMessage() {
+    LogHelper.logAtLevel(logger, Level.INFO, "", Optional.empty());
+
+    verify(logger).info("");
+    verifyNoMoreInteractions(logger);
+  }
+
+  @Test
+  void testLongMessage() {
+    StringBuilder sb = new StringBuilder();
+    for (int i = 0; i < 1000; i++) {
+      sb.append("a");
+    }
+    String longMessage = sb.toString();
+
+    LogHelper.logAtLevel(logger, Level.INFO, longMessage, Optional.empty());
+
+    verify(logger).info(longMessage);
+    verifyNoMoreInteractions(logger);
+  }
+
+  @Test
+  void testWithNullException() {
+    String message = "Test message";
+
+    LogHelper.logAtLevel(logger, Level.ERROR, message, Optional.ofNullable(null));
+
+    verify(logger).error(message);
+    verifyNoMoreInteractions(logger);
+  }
+}
diff --git a/common/src/test/java/software/amazon/s3/analyticsaccelerator/util/MetricComputationUtilsTest.java b/common/src/test/java/software/amazon/s3/analyticsaccelerator/util/MetricComputationUtilsTest.java
new file mode 100644
index 00000000..06424b0c
--- /dev/null
+++ b/common/src/test/java/software/amazon/s3/analyticsaccelerator/util/MetricComputationUtilsTest.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.util;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+public class MetricComputationUtilsTest {
+
+  private static final double DELTA = 0.0001; // Delta for comparing double values
+
+  @Test
+  public void testComputeCacheHitRate_BothZero() {
+    double result = MetricComputationUtils.computeCacheHitRate(0, 0);
+    assertEquals(0.0, result, DELTA);
+  }
+
+  @Test
+  public void testComputeCacheHitRate_AllHits() {
+    double result = MetricComputationUtils.computeCacheHitRate(100, 0);
+    assertEquals(100.0, result, DELTA);
+  }
+
+  @Test
+  public void testComputeCacheHitRate_AllMisses() {
+    double result = MetricComputationUtils.computeCacheHitRate(0, 100);
+    assertEquals(0.0, result, DELTA);
+  }
+
+  @Test
+  public void testComputeCacheHitRate_MixedHitsAndMisses() {
+    double result = MetricComputationUtils.computeCacheHitRate(75, 25);
+    assertEquals(75.0, result, DELTA);
+  }
+
+  @Test
+  public void testComputeCacheHitRate_LargeNumbers() {
+    double result = MetricComputationUtils.computeCacheHitRate(1000000, 1000000);
+    assertEquals(50.0, result, DELTA);
+  }
+
+  @Test
+  public void testComputeCacheHitRate_SingleHit() {
+    double result = MetricComputationUtils.computeCacheHitRate(1, 0);
+    assertEquals(100.0, result, DELTA);
+  }
+
+  @Test
+  public void testComputeCacheHitRate_SingleMiss() {
+    double result = MetricComputationUtils.computeCacheHitRate(0, 1);
+    assertEquals(0.0, result, DELTA);
+  }
+}
diff --git a/common/src/test/java/software/amazon/s3/analyticsaccelerator/util/OpenStreamInformationTest.java b/common/src/test/java/software/amazon/s3/analyticsaccelerator/util/OpenStreamInformationTest.java
new file mode 100644
index 00000000..f39538c2
--- /dev/null
+++ b/common/src/test/java/software/amazon/s3/analyticsaccelerator/util/OpenStreamInformationTest.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.util;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import software.amazon.s3.analyticsaccelerator.request.ObjectMetadata;
+import software.amazon.s3.analyticsaccelerator.request.StreamContext;
+
+public class OpenStreamInformationTest {
+
+  @Test
+  public void testDefaultInstance() {
+    OpenStreamInformation info = OpenStreamInformation.DEFAULT;
+
+    assertNotNull(info, "Default instance should not be null");
+    assertNull(info.getStreamContext(), "Default streamContext should be null");
+    assertNull(info.getObjectMetadata(), "Default objectMetadata should be null");
+    assertNull(info.getInputPolicy(), "Default inputPolicy should be null");
+  }
+
+  @Test
+  public void testBuilderWithAllFields() {
+    StreamContext mockContext = Mockito.mock(StreamContext.class);
+    ObjectMetadata mockMetadata = Mockito.mock(ObjectMetadata.class);
+    InputPolicy mockPolicy = Mockito.mock(InputPolicy.class);
+
+    OpenStreamInformation info =
+        OpenStreamInformation.builder()
+            .streamContext(mockContext)
+            .objectMetadata(mockMetadata)
+            .inputPolicy(mockPolicy)
+            .build();
+
+    assertSame(mockContext, info.getStreamContext(), "StreamContext should match");
+    assertSame(mockMetadata, info.getObjectMetadata(), "ObjectMetadata should match");
+    assertSame(mockPolicy, info.getInputPolicy(), "InputPolicy should match");
+  }
+
+  @Test
+  public void testBuilderWithPartialFields() {
+    StreamContext mockContext = Mockito.mock(StreamContext.class);
+    ObjectMetadata mockMetadata = Mockito.mock(ObjectMetadata.class);
+
+    OpenStreamInformation info =
+        OpenStreamInformation.builder()
+            .streamContext(mockContext)
+            .objectMetadata(mockMetadata)
+            .build();
+
+    assertSame(mockContext, info.getStreamContext(), "StreamContext should match");
+    assertSame(mockMetadata, info.getObjectMetadata(), "ObjectMetadata should match");
+    assertNull(info.getInputPolicy(), "InputPolicy should be null");
+  }
+
+  @Test
+  public void testBuilderFieldRetention() {
+    // Create mocks
+    StreamContext mockContext = Mockito.mock(StreamContext.class);
+    ObjectMetadata mockMetadata = Mockito.mock(ObjectMetadata.class);
+    InputPolicy mockPolicy = Mockito.mock(InputPolicy.class);
+
+    // Build object
+    OpenStreamInformation info =
+        OpenStreamInformation.builder()
+            .streamContext(mockContext)
+            .objectMetadata(mockMetadata)
+            .inputPolicy(mockPolicy)
+            .build();
+
+    // Verify field retention
+    assertNotNull(info, "Built object should not be null");
+    assertNotNull(info.getStreamContext(), "StreamContext should be retained");
+    assertNotNull(info.getObjectMetadata(), "ObjectMetadata should be retained");
+    assertNotNull(info.getInputPolicy(), "InputPolicy should be retained");
+  }
+
+  @Test
+  public void testNullFields() {
+    OpenStreamInformation info =
+        OpenStreamInformation.builder()
+            .streamContext(null)
+            .objectMetadata(null)
+            .inputPolicy(null)
+            .build();
+
+    assertNull(info.getStreamContext(), "StreamContext should be null");
+    assertNull(info.getObjectMetadata(), "ObjectMetadata should be null");
+    assertNull(info.getInputPolicy(), "InputPolicy should be null");
+  }
+}
diff --git a/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamFactory.java b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamFactory.java
index 7be86a78..f1a587ca 100644
--- a/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamFactory.java
+++ b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamFactory.java
@@ -21,6 +21,7 @@
 import lombok.NonNull;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import software.amazon.s3.analyticsaccelerator.common.Metrics;
 import software.amazon.s3.analyticsaccelerator.common.telemetry.Telemetry;
 import software.amazon.s3.analyticsaccelerator.io.logical.LogicalIO;
 import software.amazon.s3.analyticsaccelerator.io.logical.impl.DefaultLogicalIOImpl;
@@ -54,6 +55,7 @@ public class S3SeekableInputStreamFactory implements AutoCloseable {
   private final BlobStore objectBlobStore;
   private final Telemetry telemetry;
   private final ObjectFormatSelector objectFormatSelector;
+  private final Metrics metrics;
 
   private static final Logger LOG = LoggerFactory.getLogger(S3SeekableInputStreamFactory.class);
 
@@ -70,6 +72,7 @@ public S3SeekableInputStreamFactory(
       @NonNull S3SeekableInputStreamConfiguration configuration) {
     LOG.debug("Initializing S3SeekableInputStreamFactory with configuration: {}", configuration);
     this.configuration = configuration;
+    this.metrics = new Metrics();
     this.telemetry = Telemetry.createTelemetry(configuration.getTelemetryConfiguration());
     this.parquetColumnPrefetchStore =
         new ParquetColumnPrefetchStore(configuration.getLogicalIOConfiguration());
@@ -77,7 +80,7 @@ public S3SeekableInputStreamFactory(
         new MetadataStore(objectClient, telemetry, configuration.getPhysicalIOConfiguration());
     this.objectFormatSelector = new ObjectFormatSelector(configuration.getLogicalIOConfiguration());
     this.objectBlobStore =
-        new BlobStore(objectClient, telemetry, configuration.getPhysicalIOConfiguration());
+        new BlobStore(objectClient, telemetry, configuration.getPhysicalIOConfiguration(), metrics);
   }
 
   /**
diff --git a/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/Blob.java b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/Blob.java
index b51086a1..6818e99b 100644
--- a/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/Blob.java
+++ b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/Blob.java
@@ -75,6 +75,16 @@ public int read(long pos) throws IOException {
     return blockManager.getBlock(pos).get().read(pos);
   }
 
+  /**
+   * Returns the memory used by the blob
+   *
+   * @return the memory used by the blob
+   */
+  public long getMemoryUsageOfBlob() {
+
+    return blockManager.getMemoryUsageOfBlob();
+  }
+
   /**
    * Reads data into the provided buffer
    *
diff --git a/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
index 3d7b4da7..68c63555 100644
--- a/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
+++ b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
@@ -20,12 +20,18 @@
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import lombok.Getter;
 import lombok.NonNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import software.amazon.s3.analyticsaccelerator.common.Metrics;
 import software.amazon.s3.analyticsaccelerator.common.telemetry.Telemetry;
 import software.amazon.s3.analyticsaccelerator.io.physical.PhysicalIOConfiguration;
 import software.amazon.s3.analyticsaccelerator.request.ObjectClient;
 import software.amazon.s3.analyticsaccelerator.request.ObjectMetadata;
 import software.amazon.s3.analyticsaccelerator.request.StreamContext;
+import software.amazon.s3.analyticsaccelerator.util.MetricComputationUtils;
+import software.amazon.s3.analyticsaccelerator.util.MetricKey;
 import software.amazon.s3.analyticsaccelerator.util.ObjectKey;
 
 /** A BlobStore is a container for Blobs and functions as a data cache. */
@@ -35,29 +41,46 @@
         "Inner class is created very infrequently, and fluency justifies the extra pointer")
 public class BlobStore implements Closeable {
   private final Map<ObjectKey, Blob> blobMap;
+  private static final Logger LOG = LoggerFactory.getLogger(BlobStore.class);
   private final ObjectClient objectClient;
   private final Telemetry telemetry;
   private final PhysicalIOConfiguration configuration;
 
+  @Getter private final Metrics metrics;
+
   /**
    * Construct an instance of BlobStore.
    *
    * @param objectClient object client capable of interacting with the underlying object store
    * @param telemetry an instance of {@link Telemetry} to use
    * @param configuration the PhysicalIO configuration
+   * @param metrics an instance of {@link Metrics} to track metrics across the factory
    */
   public BlobStore(
       @NonNull ObjectClient objectClient,
       @NonNull Telemetry telemetry,
-      @NonNull PhysicalIOConfiguration configuration) {
+      @NonNull PhysicalIOConfiguration configuration,
+      @NonNull Metrics metrics) {
     this.objectClient = objectClient;
     this.telemetry = telemetry;
+    this.metrics = metrics;
     this.blobMap =
         Collections.synchronizedMap(
             new LinkedHashMap<ObjectKey, Blob>() {
               @Override
               protected boolean removeEldestEntry(final Map.Entry<ObjectKey, Blob> eldest) {
-                return this.size() > configuration.getBlobStoreCapacity();
+                boolean shouldRemove = this.size() > configuration.getBlobStoreCapacity();
+                if (shouldRemove) {
+                  LOG.debug(
+                      "Current memory usage of blobMap in bytes before eviction is: {}",
+                      metrics.get(MetricKey.MEMORY_USAGE));
+                  Blob blobToRemove = eldest.getValue();
+                  metrics.reduce(MetricKey.MEMORY_USAGE, blobToRemove.getMemoryUsageOfBlob());
+                  LOG.debug(
+                      "Current memory usage of blobMap in bytes after eviction is: {}",
+                      metrics.get(MetricKey.MEMORY_USAGE));
+                }
+                return shouldRemove;
               }
             });
     this.configuration = configuration;
@@ -79,7 +102,7 @@ public Blob get(ObjectKey objectKey, ObjectMetadata metadata, StreamContext stre
                 uri,
                 metadata,
                 new BlockManager(
-                    uri, objectClient, metadata, telemetry, configuration, streamContext),
+                    uri, objectClient, metadata, telemetry, configuration, metrics, streamContext),
                 telemetry));
   }
 
@@ -105,6 +128,11 @@ public int blobCount() {
   /** Closes the {@link BlobStore} and frees up all resources it holds. */
   @Override
   public void close() {
+    long hits = metrics.get(MetricKey.CACHE_HIT);
+    long miss = metrics.get(MetricKey.CACHE_MISS);
+    LOG.debug(
+        "Cache Hits: {}, Misses: {}, Hit Rate: {}%",
+        hits, miss, MetricComputationUtils.computeCacheHitRate(hits, miss));
     blobMap.forEach((k, v) -> v.close());
   }
 }
diff --git a/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/Block.java b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/Block.java
index 6a140492..04ede174 100644
--- a/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/Block.java
+++ b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/Block.java
@@ -34,9 +34,7 @@
 import software.amazon.s3.analyticsaccelerator.request.ReadMode;
 import software.amazon.s3.analyticsaccelerator.request.Referrer;
 import software.amazon.s3.analyticsaccelerator.request.StreamContext;
-import software.amazon.s3.analyticsaccelerator.util.ObjectKey;
-import software.amazon.s3.analyticsaccelerator.util.StreamAttributes;
-import software.amazon.s3.analyticsaccelerator.util.StreamUtils;
+import software.amazon.s3.analyticsaccelerator.util.*;
 
 /**
  * A Block holding part of an object's data and owning its own async process for fetching part of
@@ -46,7 +44,7 @@ public class Block implements Closeable {
   private CompletableFuture<ObjectContent> source;
   private CompletableFuture<byte[]> data;
   private final ObjectKey objectKey;
-  private final Range range;
+  @Getter private final Range range;
   private final Telemetry telemetry;
   private final ObjectClient objectClient;
   private final StreamContext streamContext;
@@ -58,7 +56,7 @@ public class Block implements Closeable {
   @Getter private final long start;
   @Getter private final long end;
   @Getter private final long generation;
-
+  private final BlockMetricsHandler metricsHandler;
   private static final String OPERATION_BLOCK_GET_ASYNC = "block.get.async";
   private static final String OPERATION_BLOCK_GET_JOIN = "block.get.join";
 
@@ -76,6 +74,7 @@ public class Block implements Closeable {
    * @param readMode read mode describing whether this is a sync or async fetch
    * @param readTimeout Timeout duration (in milliseconds) for reading a block object from S3
    * @param readRetryCount Number of retries for block read failure
+   * @param metricsHandler metrics callback
    */
   public Block(
       @NonNull ObjectKey objectKey,
@@ -86,7 +85,8 @@ public Block(
       long generation,
       @NonNull ReadMode readMode,
       long readTimeout,
-      int readRetryCount)
+      int readRetryCount,
+      @NonNull BlockMetricsHandler metricsHandler)
       throws IOException {
 
     this(
@@ -99,6 +99,7 @@ public Block(
         readMode,
         readTimeout,
         readRetryCount,
+        metricsHandler,
         null);
   }
 
@@ -114,6 +115,7 @@ public Block(
    * @param readMode read mode describing whether this is a sync or async fetch
    * @param readTimeout Timeout duration (in milliseconds) for reading a block object from S3
    * @param readRetryCount Number of retries for block read failure
+   * @param metricsHandler metrics callback
    * @param streamContext contains audit headers to be attached in the request header
    */
   public Block(
@@ -126,6 +128,7 @@ public Block(
       @NonNull ReadMode readMode,
       long readTimeout,
       int readRetryCount,
+      @NonNull BlockMetricsHandler metricsHandler,
       StreamContext streamContext)
       throws IOException {
 
@@ -152,12 +155,14 @@ public Block(
     this.referrer = new Referrer(range.toHttpString(), readMode);
     this.readTimeout = readTimeout;
     this.readRetryCount = readRetryCount;
+    this.metricsHandler = metricsHandler;
 
     generateSourceAndData();
   }
 
   /** Method to help construct source and data */
   private void generateSourceAndData() throws IOException {
+
     int retries = 0;
     while (retries < this.readRetryCount) {
       try {
@@ -186,6 +191,7 @@ private void generateSourceAndData() throws IOException {
             this.source.thenApply(
                 objectContent -> {
                   try {
+                    this.metricsHandler.updateMetrics(MetricKey.MEMORY_USAGE, range.getLength());
                     return StreamUtils.toByteArray(
                         objectContent, this.objectKey, this.range, this.readTimeout);
                   } catch (IOException | TimeoutException e) {
diff --git a/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManager.java b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManager.java
index d54aa47b..82a6b357 100644
--- a/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManager.java
+++ b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManager.java
@@ -21,6 +21,7 @@
 import java.util.Optional;
 import java.util.OptionalLong;
 import lombok.NonNull;
+import software.amazon.s3.analyticsaccelerator.common.Metrics;
 import software.amazon.s3.analyticsaccelerator.common.Preconditions;
 import software.amazon.s3.analyticsaccelerator.common.telemetry.Operation;
 import software.amazon.s3.analyticsaccelerator.common.telemetry.Telemetry;
@@ -32,6 +33,8 @@
 import software.amazon.s3.analyticsaccelerator.request.Range;
 import software.amazon.s3.analyticsaccelerator.request.ReadMode;
 import software.amazon.s3.analyticsaccelerator.request.StreamContext;
+import software.amazon.s3.analyticsaccelerator.util.BlockMetricsHandler;
+import software.amazon.s3.analyticsaccelerator.util.MetricKey;
 import software.amazon.s3.analyticsaccelerator.util.ObjectKey;
 import software.amazon.s3.analyticsaccelerator.util.StreamAttributes;
 
@@ -48,7 +51,8 @@ public class BlockManager implements Closeable {
   private final PhysicalIOConfiguration configuration;
   private final RangeOptimiser rangeOptimiser;
   private StreamContext streamContext;
-
+  private final Metrics blobMetrics;
+  private final BlockMetricsHandler metricsHandler;
   private static final String OPERATION_MAKE_RANGE_AVAILABLE = "block.manager.make.range.available";
 
   /**
@@ -58,6 +62,7 @@ public class BlockManager implements Closeable {
    * @param objectClient object client capable of interacting with the underlying object store
    * @param telemetry an instance of {@link Telemetry} to use
    * @param metadata the metadata for the object we are reading
+   * @param aggregatingMetrics factory metrics
    * @param configuration the physicalIO configuration
    */
   public BlockManager(
@@ -65,8 +70,9 @@ public BlockManager(
       @NonNull ObjectClient objectClient,
       @NonNull ObjectMetadata metadata,
       @NonNull Telemetry telemetry,
-      @NonNull PhysicalIOConfiguration configuration) {
-    this(objectKey, objectClient, metadata, telemetry, configuration, null);
+      @NonNull PhysicalIOConfiguration configuration,
+      @NonNull Metrics aggregatingMetrics) {
+    this(objectKey, objectClient, metadata, telemetry, configuration, aggregatingMetrics, null);
   }
 
   /**
@@ -77,6 +83,7 @@ public BlockManager(
    * @param telemetry an instance of {@link Telemetry} to use
    * @param metadata the metadata for the object
    * @param configuration the physicalIO configuration
+   * @param aggregatingMetrics factory metrics
    * @param streamContext contains audit headers to be attached in the request header
    */
   public BlockManager(
@@ -85,13 +92,16 @@ public BlockManager(
       @NonNull ObjectMetadata metadata,
       @NonNull Telemetry telemetry,
       @NonNull PhysicalIOConfiguration configuration,
+      @NonNull Metrics aggregatingMetrics,
       StreamContext streamContext) {
     this.objectKey = objectKey;
     this.objectClient = objectClient;
     this.metadata = metadata;
     this.telemetry = telemetry;
     this.configuration = configuration;
-    this.blockStore = new BlockStore(objectKey, metadata);
+    this.blobMetrics = new Metrics();
+    this.metricsHandler = new BlockMetricsHandler(blobMetrics, aggregatingMetrics);
+    this.blockStore = new BlockStore(objectKey, metadata, metricsHandler);
     this.patternDetector = new SequentialPatternDetector(blockStore);
     this.sequentialReadProgression = new SequentialReadProgression(configuration);
     this.ioPlanner = new IOPlanner(blockStore);
@@ -99,6 +109,15 @@ public BlockManager(
     this.streamContext = streamContext;
   }
 
+  /**
+   * Returns the memory used by the blob.
+   *
+   * @return the memory used by the blob
+   */
+  public long getMemoryUsageOfBlob() {
+    return blobMetrics.get(MetricKey.MEMORY_USAGE);
+  }
+
   /**
    * Given the position of a byte, return the block holding it.
    *
@@ -209,12 +228,20 @@ public synchronized void makeRangeAvailable(long pos, long len, ReadMode readMod
                     readMode,
                     this.configuration.getBlockReadTimeout(),
                     this.configuration.getBlockReadRetryCount(),
+                    metricsHandler,
                     streamContext);
             blockStore.add(block);
           }
         });
   }
 
+  /*private void updateMetricsCallback(MetricKey metricKey, long value) {
+    if (metricKey.equals(MetricKey.MEMORY_USAGE)) {
+      blobMetrics.add(metricKey, value);
+    }
+    aggregatingMetrics.add(metricKey, value);
+  }*/
+
   private long getLastObjectByte() {
     return this.metadata.getContentLength() - 1;
   }
diff --git a/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockStore.java b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockStore.java
index de30552d..d4f490b1 100644
--- a/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockStore.java
+++ b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockStore.java
@@ -25,6 +25,8 @@
 import org.slf4j.LoggerFactory;
 import software.amazon.s3.analyticsaccelerator.common.Preconditions;
 import software.amazon.s3.analyticsaccelerator.request.ObjectMetadata;
+import software.amazon.s3.analyticsaccelerator.util.BlockMetricsHandler;
+import software.amazon.s3.analyticsaccelerator.util.MetricKey;
 import software.amazon.s3.analyticsaccelerator.util.ObjectKey;
 
 /** A BlockStore, which is a collection of Blocks. */
@@ -35,20 +37,24 @@ public class BlockStore implements Closeable {
   private final ObjectKey s3URI;
   private final ObjectMetadata metadata;
   private final List<Block> blocks;
+  private final BlockMetricsHandler metricsHandler;
 
   /**
    * Constructs a new instance of a BlockStore.
    *
    * @param objectKey the etag and S3 URI of the object
    * @param metadata the metadata for the object
+   * @param metricsHandler callback to update metrics
    */
-  public BlockStore(ObjectKey objectKey, ObjectMetadata metadata) {
+  public BlockStore(
+      ObjectKey objectKey, ObjectMetadata metadata, BlockMetricsHandler metricsHandler) {
     Preconditions.checkNotNull(objectKey, "`objectKey` must not be null");
     Preconditions.checkNotNull(metadata, "`metadata` must not be null");
 
     this.s3URI = objectKey;
     this.metadata = metadata;
     this.blocks = new LinkedList<>();
+    this.metricsHandler = metricsHandler;
   }
 
   /**
@@ -61,7 +67,13 @@ public BlockStore(ObjectKey objectKey, ObjectMetadata metadata) {
   public Optional<Block> getBlock(long pos) {
     Preconditions.checkArgument(0 <= pos, "`pos` must not be negative");
 
-    return blocks.stream().filter(b -> b.contains(pos)).findFirst();
+    Optional<Block> block = blocks.stream().filter(b -> b.contains(pos)).findFirst();
+    if (block.isPresent()) {
+      metricsHandler.updateMetrics(MetricKey.CACHE_HIT, 1L);
+    } else {
+      metricsHandler.updateMetrics(MetricKey.CACHE_MISS, 1L);
+    }
+    return block;
   }
 
   /**
diff --git a/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/util/BlockMetricsHandler.java b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/util/BlockMetricsHandler.java
new file mode 100644
index 00000000..142a7786
--- /dev/null
+++ b/input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/util/BlockMetricsHandler.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.util;
+
+import software.amazon.s3.analyticsaccelerator.common.Metrics;
+
+/**
+ * Handles metrics updates for blocks in the Analytics Accelerator. This class provides thread-safe
+ * operations for updating both blob-specific and aggregated metrics. It relies on the underlying
+ * thread safety of the {@link Metrics} class for concurrent operations.
+ */
+public class BlockMetricsHandler {
+
+  /** Metrics specific to individual blobs */
+  private final Metrics blobMetrics;
+
+  /** Aggregated metrics across all blobs */
+  private final Metrics aggregatingMetrics;
+
+  /**
+   * Constructs a new BlockMetricsHandler.
+   *
+   * @param blobMetrics metrics instance for tracking blob-specific metrics
+   * @param aggregatingMetrics metrics instance for tracking aggregated metrics across all blobs
+   */
+  public BlockMetricsHandler(Metrics blobMetrics, Metrics aggregatingMetrics) {
+    this.blobMetrics = blobMetrics;
+    this.aggregatingMetrics = aggregatingMetrics;
+  }
+
+  /**
+   * Updates both blob-specific and aggregated metrics with the provided value. For memory usage
+   * metrics, updates both blob-specific and aggregated metrics. For other metrics, updates only the
+   * aggregated metrics. This method is thread-safe as it relies on the thread safety of the
+   * underlying {@link Metrics} implementation.
+   *
+   * @param key the metric key to be updated
+   * @param value the value to add to the metric
+   */
+  public void updateMetrics(MetricKey key, long value) {
+    if (key.equals(MetricKey.MEMORY_USAGE)) {
+      blobMetrics.add(key, value);
+    }
+    aggregatingMetrics.add(key, value);
+  }
+}
diff --git a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamFactoryTest.java b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamFactoryTest.java
index 822b08f9..f588b273 100644
--- a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamFactoryTest.java
+++ b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamFactoryTest.java
@@ -16,6 +16,7 @@
 package software.amazon.s3.analyticsaccelerator;
 
 import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
@@ -325,6 +326,82 @@ private static void assertInputStreamHeadException(
                 assertInstanceOf(expectedException.getClass(), underlyingException));
   }
 
+  @Test
+  void testCloseMethodCleanup() throws IOException {
+    // Mock dependencies
+    ObjectClient mockObjectClient = mock(ObjectClient.class);
+
+    // Create factory with mocked dependencies
+    S3SeekableInputStreamFactory factory =
+        new S3SeekableInputStreamFactory(
+            mockObjectClient, S3SeekableInputStreamConfiguration.DEFAULT);
+
+    // Close the factory
+    factory.close();
+  }
+
+  @Test
+  void testParquetColumnPrefetchStore() {
+    ObjectClient mockObjectClient = mock(ObjectClient.class);
+    S3SeekableInputStreamFactory factory =
+        new S3SeekableInputStreamFactory(
+            mockObjectClient, S3SeekableInputStreamConfiguration.DEFAULT);
+
+    assertNotNull(
+        factory.getParquetColumnPrefetchStore(),
+        "ParquetColumnPrefetchStore should be initialized");
+  }
+
+  @Test
+  void testObjectFormatSelector() {
+    ObjectClient mockObjectClient = mock(ObjectClient.class);
+    S3SeekableInputStreamFactory factory =
+        new S3SeekableInputStreamFactory(
+            mockObjectClient, S3SeekableInputStreamConfiguration.DEFAULT);
+
+    assertNotNull(factory.getObjectFormatSelector(), "ObjectFormatSelector should be initialized");
+  }
+
+  @Test
+  void testMultipleStreamCreation() throws IOException {
+    S3SeekableInputStreamFactory factory =
+        new S3SeekableInputStreamFactory(
+            mock(ObjectClient.class), S3SeekableInputStreamConfiguration.DEFAULT);
+
+    // Store metadata
+    factory.storeObjectMetadata(s3URI, objectMetadata);
+
+    // Create multiple streams
+    S3SeekableInputStream stream1 = factory.createStream(s3URI);
+    S3SeekableInputStream stream2 = factory.createStream(s3URI);
+    S3SeekableInputStream stream3 = factory.createStream(s3URI);
+
+    assertNotNull(stream1);
+    assertNotNull(stream2);
+    assertNotNull(stream3);
+    assertNotSame(stream1, stream2, "Streams should be independent instances");
+  }
+
+  @Test
+  void testCloseWithMultipleStreams() throws IOException {
+    S3SeekableInputStreamFactory factory =
+        new S3SeekableInputStreamFactory(
+            mock(ObjectClient.class), S3SeekableInputStreamConfiguration.DEFAULT);
+
+    factory.storeObjectMetadata(s3URI, objectMetadata);
+
+    // Create multiple streams
+    S3SeekableInputStream stream1 = factory.createStream(s3URI);
+    S3SeekableInputStream stream2 = factory.createStream(s3URI);
+
+    // Close factory
+    factory.close();
+
+    // Verify streams can still be used
+    assertDoesNotThrow(() -> stream1.close());
+    assertDoesNotThrow(() -> stream2.close());
+  }
+
   private static Exception[] exceptions() {
     return ExceptionHandler.getSampleExceptions();
   }
diff --git a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamTest.java b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamTest.java
index 80671875..67c036e7 100644
--- a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamTest.java
+++ b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamTest.java
@@ -29,6 +29,7 @@
 import org.junit.jupiter.api.Test;
 import software.amazon.awssdk.utils.IoUtils;
 import software.amazon.awssdk.utils.StringUtils;
+import software.amazon.s3.analyticsaccelerator.common.Metrics;
 import software.amazon.s3.analyticsaccelerator.io.logical.LogicalIO;
 import software.amazon.s3.analyticsaccelerator.io.logical.LogicalIOConfiguration;
 import software.amazon.s3.analyticsaccelerator.io.logical.impl.ParquetColumnPrefetchStore;
@@ -351,7 +352,11 @@ void testMultiThreadUsage() throws IOException, InterruptedException {
     MetadataStore metadataStore =
         new MetadataStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
     BlobStore blobStore =
-        new BlobStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
+        new BlobStore(
+            fakeObjectClient,
+            TestTelemetry.DEFAULT,
+            PhysicalIOConfiguration.DEFAULT,
+            mock(Metrics.class));
 
     AtomicReference<Throwable> thrown = new AtomicReference<>();
 
@@ -448,7 +453,11 @@ private S3SeekableInputStream getTestStreamWithContent(String content, S3URI s3U
     MetadataStore metadataStore =
         new MetadataStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
     BlobStore blobStore =
-        new BlobStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
+        new BlobStore(
+            fakeObjectClient,
+            TestTelemetry.DEFAULT,
+            PhysicalIOConfiguration.DEFAULT,
+            mock(Metrics.class));
 
     return new S3SeekableInputStream(
         s3URI,
diff --git a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamTestBase.java b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamTestBase.java
index 8af7cef1..b70749ce 100644
--- a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamTestBase.java
+++ b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamTestBase.java
@@ -15,7 +15,10 @@
  */
 package software.amazon.s3.analyticsaccelerator;
 
+import static org.mockito.Mockito.mock;
+
 import java.io.IOException;
+import software.amazon.s3.analyticsaccelerator.common.Metrics;
 import software.amazon.s3.analyticsaccelerator.io.logical.LogicalIO;
 import software.amazon.s3.analyticsaccelerator.io.logical.LogicalIOConfiguration;
 import software.amazon.s3.analyticsaccelerator.io.logical.impl.ParquetColumnPrefetchStore;
@@ -37,7 +40,8 @@ public class S3SeekableInputStreamTestBase {
   protected final MetadataStore metadataStore =
       new MetadataStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
   protected final BlobStore blobStore =
-      new BlobStore(fakeObjectClient, TestTelemetry.DEFAULT, physicalIOConfiguration);
+      new BlobStore(
+          fakeObjectClient, TestTelemetry.DEFAULT, physicalIOConfiguration, mock(Metrics.class));
   protected final LogicalIOConfiguration logicalIOConfiguration = LogicalIOConfiguration.DEFAULT;
 
   protected final LogicalIO fakeLogicalIO;
diff --git a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/logical/impl/ParquetLogicalIOImplTest.java b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/logical/impl/ParquetLogicalIOImplTest.java
index 1c0d30db..32262a38 100644
--- a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/logical/impl/ParquetLogicalIOImplTest.java
+++ b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/logical/impl/ParquetLogicalIOImplTest.java
@@ -23,6 +23,7 @@
 import java.util.concurrent.CompletableFuture;
 import org.junit.jupiter.api.Test;
 import software.amazon.s3.analyticsaccelerator.TestTelemetry;
+import software.amazon.s3.analyticsaccelerator.common.Metrics;
 import software.amazon.s3.analyticsaccelerator.io.logical.LogicalIOConfiguration;
 import software.amazon.s3.analyticsaccelerator.io.physical.PhysicalIO;
 import software.amazon.s3.analyticsaccelerator.io.physical.PhysicalIOConfiguration;
@@ -138,7 +139,11 @@ void testMetadaWithZeroContentLength() throws IOException {
     MetadataStore metadataStore =
         new MetadataStore(mockClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
     BlobStore blobStore =
-        new BlobStore(mockClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
+        new BlobStore(
+            mockClient,
+            TestTelemetry.DEFAULT,
+            PhysicalIOConfiguration.DEFAULT,
+            mock(Metrics.class));
     PhysicalIOImpl physicalIO =
         new PhysicalIOImpl(s3URI, metadataStore, blobStore, TestTelemetry.DEFAULT);
     assertDoesNotThrow(
diff --git a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStoreTest.java b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStoreTest.java
index 00822144..55f1bdd3 100644
--- a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStoreTest.java
+++ b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStoreTest.java
@@ -23,17 +23,18 @@
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import software.amazon.s3.analyticsaccelerator.TestTelemetry;
+import software.amazon.s3.analyticsaccelerator.common.Metrics;
 import software.amazon.s3.analyticsaccelerator.common.telemetry.Telemetry;
 import software.amazon.s3.analyticsaccelerator.io.physical.PhysicalIOConfiguration;
 import software.amazon.s3.analyticsaccelerator.request.ObjectClient;
 import software.amazon.s3.analyticsaccelerator.request.ObjectMetadata;
 import software.amazon.s3.analyticsaccelerator.request.StreamContext;
-import software.amazon.s3.analyticsaccelerator.util.FakeObjectClient;
-import software.amazon.s3.analyticsaccelerator.util.ObjectKey;
-import software.amazon.s3.analyticsaccelerator.util.S3URI;
+import software.amazon.s3.analyticsaccelerator.util.*;
 
 @SuppressFBWarnings(
     value = "NP_NONNULL_PARAM_VIOLATION",
@@ -55,23 +56,43 @@ void setUp() throws IOException {
     MetadataStore metadataStore = mock(MetadataStore.class);
     when(metadataStore.get(any()))
         .thenReturn(ObjectMetadata.builder().contentLength(TEST_DATA.length()).etag(ETAG).build());
-    blobStore = new BlobStore(objectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
+    Metrics metrics = new Metrics();
+    blobStore =
+        new BlobStore(
+            objectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT, metrics);
   }
 
   @Test
   void testCreateBoundaries() {
     assertThrows(
         NullPointerException.class,
-        () -> new BlobStore(null, mock(Telemetry.class), mock(PhysicalIOConfiguration.class)));
+        () ->
+            new BlobStore(
+                null,
+                mock(Telemetry.class),
+                mock(PhysicalIOConfiguration.class),
+                mock(Metrics.class)));
     assertThrows(
         NullPointerException.class,
-        () -> new BlobStore(null, mock(Telemetry.class), mock(PhysicalIOConfiguration.class)));
+        () ->
+            new BlobStore(
+                null,
+                mock(Telemetry.class),
+                mock(PhysicalIOConfiguration.class),
+                mock(Metrics.class)));
     assertThrows(
         NullPointerException.class,
-        () -> new BlobStore(mock(ObjectClient.class), null, mock(PhysicalIOConfiguration.class)));
+        () ->
+            new BlobStore(
+                mock(ObjectClient.class),
+                null,
+                mock(PhysicalIOConfiguration.class),
+                mock(Metrics.class)));
     assertThrows(
         NullPointerException.class,
-        () -> new BlobStore(mock(ObjectClient.class), mock(Telemetry.class), null));
+        () ->
+            new BlobStore(
+                mock(ObjectClient.class), mock(Telemetry.class), null, mock(Metrics.class)));
   }
 
   @Test
@@ -108,4 +129,155 @@ void testEvictKey_NonExistingKey() {
     assertFalse(result, "Evicting non-existing key should return false");
     assertEquals(0, blobStore.blobCount(), "Cache should remain empty");
   }
+
+  @Test
+  void testMemoryUsageTracking() throws IOException {
+    // Given: Initial memory usage is 0
+    assertEquals(0, blobStore.getMetrics().get(MetricKey.MEMORY_USAGE));
+
+    // When: Reading data which causes memory allocation
+    Blob blob = blobStore.get(objectKey, objectMetadata, mock(StreamContext.class));
+    byte[] b = new byte[TEST_DATA.length()];
+    blob.read(b, 0, b.length, 0);
+
+    // Then: Memory usage should be updated
+    assertTrue(blobStore.getMetrics().get(MetricKey.MEMORY_USAGE) > 0);
+    assertEquals(TEST_DATA.length(), blobStore.getMetrics().get(MetricKey.MEMORY_USAGE));
+  }
+
+  @Test
+  void testCacheHitsAndMisses() throws IOException {
+    // Given: Initial cache hits and misses are 0
+    assertEquals(0, blobStore.getMetrics().get(MetricKey.CACHE_HIT));
+    assertEquals(0, blobStore.getMetrics().get(MetricKey.CACHE_MISS));
+
+    Blob blob = blobStore.get(objectKey, objectMetadata, mock(StreamContext.class));
+    byte[] b = new byte[TEST_DATA.length()];
+    blob.read(b, 0, b.length, 0);
+
+    // Then: Should record a cache miss
+    assertEquals(1, blobStore.getMetrics().get(MetricKey.CACHE_HIT));
+
+    // When: Second time access to same data (should be a hit)
+    blob.read(b, 0, b.length, 0);
+
+    // Then: Should record a cache hit
+    assertEquals(3, blobStore.getMetrics().get(MetricKey.CACHE_HIT));
+  }
+
+  @Test
+  void testMemoryUsageAfterEviction() throws IOException {
+    final int BLOB_STORE_CAPACITY = 2;
+    PhysicalIOConfiguration config =
+        PhysicalIOConfiguration.builder().blobStoreCapacity(BLOB_STORE_CAPACITY).build();
+
+    ObjectClient objectClient = new FakeObjectClient(TEST_DATA);
+    Metrics metrics = new Metrics();
+    BlobStore blobStore = new BlobStore(objectClient, TestTelemetry.DEFAULT, config, metrics);
+
+    // Create multiple ObjectKeys
+    ObjectKey key1 = ObjectKey.builder().s3URI(S3URI.of("test", "test1")).etag(ETAG).build();
+    ObjectKey key2 = ObjectKey.builder().s3URI(S3URI.of("test", "test2")).etag(ETAG).build();
+    ObjectKey key3 = ObjectKey.builder().s3URI(S3URI.of("test", "test3")).etag(ETAG).build();
+
+    // When: Add blobs up to capacity
+    Blob blob1 = blobStore.get(key1, objectMetadata, mock(StreamContext.class));
+    Blob blob2 = blobStore.get(key2, objectMetadata, mock(StreamContext.class));
+
+    // Force data loading
+    byte[] data = new byte[TEST_DATA.length()];
+    blob1.read(data, 0, data.length, 0);
+    blob2.read(data, 0, data.length, 0);
+
+    // Record initial memory usage
+    long initialMemoryUsage = blobStore.getMetrics().get(MetricKey.MEMORY_USAGE);
+
+    // Then: Adding one more blob should trigger eviction
+    Blob blob3 = blobStore.get(key3, objectMetadata, mock(StreamContext.class));
+    blob3.read(data, 0, data.length, 0);
+
+    // Verify
+    assertEquals(
+        BLOB_STORE_CAPACITY, blobStore.blobCount(), "BlobStore should maintain capacity limit");
+
+    // Verify memory usage decreased after eviction
+    long finalMemoryUsage = blobStore.getMetrics().get(MetricKey.MEMORY_USAGE);
+    System.out.println("Memory final " + finalMemoryUsage);
+
+    assertEquals(
+        initialMemoryUsage, finalMemoryUsage, "Memory usage should decrease after eviction");
+  }
+
+  @Test
+  void testConcurrentMemoryUpdates() throws Exception {
+    // Given: Multiple threads updating memory
+    final int threadCount = 10;
+    final int bytesPerThread = 100;
+    final long expectedTotalMemory =
+        (long) threadCount * bytesPerThread; // Cast to long before multiplication
+    final CountDownLatch latch = new CountDownLatch(threadCount);
+
+    // When: Concurrent memory updates
+    for (int i = 0; i < threadCount; i++) {
+      final int threadId = i;
+      new Thread(
+              () -> {
+                try {
+                  ObjectKey threadKey =
+                      ObjectKey.builder()
+                          .s3URI(S3URI.of("test", "test" + threadId))
+                          .etag(ETAG)
+                          .build();
+                  ObjectMetadata threadMetadata =
+                      ObjectMetadata.builder().contentLength(bytesPerThread).etag(ETAG).build();
+
+                  Blob blob = blobStore.get(threadKey, threadMetadata, mock(StreamContext.class));
+                  byte[] b = new byte[bytesPerThread];
+                  blob.read(b, 0, b.length, 0);
+                } catch (IOException e) {
+                  throw new RuntimeException(e);
+                } finally {
+                  latch.countDown();
+                }
+              })
+          .start();
+    }
+
+    // Then: Wait for all threads and verify total memory
+    assertTrue(latch.await(5, TimeUnit.SECONDS));
+    assertEquals(expectedTotalMemory, blobStore.getMetrics().get(MetricKey.MEMORY_USAGE));
+  }
+
+  @Test
+  void testClose() {
+    // Given: Create multiple blobs and force data loading
+    ObjectKey key1 = ObjectKey.builder().s3URI(S3URI.of("test", "test1")).etag(ETAG).build();
+    ObjectKey key2 = ObjectKey.builder().s3URI(S3URI.of("test", "test2")).etag(ETAG).build();
+
+    Blob blob1 = blobStore.get(key1, objectMetadata, mock(StreamContext.class));
+    Blob blob2 = blobStore.get(key2, objectMetadata, mock(StreamContext.class));
+
+    byte[] data = new byte[TEST_DATA.length()];
+    try {
+
+      for (int i = 0; i <= 10; i++) {
+        blob1.read(data, 0, data.length, 0);
+        blob2.read(data, 0, data.length, 0);
+      }
+
+    } catch (IOException e) {
+      fail("Failed to read data from blobs", e);
+    }
+
+    // Record metrics before close
+    long cacheHits = blobStore.getMetrics().get(MetricKey.CACHE_HIT);
+    long cacheMisses = blobStore.getMetrics().get(MetricKey.CACHE_MISS);
+    double expectedHitRate = MetricComputationUtils.computeCacheHitRate(cacheHits, cacheMisses);
+
+    // When: Close the BlobStore
+    blobStore.close();
+
+    // Then: Verify the hit rate
+    assertEquals(60.0, expectedHitRate, 0.01, "Hit rate should be approximately 60%");
+  }
 }
diff --git a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobTest.java b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobTest.java
index c6aa2795..dc6c5f29 100644
--- a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobTest.java
+++ b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobTest.java
@@ -28,6 +28,7 @@
 import java.util.List;
 import org.junit.jupiter.api.Test;
 import software.amazon.s3.analyticsaccelerator.TestTelemetry;
+import software.amazon.s3.analyticsaccelerator.common.Metrics;
 import software.amazon.s3.analyticsaccelerator.io.physical.PhysicalIOConfiguration;
 import software.amazon.s3.analyticsaccelerator.io.physical.plan.IOPlan;
 import software.amazon.s3.analyticsaccelerator.io.physical.plan.IOPlanExecution;
@@ -176,7 +177,8 @@ private Blob getTestBlob(String data) {
             fakeObjectClient,
             mockMetadataStore,
             TestTelemetry.DEFAULT,
-            PhysicalIOConfiguration.DEFAULT);
+            PhysicalIOConfiguration.DEFAULT,
+            mock(Metrics.class));
 
     return new Blob(objectKey, mockMetadataStore, blockManager, TestTelemetry.DEFAULT);
   }
diff --git a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManagerTest.java b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManagerTest.java
index 7559527f..4dcc4e11 100644
--- a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManagerTest.java
+++ b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManagerTest.java
@@ -33,6 +33,7 @@
 import org.mockito.ArgumentCaptor;
 import software.amazon.awssdk.services.s3.model.S3Exception;
 import software.amazon.s3.analyticsaccelerator.TestTelemetry;
+import software.amazon.s3.analyticsaccelerator.common.Metrics;
 import software.amazon.s3.analyticsaccelerator.common.telemetry.Telemetry;
 import software.amazon.s3.analyticsaccelerator.io.physical.PhysicalIOConfiguration;
 import software.amazon.s3.analyticsaccelerator.request.*;
@@ -58,7 +59,8 @@ void testCreateBoundaries() {
                 mock(ObjectClient.class),
                 mock(ObjectMetadata.class),
                 mock(Telemetry.class),
-                mock(PhysicalIOConfiguration.class)));
+                mock(PhysicalIOConfiguration.class),
+                mock(Metrics.class)));
     assertThrows(
         NullPointerException.class,
         () ->
@@ -67,7 +69,8 @@ void testCreateBoundaries() {
                 null,
                 mock(ObjectMetadata.class),
                 mock(Telemetry.class),
-                mock(PhysicalIOConfiguration.class)));
+                mock(PhysicalIOConfiguration.class),
+                mock(Metrics.class)));
     assertThrows(
         NullPointerException.class,
         () ->
@@ -76,7 +79,8 @@ void testCreateBoundaries() {
                 mock(ObjectClient.class),
                 null,
                 mock(Telemetry.class),
-                mock(PhysicalIOConfiguration.class)));
+                mock(PhysicalIOConfiguration.class),
+                mock(Metrics.class)));
     assertThrows(
         NullPointerException.class,
         () ->
@@ -85,7 +89,8 @@ void testCreateBoundaries() {
                 mock(ObjectClient.class),
                 mock(ObjectMetadata.class),
                 null,
-                mock(PhysicalIOConfiguration.class)));
+                mock(PhysicalIOConfiguration.class),
+                mock(Metrics.class)));
     assertThrows(
         NullPointerException.class,
         () ->
@@ -94,7 +99,8 @@ void testCreateBoundaries() {
                 mock(ObjectClient.class),
                 mock(ObjectMetadata.class),
                 mock(Telemetry.class),
-                null));
+                null,
+                mock(Metrics.class)));
   }
 
   @Test
@@ -286,6 +292,11 @@ private BlockManager getTestBlockManager(
     metadataStore = ObjectMetadata.builder().contentLength(size).etag(ETAG).build();
 
     return new BlockManager(
-        objectKey, objectClient, metadataStore, TestTelemetry.DEFAULT, configuration);
+        objectKey,
+        objectClient,
+        metadataStore,
+        TestTelemetry.DEFAULT,
+        configuration,
+        mock(Metrics.class));
   }
 }
diff --git a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockStoreTest.java b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockStoreTest.java
index 09a64214..5fa7eda8 100644
--- a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockStoreTest.java
+++ b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockStoreTest.java
@@ -15,8 +15,7 @@
  */
 package software.amazon.s3.analyticsaccelerator.io.physical.data;
 
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.times;
@@ -26,15 +25,18 @@
 import java.nio.charset.StandardCharsets;
 import java.util.Optional;
 import java.util.OptionalLong;
+import java.util.concurrent.*;
 import lombok.SneakyThrows;
 import org.junit.jupiter.api.Test;
 import software.amazon.s3.analyticsaccelerator.TestTelemetry;
 import software.amazon.s3.analyticsaccelerator.request.ObjectMetadata;
 import software.amazon.s3.analyticsaccelerator.request.ReadMode;
+import software.amazon.s3.analyticsaccelerator.util.BlockMetricsHandler;
 import software.amazon.s3.analyticsaccelerator.util.FakeObjectClient;
 import software.amazon.s3.analyticsaccelerator.util.ObjectKey;
 import software.amazon.s3.analyticsaccelerator.util.S3URI;
 
+@SuppressWarnings("unchecked")
 public class BlockStoreTest {
 
   private static final S3URI TEST_URI = S3URI.of("foo", "bar");
@@ -51,7 +53,8 @@ public void test__blockStore__getBlockAfterAddBlock() {
     FakeObjectClient fakeObjectClient = new FakeObjectClient("test-data");
     ObjectMetadata mockMetadataStore =
         ObjectMetadata.builder().contentLength(OBJECT_SIZE).etag(ETAG).build();
-    BlockStore blockStore = new BlockStore(objectKey, mockMetadataStore);
+    BlockStore blockStore =
+        new BlockStore(objectKey, mockMetadataStore, mock(BlockMetricsHandler.class));
 
     // When: a new block is added
     blockStore.add(
@@ -64,7 +67,8 @@ public void test__blockStore__getBlockAfterAddBlock() {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT));
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class)));
 
     // Then: getBlock can retrieve the same block
     Optional<Block> b = blockStore.getBlock(4);
@@ -83,7 +87,8 @@ public void test__blockStore__findNextMissingByteCorrect() throws IOException {
     int size = X_TIMES_16.getBytes(StandardCharsets.UTF_8).length;
     ObjectMetadata mockMetadataStore =
         ObjectMetadata.builder().contentLength(size).etag(ETAG).build();
-    BlockStore blockStore = new BlockStore(objectKey, mockMetadataStore);
+    BlockStore blockStore =
+        new BlockStore(objectKey, mockMetadataStore, mock(BlockMetricsHandler.class));
 
     blockStore.add(
         new Block(
@@ -95,7 +100,8 @@ public void test__blockStore__findNextMissingByteCorrect() throws IOException {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT));
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class)));
     blockStore.add(
         new Block(
             objectKey,
@@ -106,7 +112,8 @@ public void test__blockStore__findNextMissingByteCorrect() throws IOException {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT));
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class)));
     blockStore.add(
         new Block(
             objectKey,
@@ -117,7 +124,8 @@ public void test__blockStore__findNextMissingByteCorrect() throws IOException {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT));
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class)));
 
     // When & Then: we query for the next missing byte, the result is correct
     assertEquals(OptionalLong.of(0), blockStore.findNextMissingByte(0));
@@ -138,7 +146,8 @@ public void test__blockStore__findNextAvailableByteCorrect() {
     FakeObjectClient fakeObjectClient = new FakeObjectClient(X_TIMES_16);
     ObjectMetadata mockMetadataStore =
         ObjectMetadata.builder().contentLength(OBJECT_SIZE).etag(ETAG).build();
-    BlockStore blockStore = new BlockStore(objectKey, mockMetadataStore);
+    BlockStore blockStore =
+        new BlockStore(objectKey, mockMetadataStore, mock(BlockMetricsHandler.class));
 
     blockStore.add(
         new Block(
@@ -150,7 +159,8 @@ public void test__blockStore__findNextAvailableByteCorrect() {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT));
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class)));
     blockStore.add(
         new Block(
             objectKey,
@@ -161,7 +171,8 @@ public void test__blockStore__findNextAvailableByteCorrect() {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT));
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class)));
     blockStore.add(
         new Block(
             objectKey,
@@ -172,7 +183,8 @@ public void test__blockStore__findNextAvailableByteCorrect() {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT));
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class)));
 
     // When & Then: we query for the next available byte, the result is correct
     assertEquals(OptionalLong.of(2), blockStore.findNextLoadedByte(0));
@@ -190,7 +202,8 @@ public void test__blockStore__closesBlocks() {
     // Given: BlockStore with a block
     ObjectMetadata mockMetadataStore =
         ObjectMetadata.builder().contentLength(OBJECT_SIZE).etag(ETAG).build();
-    BlockStore blockStore = new BlockStore(objectKey, mockMetadataStore);
+    BlockStore blockStore =
+        new BlockStore(objectKey, mockMetadataStore, mock(BlockMetricsHandler.class));
     Block block = mock(Block.class);
     blockStore.add(block);
 
@@ -206,7 +219,8 @@ public void test__blockStore__closeWorksWithExceptions() {
     // Given: BlockStore with two blocks
     ObjectMetadata mockMetadataStore =
         ObjectMetadata.builder().contentLength(OBJECT_SIZE).etag(ETAG).build();
-    BlockStore blockStore = new BlockStore(objectKey, mockMetadataStore);
+    BlockStore blockStore =
+        new BlockStore(objectKey, mockMetadataStore, mock(BlockMetricsHandler.class));
     Block b1 = mock(Block.class);
     Block b2 = mock(Block.class);
     blockStore.add(b1);
diff --git a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockTest.java b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockTest.java
index 1f521983..2a260c87 100644
--- a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockTest.java
+++ b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockTest.java
@@ -16,24 +16,23 @@
 package software.amazon.s3.analyticsaccelerator.io.physical.data;
 
 import static org.junit.jupiter.api.Assertions.*;
-import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.mock;
 
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 import java.io.IOException;
 import java.nio.charset.StandardCharsets;
+import java.util.concurrent.*;
 import lombok.SneakyThrows;
 import org.junit.jupiter.api.Test;
 import software.amazon.s3.analyticsaccelerator.TestTelemetry;
 import software.amazon.s3.analyticsaccelerator.request.ObjectClient;
 import software.amazon.s3.analyticsaccelerator.request.ReadMode;
-import software.amazon.s3.analyticsaccelerator.util.FakeObjectClient;
-import software.amazon.s3.analyticsaccelerator.util.FakeStuckObjectClient;
-import software.amazon.s3.analyticsaccelerator.util.ObjectKey;
-import software.amazon.s3.analyticsaccelerator.util.S3URI;
+import software.amazon.s3.analyticsaccelerator.util.*;
 
 @SuppressFBWarnings(
     value = "NP_NONNULL_PARAM_VIOLATION",
     justification = "We mean to pass nulls to checks")
+@SuppressWarnings("unchecked")
 public class BlockTest {
   private static final S3URI TEST_URI = S3URI.of("foo", "bar");
   private static final String ETAG = "RandomString";
@@ -56,7 +55,8 @@ public void testSingleByteReadReturnsCorrectByte() throws IOException {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT);
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class));
 
     // When: bytes are requested from the block
     int r1 = block.read(0);
@@ -84,7 +84,8 @@ public void testBufferedReadReturnsCorrectBytes() throws IOException {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT);
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class));
 
     // When: bytes are requested from the block
     byte[] b1 = new byte[4];
@@ -179,7 +180,7 @@ void testBoundaries() {
                 ReadMode.SYNC,
                 DEFAULT_READ_TIMEOUT,
                 DEFAULT_READ_RETRY_COUNT,
-                null));
+                mock(BlockMetricsHandler.class)));
     assertThrows(
         IllegalArgumentException.class,
         () ->
@@ -193,7 +194,7 @@ void testBoundaries() {
                 ReadMode.SYNC,
                 DEFAULT_READ_TIMEOUT,
                 DEFAULT_READ_RETRY_COUNT,
-                null));
+                mock(BlockMetricsHandler.class)));
     assertThrows(
         IllegalArgumentException.class,
         () ->
@@ -206,7 +207,8 @@ void testBoundaries() {
                 0,
                 ReadMode.SYNC,
                 DEFAULT_READ_TIMEOUT,
-                DEFAULT_READ_RETRY_COUNT));
+                DEFAULT_READ_RETRY_COUNT,
+                mock(BlockMetricsHandler.class)));
     assertThrows(
         IllegalArgumentException.class,
         () ->
@@ -219,7 +221,8 @@ void testBoundaries() {
                 -1,
                 ReadMode.SYNC,
                 DEFAULT_READ_TIMEOUT,
-                DEFAULT_READ_RETRY_COUNT));
+                DEFAULT_READ_RETRY_COUNT,
+                mock(BlockMetricsHandler.class)));
     assertThrows(
         IllegalArgumentException.class,
         () ->
@@ -233,7 +236,7 @@ void testBoundaries() {
                 ReadMode.SYNC,
                 DEFAULT_READ_TIMEOUT,
                 DEFAULT_READ_RETRY_COUNT,
-                null));
+                mock(BlockMetricsHandler.class)));
   }
 
   @SneakyThrows
@@ -252,7 +255,8 @@ void testReadBoundaries() {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT);
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class));
     assertThrows(IllegalArgumentException.class, () -> block.read(-10));
     assertThrows(NullPointerException.class, () -> block.read(null, 0, 3, 1));
     assertThrows(IllegalArgumentException.class, () -> block.read(b, -5, 3, 1));
@@ -275,7 +279,8 @@ void testContains() {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT);
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class));
     assertTrue(block.contains(0));
     assertFalse(block.contains(TEST_DATA.length() + 1));
   }
@@ -295,7 +300,8 @@ void testContainsBoundaries() {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT);
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class));
     assertThrows(IllegalArgumentException.class, () -> block.contains(-1));
   }
 
@@ -316,7 +322,8 @@ void testReadTimeoutAndRetry() throws IOException {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT);
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class));
     assertThrows(IOException.class, () -> block.read(4));
   }
 
@@ -335,7 +342,8 @@ void testClose() {
             0,
             ReadMode.SYNC,
             DEFAULT_READ_TIMEOUT,
-            DEFAULT_READ_RETRY_COUNT);
+            DEFAULT_READ_RETRY_COUNT,
+            mock(BlockMetricsHandler.class));
     block.close();
     block.close();
   }
diff --git a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/IOPlannerTest.java b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/IOPlannerTest.java
index 6a7410e2..dc3b1185 100644
--- a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/IOPlannerTest.java
+++ b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/data/IOPlannerTest.java
@@ -16,6 +16,7 @@
 package software.amazon.s3.analyticsaccelerator.io.physical.data;
 
 import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.mock;
 
 import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 import java.io.IOException;
@@ -27,6 +28,7 @@
 import software.amazon.s3.analyticsaccelerator.request.ObjectMetadata;
 import software.amazon.s3.analyticsaccelerator.request.Range;
 import software.amazon.s3.analyticsaccelerator.request.ReadMode;
+import software.amazon.s3.analyticsaccelerator.util.BlockMetricsHandler;
 import software.amazon.s3.analyticsaccelerator.util.FakeObjectClient;
 import software.amazon.s3.analyticsaccelerator.util.ObjectKey;
 import software.amazon.s3.analyticsaccelerator.util.S3URI;
@@ -34,6 +36,7 @@
 @SuppressFBWarnings(
     value = "NP_NONNULL_PARAM_VIOLATION",
     justification = "We mean to pass nulls to checks")
+@SuppressWarnings("unchecked")
 public class IOPlannerTest {
   private static final S3URI TEST_URI = S3URI.of("foo", "bar");
   private static final String ETAG = "RandomString";
@@ -50,7 +53,8 @@ void testPlanReadBoundaries() throws IOException {
     final int OBJECT_SIZE = 10_000;
     ObjectMetadata mockMetadataStore =
         ObjectMetadata.builder().contentLength(OBJECT_SIZE).etag(ETAG).build();
-    BlockStore blockStore = new BlockStore(objectKey, mockMetadataStore);
+    BlockStore blockStore =
+        new BlockStore(objectKey, mockMetadataStore, mock(BlockMetricsHandler.class));
     IOPlanner ioPlanner = new IOPlanner(blockStore);
 
     assertThrows(IllegalArgumentException.class, () -> ioPlanner.planRead(-5, 10, 100));
@@ -64,7 +68,8 @@ public void testPlanReadNoopWhenBlockStoreEmpty() throws IOException {
     final int OBJECT_SIZE = 10_000;
     ObjectMetadata mockMetadataStore =
         ObjectMetadata.builder().contentLength(OBJECT_SIZE).etag(ETAG).build();
-    BlockStore blockStore = new BlockStore(objectKey, mockMetadataStore);
+    BlockStore blockStore =
+        new BlockStore(objectKey, mockMetadataStore, mock(BlockMetricsHandler.class));
     IOPlanner ioPlanner = new IOPlanner(blockStore);
 
     // When: a read plan is requested for a range
@@ -84,7 +89,8 @@ public void testPlanReadDoesNotDoubleRead() throws IOException {
     byte[] content = new byte[OBJECT_SIZE];
     ObjectMetadata mockMetadataStore =
         ObjectMetadata.builder().contentLength(OBJECT_SIZE).etag(ETAG).build();
-    BlockStore blockStore = new BlockStore(objectKey, mockMetadataStore);
+    BlockStore blockStore =
+        new BlockStore(objectKey, mockMetadataStore, mock(BlockMetricsHandler.class));
     FakeObjectClient fakeObjectClient =
         new FakeObjectClient(new String(content, StandardCharsets.UTF_8));
     blockStore.add(
@@ -97,7 +103,8 @@ public void testPlanReadDoesNotDoubleRead() throws IOException {
             0,
             ReadMode.SYNC,
             120_000,
-            20));
+            20,
+            mock(BlockMetricsHandler.class)));
     IOPlanner ioPlanner = new IOPlanner(blockStore);
 
     // When: a read plan is requested for a range (0, 400)
@@ -117,7 +124,8 @@ public void testPlanReadRegressionSingleByteObject() throws IOException {
     final int OBJECT_SIZE = 1;
     ObjectMetadata mockMetadataStore =
         ObjectMetadata.builder().contentLength(OBJECT_SIZE).etag(ETAG).build();
-    BlockStore blockStore = new BlockStore(objectKey, mockMetadataStore);
+    BlockStore blockStore =
+        new BlockStore(objectKey, mockMetadataStore, mock(BlockMetricsHandler.class));
     IOPlanner ioPlanner = new IOPlanner(blockStore);
 
     // When: a read plan is requested for a range (0, 400)
diff --git a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/impl/PhysicalIOImplTest.java b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/impl/PhysicalIOImplTest.java
index 191893f4..476e59d4 100644
--- a/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/impl/PhysicalIOImplTest.java
+++ b/input-stream/src/test/java/software/amazon/s3/analyticsaccelerator/io/physical/impl/PhysicalIOImplTest.java
@@ -33,12 +33,14 @@
 import software.amazon.awssdk.services.s3.model.S3Exception;
 import software.amazon.s3.analyticsaccelerator.S3SdkObjectClient;
 import software.amazon.s3.analyticsaccelerator.TestTelemetry;
+import software.amazon.s3.analyticsaccelerator.common.Metrics;
 import software.amazon.s3.analyticsaccelerator.io.physical.PhysicalIOConfiguration;
 import software.amazon.s3.analyticsaccelerator.io.physical.data.BlobStore;
 import software.amazon.s3.analyticsaccelerator.io.physical.data.MetadataStore;
 import software.amazon.s3.analyticsaccelerator.request.ObjectMetadata;
 import software.amazon.s3.analyticsaccelerator.request.StreamContext;
 import software.amazon.s3.analyticsaccelerator.util.FakeObjectClient;
+import software.amazon.s3.analyticsaccelerator.util.MetricKey;
 import software.amazon.s3.analyticsaccelerator.util.S3URI;
 
 @SuppressFBWarnings(
@@ -121,7 +123,11 @@ public void test__readSingleByte_isCorrect() throws IOException {
     MetadataStore metadataStore =
         new MetadataStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
     BlobStore blobStore =
-        new BlobStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
+        new BlobStore(
+            fakeObjectClient,
+            TestTelemetry.DEFAULT,
+            PhysicalIOConfiguration.DEFAULT,
+            mock(Metrics.class));
     PhysicalIOImpl physicalIOImplV2 =
         new PhysicalIOImpl(s3URI, metadataStore, blobStore, TestTelemetry.DEFAULT);
 
@@ -140,7 +146,11 @@ public void test__regression_singleByteStream() throws IOException {
     MetadataStore metadataStore =
         new MetadataStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
     BlobStore blobStore =
-        new BlobStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
+        new BlobStore(
+            fakeObjectClient,
+            TestTelemetry.DEFAULT,
+            PhysicalIOConfiguration.DEFAULT,
+            mock(Metrics.class));
     PhysicalIOImpl physicalIOImplV2 =
         new PhysicalIOImpl(s3URI, metadataStore, blobStore, TestTelemetry.DEFAULT);
 
@@ -156,7 +166,11 @@ void testReadWithBuffer() throws IOException {
     MetadataStore metadataStore =
         new MetadataStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
     BlobStore blobStore =
-        new BlobStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
+        new BlobStore(
+            fakeObjectClient,
+            TestTelemetry.DEFAULT,
+            PhysicalIOConfiguration.DEFAULT,
+            mock(Metrics.class));
     PhysicalIOImpl physicalIOImplV2 =
         new PhysicalIOImpl(s3URI, metadataStore, blobStore, TestTelemetry.DEFAULT);
 
@@ -171,7 +185,11 @@ void testReadTail() throws IOException {
     MetadataStore metadataStore =
         new MetadataStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
     BlobStore blobStore =
-        new BlobStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
+        new BlobStore(
+            fakeObjectClient,
+            TestTelemetry.DEFAULT,
+            PhysicalIOConfiguration.DEFAULT,
+            mock(Metrics.class));
     PhysicalIOImpl physicalIOImplV2 =
         new PhysicalIOImpl(s3URI, metadataStore, blobStore, TestTelemetry.DEFAULT);
     byte[] buffer = new byte[5];
@@ -209,7 +227,8 @@ public void test_FailureEvictsObjectsAsExpected() throws IOException {
     ObjectMetadata objectMetadata = ObjectMetadata.builder().contentLength(100).etag(etag).build();
     metadataStore.storeObjectMetadata(s3URI, objectMetadata);
     BlobStore blobStore =
-        new BlobStore(client, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
+        new BlobStore(
+            client, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT, mock(Metrics.class));
     PhysicalIOImpl physicalIOImplV2 =
         new PhysicalIOImpl(s3URI, metadataStore, blobStore, TestTelemetry.DEFAULT);
 
@@ -237,7 +256,8 @@ public void test_FailureEvictsObjectsAsExpected_WhenSDKClientGetsStuck() throws
     ObjectMetadata objectMetadata = ObjectMetadata.builder().contentLength(100).etag(etag).build();
     metadataStore.storeObjectMetadata(s3URI, objectMetadata);
     BlobStore blobStore =
-        new BlobStore(client, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
+        new BlobStore(
+            client, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT, mock(Metrics.class));
     PhysicalIOImpl physicalIOImplV2 =
         new PhysicalIOImpl(s3URI, metadataStore, blobStore, TestTelemetry.DEFAULT);
 
@@ -245,4 +265,64 @@ public void test_FailureEvictsObjectsAsExpected_WhenSDKClientGetsStuck() throws
     assertEquals(0, blobStore.blobCount());
     assertThrows(Exception.class, () -> metadataStore.get(s3URI));
   }
+
+  @Test
+  void testClose_WithoutEviction() throws IOException {
+    // Given
+    final String TEST_DATA = "test";
+    FakeObjectClient fakeObjectClient = new FakeObjectClient(TEST_DATA);
+    MetadataStore metadataStore =
+        new MetadataStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
+    Metrics metrics = new Metrics();
+    BlobStore blobStore =
+        new BlobStore(
+            fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT, metrics);
+    PhysicalIOImpl physicalIO =
+        new PhysicalIOImpl(s3URI, metadataStore, blobStore, TestTelemetry.DEFAULT);
+
+    // When: Read data to ensure blob is created
+    byte[] buffer = new byte[4];
+    physicalIO.read(buffer, 0, 4, 0);
+
+    // Then: Memory usage should equal bytes read
+    assertEquals(
+        TEST_DATA.length(),
+        metrics.get(MetricKey.MEMORY_USAGE),
+        "Memory usage should equal bytes read");
+
+    // When: Close without eviction
+    physicalIO.close();
+
+    // Then: Memory usage should remain unchanged
+    assertEquals(
+        TEST_DATA.length(),
+        metrics.get(MetricKey.MEMORY_USAGE),
+        "Memory usage should remain unchanged after close without eviction");
+  }
+
+  @Test
+  void testPartialRead() throws IOException {
+    // Given
+    final String TEST_DATA = "test data longer than buffer";
+    FakeObjectClient fakeObjectClient = new FakeObjectClient(TEST_DATA);
+    MetadataStore metadataStore =
+        new MetadataStore(fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT);
+    Metrics metrics = new Metrics();
+    BlobStore blobStore =
+        new BlobStore(
+            fakeObjectClient, TestTelemetry.DEFAULT, PhysicalIOConfiguration.DEFAULT, metrics);
+    PhysicalIOImpl physicalIO =
+        new PhysicalIOImpl(s3URI, metadataStore, blobStore, TestTelemetry.DEFAULT);
+
+    // When: Read partial data
+    byte[] buffer = new byte[4];
+    int bytesRead = physicalIO.read(buffer, 0, 4, 0);
+
+    // Then: Memory usage should equal total data length, not just bytes read
+    assertEquals(
+        TEST_DATA.length(),
+        metrics.get(MetricKey.MEMORY_USAGE),
+        "Memory usage should equal total data length");
+    assertEquals(4, bytesRead, "Should have read requested number of bytes");
+  }
 }


Reviews:
user: login: fuatbasik
body: Thanks a lot @rajdchak this is awesome. I think we should not violate BlockManager-BlockStore encapsulation and avoid accessing to Store and Blocks directly. Other than these an awesome change. 
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T09:20:01Z
user: login: fuatbasik
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-17T17:45:36Z
user: login: fuatbasik
body: Hi @rajdchak ,
I had another look and put some more comments. In my opinion, you should try to avoid statics and global variables as much as possible as they make it harder to debug and reason about the correctness. Another thing is, In the current structure we have States (BlobStore/BlockStore) and StateManagers(BlobManager, BlockManager). We should avoid accessing state out of control of the Manager. So if you need to access BlobStore, it should be through BlobManager.


My main concerns are
1/ You do not need any Statics at CacheStats and MemoryStats. 
2/ You do not need to pass a global CacheStats or MemoryStats. You can aggregate all metrics at a higher level therefore, it is OK to create 1 CacheStats and 1 MemoryStats metric per Blob and have AggregatingCacheStats and AggregatingMemoryStats at BlobStore level. When metrics asked, BlobStore will aggregate metrics per Blob. Current version creates an interesting dependency in between the classes. 

I also like to hear from others. @ahmarsuhail @stubz151 
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T09:30:09Z
user: login: rajdchak
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T11:15:38Z
user: login: rajdchak
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T11:17:43Z
user: login: fuatbasik
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T14:13:00Z
user: login: rajdchak
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T00:55:10Z
user: login: ahmarsuhail
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T10:30:52Z
user: login: fuatbasik
body: thanks a lot @rajdchak . Approved!
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-04-24T08:22:53Z
user: login: ahmarsuhail
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T08:39:59Z
user: login: stubz151
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T08:53:49Z
user: login: ahmarsuhail
body: just a couple of minor changes!
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T09:17:33Z
user: login: rajdchak
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T12:31:08Z
user: login: rajdchak
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T12:36:31Z
user: login: rajdchak
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T12:57:58Z
user: login: fuatbasik
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-04-24T13:46:38Z
user: login: ahmarsuhail
body: +1, LGTM
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T13:53:02Z
user: login: fuatbasik
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-25T08:28:39Z

Review comments:
diff_hunk: @@ -0,0 +1,56 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.stats;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Tracks memory usage statistics across all blob maps in the system. This class provides
+ * thread-safe methods to record, retrieve, and reset memory usage statistics.
+ */
+public class MemoryUsageStats {
+  /** Atomic counter for tracking total memory usage across all blob maps. */
+  private static final AtomicLong memoryUsageAcrossBlobMap = new AtomicLong(0);
path: common/src/main/java/software/amazon/s3/analyticsaccelerator/stats/MemoryUsageStats.java
user: login: fuatbasik
body: nit: Maybe drop BlobMap from the name and call it memoryUsage only. This is part of Common package which doesn't know about the BlobMap at all. 
created_at: 2025-04-17T09:08:41Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,140 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.request;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+@SuppressFBWarnings(
+    value = "NP_NONNULL_PARAM_VIOLATION",
+    justification = "Null values are intentionally tested")
+class ObjectMetadataTest {
path: common/src/test/java/software/amazon/s3/analyticsaccelerator/request/ObjectMetadataTest.java
user: login: fuatbasik
body: wait a sec. We didn't have this before? Thanks a ton!
created_at: 2025-04-17T09:09:35Z
author_association: COLLABORATOR
diff_hunk: @@ -72,7 +73,9 @@ public Blob(
   public int read(long pos) throws IOException {
     Preconditions.checkArgument(pos >= 0, "`pos` must be non-negative");
     blockManager.makePositionAvailable(pos, ReadMode.SYNC);
-    return blockManager.getBlock(pos).get().read(pos);
+    Block block = blockManager.getBlock(pos).get();
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/Blob.java
user: login: fuatbasik
body: asking for my learning purposes, why are we changing here?
created_at: 2025-04-17T09:11:13Z
author_association: COLLABORATOR
diff_hunk: @@ -57,7 +63,23 @@ public BlobStore(
             new LinkedHashMap<ObjectKey, Blob>() {
               @Override
               protected boolean removeEldestEntry(final Map.Entry<ObjectKey, Blob> eldest) {
-                return this.size() > configuration.getBlobStoreCapacity();
+                boolean shouldRemove = this.size() > configuration.getBlobStoreCapacity();
+                if (shouldRemove) {
+                  Blob blobToRemove = eldest.getValue();
+                  long blobMemoryToSubtract = 0;
+
+                  // Calculate memory used by this blob
+                  List<Block> blocks = blobToRemove.getBlockManager().getBlockStore().getBlocks();
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
user: login: fuatbasik
body: wait a sec. do we need to move block by block here when removing? Can we add a filed to Blob (sumOfBytes) and update it when adding, and just substract here when removing?
created_at: 2025-04-17T09:15:16Z
author_association: COLLABORATOR
diff_hunk: @@ -57,7 +63,23 @@ public BlobStore(
             new LinkedHashMap<ObjectKey, Blob>() {
               @Override
               protected boolean removeEldestEntry(final Map.Entry<ObjectKey, Blob> eldest) {
-                return this.size() > configuration.getBlobStoreCapacity();
+                boolean shouldRemove = this.size() > configuration.getBlobStoreCapacity();
+                if (shouldRemove) {
+                  Blob blobToRemove = eldest.getValue();
+                  long blobMemoryToSubtract = 0;
+
+                  // Calculate memory used by this blob
+                  List<Block> blocks = blobToRemove.getBlockManager().getBlockStore().getBlocks();
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
user: login: fuatbasik
body: i think this is even risky to iterate through blocks out of control of the BlockManager/BlockStore, worst case we should write a method to blockStore `getTotalSize` and call it from here. 
created_at: 2025-04-17T09:16:28Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,56 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.stats;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Tracks memory usage statistics across all blob maps in the system. This class provides
+ * thread-safe methods to record, retrieve, and reset memory usage statistics.
+ */
+public class MemoryUsageStats {
+  /** Atomic counter for tracking total memory usage across all blob maps. */
+  private static final AtomicLong memoryUsageAcrossBlobMap = new AtomicLong(0);
path: common/src/main/java/software/amazon/s3/analyticsaccelerator/stats/MemoryUsageStats.java
user: login: fuatbasik
body: I am sorry but that doesn't make much sense to me. Why not create an instance of this object (make fields non-static) in the blobMap and call it blobMapMemoryTracker and have increment decrement methods. I do not agree that the fieldName of this class should be consumer specific.
created_at: 2025-04-17T17:45:36Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,75 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.stats;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * CacheStats provides methods to record and retrieve cache hit and miss statistics. This class uses
+ * static methods and fields to maintain global statistics across the application.
+ */
+public class CacheStats {
+  /** Atomic counter for cache hits. */
+  private static final AtomicLong cacheHits = new AtomicLong(0);
path: common/src/main/java/software/amazon/s3/analyticsaccelerator/stats/CacheStats.java
user: login: fuatbasik
body: why these fields should be static?
created_at: 2025-04-17T17:46:40Z
author_association: COLLABORATOR
diff_hunk: @@ -177,8 +179,14 @@ void storeObjectMetadata(S3URI s3URI, ObjectMetadata metadata) {
    */
   @Override
   public void close() throws IOException {
+    LOG.debug(
+        "Cache Hits: {}, Misses: {}, Hit Rate: {}%",
+        CacheStats.getHits(), CacheStats.getMisses(), CacheStats.getHitRate() * 100);
+
     this.objectMetadataStore.close();
     this.objectBlobStore.close();
     this.telemetry.close();
+    MemoryUsageStats.resetStats();
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/S3SeekableInputStreamFactory.java
user: login: fuatbasik
body: do we have to use static methods here? I am not sure if I am following the need for static across the change.
created_at: 2025-04-17T17:47:55Z
author_association: COLLABORATOR
diff_hunk: @@ -61,7 +62,13 @@ public BlockStore(ObjectKey objectKey, ObjectMetadata metadata) {
   public Optional<Block> getBlock(long pos) {
     Preconditions.checkArgument(0 <= pos, "`pos` must not be negative");
 
-    return blocks.stream().filter(b -> b.contains(pos)).findFirst();
+    Optional<Block> block = blocks.stream().filter(b -> b.contains(pos)).findFirst();
+    if (block.isPresent()) {
+      CacheStats.recordHit();
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockStore.java
user: login: fuatbasik
body: should we record a cache hit if block is there or if blocks data is there?
created_at: 2025-04-17T17:49:26Z
author_association: COLLABORATOR
diff_hunk: @@ -86,7 +90,8 @@ public Block(
       long generation,
       @NonNull ReadMode readMode,
       long readTimeout,
-      int readRetryCount)
+      int readRetryCount,
+      AtomicLong memoryUsage)
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/Block.java
user: login: fuatbasik
body: why take this as a parameter? Do we ever expect to have a non-zero value here?
created_at: 2025-04-17T17:50:54Z
author_association: COLLABORATOR
diff_hunk: @@ -34,7 +38,8 @@
     justification =
         "Inner class is created very infrequently, and fluency justifies the extra pointer")
 public class BlobStore implements Closeable {
-  private final Map<ObjectKey, Blob> blobMap;
+  @Getter private final Map<ObjectKey, Blob> blobMap;
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
user: login: fuatbasik
body: i do not think you should have public access to blobmap. Any operation should be done through BlobStore/BlobManager. 
created_at: 2025-04-22T09:16:25Z
author_association: COLLABORATOR
diff_hunk: @@ -61,7 +62,13 @@ public BlockStore(ObjectKey objectKey, ObjectMetadata metadata) {
   public Optional<Block> getBlock(long pos) {
     Preconditions.checkArgument(0 <= pos, "`pos` must not be negative");
 
-    return blocks.stream().filter(b -> b.contains(pos)).findFirst();
+    Optional<Block> block = blocks.stream().filter(b -> b.contains(pos)).findFirst();
+    if (block.isPresent()) {
+      CacheStats.recordHit();
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockStore.java
user: login: rajdchak
body: if block is there, but block data is still being fetched, we cannot record that as a miss right. So, imo recording a hit if a block is present should be fine.
created_at: 2025-04-22T11:15:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -34,7 +38,8 @@
     justification =
         "Inner class is created very infrequently, and fluency justifies the extra pointer")
 public class BlobStore implements Closeable {
-  private final Map<ObjectKey, Blob> blobMap;
+  @Getter private final Map<ObjectKey, Blob> blobMap;
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
user: login: rajdchak
body: I just needed this for the tests and not any other code logic, l will change this.
created_at: 2025-04-22T11:17:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -61,7 +62,13 @@ public BlockStore(ObjectKey objectKey, ObjectMetadata metadata) {
   public Optional<Block> getBlock(long pos) {
     Preconditions.checkArgument(0 <= pos, "`pos` must not be negative");
 
-    return blocks.stream().filter(b -> b.contains(pos)).findFirst();
+    Optional<Block> block = blocks.stream().filter(b -> b.contains(pos)).findFirst();
+    if (block.isPresent()) {
+      CacheStats.recordHit();
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockStore.java
user: login: fuatbasik
body: Going forward, we probably need to think about this in more detail. Having block ready but bytes not fetched are probably is not good enough.
created_at: 2025-04-22T14:13:00Z
author_association: COLLABORATOR
diff_hunk: @@ -105,6 +136,33 @@ public int blobCount() {
   /** Closes the {@link BlobStore} and frees up all resources it holds. */
   @Override
   public void close() {
+    long hits = cacheHits.get();
+    long hitsAndMiss = hits + cacheMiss.get();
+    double hitRate = hitsAndMiss == 0 ? 0 : (double) hits / hitsAndMiss;
+    LOG.debug(
+        "Cache Hits: {}, Misses: {}, Hit Rate: {}%",
+        cacheHits.get(), cacheMiss.get(), hitRate * 100);
     blobMap.forEach((k, v) -> v.close());
   }
+
+  /** Callbacks from block manager instances. */
+  public class BlobStoreCallbacks {
+
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
user: login: rajdchak
body: With memory manager changes this class will be extended to have methods to update the index cache.
created_at: 2025-04-23T00:55:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -52,15 +62,30 @@ public BlobStore(
       @NonNull PhysicalIOConfiguration configuration) {
     this.objectClient = objectClient;
     this.telemetry = telemetry;
+    this.blobStoreMemoryUsage = new AtomicLong(0);
+    this.cacheHits = new AtomicInteger(0);
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
user: login: ahmarsuhail
body: I read don't think these values belong here. Taking a long term view, you want a IoStats interface + class, which gets updated. Look at how S3A does it, as an example. But here's how it should work. 

You have a IoStats class, you instantiate it when you create a factory. This is the class that holds all values. Currently you're only measuring cache hits. in the future we will want to know how many GETs, how many bytes we read etc etc. if we start putting these values in our main classes this is going to get very messy. 

All classes that want to update statistics, should use callbacks to update these IoStats. 

I understand that here you want statistics per blobMap, S3A has a way of doing this too. it has this concept called IoStreamStatistics, those are statistics per IoStream. you probably want something similar here. And then on the blobstore close, you can push those up to a factory statistics. 

I know this more work than you signed up for, and it's not as straightforward. But I don't think we should do it this way. 
created_at: 2025-04-23T10:15:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,6 +136,35 @@ public int blobCount() {
   /** Closes the {@link BlobStore} and frees up all resources it holds. */
   @Override
   public void close() {
+    long hits = cacheHits.get();
+    long hitsAndMiss = hits + cacheMiss.get();
+    double hitRate = hitsAndMiss == 0 ? 0 : (double) hits / hitsAndMiss;
+    LOG.debug(
+        "Cache Hits: {}, Misses: {}, Hit Rate: {}%",
+        cacheHits.get(), cacheMiss.get(), hitRate * 100);
     blobMap.forEach((k, v) -> v.close());
   }
+
+  /** Callbacks from block manager instances. */
+  public class BlobStoreCallbacks {
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
user: login: ahmarsuhail
body: similar to above, we should put all IoStats methods in one place, and they shouldn't be in this class. 
created_at: 2025-04-23T10:24:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -230,4 +243,28 @@ private long truncatePos(long pos) {
   public void close() {
     blockStore.close();
   }
+
+  /** Callbacks from block instances. */
+  public class BlockManagerCallback {
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManager.java
user: login: ahmarsuhail
body: having that IoStats class should you don't need to define callbacks twice
created_at: 2025-04-23T10:30:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,67 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package software.amazon.s3.analyticsaccelerator.common;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+import software.amazon.s3.analyticsaccelerator.util.MetricKey;
+
+/**
+ * A thread-safe metrics collection class that maintains counters for different types of metrics.
+ * Each metric is identified by a {@link MetricKey} and stored as an {@link AtomicLong} value.
+ */
+public class Metrics {
+
+  /**
+   * Thread-safe map storing metric values. Keys are {@link MetricKey} enums and values are {@link
+   * AtomicLong} counters for each metric.
+   */
+  private final Map<MetricKey, AtomicLong> metrics = new ConcurrentHashMap<>();
+
+  /**
+   * Adds the specified delta to the metric identified by the given key. If the metric doesn't
+   * exist, it will be created with an initial value of 0 before adding the delta.
+   *
+   * @param key the metric key to identify which metric to update
+   * @param delta the value to add to the metric (can be negative for decrements)
+   */
+  public void add(MetricKey key, long delta) {
path: common/src/main/java/software/amazon/s3/analyticsaccelerator/common/Metrics.java
user: login: fuatbasik
body: nit: A helper toString() might help when you are logging the metrics. Not for now, we can add later. 
created_at: 2025-04-24T08:09:23Z
author_association: COLLABORATOR
diff_hunk: @@ -186,8 +195,11 @@ private void generateSourceAndData() throws IOException {
             this.source.thenApply(
                 objectContent -> {
                   try {
-                    return StreamUtils.toByteArray(
-                        objectContent, this.objectKey, this.range, this.readTimeout);
+                    byte[] bytes =
+                        StreamUtils.toByteArray(
+                            objectContent, this.objectKey, this.range, this.readTimeout);
+                    metricsCallback.accept(MetricKey.MEMORY_USAGE, range.getLength());
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/Block.java
user: login: fuatbasik
body: nit: OK for now, in the future, might want to take values from here and do more businessLogic on the BlockManager side. 
created_at: 2025-04-24T08:12:13Z
author_association: COLLABORATOR
diff_hunk: @@ -48,6 +52,8 @@ public class BlockManager implements Closeable {
   private final PhysicalIOConfiguration configuration;
   private final RangeOptimiser rangeOptimiser;
   private StreamContext streamContext;
+  @Getter private AtomicLong blobMemoryUsage;
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManager.java
user: login: fuatbasik
body: you can even avoid this right, all you need is another metrics instance specific to this blob. 
created_at: 2025-04-24T08:12:44Z
author_association: COLLABORATOR
diff_hunk: @@ -48,6 +52,8 @@ public class BlockManager implements Closeable {
   private final PhysicalIOConfiguration configuration;
   private final RangeOptimiser rangeOptimiser;
   private StreamContext streamContext;
+  @Getter private AtomicLong blobMemoryUsage;
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManager.java
user: login: fuatbasik
body: why do you need getter for this field?
created_at: 2025-04-24T08:12:57Z
author_association: COLLABORATOR
diff_hunk: @@ -85,18 +94,21 @@ public BlockManager(
       @NonNull ObjectMetadata metadata,
       @NonNull Telemetry telemetry,
       @NonNull PhysicalIOConfiguration configuration,
+      @NonNull Metrics metrics,
       StreamContext streamContext) {
     this.objectKey = objectKey;
     this.objectClient = objectClient;
     this.metadata = metadata;
     this.telemetry = telemetry;
     this.configuration = configuration;
-    this.blockStore = new BlockStore(objectKey, metadata);
+    this.metrics = metrics;
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManager.java
user: login: fuatbasik
body: nit: maybe call it AggregatingMetrics and create another one blobMetrics and update both on your callback. 
created_at: 2025-04-24T08:17:44Z
author_association: COLLABORATOR
diff_hunk: @@ -105,6 +128,11 @@ public int blobCount() {
   /** Closes the {@link BlobStore} and frees up all resources it holds. */
   @Override
   public void close() {
+    long hits = metrics.get(MetricKey.CACHE_HIT);
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
user: login: ahmarsuhail
body: nit:  can we move this calculation into a utils class? 
created_at: 2025-04-24T08:39:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,29 +40,47 @@
         "Inner class is created very infrequently, and fluency justifies the extra pointer")
 public class BlobStore implements Closeable {
   private final Map<ObjectKey, Blob> blobMap;
+  private static final Logger LOG = LoggerFactory.getLogger(BlobStore.class);
   private final ObjectClient objectClient;
   private final Telemetry telemetry;
   private final PhysicalIOConfiguration configuration;
 
+  @Getter
+  private final Metrics metrics;
+
   /**
    * Construct an instance of BlobStore.
    *
    * @param objectClient object client capable of interacting with the underlying object store
    * @param telemetry an instance of {@link Telemetry} to use
    * @param configuration the PhysicalIO configuration
+   * @param metrics an instance of {@link Metrics} to track metrics across the factory
    */
   public BlobStore(
       @NonNull ObjectClient objectClient,
       @NonNull Telemetry telemetry,
-      @NonNull PhysicalIOConfiguration configuration) {
+      @NonNull PhysicalIOConfiguration configuration,
+      @NonNull Metrics metrics) {
     this.objectClient = objectClient;
     this.telemetry = telemetry;
+    this.metrics = metrics;
     this.blobMap =
         Collections.synchronizedMap(
             new LinkedHashMap<ObjectKey, Blob>() {
               @Override
               protected boolean removeEldestEntry(final Map.Entry<ObjectKey, Blob> eldest) {
-                return this.size() > configuration.getBlobStoreCapacity();
+                boolean shouldRemove = this.size() > configuration.getBlobStoreCapacity();
+                if (shouldRemove) {
+                  LOG.debug(
+                      "Current memory usage of blobMap in bytes before eviction is: {}",
+                      metrics.get(MetricKey.MEMORY_USAGE));
+                  Blob blobToRemove = eldest.getValue();
+                  metrics.add(MetricKey.MEMORY_USAGE, -blobToRemove.getMemoryUsageOfBlob());
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
user: login: ahmarsuhail
body: instead of the `-` sign, can we add a `metrics.reduce` method or similar. that `-` makes me a bit nervous..too easy to remove and will change the behaviour
created_at: 2025-04-24T09:01:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,6 +128,11 @@ public int blobCount() {
   /** Closes the {@link BlobStore} and frees up all resources it holds. */
   @Override
   public void close() {
+    long hits = metrics.get(MetricKey.CACHE_HIT);
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
user: login: ahmarsuhail
body: actually, maybe you can just have a average() method in your metrics class, that takes the two statistics you want to calc the average for. really don't want this logic in this class
created_at: 2025-04-24T09:04:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,6 +52,8 @@ public class BlockManager implements Closeable {
   private final PhysicalIOConfiguration configuration;
   private final RangeOptimiser rangeOptimiser;
   private StreamContext streamContext;
+  @Getter private AtomicLong blobMemoryUsage;
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManager.java
user: login: ahmarsuhail
body: +1, don't think you need the getter. 

Would also prefer the metrics class rather than the atomic long.. but in two minds. can't figure out if that's an overkill here 
created_at: 2025-04-24T09:12:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,6 +52,8 @@ public class BlockManager implements Closeable {
   private final PhysicalIOConfiguration configuration;
   private final RangeOptimiser rangeOptimiser;
   private StreamContext streamContext;
+  @Getter private AtomicLong blobMemoryUsage;
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManager.java
user: login: rajdchak
body: yeah I didn't pay much attention to this because I will be removing this memory usage from here when i have the memory manager changes because we do not need to keep a track of blob level memory usage then.
created_at: 2025-04-24T12:31:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,6 +52,8 @@ public class BlockManager implements Closeable {
   private final PhysicalIOConfiguration configuration;
   private final RangeOptimiser rangeOptimiser;
   private StreamContext streamContext;
+  @Getter private AtomicLong blobMemoryUsage;
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlockManager.java
user: login: rajdchak
body: Needed a getter for this call from blob
```

  /**
   * Returns the memory used by the blob
   *
   * @return the memory used by the blob
   */
  public long getMemoryUsageOfBlob() {
    return blockManager.getBlobMemoryUsage().get();
  }
```
created_at: 2025-04-24T12:36:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -105,6 +128,11 @@ public int blobCount() {
   /** Closes the {@link BlobStore} and frees up all resources it holds. */
   @Override
   public void close() {
+    long hits = metrics.get(MetricKey.CACHE_HIT);
path: input-stream/src/main/java/software/amazon/s3/analyticsaccelerator/io/physical/data/BlobStore.java
user: login: rajdchak
body: moving this to utils class as moving it to Metrics class specifically to calculate hit rate doesn't make sense
created_at: 2025-04-24T12:57:58Z
author_association: CONTRIBUTOR

PR comments:
user: login: stubz151
created_at: 2025-04-17T13:18:08Z
body: https://github.com/ben-manes/caffeine/wiki/Statistics question why aren't we using this?
author_association: CONTRIBUTOR
user: login: rajdchak
created_at: 2025-04-17T13:54:35Z
body: > https://github.com/ben-manes/caffeine/wiki/Statistics question why aren't we using this?

Right now the blob map is not a caffeine cache. When we have the memory manager changes we are adopting caffeine but that is only to store the indexes, we will continue to have the blopmap and other structures as we have today. Instead of trusting the caffeine stats these stats are more reliable.
author_association: CONTRIBUTOR
user: login: stubz151
created_at: 2025-04-17T14:02:58Z
body: > > https://github.com/ben-manes/caffeine/wiki/Statistics question why aren't we using this?
> 
> Right now the blob map is not a caffeine cache. When we have the memory manager changes we are adopting caffeine but that is only to store the indexes, we will continue to have the blopmap and other structures as we have today. Instead of trusting the caffeine stats these stats are more reliable.

Ahh makes perfect sense. Sorry for the miss.
author_association: CONTRIBUTOR
user: login: rajdchak
created_at: 2025-04-22T11:52:35Z
body: > Hi @rajdchak , I had another look and put some more comments. In my opinion, you should try to avoid statics and global variables as much as possible as they make it harder to debug and reason about the correctness. Another thing is, In the current structure we have States (BlobStore/BlockStore) and StateManagers(BlobManager, BlockManager). We should avoid accessing state out of control of the Manager. So if you need to access BlobStore, it should be through BlobManager.
> 
> My main concerns are 1/ You do not need any Statics at CacheStats and MemoryStats. 2/ You do not need to pass a global CacheStats or MemoryStats. You can aggregate all metrics at a higher level therefore, it is OK to create 1 CacheStats and 1 MemoryStats metric per Blob and have AggregatingCacheStats and AggregatingMemoryStats at BlobStore level. When metrics asked, BlobStore will aggregate metrics per Blob. Current version creates an interesting dependency in between the classes.
> 
> I also like to hear from others. @ahmarsuhail @stubz151

I will make things non static.

However, if we do not maintain the exact memory usage numbers at the global level, then everytime during eviction when we publish the metric we will need to loop over blobs and aggregate to measure, do we want to do that?
When the memory manager changes will be in, we will run evictions at regular intervals and it will lead to computing aggregations frequently. Also with the memory manager changes, we do not need to maintain the memory usage at a blob level because we will not evict entire blob in one go. Right now we need the memory usage at blob level just to subtract the size of the blob during eviction.
author_association: CONTRIBUTOR
user: login: stubz151
created_at: 2025-04-22T12:55:52Z
body: The only way I could see us avoiding using static variables is creating some sort of factory that we can use to get an instance of cacheStats or MemoryStats. But the callout about keeping an exact number for memory usage makes sense to me because I don't think we want to be recomputing it if we don't have to.
author_association: CONTRIBUTOR