/* * Copyright (C) 2025 Savoir-faire Linux Inc. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include "test_opcache.h" // Hack to test internal class #include "../src/op_cache.cpp" namespace test { CPPUNIT_TEST_SUITE_REGISTRATION(OpCacheTester); void OpCacheTester::setUp() {} void OpCacheTester::tearDown() {} void OpCacheTester::testUpdateRefCount() { dht::OpValueCache cache([](const std::vector>&, bool) { return true; }); auto v1 = std::make_shared(); v1->id = 1; v1->seq = 1; std::string data1 = "data1"; v1->data = {data1.begin(), data1.end()}; auto v2 = std::make_shared(); v2->id = 1; v2->seq = 2; std::string data2 = "data2"; v2->data = {data2.begin(), data2.end()}; // Add v1 cache.onValuesAdded({v1}); // Should have 1 value CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size()); auto stored = cache.get(1); CPPUNIT_ASSERT(stored); CPPUNIT_ASSERT_EQUAL(v1->seq, stored->seq); // Add v2 (update) cache.onValuesAdded({v2}); // Should still have 1 value (updated) CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size()); stored = cache.get(1); CPPUNIT_ASSERT(stored); CPPUNIT_ASSERT_EQUAL(v2->seq, stored->seq); // Now expire once — since refCount is not incremented on edit, // a single expire removes the value. cache.onValuesExpired({v1}); stored = cache.get(1); CPPUNIT_ASSERT_MESSAGE("Value should be removed after single expire (refCount=1)", stored == nullptr); } void OpCacheTester::testBasicAddExpire() { dht::OpValueCache cache([](const std::vector>&, bool) { return true; }); auto v1 = std::make_shared(); v1->id = 10; v1->data = {'A'}; cache.onValuesAdded({v1}); CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size()); CPPUNIT_ASSERT(cache.get(10)); std::vector ids = {v1->id}; cache.onValuesExpired(ids); CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size()); CPPUNIT_ASSERT(!cache.get(10)); } void OpCacheTester::testMultipleSources() { dht::OpValueCache cache([](const std::vector>&, bool) { return true; }); auto v1 = std::make_shared(); v1->id = 20; v1->data = {'B'}; // Add same value twice (simulating two sources) // refCount does not increment on duplicate: stays at 1. cache.onValuesAdded({v1}); cache.onValuesAdded({v1}); CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size()); // Single expire removes the value cache.onValuesExpired({v1}); CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size()); } void OpCacheTester::testUpdateSequence() { dht::OpValueCache cache([](const std::vector>&, bool) { return true; }); auto v1 = std::make_shared(); v1->id = 30; v1->seq = 10; v1->data = {'C'}; auto v2 = std::make_shared(); v2->id = 30; v2->seq = 20; v2->data = {'D'}; auto v3 = std::make_shared(); v3->id = 30; v3->seq = 5; v3->data = {'E'}; // Add seq 10 cache.onValuesAdded({v1}); auto stored = cache.get(30); CPPUNIT_ASSERT_EQUAL((uint16_t) 10, stored->seq); // Add seq 20 (update) cache.onValuesAdded({v2}); stored = cache.get(30); CPPUNIT_ASSERT_EQUAL((uint16_t) 20, stored->seq); // Add seq 5 (older, should not update content) cache.onValuesAdded({v3}); stored = cache.get(30); CPPUNIT_ASSERT_EQUAL((uint16_t) 20, stored->seq); // RefCount stays at 1 regardless of how many times we add. // A single expire removes the value. cache.onValuesExpired({v1}); CPPUNIT_ASSERT(!cache.get(30)); } void OpCacheTester::testCallbacks() { int addCount = 0; int expireCount = 0; dht::OpValueCache cache([&](const std::vector>& vals, bool expired) { if (expired) expireCount += vals.size(); else addCount += vals.size(); return true; }); auto v1 = std::make_shared(); v1->id = 40; cache.onValuesAdded({v1}); CPPUNIT_ASSERT_EQUAL(1, addCount); CPPUNIT_ASSERT_EQUAL(0, expireCount); // Adding same value again should NOT trigger callback (no new value added) // Wait, looking at code: // if (viop.second) { newValues.emplace_back(v); } ... // return newValues.empty() ? true : callback(newValues, false); // So if it's a duplicate or update, does it callback? // If update: newValues.emplace_back(v) IS called. // If duplicate (refCount++ only): newValues is NOT added. cache.onValuesAdded({v1}); CPPUNIT_ASSERT_EQUAL(1, addCount); // Should still be 1 auto v2 = std::make_shared(); v2->id = 40; v2->seq = 100; // Update v2->data = {'M', 'O', 'D'}; // Change data to ensure inequality cache.onValuesAdded({v2}); CPPUNIT_ASSERT_EQUAL(2, addCount); // Should be 2 now (update triggers callback) // RefCount is 1 — single expire removes the value. cache.onValuesExpired({v1}); CPPUNIT_ASSERT_EQUAL(1, expireCount); } void OpCacheTester::testFilters() { dht::OpValueCache cache([](const std::vector>&, bool) { return true; }); auto v1 = std::make_shared(); v1->id = 50; v1->type = 1; auto v2 = std::make_shared(); v2->id = 51; v2->type = 2; cache.onValuesAdded({v1, v2}); auto f1 = dht::Value::TypeFilter(1); auto res1 = cache.get(f1); CPPUNIT_ASSERT_EQUAL((size_t) 1, res1.size()); CPPUNIT_ASSERT_EQUAL(v1->id, res1[0]->id); auto f2 = dht::Value::TypeFilter(2); auto res2 = cache.get(f2); CPPUNIT_ASSERT_EQUAL((size_t) 1, res2.size()); CPPUNIT_ASSERT_EQUAL(v2->id, res2[0]->id); auto all = cache.getValues(); CPPUNIT_ASSERT_EQUAL((size_t) 2, all.size()); } void OpCacheTester::testSyncStatus() { dht::OpValueCache cache([](const std::vector>&, bool) { return true; }); // Initial state CPPUNIT_ASSERT(!cache.isSynced()); // Add a node cache.onNodeChanged(dht::ListenSyncStatus::ADDED); // 1 node, 0 synced -> not synced CPPUNIT_ASSERT(!cache.isSynced()); // Sync the node cache.onNodeChanged(dht::ListenSyncStatus::SYNCED); // 1 node, 1 synced -> synced CPPUNIT_ASSERT(cache.isSynced()); // Add another node cache.onNodeChanged(dht::ListenSyncStatus::ADDED); // 2 nodes, 1 synced -> not synced CPPUNIT_ASSERT(!cache.isSynced()); // Sync second node cache.onNodeChanged(dht::ListenSyncStatus::SYNCED); // 2 nodes, 2 synced -> synced CPPUNIT_ASSERT(cache.isSynced()); // Unsync one cache.onNodeChanged(dht::ListenSyncStatus::UNSYNCED); CPPUNIT_ASSERT(!cache.isSynced()); // Remove one (the unsynced one) cache.onNodeChanged(dht::ListenSyncStatus::REMOVED); // 1 node, 1 synced -> synced CPPUNIT_ASSERT(cache.isSynced()); } void OpCacheTester::testGetWhileSynced() { // Test that get() on a SearchCache with a synced listen returns cached values // and completes the get (returns true) without causing duplicate delivery. dht::SearchCache searchCache; int getCallCount = 0; bool doneCalled = false; bool doneSuccess = false; auto v1 = std::make_shared(); v1->id = 100; v1->data = {'X'}; auto v2 = std::make_shared(); v2->id = 101; v2->data = {'Y'}; auto query = std::make_shared(); // Start a listen size_t listenToken = searchCache.listen([](const std::vector>&, bool) { return true; }, query, {}, [&](const std::shared_ptr& q, dht::ValueCallback vcb, dht::SyncCallback scb) -> size_t { // Simulate node added and synced scb(dht::ListenSyncStatus::ADDED); // Inject values vcb({v1, v2}, false); // Mark as synced scb(dht::ListenSyncStatus::SYNCED); return 1; }); CPPUNIT_ASSERT(listenToken != 0); // Now perform a get on the synced cache bool result = searchCache.get( {}, query, [&](const std::vector>& vals) { getCallCount++; CPPUNIT_ASSERT_EQUAL((size_t) 2, vals.size()); return true; }, [&](bool success, const std::vector>&) { doneCalled = true; doneSuccess = success; }); // get should be served from cache CPPUNIT_ASSERT_MESSAGE("get() should return true when cache is synced", result); CPPUNIT_ASSERT_EQUAL_MESSAGE("get callback should be called exactly once", 1, getCallCount); CPPUNIT_ASSERT_MESSAGE("done callback should be called", doneCalled); CPPUNIT_ASSERT_MESSAGE("done callback should indicate success", doneSuccess); } void OpCacheTester::testGetWhileNotSynced() { // Test that when a get() is performed on a non-synced cache, values // are not delivered twice: once from the cache and once from the // simulated network get that Search::get would queue. dht::SearchCache searchCache; // Track every value delivery with (value_id, source) pairs std::vector> deliveries; auto v1 = std::make_shared(); v1->id = 200; v1->data = {'A'}; auto query = std::make_shared(); // Start a listen but DON'T mark as synced dht::ValueCallback storedVcb; dht::SyncCallback storedScb; size_t listenToken = searchCache.listen([](const std::vector>&, bool) { return true; }, query, {}, [&](const std::shared_ptr& q, dht::ValueCallback vcb, dht::SyncCallback scb) -> size_t { storedVcb = vcb; storedScb = scb; // Node added but NOT synced scb(dht::ListenSyncStatus::ADDED); // Inject a value vcb({v1}, false); return 1; }); CPPUNIT_ASSERT(listenToken != 0); // Simulate what Search::get does: try cache first, if false, queue network get bool doneCalled = false; auto gcb = [&](const std::vector>& vals) { for (const auto& v : vals) deliveries.emplace_back(v->id, "get"); return true; }; auto dcb = [&](bool, const std::vector>&) { doneCalled = true; }; bool servedFromCache = searchCache.get({}, query, gcb, dcb); // Cache is not synced so get should not be served from cache CPPUNIT_ASSERT_MESSAGE("get() should return false when cache is not synced", !servedFromCache); // Simulate the network get completing with the same value // (this is what onGetValuesDone does — calls gcb for each matching get) gcb({v1}); // Exactly one delivery expected — from the network get only CPPUNIT_ASSERT_EQUAL_MESSAGE("Value should be delivered exactly once (from network get only)", (size_t) 1, deliveries.size()); CPPUNIT_ASSERT_EQUAL(v1->id, deliveries[0].first); CPPUNIT_ASSERT_EQUAL(std::string("get"), deliveries[0].second); // Now sync the listen and perform another get — this time it should be served from cache storedScb(dht::ListenSyncStatus::SYNCED); deliveries.clear(); doneCalled = false; bool servedFromCache2 = searchCache.get({}, query, gcb, dcb); CPPUNIT_ASSERT_MESSAGE("get() should return true once cache is synced", servedFromCache2); CPPUNIT_ASSERT_MESSAGE("done callback should be called when served from synced cache", doneCalled); CPPUNIT_ASSERT_EQUAL_MESSAGE("Synced cache get should deliver the value exactly once", (size_t) 1, deliveries.size()); } void OpCacheTester::testGetEmptySynced() { // Test that get() on a synced but empty cache calls dcb but not gcb. dht::SearchCache searchCache; int getCallCount = 0; bool doneCalled = false; bool doneSuccess = false; auto query = std::make_shared(); // Start a listen, inject no values, mark as synced size_t listenToken = searchCache.listen([](const std::vector>&, bool) { return true; }, query, {}, [&](const std::shared_ptr& q, dht::ValueCallback vcb, dht::SyncCallback scb) -> size_t { scb(dht::ListenSyncStatus::ADDED); scb(dht::ListenSyncStatus::SYNCED); return 1; }); CPPUNIT_ASSERT(listenToken != 0); bool result = searchCache.get( {}, query, [&](const std::vector>& vals) { getCallCount++; return true; }, [&](bool success, const std::vector>&) { doneCalled = true; doneSuccess = success; }); // Synced + empty: get should succeed but gcb should not be called CPPUNIT_ASSERT_MESSAGE("get() should return true when cache is synced (even if empty)", result); CPPUNIT_ASSERT_EQUAL_MESSAGE("get callback should NOT be called (no values)", 0, getCallCount); CPPUNIT_ASSERT_MESSAGE("done callback should be called", doneCalled); CPPUNIT_ASSERT_MESSAGE("done callback should indicate success", doneSuccess); } void OpCacheTester::testValueExpirationDuringListen() { // Test that values correctly expire via refCount during listen with multiple sources. dht::OpValueCache cache([](const std::vector>&, bool) { return true; }); auto v1 = std::make_shared(); v1->id = 300; v1->data = {'D'}; // Simulate two nodes reporting the same value cache.onNodeChanged(dht::ListenSyncStatus::ADDED); cache.onNodeChanged(dht::ListenSyncStatus::ADDED); cache.onValuesAdded({v1}); // source 1 cache.onValuesAdded({v1}); // source 2 (no refCount increment) CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size()); CPPUNIT_ASSERT(cache.get(300) != nullptr); // Single expire removes the value (refCount = 1) cache.onValuesExpired({v1}); CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size()); CPPUNIT_ASSERT(cache.get(300) == nullptr); } void OpCacheTester::testTimestampOrdering() { // Test that expired values with timestamps older than the last update are ignored. dht::OpValueCache cache([](const std::vector>&, bool) { return true; }); auto v1 = std::make_shared(); v1->id = 400; v1->data = {'T'}; auto t1 = std::chrono::system_clock::now(); auto t2 = t1 + std::chrono::seconds(10); // Add value at time t2 cache.onValue({v1}, false, t2); CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size()); // Try to expire at time t1 (older) — should be ignored cache.onValue({v1}, true, t1); CPPUNIT_ASSERT_EQUAL_MESSAGE("Stale expiration should be ignored", (size_t) 1, cache.size()); CPPUNIT_ASSERT(cache.get(400) != nullptr); // Expire at time t2 — should succeed cache.onValue({v1}, true, t2); CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size()); } void OpCacheTester::testShortExpirationPhantomExpiry() { // End-to-end test: demonstrates phantom expire / re-add when multiple // per-node ValueCaches feed into a single OpValueCache and a short-lived // value expires from all per-node caches before the listen refresh. // // The fixed behavior (ValueCache does not time-expire while synced) // prevents the phantom. Here we verify the OpValueCache stays consistent. using namespace std::chrono; int addCount = 0; int expireCount = 0; dht::OpValueCache opCache([&](const std::vector>& vals, bool expired) { if (expired) expireCount += vals.size(); else addCount += vals.size(); return true; }); auto v = std::make_shared(); v->id = 900; v->data = {'Z'}; // Simulate 3 nodes each adding the same value // refCount stays at 1 (no increment on duplicate adds) opCache.onNodeChanged(dht::ListenSyncStatus::ADDED); opCache.onNodeChanged(dht::ListenSyncStatus::ADDED); opCache.onNodeChanged(dht::ListenSyncStatus::ADDED); opCache.onValuesAdded({v}); opCache.onValuesAdded({v}); opCache.onValuesAdded({v}); CPPUNIT_ASSERT_EQUAL(1, addCount); // first add fires callback CPPUNIT_ASSERT_EQUAL((size_t) 1, opCache.size()); // Single expire removes the value opCache.onValuesExpired({v}); CPPUNIT_ASSERT_EQUAL(1, expireCount); CPPUNIT_ASSERT_EQUAL((size_t) 0, opCache.size()); // New nodes report the same value again (re-listen completed) opCache.onValuesAdded({v}); opCache.onValuesAdded({v}); CPPUNIT_ASSERT_EQUAL(2, addCount); // value re-appeared CPPUNIT_ASSERT_EQUAL((size_t) 1, opCache.size()); // Single expire removes opCache.onValuesExpired({v}); CPPUNIT_ASSERT_EQUAL((size_t) 0, opCache.size()); CPPUNIT_ASSERT_EQUAL(2, expireCount); } void OpCacheTester::testHighChurnRefCountConsistency() { // Rapid node churn: nodes join and leave quickly, each reporting the same // value. Verify that the refCount never goes negative and the value is // correctly expired only when all sources are gone. bool callbackFailed = false; dht::OpValueCache cache([&](const std::vector>&, bool) { return true; }); auto v = std::make_shared(); v->id = 800; v->data = {'C', 'H', 'U', 'R', 'N'}; // Simulate 50 rapid churn cycles: // - node joins, adds V (first add sets refCount=1) // - node leaves, expires V (refCount→0, removed) for (int i = 0; i < 50; i++) { cache.onNodeChanged(dht::ListenSyncStatus::ADDED); cache.onValuesAdded({v}); cache.onValuesExpired({v}); cache.onNodeChanged(dht::ListenSyncStatus::REMOVED); } // After all churn, value should be completely gone. CPPUNIT_ASSERT_EQUAL_MESSAGE("All sources gone — value must be removed", (size_t) 0, cache.size()); // Now simulate overlapping churn: add N, add N+1, expire N, expire N+1 // Since refCount stays at 1, the first expire removes the value. // The second expire is a no-op. for (int i = 0; i < 20; i++) { cache.onNodeChanged(dht::ListenSyncStatus::ADDED); cache.onValuesAdded({v}); cache.onNodeChanged(dht::ListenSyncStatus::ADDED); cache.onValuesAdded({v}); // no increment // First node leaves — refCount 1→0, value removed cache.onValuesExpired({v}); cache.onNodeChanged(dht::ListenSyncStatus::REMOVED); CPPUNIT_ASSERT_EQUAL_MESSAGE("Value removed after single expire", (size_t) 0, cache.size()); // Second expire is a no-op (value already gone) cache.onValuesExpired({v}); cache.onNodeChanged(dht::ListenSyncStatus::REMOVED); } CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size()); } void OpCacheTester::testValueUpdateSingleSourcePhantom() { // Verify that when a value is edited (same id, higher seq), the user // sees only the new version as an add — no phantom expiration of the // old version. std::vector> events; // (id, expired?) dht::OpValueCache cache([&](const std::vector>& vals, bool expired) { for (const auto& v : vals) events.emplace_back(v->id, expired); return true; }); auto v1 = std::make_shared(); v1->id = 700; v1->seq = 1; v1->data = {'O', 'L', 'D'}; auto v2 = std::make_shared(); v2->id = 700; v2->seq = 2; v2->data = {'N', 'E', 'W'}; // Single source adds v1 cache.onValuesAdded({v1}); CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size()); events.clear(); // ValueCache::addValues now only emits add(new) for edits, // so OpValueCache updates in place via onValuesAdded. cache.onValuesAdded({v2}); // User should see exactly one event: the updated value as an add. CPPUNIT_ASSERT_EQUAL_MESSAGE("Expected 1 event (update add)", (size_t) 1, events.size()); CPPUNIT_ASSERT_EQUAL_MESSAGE("Event should be an addition", false, events[0].second); CPPUNIT_ASSERT_EQUAL_MESSAGE("Event should carry the new value id", v2->id, events[0].first); // Final state: value exists with updated data auto stored = cache.get(700); CPPUNIT_ASSERT(stored != nullptr); CPPUNIT_ASSERT_EQUAL(v2->seq, stored->seq); } } // namespace test