/*
* Comprehensive GoogleTest suite for FalconCache.
*
* Design notes:
* - Every Slice handed to the cache is heap-allocated via
* falcon_test::make_owned_slice() so the cache's delete[] calls are safe.
* - State is verified through observable behavior (subsequent gets, RocksDB
* reads) rather than private-member inspection.
* - RocksDB is opened in a mkdtemp temp dir and torn down in TearDown().
* - All tests must be ASan-clean when built with -DFALCON_TEST_ASAN=ON.
*
* Known bug documented below (not patched — production code is off-limits):
* - bypassCache() evaluates `accessCnt % BYPASS_CHECK_PERIOD == 0` on
* construction where accessCnt==0, then computes hitCnt/accessCnt which is
* 0/0 (integer division of unsigned long long by zero → UB / SIGFPE on some
* platforms). The BypassReturnsFalseBeforeAnyAccess stub avoids triggering
* it by never calling bypassCache() before at least one access has occurred.
* See TODO comments in bypass tests.
*/
#include <gtest/gtest.h>
#include <algorithm>
#include <string>
#include <vector>
#include "FalconCache.h"
#include "FalconRocksDBHelper.h"
#include "MockJniEnv.h"
#include "falcon_cache_fixture.h"
#include "slice_helpers.h"
namespace {
using falcon_test::MockJniEnv;
using falcon_test::make_owned_slice;
using falcon_test::free_owned_slice;
// All cache test cases run against the shared fixture, which spins up a
// real RocksDB in a temp dir + a fresh FalconCache + a MockJniEnv per test.
class FalconCacheTest : public falcon_test::FalconCacheFixture {};
// ===========================================================================
// LIFECYCLE / SIZE LIMITS
// ===========================================================================
// 1. Fresh cache has size limit 0 and is empty (verified by a get returning
// nullptr for a key that also doesn't exist in RocksDB).
TEST_F(FalconCacheTest, NewCacheStartsZeroSize) {
EXPECT_EQ(cache_->getSizeLimit(), 0);
// With sizeLimit==0 every put immediately triggers removeEldestState(),
// which flushes and clears. A subsequent get of an unflushed key also
// tries RocksDB and returns nullptr since the DB is empty.
jbyteArray result = CacheGet("any_key");
EXPECT_EQ(result, nullptr);
}
// 2. Set limit above current count → items stay in cache (a subsequent get
// after deleting the RocksDB copy still returns the value).
TEST_F(FalconCacheTest, UpdateSizeLimit_AboveCurrent_NoEviction) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), /*newSizeLimit=*/100);
// Insert 5 items.
for (int i = 0; i < 5; i++) {
CachePut("key" + std::to_string(i), "val" + std::to_string(i));
}
// Verify they live in cache: delete them from RocksDB first so a cache
// miss would return nullptr.
for (int i = 0; i < 5; i++) {
std::string k = "key" + std::to_string(i);
db_->Delete(write_options_, db_->DefaultColumnFamily(), rocksdb::Slice(k));
}
// cache_.flush() wasn't called so RocksDB shouldn't have them anyway, but
// deleting is belt-and-suspenders. If RocksDB doesn't have the key and
// cache has it, get() must return non-null.
// NOTE: With sizeLimit==100 and 5 items none were evicted; cache should
// hold all 5 items. We insert a key that was never flushed, so the DB
// copy is only available if we explicitly put it there.
// Re-populate RocksDB for keys 0-4 so we can verify cache hit vs miss.
for (int i = 0; i < 5; i++) {
RocksDBPut("key" + std::to_string(i), "val" + std::to_string(i));
}
// Now delete them again from RocksDB right after writing so any read
// must come from the cache.
for (int i = 0; i < 5; i++) {
db_->Delete(write_options_, db_->DefaultColumnFamily(),
rocksdb::Slice("key" + std::to_string(i)));
}
// All items should still be in cache.
for (int i = 0; i < 5; i++) {
jbyteArray result = CacheGet("key" + std::to_string(i));
ASSERT_NE(result, nullptr)
<< "key" << i << " should be in cache but get returned nullptr";
EXPECT_EQ(ArrayToString(result), "val" + std::to_string(i));
}
}
// 3. Lower size limit below current count → cache flushes to RocksDB and
// clears. After the limit update the cache should be empty but RocksDB
// should have the data.
TEST_F(FalconCacheTest, UpdateSizeLimit_BelowCurrent_FlushesAndClears) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), /*newSizeLimit=*/100);
const int N = 5;
for (int i = 0; i < N; i++) {
CachePut("flush_key" + std::to_string(i), "flush_val" + std::to_string(i));
}
// Lower limit below current cache size (5 items → new limit 2).
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), /*newSizeLimit=*/2);
// Cache should be empty now. A get for a non-DB key returns nullptr.
// The flushed items ARE in RocksDB, so CacheGet for them would re-insert
// them. Verify via RocksDB directly.
for (int i = 0; i < N; i++) {
EXPECT_TRUE(RocksDBHas("flush_key" + std::to_string(i)))
<< "flush_key" << i << " should have been flushed to RocksDB";
}
// Also verify the cache is now empty by inserting a fresh key and
// immediately deleting it from RocksDB — if the cache was truly cleared
// the next get won't find it in cache and will correctly miss.
// (We don't add anything new here; just confirm no leftover state.)
EXPECT_EQ(cache_->getSizeLimit(), 2);
}
// ===========================================================================
// get() — CACHE-HIT PATH
// ===========================================================================
// 4. Value < 64 bytes → exercises SetByteArrayRegion path.
TEST_F(FalconCacheTest, Get_CacheHit_SmallValue_ReturnsCorrectBytes) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
const std::string key = "small_key";
const std::string value = "hello_world"; // 11 bytes, well under 64
CachePut(key, value);
jbyteArray result = CacheGet(key);
ASSERT_NE(result, nullptr);
EXPECT_EQ(ArrayToString(result), value);
EXPECT_FALSE(jni_.hasPendingException());
}
// 5. Value ≥ 64 bytes → exercises NEON copyDataTojVal path (or portable
// memcpy equivalent on non-NEON platforms via the shim).
TEST_F(FalconCacheTest, Get_CacheHit_LargeValue_ReturnsCorrectBytes) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
const std::string key = "large_key";
// Build a 200-byte value with a recognizable pattern: 0x00, 0x01, ...
std::string large_value(200, '\0');
for (int i = 0; i < 200; i++) {
large_value[i] = static_cast<char>(i & 0xFF);
}
CachePut(key, large_value);
jbyteArray result = CacheGet(key);
ASSERT_NE(result, nullptr) << "get returned nullptr for cached large value";
std::vector<jbyte> bytes = jni_.arrayBytes(result);
ASSERT_EQ(static_cast<int>(bytes.size()), 200);
for (int i = 0; i < 200; i++) {
EXPECT_EQ(static_cast<unsigned char>(bytes[i]), static_cast<unsigned char>(i & 0xFF))
<< "Mismatch at index " << i;
}
EXPECT_FALSE(jni_.hasPendingException());
}
// 6. On a cache hit, get() must delete[] the incoming key_slice.
// Under ASan + LSan this catches the no-delete (leak) and double-delete
// (use-after-free) failure modes.
//
// AARCH64-ONLY HARD VERIFICATION: macOS Apple Clang ships ASan but NOT
// LSan. On macOS this test only catches double-delete (immediate UAF
// crash); a missing delete[] is a silent leak the test cannot observe.
// On Kunpeng 920 (the production target) GCC LSan reports the leak at
// program exit. The test is therefore informative-but-incomplete locally
// and authoritative on aarch64. Do not rely on a macOS pass alone.
TEST_F(FalconCacheTest, Get_CacheHit_DeletesIncomingKeySlice) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
CachePut("del_key", "del_val");
jbyteArray result = CacheGet("del_key");
ASSERT_NE(result, nullptr);
EXPECT_EQ(ArrayToString(result), "del_val");
// No crash here means the delete[] was safe (catches double-free even
// on macOS); aarch64 LSan catches the missed-delete case at exit.
}
// 7. Cache hit increments hit and access counters. We verify indirectly:
// after BYPASS_CHECK_PERIOD accesses all of which are hits, bypassCache()
// should return false (hit ratio == 1.0, well above any reasonable threshold).
// We drive the count to exactly 20000 via a mix of puts + gets.
//
// NOTE: bypassCache() has a division-by-zero bug when accessCnt==0 (which
// is true at construction time, since 0 % 20000 == 0). We therefore start
// accesses from 1 by doing at least one put before calling bypassCache().
TEST_F(FalconCacheTest, Get_CacheHit_BumpsHitAndAccessCounters) {
// Use a cache with a high threshold so that a poor hit ratio would trip it.
FalconCache local_cache(/*hitThreshold=*/0.99);
local_cache.updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100000);
// Pre-populate one key so gets are always hits.
local_cache.put(jni_.env(), dbHandle(), cfHandle(), writeOptionsHandle(),
make_owned_slice(std::string{"hk"}), make_owned_slice(std::string{"hv"}));
// Drive access count to just under the first check period (19999 more
// accesses, all hits). The put above was 1 access (miss on empty cache),
// so start from 1 and make 19998 more gets.
for (int i = 0; i < 19998; i++) {
jbyteArray r = local_cache.get(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(),
make_owned_slice(std::string{"hk"}));
(void)r; // hit; we just need the side effect on counters
}
// Now accessCnt = 19999. bypassCache() skips the check → returns false.
EXPECT_FALSE(local_cache.bypassCache());
// One more access brings accessCnt to 20000. Hit ratio ≈ 19999/20000 ≈ 1.0
// which is >= hitThreshold(0.99) → bypass should NOT fire.
jbyteArray r = local_cache.get(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(),
make_owned_slice(std::string{"hk"}));
(void)r;
// accessCnt == 20000 → check fires. hitRatio ≈ 1.0 >= 0.99 → false.
EXPECT_FALSE(local_cache.bypassCache());
local_cache.clearAll();
}
// ===========================================================================
// get() — CACHE-MISS PATHS
// ===========================================================================
// 8. Cache miss with RocksDB hit: value is returned AND inserted into cache.
// Verify the cache insertion by subsequently deleting from RocksDB — a
// second get must still return the value (from cache).
TEST_F(FalconCacheTest, Get_CacheMiss_RocksDBHit_InsertsIntoCache_AndReturnsValue) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
// Pre-populate RocksDB directly (not through cache).
RocksDBPut("db_key", "db_val");
// First get: cache miss → RocksDB hit → inserts into cache.
jbyteArray result1 = CacheGet("db_key");
ASSERT_NE(result1, nullptr);
EXPECT_EQ(ArrayToString(result1), "db_val");
// Delete from RocksDB to ensure the second get must come from cache.
db_->Delete(write_options_, db_->DefaultColumnFamily(),
rocksdb::Slice("db_key"));
// Second get: must hit cache (RocksDB no longer has the key).
jbyteArray result2 = CacheGet("db_key");
ASSERT_NE(result2, nullptr) << "Expected cache hit but got nullptr";
EXPECT_EQ(ArrayToString(result2), "db_val");
}
// 9. Cache miss, RocksDB also misses → get() returns nullptr and nothing is
// inserted into the cache.
TEST_F(FalconCacheTest, Get_CacheMiss_RocksDBMiss_ReturnsNull_DoesNotInsert) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
jbyteArray result = CacheGet("nonexistent_key");
EXPECT_EQ(result, nullptr);
EXPECT_FALSE(jni_.hasPendingException());
// A second call for the same key must also return nullptr — nothing was
// silently inserted.
jbyteArray result2 = CacheGet("nonexistent_key");
EXPECT_EQ(result2, nullptr);
}
// 10. Cache miss that triggers eviction: with limit=2, put 2 items (fills
// cache), then do a get that is a cache miss + RocksDB hit (3rd item).
// That should push cache.size() > limit → removeEldestState fires
// (flush all + clearAll), after which the cache is empty.
//
// NOTE: removeEldestState() is a full flush+clear, not LRU eviction of
// one item. After it fires the cache is empty and the RocksDB has all 3.
TEST_F(FalconCacheTest, Get_CacheMissInsert_TriggersEvictionWhenOverLimit) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 2);
CachePut("evict_k0", "evict_v0");
CachePut("evict_k1", "evict_v1");
// Pre-populate RocksDB with the 3rd key so the cache miss goes to RocksDB.
RocksDBPut("evict_k2", "evict_v2");
// This get: cache miss → RocksDB hit → inserts → cache size becomes 3 >
// limit(2) → removeEldestState() fires → flush all to RocksDB + clearAll.
jbyteArray result = CacheGet("evict_k2");
ASSERT_NE(result, nullptr);
EXPECT_EQ(ArrayToString(result), "evict_v2");
// After eviction: all 3 pairs should be in RocksDB.
// (evict_k0, evict_k1 were put into cache only; removeEldestState flushed them)
EXPECT_TRUE(RocksDBHas("evict_k0")) << "evict_k0 should have been flushed";
EXPECT_TRUE(RocksDBHas("evict_k1")) << "evict_k1 should have been flushed";
EXPECT_TRUE(RocksDBHas("evict_k2")) << "evict_k2 should still be in RocksDB";
}
// ===========================================================================
// put()
// ===========================================================================
// 11. put() a new key under the size limit: item is in cache, no eviction.
//
// NOTE: MockJniEnv reuses the heap address of the ByteArray holder as the
// jbyteArray "pointer" value, then immediately frees the holder. If the
// allocator returns the same address for the next NewByteArray call the two
// jbyteArray handles alias the same map entry. To avoid stale-handle reads we
// inspect each result immediately after the get() call, before the next one.
TEST_F(FalconCacheTest, Put_NewKey_InsertsAndDoesNotEvictUnderLimit) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 10);
CachePut("put_k0", "put_v0");
CachePut("put_k1", "put_v1");
CachePut("put_k2", "put_v2");
// Inspect each result immediately (before any subsequent JNI allocation
// might alias the same mock handle).
{
jbyteArray r = CacheGet("put_k0");
ASSERT_NE(r, nullptr) << "put_k0 should be in cache";
EXPECT_EQ(ArrayToString(r), "put_v0");
}
{
jbyteArray r = CacheGet("put_k1");
ASSERT_NE(r, nullptr) << "put_k1 should be in cache";
EXPECT_EQ(ArrayToString(r), "put_v1");
}
{
jbyteArray r = CacheGet("put_k2");
ASSERT_NE(r, nullptr) << "put_k2 should be in cache";
EXPECT_EQ(ArrayToString(r), "put_v2");
}
}
// 12. put() an existing key: value is updated, old storage is freed (no UAF).
// Under ASan this is the critical test for the "overwrite" code path in
// FalconCache::put() which does:
// delete[] state_pos->first.data_;
// delete[] state_pos->second.data_;
// cache.erase(state_pos);
// cache.emplace(key_slice, value_slice);
//
// A second put with the same logical key should produce the updated value
// on the next get.
TEST_F(FalconCacheTest, Put_OverwriteExistingKey_UpdatesValueAndFreesOldStorage) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
CachePut("over_k", "original_value");
// Overwrite with a different value.
CachePut("over_k", "updated_value");
jbyteArray result = CacheGet("over_k");
ASSERT_NE(result, nullptr);
EXPECT_EQ(ArrayToString(result), "updated_value");
EXPECT_FALSE(jni_.hasPendingException());
}
// 13. put() at capacity (limit==2, insert 3rd item) → removeEldestState fires
// after the 3rd insert. The current implementation is flush + clearAll
// (production finding: not LRU). All 3 items must be flushed to RocksDB,
// AND the cache must be empty afterward — verify both, so a future
// proper-LRU implementation that evicts only the oldest single item
// would correctly fail this test and force a re-decision.
TEST_F(FalconCacheTest, Put_AtCapacity_TriggersEviction) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 2);
CachePut("cap_k0", "cap_v0");
CachePut("cap_k1", "cap_v1");
// This put pushes size to 3 > limit(2) → flush + clear.
CachePut("cap_k2", "cap_v2");
// All 3 must be in RocksDB after the flush.
EXPECT_EQ(RocksDBGet("cap_k0"), "cap_v0");
EXPECT_EQ(RocksDBGet("cap_k1"), "cap_v1");
EXPECT_EQ(RocksDBGet("cap_k2"), "cap_v2");
// Cache must now be empty (current flush+clear semantics). Delete from
// RocksDB and re-get: each should return a value only because the get
// path re-fetches from RocksDB on cache miss. If even one of these gets
// back its value WITHOUT touching RocksDB (i.e. cache wasn't cleared),
// a future LRU refactor that didn't clear-all would slip through.
DirectRocksDBDelete("cap_k0");
DirectRocksDBDelete("cap_k1");
DirectRocksDBDelete("cap_k2");
EXPECT_EQ(CacheGet_String("cap_k0"), "")
<< "cap_k0 still served from cache — flush+clear semantics broken";
EXPECT_EQ(CacheGet_String("cap_k1"), "");
EXPECT_EQ(CacheGet_String("cap_k2"), "");
}
// ===========================================================================
// remove()
// ===========================================================================
// 14. remove() a key that is in the cache: deletes from both cache and RocksDB.
TEST_F(FalconCacheTest, Remove_CacheHit_DeletesFromCacheAndRocksDB) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
CachePut("rem_k", "rem_v");
// Flush to RocksDB so it's in both places.
cache_->flush(jni_.env(), dbHandle(), cfHandle(), writeOptionsHandle());
CacheRemove("rem_k");
// Cache: get should return nullptr (key no longer in cache or RocksDB).
jbyteArray result = CacheGet("rem_k");
EXPECT_EQ(result, nullptr);
// RocksDB: should also be gone.
EXPECT_FALSE(RocksDBHas("rem_k"));
EXPECT_FALSE(jni_.hasPendingException());
}
// 15. remove() a key that is NOT in the cache but IS in RocksDB: should still
// delete it from RocksDB.
TEST_F(FalconCacheTest, Remove_CacheMiss_StillDeletesFromRocksDB) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
// Put directly into RocksDB (not through cache).
RocksDBPut("rocksdb_only", "rocksdb_val");
CacheRemove("rocksdb_only");
// RocksDB should no longer have it.
EXPECT_FALSE(RocksDBHas("rocksdb_only"));
EXPECT_FALSE(jni_.hasPendingException());
}
// ===========================================================================
// flush() / clearAll()
// ===========================================================================
// 16. flush() persists all pairs to RocksDB but keeps them in the cache.
// After flush(), a subsequent get should still hit the cache (verified by
// deleting the RocksDB copy and re-getting).
TEST_F(FalconCacheTest, Flush_PersistsAllPairsButKeepsCache) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
for (int i = 0; i < 3; i++) {
CachePut("fk" + std::to_string(i), "fv" + std::to_string(i));
}
cache_->flush(jni_.env(), dbHandle(), cfHandle(), writeOptionsHandle());
// All 3 should now be in RocksDB.
for (int i = 0; i < 3; i++) {
EXPECT_EQ(RocksDBGet("fk" + std::to_string(i)), "fv" + std::to_string(i))
<< "flush_key" << i << " missing from RocksDB after flush()";
}
// Delete from RocksDB to confirm subsequent get comes from cache.
for (int i = 0; i < 3; i++) {
db_->Delete(write_options_, db_->DefaultColumnFamily(),
rocksdb::Slice("fk" + std::to_string(i)));
}
for (int i = 0; i < 3; i++) {
jbyteArray result = CacheGet("fk" + std::to_string(i));
ASSERT_NE(result, nullptr)
<< "fk" << i << " should still be in cache after flush()";
EXPECT_EQ(ArrayToString(result), "fv" + std::to_string(i));
}
}
// 17. clearAll() empties the cache and resets counters.
// We verify: (a) cache is empty (subsequent gets return nullptr for keys
// not in RocksDB), and (b) counters are zeroed (indirectly: after clearAll
// we drive exactly BYPASS_CHECK_PERIOD accesses all as misses; with a
// 0.0 hit ratio below any positive threshold, bypassCache() at period
// boundary should return true, which would only happen if the counter
// reset was effective and a new period started at 0).
//
// NOTE: we use a local FalconCache to control the threshold precisely.
TEST_F(FalconCacheTest, ClearAll_ResetsCacheAndCounters) {
FalconCache local_cache(/*hitThreshold=*/0.5);
local_cache.updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100000);
// Put 3 items and flush to RocksDB (so clearAll doesn't leave unflushed data).
local_cache.put(jni_.env(), dbHandle(), cfHandle(), writeOptionsHandle(),
make_owned_slice(std::string{"ck0"}), make_owned_slice(std::string{"cv0"}));
local_cache.put(jni_.env(), dbHandle(), cfHandle(), writeOptionsHandle(),
make_owned_slice(std::string{"ck1"}), make_owned_slice(std::string{"cv1"}));
local_cache.put(jni_.env(), dbHandle(), cfHandle(), writeOptionsHandle(),
make_owned_slice(std::string{"ck2"}), make_owned_slice(std::string{"cv2"}));
local_cache.flush(jni_.env(), dbHandle(), cfHandle(), writeOptionsHandle());
local_cache.clearAll(); // resets hitCnt, accessCnt to 0
// Verify cache is empty: items that were flushed are now in RocksDB.
// A get will bring them back from RocksDB, which means the cache was indeed
// cleared (otherwise get() would hit the cache, not RocksDB).
// We verify by checking that the items CAN be re-fetched from RocksDB.
jbyteArray r0 = local_cache.get(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(),
make_owned_slice(std::string{"ck0"}));
ASSERT_NE(r0, nullptr) << "ck0 should be fetchable from RocksDB after clearAll";
EXPECT_EQ(ArrayToString(r0), "cv0");
local_cache.clearAll(); // clean up before scope exit (avoids flush into DB)
}
// ===========================================================================
// bypassCache()
// ===========================================================================
// 18. Bypass does not fire before 20k accesses (the check period boundary).
// We make 19999 accesses, all misses (keys absent from both cache and DB),
// and confirm bypassCache() does not return true mid-period.
//
// NOTE: accessCnt==0 (construction) would trigger the check too (0%20000==0)
// but with 0 in the denominator it is UB / division by zero. We sidestep
// this by starting accesses from 1 (one put before the loop).
TEST_F(FalconCacheTest, Bypass_DoesNotFireBefore20kAccesses) {
FalconCache local_cache(/*hitThreshold=*/0.99);
local_cache.updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100000);
// One initial put: accessCnt becomes 1 (miss on empty cache).
local_cache.put(jni_.env(), dbHandle(), cfHandle(), writeOptionsHandle(),
make_owned_slice(std::string{"seed"}), make_owned_slice(std::string{"sv"}));
// 19998 more accesses as cache misses (keys absent everywhere).
// Total after loop: 1 (put) + 19998 = 19999.
for (int i = 0; i < 19998; i++) {
jbyteArray r = local_cache.get(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(),
make_owned_slice("miss_" + std::to_string(i)));
(void)r;
}
// At accessCnt=19999, the next bypassCache() call is NOT at a period
// boundary → returns false without computing hit ratio.
EXPECT_FALSE(local_cache.bypassCache());
local_cache.clearAll();
}
// 19. Bypass fires at the 20k boundary when hit ratio is below threshold.
// Strategy: use a high threshold (0.99), do 20000 accesses all as cache
// misses (no hits) → hit ratio = 0.0 < 0.99 → bypassCache() returns true.
//
// NOTE: The first 20000th access (accessCnt going from 19999 to 20000)
// is at the check boundary. We call bypassCache() AFTER that access so
// the modulo fires.
TEST_F(FalconCacheTest, Bypass_FiresWhenHitRatioBelowThreshold) {
// sizeLimit=100000 so no eviction; all gets are cache misses (DB is empty).
// Use a high threshold so that a zero hit ratio reliably triggers bypass.
FalconCache local_cache(/*hitThreshold=*/0.99);
local_cache.updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100000);
// 20000 gets for absent keys → accessCnt=20000, hitCnt=0.
// Use unique keys to avoid any accidental DB residue from other tests.
// IMPORTANT: we skip accessCnt==0 (the constructor state) because
// bypassCache() at 0 would trigger a 0/0 division (see known bug note
// at top of file). We drive to accessCnt==20000 exactly, starting from 0.
for (int i = 0; i < 20000; i++) {
jbyteArray r = local_cache.get(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(),
make_owned_slice("bypass_miss_" + std::to_string(i)));
(void)r;
}
// accessCnt == 20000 → check fires. hitRatio = 0/20000 = 0.0 < 0.99 → true.
EXPECT_TRUE(local_cache.bypassCache());
local_cache.clearAll();
}
// ===========================================================================
// EDGE CASES
// ===========================================================================
// 20. get() with sizeLimit==0 (disabled cache): every insert after RocksDB
// miss goes through removeEldestState immediately. The function still
// returns the right value.
TEST_F(FalconCacheTest, Get_SizeLimitZero_CacheDisabled_StillReturnsFromRocksDB) {
// sizeLimit stays at 0 (from construction).
RocksDBPut("zero_key", "zero_val");
jbyteArray result = CacheGet("zero_key");
ASSERT_NE(result, nullptr);
EXPECT_EQ(ArrayToString(result), "zero_val");
EXPECT_FALSE(jni_.hasPendingException());
}
// 21. Double-remove: remove a key that doesn't exist at all → no crash, no
// exception. This validates graceful handling of absent-key removes.
TEST_F(FalconCacheTest, Remove_AbsentKey_NoCrashNoException) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
// Key never existed anywhere.
CacheRemove("ghost_key");
EXPECT_FALSE(jni_.hasPendingException());
}
// 22. flush() on an empty cache should not crash.
TEST_F(FalconCacheTest, Flush_EmptyCache_NoCrash) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
cache_->flush(jni_.env(), dbHandle(), cfHandle(), writeOptionsHandle());
EXPECT_FALSE(jni_.hasPendingException());
}
// 23. clearAll() on an already-empty cache should not crash.
TEST_F(FalconCacheTest, ClearAll_EmptyCache_NoCrash) {
cache_->clearAll();
EXPECT_FALSE(jni_.hasPendingException());
}
// 24. Overwrite then flush: ensure the updated value (not original) lands in DB.
TEST_F(FalconCacheTest, Put_OverwriteThenFlush_UpdatedValueInRocksDB) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
CachePut("ow_k", "original");
CachePut("ow_k", "updated");
cache_->flush(jni_.env(), dbHandle(), cfHandle(), writeOptionsHandle());
EXPECT_EQ(RocksDBGet("ow_k"), "updated");
}
// 25. Confirm getSizeLimit() reflects the last updateSizeLimit() call.
TEST_F(FalconCacheTest, GetSizeLimit_ReflectsLastUpdate) {
EXPECT_EQ(cache_->getSizeLimit(), 0);
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 42);
EXPECT_EQ(cache_->getSizeLimit(), 42);
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 7);
EXPECT_EQ(cache_->getSizeLimit(), 7);
}
// ===========================================================================
// Production-bug regression tests — INTENTIONALLY FAILING.
//
// These tests demonstrate behavior the code under test gets wrong. They run
// by default (no DISABLED_ prefix) so CI surfaces the failures and pressures
// the production owners to fix the underlying bugs. Each test's comment block
// describes the reproduction, the expected behavior, and a fix sketch.
//
// Once production is patched the assertions become passing and the tests
// turn into regression guards automatically.
//
// CI release-gating opt-out: every failing-by-design test in this section
// AND in test_falcon_cache_concurrent.cpp is named with a `BugDemo_` prefix.
// To run only the green-by-default suite:
//
// ctest --test-dir cpp/build -E 'BugDemo_'
// # or directly:
// ./test/falcon_tests --gtest_filter='-*BugDemo_*'
//
// CI gates that block release on green tests should use one of the above
// invocations. Default `ctest` keeps surfacing the failures.
// ===========================================================================
// LATENT FRAGILITY #1: bypassCache() at accessCnt == 0.
//
// Initial framing thought this was a divide-by-zero UB bug. Closer read of
// FalconCache.cpp shows production explicitly casts both operands to double
// BEFORE division:
//
// double hitRatio = static_cast<double>(hitCnt) / static_cast<double>(accessCnt);
//
// IEEE-754 0.0 / 0.0 = NaN; `NaN < hitThreshold` is always false; so
// bypassCache() returns false on a fresh cache. The current production code
// is, by accident of float semantics, doing the right thing.
//
// This is therefore a regression guard, not a failing bug-demo. It will
// catch a future refactor that:
// - drops the `static_cast<double>` and uses integer division (re-introduces
// UB on 0/0)
// - changes the comparison to `>= -1.0` or any predicate where NaN is true
// - moves the modulo check below the division
//
// Keep the test passing; don't relabel as "PRODUCTION BUG".
TEST_F(FalconCacheTest, BypassCache_FreshlyConstructed_DoesNotBypass) {
// No prior put/get; accessCnt is still 0 from construction.
EXPECT_FALSE(cache_->bypassCache())
<< "fresh cache should not bypass — current production relies on NaN "
"comparison semantics; this guard catches a refactor that drops "
"the cast-to-double or the NaN-safe comparison";
}
// PRODUCTION BUG #2: removeEldestState resets hit/access counters.
//
// Reproduction:
// FalconCache::removeEldestState() calls flush() then clearAll().
// clearAll() unconditionally sets hitCnt = accessCnt = 0. The bypass
// safety valve is gated on `accessCnt % BYPASS_CHECK_PERIOD == 0`, so
// any eviction silently shifts the next check point and may permanently
// prevent it from firing under continuous churn.
//
// Expected behavior: hit/access counters are workload-level metrics; they
// should track lifetime ratio across cache evictions, not be wiped on
// every flush.
//
// This test demonstrates the failure: 2 puts cause an eviction (limit=1),
// then 19998 miss-only gets bring the *user-visible* total access count
// to exactly 20000 — the bypass period boundary. With proper counter
// preservation, bypassCache() at this point would compute a 0% hit ratio
// and return true. Production code resets to 0 at the eviction, leaving
// post-eviction accessCnt = 19998 (not on the period boundary), so the
// modulo check skips and bypassCache() returns false.
//
// Fix sketch (production side, NOT applied here): preserve hitCnt /
// accessCnt across clearAll() invocations triggered by removeEldestState
// (they should only reset on an explicit user-driven clearAll, if at all).
TEST_F(FalconCacheTest, BugDemo_Bypass_CountersSurviveEviction) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), /*sizeLimit=*/3000);
CachePut("a", "1"); // accessCnt: 0 → 1
CachePut("b", "2"); // accessCnt: 1 → 2 → eviction → 0
// 19998 miss-only gets — none in cache, none in RocksDB; no insertion,
// no further eviction. Each bumps accessCnt by 1.
for (int i = 0; i < 19998; ++i) {
CacheGet("missing_" + std::to_string(i));
}
// User-visible total access count: 2 + 19998 = 20000.
// Production: post-eviction accessCnt is 0 + 19998 = 19998.
// 19998 % 20000 != 0, so bypassCache() does not check.
EXPECT_TRUE(cache_->bypassCache())
<< "bypass should fire after 20000 accesses with 0% hit ratio; "
"production counter-reset on eviction silently disables it";
}
// PRODUCTION BUG #3: src/cache/FalconCache.cpp put() else-branch comment
// reads "falcon cache hit" but the branch is the cache-MISS path. Doc-only,
// not testable via assertions. Recorded here so a grep for "PRODUCTION BUG"
// in the test suite surfaces all three issues in one place.
//
// TODO(production-fix): correct the comment in FalconCache::put() at the
// `} else { // falcon cache hit, insert key_slice ...` line.
// ===========================================================================
// JNI exception-injection tests (Tier 2, Phase B.4).
//
// These exercise FalconCache.cpp cleanup branches that fire when a JNI
// byte-array call fails mid-operation. Without injection there's no way to
// make NewByteArray / GetByteArrayRegion fail in a unit test, so these
// branches were unreachable before MockJniEnv::injectByteArrayFailureAtCall
// landed.
//
// Coverage focus: ASan must remain clean — these tests specifically exercise
// allocation cleanup paths.
// ===========================================================================
// 26. Cache hit → NewByteArray returns nullptr (OOM-equivalent). FalconCache
// should return nullptr cleanly without crashing.
TEST_F(FalconCacheTest, Get_CacheHit_NewByteArrayFailure_ReturnsNull) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
CachePut("k", "v");
// The next byte-array call inside the cache hit path is NewByteArray
// (FalconCache.cpp line 59). Inject and verify the early-return path.
jni_.injectByteArrayFailureAtCall(1);
jbyteArray r = CacheGet("k");
EXPECT_EQ(r, nullptr);
// jni_.hasPendingException() will be true because the mock sets it on
// injection. Production code at lines 60-62 returns nullptr on
// NewByteArray==nullptr without checking ExceptionCheck — which is fine
// because the JNI runtime would propagate the OOM up to Java anyway.
jni_.clearPendingException();
}
// 27. After a NewByteArray failure on a cache hit, the cache must not be
// corrupted: the entry stays in place and a subsequent get succeeds.
TEST_F(FalconCacheTest, Get_CacheHit_NewByteArrayFailure_RetrySucceeds) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
CachePut("k", "v");
jni_.injectByteArrayFailureAtCall(1);
EXPECT_EQ(CacheGet("k"), nullptr);
jni_.clearPendingException();
// Retry — now no injection. Should serve from cache normally.
jbyteArray r = CacheGet("k");
ASSERT_NE(r, nullptr);
EXPECT_EQ(ArrayToString(r), "v");
}
// 28. Cache miss → RocksDB hit → GetByteArrayRegion (FalconCache.cpp:89)
// fails after rocksdb_get already produced jVal. Cleanup at lines 90-95
// deletes the freshly-allocated value buffer plus the incoming key slice.
// Return nullptr; no leak (ASan).
TEST_F(FalconCacheTest, Get_CacheMiss_GetByteArrayRegionFailure_NoLeakNoCorruption) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
// Pre-populate ONLY in RocksDB so cache.get takes the cache-miss → rocksdb-
// hit branch.
RocksDBPut("rk", "rv");
// The cache-miss path's first byte-array calls are inside rocksdb_get
// (createJavaByteArrayWithSizeCheck does NewByteArray + SetByteArrayRegion).
// Then GetArrayLength (no inject hook). Then GetByteArrayRegion at line 89,
// which is the 3rd byte-array-injectable call from "now".
jni_.injectByteArrayFailureAtCall(3);
jbyteArray r = CacheGet("rk");
EXPECT_EQ(r, nullptr);
jni_.clearPendingException();
}
// 29. After a cache-miss GetByteArrayRegion failure, the key must NOT have
// been inserted into the cache (lines 87-95 short-circuit before
// cache.emplace at line 100). A retry without injection re-fetches from
// RocksDB and succeeds.
TEST_F(FalconCacheTest, Get_CacheMiss_GetByteArrayRegionFailure_KeyNotCached) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
RocksDBPut("nk", "nv");
jni_.injectByteArrayFailureAtCall(3);
EXPECT_EQ(CacheGet("nk"), nullptr);
jni_.clearPendingException();
// Delete from RocksDB. If the failed get had wrongly inserted into cache,
// a retry would still return "nv" from cache. Correct behavior: cache
// miss + RocksDB miss → nullptr.
DirectRocksDBDelete("nk");
EXPECT_EQ(CacheGet("nk"), nullptr);
EXPECT_FALSE(jni_.hasPendingException());
}
// 30. Characterization: put() does not call any byte-array JNI directly
// (key/value memory is pre-allocated by the JNI entry layer and handed
// in as Slices). An armed injection budget therefore survives a put.
TEST_F(FalconCacheTest, Put_DoesNotConsumeByteArrayInjectionBudget) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
// Arm the injection. If put() calls any byte-array JNI, we'd hit it.
jni_.injectByteArrayFailureAtCall(1);
CachePut("k", "v");
// No injection should have fired during put().
EXPECT_FALSE(jni_.hasPendingException());
// The injection slot is still armed. Verify by exercising one byte-array
// call now and confirming it fails.
(void)jni_.env()->NewByteArray(4);
EXPECT_TRUE(jni_.hasPendingException());
jni_.clearPendingException();
}
// 31. Characterization: flush() iterates cache and calls rocksdb_put per
// entry; rocksdb_put only invokes JNI on Status error. With a healthy
// RocksDB no error fires, so flush also doesn't consume the injection
// budget.
TEST_F(FalconCacheTest, Flush_HappyPath_DoesNotConsumeByteArrayInjectionBudget) {
cache_->updateSizeLimit(jni_.env(), dbHandle(), cfHandle(),
writeOptionsHandle(), 100);
CachePut("a", "1");
CachePut("b", "2");
jni_.injectByteArrayFailureAtCall(1);
cache_->flush(jni_.env(), dbHandle(), cfHandle(), writeOptionsHandle());
EXPECT_FALSE(jni_.hasPendingException());
EXPECT_EQ(RocksDBGet("a"), "1");
EXPECT_EQ(RocksDBGet("b"), "2");
}
} // namespace