mirror of
https://github.com/savoirfairelinux/opendht.git
synced 2026-11-08 12:28:42 +00:00
tests: add OpCacheTester and ValueCacheTester
Add unit tests for OpValueCache and ValueCache in isolation. OpCacheTester covers add/edit/expire flows, refcount management, and value synchronization lifecycle. ValueCacheTester covers edition handling and value expiration.
This commit is contained in:
1 parent
3a383ac873
commit
527ecd9d19
5 files changed
+1317
No files matched your search
@@ -870,6 +870,10 @@ if (BUILD_TESTING)
|
||||
tests/test_parsedmessage.cpp
|
||||
tests/test_networkengine.h
|
||||
tests/test_networkengine.cpp
|
||||
tests/test_opcache.h
|
||||
tests/test_opcache.cpp
|
||||
tests/test_valuecache.h
|
||||
tests/test_valuecache.cpp
|
||||
)
|
||||
if (OPENDHT_PROXY_CLIENT)
|
||||
list (APPEND test_FILES
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#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<std::shared_ptr<dht::Value>>&, bool) { return true; });
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 1;
|
||||
v1->seq = 1;
|
||||
std::string data1 = "data1";
|
||||
v1->data = {data1.begin(), data1.end()};
|
||||
|
||||
auto v2 = std::make_shared<dht::Value>();
|
||||
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<std::shared_ptr<dht::Value>>&, bool) { return true; });
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 10;
|
||||
v1->data = {'A'};
|
||||
|
||||
cache.onValuesAdded({v1});
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
|
||||
CPPUNIT_ASSERT(cache.get(10));
|
||||
|
||||
std::vector<dht::Value::Id> 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<std::shared_ptr<dht::Value>>&, bool) { return true; });
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
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<std::shared_ptr<dht::Value>>&, bool) { return true; });
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 30;
|
||||
v1->seq = 10;
|
||||
v1->data = {'C'};
|
||||
|
||||
auto v2 = std::make_shared<dht::Value>();
|
||||
v2->id = 30;
|
||||
v2->seq = 20;
|
||||
v2->data = {'D'};
|
||||
|
||||
auto v3 = std::make_shared<dht::Value>();
|
||||
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<std::shared_ptr<dht::Value>>& vals, bool expired) {
|
||||
if (expired)
|
||||
expireCount += vals.size();
|
||||
else
|
||||
addCount += vals.size();
|
||||
return true;
|
||||
});
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
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<dht::Value>();
|
||||
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<std::shared_ptr<dht::Value>>&, bool) { return true; });
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 50;
|
||||
v1->type = 1;
|
||||
|
||||
auto v2 = std::make_shared<dht::Value>();
|
||||
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<std::shared_ptr<dht::Value>>&, 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<dht::Value>();
|
||||
v1->id = 100;
|
||||
v1->data = {'X'};
|
||||
auto v2 = std::make_shared<dht::Value>();
|
||||
v2->id = 101;
|
||||
v2->data = {'Y'};
|
||||
|
||||
auto query = std::make_shared<dht::Query>();
|
||||
|
||||
// Start a listen
|
||||
size_t listenToken = searchCache.listen([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; },
|
||||
query,
|
||||
{},
|
||||
[&](const std::shared_ptr<dht::Query>& 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<std::shared_ptr<dht::Value>>& vals) {
|
||||
getCallCount++;
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 2, vals.size());
|
||||
return true;
|
||||
},
|
||||
[&](bool success, const std::vector<std::shared_ptr<dht::Node>>&) {
|
||||
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<std::pair<dht::Value::Id, std::string>> deliveries;
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 200;
|
||||
v1->data = {'A'};
|
||||
|
||||
auto query = std::make_shared<dht::Query>();
|
||||
|
||||
// Start a listen but DON'T mark as synced
|
||||
dht::ValueCallback storedVcb;
|
||||
dht::SyncCallback storedScb;
|
||||
size_t listenToken = searchCache.listen([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; },
|
||||
query,
|
||||
{},
|
||||
[&](const std::shared_ptr<dht::Query>& 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<std::shared_ptr<dht::Value>>& vals) {
|
||||
for (const auto& v : vals)
|
||||
deliveries.emplace_back(v->id, "get");
|
||||
return true;
|
||||
};
|
||||
auto dcb = [&](bool, const std::vector<std::shared_ptr<dht::Node>>&) {
|
||||
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<dht::Query>();
|
||||
|
||||
// Start a listen, inject no values, mark as synced
|
||||
size_t listenToken = searchCache.listen([](const std::vector<std::shared_ptr<dht::Value>>&, bool) { return true; },
|
||||
query,
|
||||
{},
|
||||
[&](const std::shared_ptr<dht::Query>& 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<std::shared_ptr<dht::Value>>& vals) {
|
||||
getCallCount++;
|
||||
return true;
|
||||
},
|
||||
[&](bool success, const std::vector<std::shared_ptr<dht::Node>>&) {
|
||||
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<std::shared_ptr<dht::Value>>&, bool) { return true; });
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
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<std::shared_ptr<dht::Value>>&, bool) { return true; });
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
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<std::shared_ptr<dht::Value>>& vals, bool expired) {
|
||||
if (expired)
|
||||
expireCount += vals.size();
|
||||
else
|
||||
addCount += vals.size();
|
||||
return true;
|
||||
});
|
||||
|
||||
auto v = std::make_shared<dht::Value>();
|
||||
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<std::shared_ptr<dht::Value>>&, bool) { return true; });
|
||||
|
||||
auto v = std::make_shared<dht::Value>();
|
||||
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<std::pair<dht::Value::Id, bool>> events; // (id, expired?)
|
||||
|
||||
dht::OpValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
|
||||
for (const auto& v : vals)
|
||||
events.emplace_back(v->id, expired);
|
||||
return true;
|
||||
});
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 700;
|
||||
v1->seq = 1;
|
||||
v1->data = {'O', 'L', 'D'};
|
||||
|
||||
auto v2 = std::make_shared<dht::Value>();
|
||||
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
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Savoir-faire Linux Inc.
|
||||
*
|
||||
* Author: GitHub Copilot
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cppunit/TestFixture.h>
|
||||
#include <cppunit/extensions/HelperMacros.h>
|
||||
|
||||
namespace test {
|
||||
|
||||
class OpCacheTester : public CppUnit::TestFixture
|
||||
{
|
||||
CPPUNIT_TEST_SUITE(OpCacheTester);
|
||||
CPPUNIT_TEST(testUpdateRefCount);
|
||||
CPPUNIT_TEST(testBasicAddExpire);
|
||||
CPPUNIT_TEST(testMultipleSources);
|
||||
CPPUNIT_TEST(testUpdateSequence);
|
||||
CPPUNIT_TEST(testCallbacks);
|
||||
CPPUNIT_TEST(testFilters);
|
||||
CPPUNIT_TEST(testSyncStatus);
|
||||
CPPUNIT_TEST(testGetWhileSynced);
|
||||
CPPUNIT_TEST(testGetWhileNotSynced);
|
||||
CPPUNIT_TEST(testGetEmptySynced);
|
||||
CPPUNIT_TEST(testValueExpirationDuringListen);
|
||||
CPPUNIT_TEST(testTimestampOrdering);
|
||||
CPPUNIT_TEST(testShortExpirationPhantomExpiry);
|
||||
CPPUNIT_TEST(testHighChurnRefCountConsistency);
|
||||
CPPUNIT_TEST(testValueUpdateSingleSourcePhantom);
|
||||
CPPUNIT_TEST_SUITE_END();
|
||||
|
||||
public:
|
||||
void setUp();
|
||||
void tearDown();
|
||||
|
||||
void testUpdateRefCount();
|
||||
void testBasicAddExpire();
|
||||
void testMultipleSources();
|
||||
void testUpdateSequence();
|
||||
void testCallbacks();
|
||||
void testFilters();
|
||||
void testSyncStatus();
|
||||
void testGetWhileSynced();
|
||||
void testGetWhileNotSynced();
|
||||
void testGetEmptySynced();
|
||||
void testValueExpirationDuringListen();
|
||||
void testTimestampOrdering();
|
||||
void testShortExpirationPhantomExpiry();
|
||||
void testHighChurnRefCountConsistency();
|
||||
void testValueUpdateSingleSourcePhantom();
|
||||
};
|
||||
|
||||
} // namespace test
|
||||
@@ -0,0 +1,533 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Savoir-faire Linux Inc.
|
||||
*
|
||||
* Author: GitHub Copilot
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "test_valuecache.h"
|
||||
#include "../src/value_cache.h"
|
||||
|
||||
namespace test {
|
||||
CPPUNIT_TEST_SUITE_REGISTRATION(ValueCacheTester);
|
||||
|
||||
void
|
||||
ValueCacheTester::setUp()
|
||||
{}
|
||||
|
||||
void
|
||||
ValueCacheTester::tearDown()
|
||||
{}
|
||||
|
||||
void
|
||||
ValueCacheTester::testUpdate()
|
||||
{
|
||||
int addCount = 0;
|
||||
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
|
||||
if (!expired)
|
||||
addCount += vals.size();
|
||||
});
|
||||
|
||||
dht::TypeStore types;
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 1;
|
||||
v1->seq = 1;
|
||||
v1->data = {'A'};
|
||||
|
||||
auto v2 = std::make_shared<dht::Value>();
|
||||
v2->id = 1;
|
||||
v2->seq = 2;
|
||||
v2->data = {'B'};
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
|
||||
// Add v1
|
||||
cache.onValues({v1}, {}, {}, types, now);
|
||||
CPPUNIT_ASSERT_EQUAL(1, addCount);
|
||||
|
||||
// Update v1 -> v2
|
||||
cache.onValues({v2}, {}, {}, types, now);
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE("Callback should be called for update", 2, addCount);
|
||||
}
|
||||
|
||||
void
|
||||
ValueCacheTester::testUpdateExpiration()
|
||||
{
|
||||
int addCount = 0;
|
||||
int expireCount = 0;
|
||||
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
|
||||
if (expired)
|
||||
expireCount += vals.size();
|
||||
else
|
||||
addCount += vals.size();
|
||||
});
|
||||
|
||||
dht::TypeStore types;
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 1;
|
||||
v1->seq = 1;
|
||||
v1->data = {'A'};
|
||||
|
||||
auto v2 = std::make_shared<dht::Value>();
|
||||
v2->id = 1;
|
||||
v2->seq = 2;
|
||||
v2->data = {'B'};
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
|
||||
// Add v1
|
||||
cache.onValues({v1}, {}, {}, types, now);
|
||||
CPPUNIT_ASSERT_EQUAL(1, addCount);
|
||||
CPPUNIT_ASSERT_EQUAL(0, expireCount);
|
||||
|
||||
// Update v1 -> v2: only the new value is emitted as an add,
|
||||
// no expire callback for the old value.
|
||||
cache.onValues({v2}, {}, {}, types, now);
|
||||
CPPUNIT_ASSERT_EQUAL(2, addCount);
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE("No expire callback on edit", 0, expireCount);
|
||||
}
|
||||
|
||||
void
|
||||
ValueCacheTester::testExpiration()
|
||||
{
|
||||
int expireCount = 0;
|
||||
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
|
||||
if (expired)
|
||||
expireCount += vals.size();
|
||||
});
|
||||
|
||||
dht::TypeStore types;
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 10;
|
||||
v1->data = {'A'};
|
||||
|
||||
// Default expiration is 10 minutes
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
cache.onValues({v1}, {}, {}, types, now);
|
||||
|
||||
// Advance time past expiration
|
||||
auto future = now + std::chrono::minutes(11);
|
||||
|
||||
// expireValues returns next expiration time
|
||||
cache.expireValues(future);
|
||||
|
||||
CPPUNIT_ASSERT_EQUAL(1, expireCount);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
|
||||
}
|
||||
|
||||
void
|
||||
ValueCacheTester::testRefresh()
|
||||
{
|
||||
int expireCount = 0;
|
||||
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
|
||||
if (expired)
|
||||
expireCount += vals.size();
|
||||
});
|
||||
|
||||
dht::TypeStore types;
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 20;
|
||||
v1->data = {'B'};
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
cache.onValues({v1}, {}, {}, types, now);
|
||||
|
||||
// Refresh at +5 mins
|
||||
auto later = now + std::chrono::minutes(5);
|
||||
cache.onValues({}, {v1->id}, {}, types, later);
|
||||
|
||||
// Should not expire at +11 mins (original expiration was +10, so +10 from start)
|
||||
// But refresh should extend it to +10 from 'later' = +15 from start.
|
||||
|
||||
auto checkTime = now + std::chrono::minutes(11);
|
||||
cache.expireValues(checkTime);
|
||||
|
||||
CPPUNIT_ASSERT_EQUAL(0, expireCount);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
|
||||
|
||||
// Should expire at +16 mins
|
||||
auto future = now + std::chrono::minutes(16);
|
||||
cache.expireValues(future);
|
||||
CPPUNIT_ASSERT_EQUAL(1, expireCount);
|
||||
}
|
||||
|
||||
void
|
||||
ValueCacheTester::testExplicitExpiration()
|
||||
{
|
||||
int expireCount = 0;
|
||||
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
|
||||
if (expired)
|
||||
expireCount += vals.size();
|
||||
});
|
||||
|
||||
dht::TypeStore types;
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 30;
|
||||
v1->data = {'C'};
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
cache.onValues({v1}, {}, {}, types, now);
|
||||
|
||||
cache.onValues({}, {}, {v1->id}, types, now);
|
||||
|
||||
CPPUNIT_ASSERT_EQUAL(1, expireCount);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
|
||||
}
|
||||
|
||||
void
|
||||
ValueCacheTester::testClear()
|
||||
{
|
||||
dht::ValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) {});
|
||||
dht::TypeStore types;
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 1;
|
||||
auto v2 = std::make_shared<dht::Value>();
|
||||
v2->id = 2;
|
||||
|
||||
cache.onValues({v1, v2}, {}, {}, types, now);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 2, cache.size());
|
||||
|
||||
auto cbs = cache.clear();
|
||||
// Execute callbacks
|
||||
for (auto& cb : cbs)
|
||||
cb();
|
||||
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
|
||||
}
|
||||
|
||||
void
|
||||
ValueCacheTester::testSyncStatus()
|
||||
{
|
||||
std::vector<dht::ListenSyncStatus> statuses;
|
||||
{
|
||||
dht::ValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) {},
|
||||
[&](dht::ListenSyncStatus status) { statuses.push_back(status); });
|
||||
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 1, statuses.size());
|
||||
CPPUNIT_ASSERT(statuses.back() == dht::ListenSyncStatus::ADDED);
|
||||
|
||||
cache.onSynced(true);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 2, statuses.size());
|
||||
CPPUNIT_ASSERT(statuses.back() == dht::ListenSyncStatus::SYNCED);
|
||||
|
||||
// Calling onSynced(true) again should not trigger the callback
|
||||
cache.onSynced(true);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 2, statuses.size());
|
||||
|
||||
cache.onSynced(false);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 3, statuses.size());
|
||||
CPPUNIT_ASSERT(statuses.back() == dht::ListenSyncStatus::UNSYNCED);
|
||||
|
||||
// Calling onSynced(false) again should not trigger the callback
|
||||
cache.onSynced(false);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 3, statuses.size());
|
||||
|
||||
// Set to synced before destruction to test UNSYNCED on destruction
|
||||
cache.onSynced(true);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 4, statuses.size());
|
||||
CPPUNIT_ASSERT(statuses.back() == dht::ListenSyncStatus::SYNCED);
|
||||
}
|
||||
|
||||
// Destructor should have been called
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 6, statuses.size());
|
||||
CPPUNIT_ASSERT(statuses[4] == dht::ListenSyncStatus::UNSYNCED);
|
||||
CPPUNIT_ASSERT(statuses[5] == dht::ListenSyncStatus::REMOVED);
|
||||
}
|
||||
|
||||
void
|
||||
ValueCacheTester::testMaxValues()
|
||||
{
|
||||
dht::ValueCache cache([](const std::vector<std::shared_ptr<dht::Value>>&, bool) {});
|
||||
dht::TypeStore types;
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
|
||||
// MAX_VALUES is 4096
|
||||
for (int i = 0; i < 4100; ++i) {
|
||||
auto v = std::make_shared<dht::Value>();
|
||||
v->id = (dht::Value::Id) i + 1;
|
||||
cache.onValues({v}, {}, {}, types, now + std::chrono::milliseconds(i));
|
||||
}
|
||||
|
||||
// Should be capped at 4096
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 4096, cache.size());
|
||||
}
|
||||
|
||||
void
|
||||
ValueCacheTester::testUpdateTypeExpiration()
|
||||
{
|
||||
// Test that when a value is updated with a different type, the expiration
|
||||
// uses the new type's expiration period, not the old one.
|
||||
int addCount = 0;
|
||||
int expireCount = 0;
|
||||
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
|
||||
if (expired)
|
||||
expireCount += vals.size();
|
||||
else
|
||||
addCount += vals.size();
|
||||
});
|
||||
|
||||
// Register a custom type with a short expiration (2 minutes)
|
||||
dht::TypeStore types;
|
||||
dht::ValueType shortType(42, "short", std::chrono::minutes(2));
|
||||
types.registerType(shortType);
|
||||
|
||||
// Register a custom type with a long expiration (30 minutes)
|
||||
dht::ValueType longType(99, "long", std::chrono::minutes(30));
|
||||
types.registerType(longType);
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 1;
|
||||
v1->type = 42; // short type: 2 min expiration
|
||||
v1->data = {'A'};
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
|
||||
// Add v1 with short type
|
||||
cache.onValues({v1}, {}, {}, types, now);
|
||||
CPPUNIT_ASSERT_EQUAL(1, addCount);
|
||||
|
||||
// Update with new type (long expiration)
|
||||
auto v2 = std::make_shared<dht::Value>();
|
||||
v2->id = 1;
|
||||
v2->type = 99; // long type: 30 min expiration
|
||||
v2->data = {'B'};
|
||||
|
||||
cache.onValues({v2}, {}, {}, types, now);
|
||||
CPPUNIT_ASSERT_EQUAL(2, addCount);
|
||||
|
||||
// At +3 minutes: should NOT be expired (long type has 30 min expiration)
|
||||
// Before the fix, the old type (2 min) would be used, causing expiration here
|
||||
expireCount = 0;
|
||||
auto at3min = now + std::chrono::minutes(3);
|
||||
cache.expireValues(at3min);
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE("Value updated to long-expiry type should NOT expire at 3 min", 0, expireCount);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
|
||||
|
||||
// At +31 minutes: should be expired
|
||||
auto at31min = now + std::chrono::minutes(31);
|
||||
cache.expireValues(at31min);
|
||||
CPPUNIT_ASSERT_EQUAL(1, expireCount);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
|
||||
}
|
||||
|
||||
void
|
||||
ValueCacheTester::testShortExpirationRefreshLost()
|
||||
{
|
||||
// Demonstrates that a value with a short expiration type survives
|
||||
// past its nominal expiration when the listen is synced, thanks to
|
||||
// a proportional grace period (= typeExpiration).
|
||||
//
|
||||
// For a 5s type: effective expiration when synced = expiration + 5s = 10s
|
||||
//
|
||||
// Scenario:
|
||||
// t=0 : Value V added → nominal expiration t+5s, effective t+10s
|
||||
// t=6 : Past nominal but within grace → alive
|
||||
// t=8 : Refresh arrives → V refreshed → expiration reset to t8+5s, effective t8+10s=t18s
|
||||
// t=14: Still alive (within refreshed effective range)
|
||||
// t=20: No more refreshes → past t8+10s=t18s → expired
|
||||
|
||||
int addCount = 0;
|
||||
int expireCount = 0;
|
||||
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
|
||||
if (expired)
|
||||
expireCount += vals.size();
|
||||
else
|
||||
addCount += vals.size();
|
||||
});
|
||||
|
||||
// Register a type with a very short expiration (5 seconds)
|
||||
dht::TypeStore types;
|
||||
dht::ValueType shortType(100, "short_lived", std::chrono::seconds(5));
|
||||
types.registerType(shortType);
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 1;
|
||||
v1->type = 100;
|
||||
v1->data = {'V'};
|
||||
|
||||
auto t0 = std::chrono::steady_clock::now();
|
||||
|
||||
// t=0: Listen response arrives with V
|
||||
cache.onValues({v1}, {}, {}, types, t0);
|
||||
CPPUNIT_ASSERT_EQUAL(1, addCount);
|
||||
CPPUNIT_ASSERT_EQUAL(0, expireCount);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
|
||||
|
||||
// Mark cache as synced (listen is active)
|
||||
cache.onSynced(true);
|
||||
|
||||
// t=6: Past nominal 5s expiration, but grace (5s) gives effective 10s
|
||||
auto t6 = t0 + std::chrono::seconds(6);
|
||||
cache.expireValues(t6);
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE("Value should survive past nominal expiration thanks to proportional grace",
|
||||
0,
|
||||
expireCount);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
|
||||
|
||||
// t=8: Value refresh arrives → expiration reset to t8+5s=t13s, effective t8+10s=t18s
|
||||
auto t8 = t0 + std::chrono::seconds(8);
|
||||
cache.onValues({}, {v1->id}, {}, types, t8);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
|
||||
|
||||
// t=14: Within refreshed effective range (t18s) → alive
|
||||
auto t14 = t0 + std::chrono::seconds(14);
|
||||
cache.expireValues(t14);
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE("Value should survive past short expiration after refresh", 0, expireCount);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
|
||||
|
||||
// t=20: No more refreshes → past effective t18s → must expire
|
||||
auto t20 = t0 + std::chrono::seconds(20);
|
||||
cache.expireValues(t20);
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE("Value must eventually expire even while synced if server stops refreshing",
|
||||
1,
|
||||
expireCount);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
|
||||
}
|
||||
|
||||
void
|
||||
ValueCacheTester::testShortExpirationHighChurn()
|
||||
{
|
||||
// Simulates high churn: multiple rapid add/expire/refresh cycles with
|
||||
// short-lived values, verifying the cache stays consistent.
|
||||
|
||||
int addCount = 0;
|
||||
int expireCount = 0;
|
||||
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
|
||||
if (expired)
|
||||
expireCount += vals.size();
|
||||
else
|
||||
addCount += vals.size();
|
||||
});
|
||||
|
||||
dht::TypeStore types;
|
||||
dht::ValueType shortType(101, "fast_churn", std::chrono::seconds(3));
|
||||
types.registerType(shortType);
|
||||
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
|
||||
// Simulate 10 churn cycles: add value, advance time, refresh, advance, expire
|
||||
for (int cycle = 0; cycle < 10; cycle++) {
|
||||
auto v = std::make_shared<dht::Value>();
|
||||
v->id = static_cast<dht::Value::Id>(500 + cycle);
|
||||
v->type = 101;
|
||||
v->data = {static_cast<uint8_t>('A' + cycle)};
|
||||
|
||||
auto t = now + std::chrono::seconds(cycle * 10);
|
||||
|
||||
// Add the value
|
||||
cache.onValues({v}, {}, {}, types, t);
|
||||
|
||||
// Refresh halfway through
|
||||
auto tRefresh = t + std::chrono::seconds(2);
|
||||
cache.onValues({}, {v->id}, {}, types, tRefresh);
|
||||
|
||||
// Explicit expire from server
|
||||
auto tExpire = t + std::chrono::seconds(8);
|
||||
cache.onValues({}, {}, {v->id}, types, tExpire);
|
||||
}
|
||||
|
||||
// All 10 values were added and explicitly expired
|
||||
CPPUNIT_ASSERT_EQUAL(10, addCount);
|
||||
CPPUNIT_ASSERT_EQUAL(10, expireCount);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
|
||||
}
|
||||
|
||||
void
|
||||
ValueCacheTester::testNoTimeExpirationWhileSynced()
|
||||
{
|
||||
// Verify that when the listen is synced, the proportional grace period
|
||||
// (= typeExpiration) delays time-based expiration but doesn't prevent
|
||||
// it entirely. For a 2s type: effective expiration = 2s + 2s = 4s.
|
||||
|
||||
int expireCount = 0;
|
||||
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
|
||||
if (expired)
|
||||
expireCount += vals.size();
|
||||
});
|
||||
|
||||
dht::TypeStore types;
|
||||
dht::ValueType shortType(110, "short", std::chrono::seconds(2));
|
||||
types.registerType(shortType);
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 1;
|
||||
v1->type = 110;
|
||||
v1->data = {'X'};
|
||||
|
||||
auto t0 = std::chrono::steady_clock::now();
|
||||
cache.onValues({v1}, {}, {}, types, t0);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
|
||||
|
||||
// Mark as synced
|
||||
cache.onSynced(true);
|
||||
|
||||
// t=3: Past nominal 2s, within effective 4s → grace protects
|
||||
auto t3 = t0 + std::chrono::seconds(3);
|
||||
cache.expireValues(t3);
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE("Synced cache: grace period should prevent early expiry", 0, expireCount);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 1, cache.size());
|
||||
|
||||
// t=5: Past effective 4s (2s + 2s grace) → must expire
|
||||
auto t5 = t0 + std::chrono::seconds(5);
|
||||
cache.expireValues(t5);
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE("Synced cache: value must eventually expire after grace period", 1, expireCount);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
|
||||
}
|
||||
|
||||
void
|
||||
ValueCacheTester::testUnsyncExpiresImmediately()
|
||||
{
|
||||
// When the cache goes from synced to unsynced, the grace period is
|
||||
// removed and already-past-due values expire on the next check.
|
||||
// Type expiration = 5s. Grace when synced = 5s → effective = 10s.
|
||||
|
||||
int expireCount = 0;
|
||||
dht::ValueCache cache([&](const std::vector<std::shared_ptr<dht::Value>>& vals, bool expired) {
|
||||
if (expired)
|
||||
expireCount += vals.size();
|
||||
});
|
||||
|
||||
dht::TypeStore types;
|
||||
dht::ValueType shortType(120, "short", std::chrono::seconds(5));
|
||||
types.registerType(shortType);
|
||||
|
||||
auto v1 = std::make_shared<dht::Value>();
|
||||
v1->id = 1;
|
||||
v1->type = 120;
|
||||
v1->data = {'U'};
|
||||
|
||||
auto t0 = std::chrono::steady_clock::now();
|
||||
cache.onValues({v1}, {}, {}, types, t0);
|
||||
cache.onSynced(true);
|
||||
|
||||
// t=7: past nominal 5s, within effective 10s while synced → alive
|
||||
auto t7 = t0 + std::chrono::seconds(7);
|
||||
cache.expireValues(t7);
|
||||
CPPUNIT_ASSERT_EQUAL(0, expireCount);
|
||||
|
||||
// Now unsync (node went away / connectivity loss)
|
||||
cache.onSynced(false);
|
||||
|
||||
// Same time point t=7: past nominal 5s, no grace → expires
|
||||
cache.expireValues(t7);
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE("Unsynced cache should immediately expire past-due values", 1, expireCount);
|
||||
CPPUNIT_ASSERT_EQUAL((size_t) 0, cache.size());
|
||||
}
|
||||
|
||||
} // namespace test
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (C) 2025 Savoir-faire Linux Inc.
|
||||
*
|
||||
* Author: GitHub Copilot
|
||||
*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cppunit/TestFixture.h>
|
||||
#include <cppunit/extensions/HelperMacros.h>
|
||||
|
||||
namespace test {
|
||||
|
||||
class ValueCacheTester : public CppUnit::TestFixture
|
||||
{
|
||||
CPPUNIT_TEST_SUITE(ValueCacheTester);
|
||||
CPPUNIT_TEST(testUpdate);
|
||||
CPPUNIT_TEST(testUpdateExpiration);
|
||||
CPPUNIT_TEST(testExpiration);
|
||||
CPPUNIT_TEST(testRefresh);
|
||||
CPPUNIT_TEST(testExplicitExpiration);
|
||||
CPPUNIT_TEST(testClear);
|
||||
CPPUNIT_TEST(testSyncStatus);
|
||||
CPPUNIT_TEST(testMaxValues);
|
||||
CPPUNIT_TEST(testUpdateTypeExpiration);
|
||||
CPPUNIT_TEST(testShortExpirationRefreshLost);
|
||||
CPPUNIT_TEST(testShortExpirationHighChurn);
|
||||
CPPUNIT_TEST(testNoTimeExpirationWhileSynced);
|
||||
CPPUNIT_TEST(testUnsyncExpiresImmediately);
|
||||
CPPUNIT_TEST_SUITE_END();
|
||||
|
||||
public:
|
||||
void setUp();
|
||||
void tearDown();
|
||||
|
||||
void testUpdate();
|
||||
void testUpdateExpiration();
|
||||
void testExpiration();
|
||||
void testRefresh();
|
||||
void testExplicitExpiration();
|
||||
void testClear();
|
||||
void testSyncStatus();
|
||||
void testMaxValues();
|
||||
void testUpdateTypeExpiration();
|
||||
void testShortExpirationRefreshLost();
|
||||
void testShortExpirationHighChurn();
|
||||
void testNoTimeExpirationWhileSynced();
|
||||
void testUnsyncExpiresImmediately();
|
||||
};
|
||||
|
||||
} // namespace test
|
||||
Reference in new issue
Block a user