## Breaking Change
IIO API redesigned from manual pull+if-forest to callback dispatch.
All modules must update their subscribe() calls to pass handlers.
### Before (OLD API)
```cpp
io->subscribe("input:mouse");
void process(...) {
while (io->hasMessages()) {
auto msg = io->pullMessage();
if (msg.topic == "input:mouse") {
handleMouse(msg);
} else if (msg.topic == "input:keyboard") {
handleKeyboard(msg);
}
}
}
```
### After (NEW API)
```cpp
io->subscribe("input:mouse", [this](const Message& msg) {
handleMouse(msg);
});
void process(...) {
while (io->hasMessages()) {
io->pullAndDispatch(); // Callbacks invoked automatically
}
}
```
## Changes
**Core API (include/grove/IIO.h)**
- Added: `using MessageHandler = std::function<void(const Message&)>`
- Changed: `subscribe()` now requires `MessageHandler` callback parameter
- Changed: `subscribeLowFreq()` now requires `MessageHandler` callback
- Removed: `pullMessage()`
- Added: `pullAndDispatch()` - pulls and auto-dispatches to handlers
**Implementation (src/IntraIO.cpp)**
- Store callbacks in `Subscription.handler`
- `pullAndDispatch()` matches topic against ALL subscriptions (not just first)
- Fixed: Regex pattern compilation supports both wildcards (*) and regex (.*)
- Performance: ~1000 msg/s throughput (unchanged from before)
**Files Updated**
- 31 test/module files migrated to callback API (via parallel agents)
- 8 documentation files updated (DEVELOPER_GUIDE, USER_GUIDE, module READMEs)
## Bugs Fixed During Migration
1. **pullAndDispatch() early return bug**: Was only calling FIRST matching handler
- Fix: Loop through ALL subscriptions, invoke all matching handlers
2. **Regex pattern compilation bug**: Pattern "player:.*" failed to match
- Fix: Detect ".*" in pattern → use as regex, otherwise escape and convert wildcards
## Testing
✅ test_11_io_system: PASSED (IIO pub/sub, pattern matching, batching)
✅ test_threaded_module_system: 6/6 PASSED
✅ test_threaded_stress: 5/5 PASSED (50 modules, 100x reload, concurrent ops)
✅ test_12_datanode: PASSED
✅ 10 TopicTree scenarios: 10/10 PASSED
✅ benchmark_e2e: ~1000 msg/s throughput
Total: 23+ tests passing
## Performance Impact
No performance regression from callback dispatch:
- IIO throughput: ~1000 msg/s (same as before)
- ThreadedModuleSystem: Speedup ~1.0x (barrier pattern expected)
## Migration Guide
For all modules using IIO:
1. Update subscribe() calls to include handler lambda
2. Replace pullMessage() loops with pullAndDispatch()
3. Move topic-specific logic from if-forest into callbacks
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
421 lines
16 KiB
C++
421 lines
16 KiB
C++
/**
|
|
* Scenario 13: Cross-System Integration (IO + DataNode)
|
|
*
|
|
* Tests integration between IntraIO pub/sub system and IDataTree/IDataNode system.
|
|
* Validates that modules can communicate via IO while sharing data via DataNode.
|
|
*/
|
|
|
|
#include "grove/JsonDataNode.h"
|
|
#include "grove/JsonDataTree.h"
|
|
#include "grove/IOFactory.h"
|
|
#include "../helpers/TestMetrics.h"
|
|
#include "../helpers/TestAssertions.h"
|
|
#include "../helpers/TestReporter.h"
|
|
|
|
#include <iostream>
|
|
#include <fstream>
|
|
#include <filesystem>
|
|
#include <thread>
|
|
#include <chrono>
|
|
#include <atomic>
|
|
|
|
using namespace grove;
|
|
|
|
int main() {
|
|
TestReporter reporter("Cross-System Integration Test");
|
|
TestMetrics metrics;
|
|
|
|
std::cout << "================================================================================\n";
|
|
std::cout << "TEST: Cross-System Integration (IO + DataNode)\n";
|
|
std::cout << "================================================================================\n\n";
|
|
|
|
// === SETUP ===
|
|
std::cout << "Setup: Creating test directories...\n";
|
|
std::filesystem::create_directories("test_cross/config");
|
|
std::filesystem::create_directories("test_cross/data");
|
|
std::cout << " ✓ Directories created\n";
|
|
|
|
std::cout << " Creating JsonDataTree...\n";
|
|
auto tree = std::make_unique<JsonDataTree>("test_cross");
|
|
std::cout << " ✓ JsonDataTree created\n";
|
|
|
|
// Create IO instances
|
|
std::cout << " Creating ConfigWatcherIO...\n";
|
|
auto configWatcherIO = IOFactory::create("intra", "ConfigWatcher");
|
|
std::cout << " ✓ ConfigWatcherIO created\n";
|
|
|
|
std::cout << " Creating PlayerIO...\n";
|
|
auto playerIO = IOFactory::create("intra", "Player");
|
|
std::cout << " ✓ PlayerIO created\n";
|
|
|
|
std::cout << " Creating EconomyIO...\n";
|
|
auto economyIO = IOFactory::create("intra", "Economy");
|
|
std::cout << " ✓ EconomyIO created\n";
|
|
|
|
std::cout << " Creating MetricsIO...\n";
|
|
auto metricsIO = IOFactory::create("intra", "Metrics");
|
|
std::cout << " ✓ MetricsIO created\n";
|
|
|
|
if (!configWatcherIO || !playerIO || !economyIO || !metricsIO) {
|
|
std::cerr << "❌ Failed to create IO instances\n";
|
|
return 1;
|
|
}
|
|
|
|
// ========================================================================
|
|
// TEST 1: Config Hot-Reload → IO Broadcast
|
|
// ========================================================================
|
|
std::cout << "\n=== TEST 1: Config Hot-Reload → IO Broadcast ===\n";
|
|
|
|
// Create initial config file
|
|
nlohmann::json gameplayConfig = {
|
|
{"difficulty", "normal"},
|
|
{"hpMultiplier", 1.0}
|
|
};
|
|
|
|
std::ofstream configFile("test_cross/config/gameplay.json");
|
|
configFile << gameplayConfig.dump(2);
|
|
configFile.close();
|
|
|
|
// Load config
|
|
tree->loadConfigFile("gameplay.json");
|
|
|
|
// Track config change events
|
|
std::atomic<int> configChangedEvents{0};
|
|
|
|
// Player subscribes to config changes with callback
|
|
playerIO->subscribe("config:gameplay:changed", [&](const Message& msg) {
|
|
configChangedEvents++;
|
|
// Read new config from tree
|
|
auto configRoot = tree->getConfigRoot();
|
|
auto gameplay = configRoot->getChild("gameplay");
|
|
if (gameplay) {
|
|
std::string difficulty = gameplay->getString("difficulty");
|
|
double hpMult = gameplay->getDouble("hpMultiplier");
|
|
|
|
std::cout << " PlayerModule received config change: difficulty=" << difficulty
|
|
<< ", hpMult=" << hpMult << "\n";
|
|
|
|
ASSERT_EQ(difficulty, "hard", "Difficulty should be updated");
|
|
ASSERT_TRUE(std::abs(hpMult - 1.5) < 0.001, "HP multiplier should be updated");
|
|
}
|
|
});
|
|
|
|
// Setup reload callback for ConfigWatcher
|
|
tree->onTreeReloaded([&]() {
|
|
std::cout << " → Config reloaded, publishing event...\n";
|
|
auto data = std::make_unique<JsonDataNode>("configChange", nlohmann::json{
|
|
{"config", "gameplay"},
|
|
{"timestamp", 12345}
|
|
});
|
|
configWatcherIO->publish("config:gameplay:changed", std::move(data));
|
|
});
|
|
|
|
// Modify config file
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
gameplayConfig["difficulty"] = "hard";
|
|
gameplayConfig["hpMultiplier"] = 1.5;
|
|
|
|
std::ofstream configFile2("test_cross/config/gameplay.json");
|
|
configFile2 << gameplayConfig.dump(2);
|
|
configFile2.close();
|
|
|
|
auto reloadStart = std::chrono::high_resolution_clock::now();
|
|
|
|
// Trigger reload
|
|
if (tree->reloadIfChanged()) {
|
|
std::cout << " Config was reloaded\n";
|
|
}
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
|
|
// Dispatch player messages (callback handles verification)
|
|
while (playerIO->hasMessages() > 0) {
|
|
playerIO->pullAndDispatch();
|
|
}
|
|
|
|
auto reloadEnd = std::chrono::high_resolution_clock::now();
|
|
float reloadLatency = std::chrono::duration<float, std::milli>(reloadEnd - reloadStart).count();
|
|
|
|
std::cout << "Total latency (reload + publish + subscribe + read): " << reloadLatency << "ms\n";
|
|
ASSERT_LT(reloadLatency, 200.0f, "Total latency should be reasonable");
|
|
ASSERT_EQ(configChangedEvents.load(), 1, "Should receive exactly 1 config change event");
|
|
|
|
reporter.addMetric("config_reload_latency_ms", reloadLatency);
|
|
reporter.addAssertion("config_hotreload_chain", true);
|
|
std::cout << "✓ TEST 1 PASSED\n";
|
|
|
|
// ========================================================================
|
|
// TEST 2: State Persistence + Event Publishing
|
|
// ========================================================================
|
|
std::cout << "\n=== TEST 2: State Persistence + Event Publishing ===\n";
|
|
|
|
auto dataRoot = tree->getDataRoot();
|
|
|
|
// Create player node
|
|
auto player = std::make_unique<JsonDataNode>("player", nlohmann::json::object());
|
|
auto profile = std::make_unique<JsonDataNode>("profile", nlohmann::json{
|
|
{"name", "TestPlayer"},
|
|
{"level", 5},
|
|
{"gold", 1000}
|
|
});
|
|
|
|
player->setChild("profile", std::move(profile));
|
|
dataRoot->setChild("player", std::move(player));
|
|
|
|
// Save to disk
|
|
bool saved = tree->saveData();
|
|
ASSERT_TRUE(saved, "Should save data successfully");
|
|
|
|
std::cout << " Data saved to disk\n";
|
|
|
|
int messagesReceived = 0;
|
|
int syncErrors = 0; // Will be used in TEST 3
|
|
|
|
// Economy subscribes to player events with callback
|
|
// This callback will be reused across TEST 2 and TEST 3
|
|
economyIO->subscribe("player:*", [&](const Message& msg) {
|
|
messagesReceived++;
|
|
std::cout << " EconomyModule received: " << msg.topic << "\n";
|
|
|
|
// Read player data from tree using read-only access
|
|
auto dataRoot = tree->getDataRoot();
|
|
if (dataRoot) {
|
|
auto playerData = dataRoot->getChildReadOnly("player");
|
|
if (playerData) {
|
|
auto profileData = playerData->getChildReadOnly("profile");
|
|
if (profileData) {
|
|
int gold = profileData->getInt("gold");
|
|
std::cout << " Player gold: " << gold << "\n";
|
|
|
|
// For TEST 2: verify initial gold
|
|
if (msg.topic == "player:level_up") {
|
|
ASSERT_EQ(gold, 1000, "Gold should match saved value");
|
|
}
|
|
|
|
// For TEST 3: verify synchronization
|
|
if (msg.topic == "player:gold:updated") {
|
|
int msgGold = msg.data->getInt("gold");
|
|
if (msgGold != gold) {
|
|
std::cerr << " SYNC ERROR: msg=" << msgGold << " data=" << gold << "\n";
|
|
syncErrors++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Then publish level up event
|
|
auto levelUpData = std::make_unique<JsonDataNode>("levelUp", nlohmann::json{
|
|
{"event", "level_up"},
|
|
{"newLevel", 6},
|
|
{"goldBonus", 500}
|
|
});
|
|
playerIO->publish("player:level_up", std::move(levelUpData));
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
|
|
// Dispatch economy messages (callback handles verification)
|
|
while (economyIO->hasMessages() > 0) {
|
|
economyIO->pullAndDispatch();
|
|
}
|
|
|
|
ASSERT_EQ(messagesReceived, 1, "Should receive 1 player event");
|
|
|
|
reporter.addAssertion("state_persistence_chain", true);
|
|
std::cout << "✓ TEST 2 PASSED\n";
|
|
|
|
// ========================================================================
|
|
// TEST 3: Multi-Module State Synchronization
|
|
// ========================================================================
|
|
std::cout << "\n=== TEST 3: Multi-Module State Synchronization ===\n";
|
|
|
|
// Note: syncErrors is already declared earlier and captured by the economyIO callback
|
|
// The callback will automatically verify synchronization for "player:gold:updated" messages
|
|
|
|
for (int i = 0; i < 10; i++) {
|
|
// Update gold in DataNode using read-only access
|
|
int goldValue = 1000 + i * 10;
|
|
auto dataRoot = tree->getDataRoot();
|
|
if (dataRoot) {
|
|
auto playerNode = dataRoot->getChildReadOnly("player");
|
|
if (playerNode) {
|
|
auto profileNode = playerNode->getChildReadOnly("profile");
|
|
if (profileNode) {
|
|
profileNode->setInt("gold", goldValue);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Publish event with same value
|
|
auto goldUpdate = std::make_unique<JsonDataNode>("goldUpdate", nlohmann::json{
|
|
{"event", "gold_updated"},
|
|
{"gold", goldValue}
|
|
});
|
|
playerIO->publish("player:gold:updated", std::move(goldUpdate));
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
|
|
// Dispatch economy messages (callback will verify synchronization)
|
|
while (economyIO->hasMessages() > 0) {
|
|
economyIO->pullAndDispatch();
|
|
}
|
|
}
|
|
|
|
std::cout << "Synchronization errors: " << syncErrors << " / 10\n";
|
|
ASSERT_EQ(syncErrors, 0, "Should have zero synchronization errors");
|
|
|
|
reporter.addMetric("sync_errors", syncErrors);
|
|
reporter.addAssertion("state_synchronization", syncErrors == 0);
|
|
std::cout << "✓ TEST 3 PASSED\n";
|
|
|
|
// ========================================================================
|
|
// TEST 4: Runtime Metrics Collection
|
|
// ========================================================================
|
|
std::cout << "\n=== TEST 4: Runtime Metrics Collection ===\n";
|
|
|
|
auto runtimeRoot = tree->getRuntimeRoot();
|
|
|
|
int snapshotsReceived = 0;
|
|
|
|
// Subscribe to metrics with low-frequency and callback
|
|
SubscriptionConfig metricsConfig;
|
|
metricsConfig.replaceable = true;
|
|
metricsConfig.batchInterval = 1000; // 1 second
|
|
|
|
playerIO->subscribeLowFreq("metrics:*", [&](const Message& msg) {
|
|
snapshotsReceived++;
|
|
}, metricsConfig);
|
|
|
|
// Publish 20 metrics over 2 seconds
|
|
for (int i = 0; i < 20; i++) {
|
|
auto metricsData = std::make_unique<JsonDataNode>("metrics", nlohmann::json{
|
|
{"fps", 60.0},
|
|
{"memory", 125000000 + i * 1000},
|
|
{"messageCount", i}
|
|
});
|
|
metricsIO->publish("metrics:snapshot", std::move(metricsData));
|
|
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
}
|
|
|
|
// Check batched messages
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
|
while (playerIO->hasMessages() > 0) {
|
|
playerIO->pullAndDispatch();
|
|
}
|
|
|
|
std::cout << "Snapshots received: " << snapshotsReceived << " (expected ~2 due to batching)\n";
|
|
ASSERT_TRUE(snapshotsReceived >= 1 && snapshotsReceived <= 4,
|
|
"Should receive batched snapshots");
|
|
|
|
// Verify runtime not persisted
|
|
ASSERT_FALSE(std::filesystem::exists("test_cross/runtime"),
|
|
"Runtime data should not be persisted");
|
|
|
|
reporter.addMetric("batched_snapshots", snapshotsReceived);
|
|
reporter.addAssertion("runtime_metrics", true);
|
|
std::cout << "✓ TEST 4 PASSED\n";
|
|
|
|
// ========================================================================
|
|
// TEST 5: Concurrent Access (IO + DataNode with new read-only API)
|
|
// ========================================================================
|
|
std::cout << "\n=== TEST 5: Concurrent Access (IO + DataNode) ===\n";
|
|
|
|
// Recreate player data for TEST 5 (ensure fresh data exists for concurrent reads)
|
|
auto player5 = std::make_unique<JsonDataNode>("player", nlohmann::json::object());
|
|
auto profile5 = std::make_unique<JsonDataNode>("profile", nlohmann::json{
|
|
{"name", "TestPlayer"},
|
|
{"level", 6},
|
|
{"gold", 1090} // Final gold value from TEST 3
|
|
});
|
|
player5->setChild("profile", std::move(profile5));
|
|
|
|
// Use getDataRootReadOnly() to get the actual root (not a copy) and add the child
|
|
auto dataRootPtr = tree->getDataRootReadOnly();
|
|
if (dataRootPtr) {
|
|
dataRootPtr->setChild("player", std::move(player5));
|
|
std::cout << " Player data recreated for concurrent test\n";
|
|
} else {
|
|
std::cerr << " ERROR: Could not get data root!\n";
|
|
}
|
|
|
|
std::atomic<bool> running{true};
|
|
std::atomic<int> publishCount{0};
|
|
std::atomic<int> readCount{0};
|
|
std::atomic<int> errors{0};
|
|
|
|
// Thread 1: Publish events
|
|
std::thread pubThread([&]() {
|
|
while (running) {
|
|
try {
|
|
auto data = std::make_unique<JsonDataNode>("data", nlohmann::json{{"id", publishCount++}});
|
|
playerIO->publish("concurrent:test", std::move(data));
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
} catch (...) {
|
|
errors++;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Thread 2: Read DataNode concurrently using new read-only API
|
|
std::thread readThread([&]() {
|
|
while (running) {
|
|
try {
|
|
// Use getDataRootReadOnly() instead of getDataRoot() to avoid copying
|
|
auto dataRoot = tree->getDataRootReadOnly();
|
|
if (dataRoot) {
|
|
// Use getChildReadOnly() instead of getChild() to avoid copying/ownership transfer
|
|
auto playerData = dataRoot->getChildReadOnly("player");
|
|
if (playerData) {
|
|
auto profileData = playerData->getChildReadOnly("profile");
|
|
if (profileData) {
|
|
int gold = profileData->getInt("gold", 0);
|
|
readCount++;
|
|
}
|
|
}
|
|
}
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
|
} catch (...) {
|
|
errors++;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Run for 2 seconds
|
|
std::this_thread::sleep_for(std::chrono::seconds(2));
|
|
running = false;
|
|
|
|
pubThread.join();
|
|
readThread.join();
|
|
|
|
std::cout << "Concurrent test completed:\n";
|
|
std::cout << " Publishes: " << publishCount << "\n";
|
|
std::cout << " Reads: " << readCount << "\n";
|
|
std::cout << " Errors: " << errors << "\n";
|
|
|
|
ASSERT_EQ(errors.load(), 0, "Should have zero exceptions during concurrent access");
|
|
ASSERT_GT(publishCount.load(), 0, "Should have published messages");
|
|
ASSERT_GT(readCount.load(), 0, "Should have successfully read DataNode data");
|
|
|
|
reporter.addMetric("concurrent_publishes", publishCount);
|
|
reporter.addMetric("concurrent_reads", readCount);
|
|
reporter.addMetric("concurrent_errors", errors);
|
|
reporter.addAssertion("concurrent_access", errors == 0);
|
|
std::cout << "✓ TEST 5 PASSED\n";
|
|
|
|
// ========================================================================
|
|
// CLEANUP
|
|
// ========================================================================
|
|
std::filesystem::remove_all("test_cross");
|
|
|
|
// ========================================================================
|
|
// RAPPORT FINAL
|
|
// ========================================================================
|
|
|
|
metrics.printReport();
|
|
reporter.printFinalReport();
|
|
|
|
return reporter.getExitCode();
|
|
}
|