/** * Scenario 10: Multi-Version Module Coexistence Test * * Tests ability to load multiple versions of the same module simultaneously * with canary deployment, progressive migration, and instant rollback. * * Phases: * - Phase 0: Setup baseline (v1 with 100 entities) * - Phase 1: Canary deployment (10% v2, 90% v1) * - Phase 2: Progressive migration (v1 → v2: 30%, 50%, 80%, 100%) * - Phase 3: Auto garbage collection (unload v1) * - Phase 4: Emergency rollback (v2 → v1) * - Phase 5: Three-way coexistence (20% v1, 30% v2, 50% v3) */ #include "grove/IModule.h" #include "grove/JsonDataNode.h" #include "../helpers/TestMetrics.h" #include "../helpers/TestAssertions.h" #include "../helpers/TestReporter.h" #ifdef _WIN32 #include #else #include #endif #include #include #include #include #include #include #include #include // Cross-platform dlopen wrappers #ifdef _WIN32 inline void* grove_dlopen(const char* path, int flags) { (void)flags; return LoadLibraryA(path); } inline void* grove_dlsym(void* handle, const char* symbol) { return (void*)GetProcAddress((HMODULE)handle, symbol); } inline int grove_dlclose(void* handle) { return FreeLibrary((HMODULE)handle) ? 0 : -1; } inline const char* grove_dlerror() { static thread_local char buf[256]; DWORD err = GetLastError(); snprintf(buf, sizeof(buf), "Windows error code: %lu", err); return buf; } #define RTLD_NOW 0 #define RTLD_LOCAL 0 #else #define grove_dlopen dlopen #define grove_dlsym dlsym #define grove_dlclose dlclose #define grove_dlerror dlerror #endif using namespace grove; using json = nlohmann::json; // Version-specific module handle struct VersionHandle { void* dlHandle = nullptr; IModule* instance = nullptr; std::string modulePath; int version = 0; float trafficPercent = 0.0f; // % of traffic routed to this version std::chrono::steady_clock::time_point lastUsed; size_t processedEntities = 0; bool isLoaded() const { return dlHandle != nullptr && instance != nullptr; } }; // Multi-version test engine class MultiVersionTestEngine { public: MultiVersionTestEngine() { logger_ = spdlog::default_logger(); logger_->set_level(spdlog::level::info); } ~MultiVersionTestEngine() { // Copy keys to avoid iterator invalidation during unload std::vector keys; for (const auto& [key, handle] : versions_) { keys.push_back(key); } for (const auto& key : keys) { unloadVersion(key); } } // Load a specific version of a module bool loadModuleVersion(const std::string& moduleName, int version, const std::string& path) { std::string key = moduleName + ":v" + std::to_string(version); if (versions_.count(key) > 0) { logger_->warn("Version {} already loaded", key); return false; } void* dlHandle = grove_dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); if (!dlHandle) { logger_->error("Failed to load {}: {}", key, grove_dlerror()); return false; } auto createFunc = (IModule* (*)())grove_dlsym(dlHandle, "createModule"); if (!createFunc) { logger_->error("Failed to find createModule in {}: {}", key, grove_dlerror()); grove_dlclose(dlHandle); return false; } IModule* instance = createFunc(); if (!instance) { logger_->error("createModule returned nullptr for {}", key); grove_dlclose(dlHandle); return false; } VersionHandle handle; handle.dlHandle = dlHandle; handle.instance = instance; handle.modulePath = path; handle.version = instance->getVersion(); handle.trafficPercent = 0.0f; handle.lastUsed = std::chrono::steady_clock::now(); versions_[key] = handle; // Initialize module json configJson; configJson["entityCount"] = 100; auto config = std::make_unique("config", configJson); instance->setConfiguration(*config, nullptr, nullptr); logger_->info("✓ Loaded {} (actual version: {})", key, handle.version); return true; } // Unload a specific version bool unloadVersion(const std::string& key) { auto it = versions_.find(key); if (it == versions_.end()) return false; auto& handle = it->second; if (handle.instance) { handle.instance->shutdown(); auto destroyFunc = (void (*)(IModule*))grove_dlsym(handle.dlHandle, "destroyModule"); if (destroyFunc) { destroyFunc(handle.instance); } } if (handle.dlHandle) { grove_dlclose(handle.dlHandle); } versions_.erase(it); logger_->info("✓ Unloaded {}", key); return true; } // Set traffic split across versions bool setTrafficSplit(const std::string& moduleName, const std::map& weights) { // Validate weights sum to ~1.0 float sum = 0.0f; for (const auto& [version, weight] : weights) { sum += weight; } if (std::abs(sum - 1.0f) > 0.01f) { logger_->error("Traffic weights must sum to 1.0 (got {})", sum); return false; } // Apply weights for (const auto& [version, weight] : weights) { std::string key = moduleName + ":v" + std::to_string(version); if (versions_.count(key) > 0) { versions_[key].trafficPercent = weight * 100.0f; } } logger_->info("✓ Traffic split configured:"); for (const auto& [version, weight] : weights) { logger_->info(" v{}: {}%", version, weight * 100.0f); } return true; } // Get current traffic split std::map getTrafficSplit(const std::string& moduleName) const { std::map split; for (const auto& [key, handle] : versions_) { if (key.find(moduleName + ":v") == 0) { split[handle.version] = handle.trafficPercent; } } return split; } // Migrate state from one version to another bool migrateState(const std::string& moduleName, int fromVersion, int toVersion) { std::string fromKey = moduleName + ":v" + std::to_string(fromVersion); std::string toKey = moduleName + ":v" + std::to_string(toVersion); if (versions_.count(fromKey) == 0 || versions_.count(toKey) == 0) { logger_->error("Cannot migrate: version not loaded"); return false; } auto startTime = std::chrono::high_resolution_clock::now(); // Extract state from source version auto oldState = versions_[fromKey].instance->getState(); // Migrate to target version bool success = versions_[toKey].instance->migrateStateFrom(fromVersion, *oldState); auto endTime = std::chrono::high_resolution_clock::now(); auto duration = std::chrono::duration_cast(endTime - startTime); if (success) { logger_->info("✓ State migrated v{} → v{} in {}ms", fromVersion, toVersion, duration.count()); } else { logger_->error("✗ State migration v{} → v{} failed", fromVersion, toVersion); } return success; } // Check if version is loaded bool isVersionLoaded(const std::string& moduleName, int version) const { std::string key = moduleName + ":v" + std::to_string(version); return versions_.count(key) > 0; } // Get entity count for a version size_t getEntityCount(const std::string& moduleName, int version) const { std::string key = moduleName + ":v" + std::to_string(version); auto it = versions_.find(key); if (it == versions_.end()) return 0; auto state = it->second.instance->getState(); const auto* jsonState = dynamic_cast(state.get()); if (jsonState) { const auto& jsonData = jsonState->getJsonData(); if (jsonData.contains("entityCount")) { return jsonData["entityCount"]; } } return 0; } // Process all versions (simulates traffic routing) void processAllVersions(float deltaTime) { json inputJson; inputJson["deltaTime"] = deltaTime; auto input = std::make_unique("input", inputJson); for (auto& [key, handle] : versions_) { if (handle.isLoaded() && handle.trafficPercent > 0.0f) { handle.instance->process(*input); handle.lastUsed = std::chrono::steady_clock::now(); } } } // Auto garbage collection of unused versions void autoGC(float unusedThresholdSeconds = 10.0f) { auto now = std::chrono::steady_clock::now(); std::vector toUnload; for (const auto& [key, handle] : versions_) { if (handle.trafficPercent == 0.0f) { auto elapsed = std::chrono::duration_cast(now - handle.lastUsed); if (elapsed.count() >= unusedThresholdSeconds) { toUnload.push_back(key); } } } for (const auto& key : toUnload) { logger_->info("Auto GC: unloading unused version {}", key); unloadVersion(key); } } // Get all loaded versions for a module std::vector getLoadedVersions(const std::string& moduleName) const { std::vector versions; for (const auto& [key, handle] : versions_) { if (key.find(moduleName + ":v") == 0) { versions.push_back(handle.version); } } return versions; } private: std::map versions_; std::shared_ptr logger_; }; int main() { std::cout << "================================================================================\n"; std::cout << "TEST: Multi-Version Module Coexistence\n"; std::cout << "================================================================================\n\n"; TestReporter reporter("Multi-Version Module Coexistence"); // Local metrics storage std::map metrics; MultiVersionTestEngine engine; try { std::cout << "=== Phase 0: Setup Baseline (v1 with 100 entities) ===\n"; // Load v1 std::string v1Path = "./libGameLogicModuleV1.so"; ASSERT_TRUE(engine.loadModuleVersion("GameLogic", 1, v1Path), "Load GameLogic v1"); // Configure 100% traffic to v1 engine.setTrafficSplit("GameLogic", {{1, 1.0f}}); // Verify ASSERT_EQ(engine.getEntityCount("GameLogic", 1), 100, "v1 has 100 entities"); std::cout << "✓ Baseline established: v1 with 100 entities\n"; std::this_thread::sleep_for(std::chrono::seconds(2)); // ======================================== std::cout << "\n=== Phase 1: Canary Deployment (10% v2, 90% v1) ===\n"; auto phase1Start = std::chrono::high_resolution_clock::now(); // Load v2 std::string v2Path = "./libGameLogicModuleV2.so"; ASSERT_TRUE(engine.loadModuleVersion("GameLogic", 2, v2Path), "Load GameLogic v2"); auto phase1End = std::chrono::high_resolution_clock::now(); auto loadTime = std::chrono::duration_cast(phase1End - phase1Start); metrics["version_load_time_ms"] = loadTime.count(); std::cout << "Version load time: " << loadTime.count() << "ms\n"; ASSERT_LT(loadTime.count(), 200, "Load time < 200ms"); // Configure canary: 10% v2, 90% v1 engine.setTrafficSplit("GameLogic", {{1, 0.9f}, {2, 0.1f}}); // Verify both versions loaded ASSERT_TRUE(engine.isVersionLoaded("GameLogic", 1), "v1 still loaded"); ASSERT_TRUE(engine.isVersionLoaded("GameLogic", 2), "v2 loaded"); auto split = engine.getTrafficSplit("GameLogic"); ASSERT_NEAR(split[1], 90.0f, 2.0f, "v1 traffic ~90%"); ASSERT_NEAR(split[2], 10.0f, 2.0f, "v2 traffic ~10%"); std::cout << "✓ Canary deployment active: 10% v2, 90% v1\n"; std::this_thread::sleep_for(std::chrono::seconds(5)); // ======================================== std::cout << "\n=== Phase 2: Progressive Migration v1 → v2 ===\n"; // Step 1: 30% v2, 70% v1 std::cout << "t=0s: Traffic split → 30% v2, 70% v1\n"; engine.setTrafficSplit("GameLogic", {{1, 0.7f}, {2, 0.3f}}); std::this_thread::sleep_for(std::chrono::seconds(3)); split = engine.getTrafficSplit("GameLogic"); ASSERT_NEAR(split[2], 30.0f, 2.0f, "v2 traffic ~30%"); // Step 2: 50% v2, 50% v1 std::cout << "t=3s: Traffic split → 50% v2, 50% v1\n"; engine.setTrafficSplit("GameLogic", {{1, 0.5f}, {2, 0.5f}}); std::this_thread::sleep_for(std::chrono::seconds(3)); split = engine.getTrafficSplit("GameLogic"); ASSERT_NEAR(split[2], 50.0f, 2.0f, "v2 traffic ~50%"); // Step 3: 80% v2, 20% v1 std::cout << "t=6s: Traffic split → 80% v2, 20% v1\n"; engine.setTrafficSplit("GameLogic", {{1, 0.2f}, {2, 0.8f}}); std::this_thread::sleep_for(std::chrono::seconds(3)); split = engine.getTrafficSplit("GameLogic"); ASSERT_NEAR(split[2], 80.0f, 2.0f, "v2 traffic ~80%"); // Step 4: 100% v2, 0% v1 (migration complete) std::cout << "t=9s: Traffic split → 100% v2, 0% v1 (migration complete)\n"; auto migrationStart = std::chrono::high_resolution_clock::now(); bool migrated = engine.migrateState("GameLogic", 1, 2); auto migrationEnd = std::chrono::high_resolution_clock::now(); auto migrationTime = std::chrono::duration_cast(migrationEnd - migrationStart); metrics["state_migration_time_ms"] = migrationTime.count(); ASSERT_TRUE(migrated, "State migration successful"); ASSERT_LT(migrationTime.count(), 500, "Migration time < 500ms"); engine.setTrafficSplit("GameLogic", {{1, 0.0f}, {2, 1.0f}}); split = engine.getTrafficSplit("GameLogic"); ASSERT_NEAR(split[2], 100.0f, 2.0f, "v2 traffic ~100%"); std::cout << "✓ Progressive migration complete\n"; std::this_thread::sleep_for(std::chrono::seconds(3)); // ======================================== std::cout << "\n=== Phase 3: Garbage Collection (unload v1) ===\n"; std::cout << "v1 unused for 10s → triggering auto GC...\n"; // Wait for GC threshold std::this_thread::sleep_for(std::chrono::seconds(12)); auto gcStart = std::chrono::high_resolution_clock::now(); engine.autoGC(10.0f); auto gcEnd = std::chrono::high_resolution_clock::now(); auto gcTime = std::chrono::duration_cast(gcEnd - gcStart); metrics["gc_time_ms"] = gcTime.count(); ASSERT_LT(gcTime.count(), 100, "GC time < 100ms"); // Verify v1 unloaded ASSERT_FALSE(engine.isVersionLoaded("GameLogic", 1), "v1 unloaded"); ASSERT_TRUE(engine.isVersionLoaded("GameLogic", 2), "v2 still loaded"); std::cout << "✓ Auto GC complete: v1 unloaded, v2 continues\n"; std::this_thread::sleep_for(std::chrono::seconds(2)); // ======================================== std::cout << "\n=== Phase 4: Emergency Rollback (v2 → v1) ===\n"; std::cout << "Simulating critical bug in v2 → triggering emergency rollback\n"; auto rollbackStart = std::chrono::high_resolution_clock::now(); // Reload v1 ASSERT_TRUE(engine.loadModuleVersion("GameLogic", 1, v1Path), "Reload v1 for rollback"); // Migrate state back v2 → v1 (if possible, otherwise fresh start) // Note: v1 cannot migrate from v2 (fields missing), so this will fail gracefully engine.migrateState("GameLogic", 2, 1); // Redirect traffic to v1 engine.setTrafficSplit("GameLogic", {{1, 1.0f}, {2, 0.0f}}); auto rollbackEnd = std::chrono::high_resolution_clock::now(); auto rollbackTime = std::chrono::duration_cast(rollbackEnd - rollbackStart); metrics["rollback_time_ms"] = rollbackTime.count(); ASSERT_LT(rollbackTime.count(), 300, "Rollback time < 300ms"); // Verify rollback ASSERT_TRUE(engine.isVersionLoaded("GameLogic", 1), "v1 reloaded"); split = engine.getTrafficSplit("GameLogic"); ASSERT_NEAR(split[1], 100.0f, 2.0f, "v1 traffic ~100%"); std::cout << "✓ Emergency rollback complete in " << rollbackTime.count() << "ms\n"; std::this_thread::sleep_for(std::chrono::seconds(3)); // ======================================== std::cout << "\n=== Phase 5: Three-Way Coexistence (v1, v2, v3) ===\n"; // Load v3 std::string v3Path = "./libGameLogicModuleV3.so"; ASSERT_TRUE(engine.loadModuleVersion("GameLogic", 3, v3Path), "Load GameLogic v3"); // Configure 3-way split: 20% v1, 30% v2, 50% v3 engine.setTrafficSplit("GameLogic", {{1, 0.2f}, {2, 0.3f}, {3, 0.5f}}); // Verify 3 versions coexisting ASSERT_TRUE(engine.isVersionLoaded("GameLogic", 1), "v1 loaded"); ASSERT_TRUE(engine.isVersionLoaded("GameLogic", 2), "v2 loaded"); ASSERT_TRUE(engine.isVersionLoaded("GameLogic", 3), "v3 loaded"); auto versions = engine.getLoadedVersions("GameLogic"); ASSERT_EQ(versions.size(), 3, "3 versions loaded"); split = engine.getTrafficSplit("GameLogic"); ASSERT_NEAR(split[1], 20.0f, 2.0f, "v1 traffic ~20%"); ASSERT_NEAR(split[2], 30.0f, 2.0f, "v2 traffic ~30%"); ASSERT_NEAR(split[3], 50.0f, 2.0f, "v3 traffic ~50%"); metrics["multi_version_count"] = versions.size(); std::cout << "✓ Three-way coexistence active:\n"; std::cout << " v1: 20% traffic\n"; std::cout << " v2: 30% traffic\n"; std::cout << " v3: 50% traffic\n"; std::this_thread::sleep_for(std::chrono::seconds(5)); // ======================================== std::cout << "\n=== Metrics Summary ===\n"; reporter.addMetric("version_load_time_ms", metrics["version_load_time_ms"]); reporter.addMetric("state_migration_time_ms", metrics["state_migration_time_ms"]); reporter.addMetric("rollback_time_ms", metrics["rollback_time_ms"]); reporter.addMetric("gc_time_ms", metrics["gc_time_ms"]); reporter.addMetric("multi_version_count", metrics["multi_version_count"]); // Validate metrics ASSERT_LT(metrics["version_load_time_ms"], 200.0, "Version load time < 200ms"); ASSERT_LT(metrics["state_migration_time_ms"], 500.0, "State migration < 500ms"); ASSERT_LT(metrics["rollback_time_ms"], 300.0, "Rollback time < 300ms"); ASSERT_LT(metrics["gc_time_ms"], 100.0, "GC time < 100ms"); ASSERT_EQ(metrics["multi_version_count"], 3.0, "3 versions coexisting"); // ======================================== std::cout << "\n=== Final Report ===\n"; reporter.printFinalReport(); return reporter.getExitCode(); } catch (const std::exception& e) { std::cerr << "Exception: " << e.what() << "\n"; return 1; } }