feat: Add StillHammer Logger & IntraIO batching (WIP)

- Add StillHammer Logger library (external/StillHammer/logger/)
  * Elegant wrapper around spdlog (1 line instead of 10+)
  * Auto-organize logs by domain: logs/domain/component.log
  * Snake_case conversion: NetworkIO → network_io.log
  * Thread-safe, zero-overhead, includes demo and tests

- Add IntraIO low-frequency batching infrastructure
  * BatchBuffer structure for message accumulation
  * batchFlushLoop() thread for periodic flushing
  * Pattern matching lambda for detecting low-freq subscriptions
  * WIP: test_11 scenario 4 still failing (100 batches instead of ~2)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
StillHammer 2025-11-20 03:01:09 +08:00
parent a846ed26d7
commit 3923e3cbbe
13 changed files with 988 additions and 49 deletions

View File

@ -25,8 +25,9 @@ FetchContent_Declare(
)
FetchContent_MakeAvailable(spdlog)
# TopicTree - StillHammer's ultra-fast topic routing
add_subdirectory(external/StillHammer/topictree)
# StillHammer libraries
add_subdirectory(external/StillHammer/topictree) # Ultra-fast topic routing
add_subdirectory(external/StillHammer/logger) # Domain-organized logging
# Core library (INTERFACE - header-only pour les interfaces)
add_library(grove_core INTERFACE)

View File

@ -0,0 +1,40 @@
cmake_minimum_required(VERSION 3.15)
project(StillHammer_Logger VERSION 1.0.0 LANGUAGES CXX)
# C++17 required
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# spdlog should be available from parent project
# (either via find_package or FetchContent)
# Library target
add_library(stillhammer_logger
src/Logger.cpp
)
target_include_directories(stillhammer_logger
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
target_link_libraries(stillhammer_logger
PUBLIC
spdlog::spdlog
)
# Tests (will be enabled when integrated into full project)
# For now, tests can be run manually or via separate test project
# Export targets
install(TARGETS stillhammer_logger
EXPORT StillHammerLoggerTargets
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib
RUNTIME DESTINATION bin
)
install(DIRECTORY include/
DESTINATION include
)

169
external/StillHammer/logger/README.md vendored Normal file
View File

@ -0,0 +1,169 @@
# StillHammer Logger
Elegant, domain-organized logging wrapper around spdlog.
## Features
- **Domain-based organization**: Automatically organize logs by domain (network, engine, io, etc.)
- **Simplified API**: 1 line instead of 10+ for common use cases
- **Zero-overhead**: Thin wrapper around spdlog (no performance cost)
- **Thread-safe**: Built on spdlog's thread-safe infrastructure
- **Flexible**: Easy to configure, supports console + file logging
## Quick Start
```cpp
#include <logger/Logger.h>
// Simple logger
auto log = stillhammer::createLogger("MyComponent");
log->info("Hello world");
log->debug("Debug info");
log->warn("Warning!");
// Domain-organized logger (logs/network/network_io.log)
auto netLog = stillhammer::createDomainLogger("NetworkIO", "network");
netLog->info("Packet received");
// Custom configuration
stillhammer::LoggerConfig config;
config.setDomain("custom")
.setConsoleLevel(stillhammer::LogLevel::Warn)
.setFileLevel(stillhammer::LogLevel::Debug);
auto customLog = stillhammer::createLogger("MyLogger", config);
```
## Log Organization
Logs are automatically organized by domain:
```
logs/
├── component.log # Root-level loggers
├── network/
│ ├── tcp_server.log
│ └── udp_client.log
├── engine/
│ ├── renderer.log
│ └── physics.log
└── io/
├── intra_io.log
└── io_manager.log
```
## API Reference
### Creating Loggers
```cpp
// Basic logger (logs to logs/component_name.log)
auto log = stillhammer::createLogger("ComponentName");
// Domain logger (logs to logs/domain/component_name.log)
auto log = stillhammer::createDomainLogger("ComponentName", "domain");
// Custom config
stillhammer::LoggerConfig config;
config.setDomain("network")
.setConsoleLevel(stillhammer::LogLevel::Info)
.setFileLevel(stillhammer::LogLevel::Debug)
.setPattern("[%H:%M:%S] [%n] %v");
auto log = stillhammer::createLogger("NetworkIO", config);
```
### Logging
Uses standard spdlog API:
```cpp
log->trace("Very detailed info");
log->debug("Debug info");
log->info("Normal info");
log->warn("Warning");
log->error("Error occurred");
log->critical("Critical failure");
```
### Utility Functions
```cpp
// Get existing logger
auto log = stillhammer::getLogger("ComponentName");
// Set global log level
stillhammer::setGlobalLogLevel(stillhammer::LogLevel::Debug);
// Flush all loggers (useful before shutdown)
stillhammer::flushAll();
```
## Building
```bash
mkdir build && cd build
cmake .. -DBUILD_TESTING=ON
make
ctest # Run tests
```
## Requirements
- C++17
- spdlog (automatically fetched via CMake)
## License
MIT License - Part of StillHammer toolkit
## Integration
### CMake
```cmake
add_subdirectory(external/StillHammer/logger)
target_link_libraries(your_target PRIVATE stillhammer_logger)
```
### Usage
```cpp
#include <logger/Logger.h>
int main() {
auto log = stillhammer::createLogger("MyApp");
log->info("Application started");
// Your code...
stillhammer::flushAll();
return 0;
}
```
## Comparison with raw spdlog
**Before (raw spdlog):**
```cpp
auto console_sink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
auto file_sink = std::make_shared<spdlog::sinks::basic_file_sink_mt>("logs/io/intra_io.log", true);
console_sink->set_level(spdlog::level::info);
file_sink->set_level(spdlog::level::debug);
auto logger = std::make_shared<spdlog::logger>("IntraIO",
spdlog::sinks_init_list{console_sink, file_sink});
logger->set_level(spdlog::level::debug);
logger->set_pattern("[%Y-%m-%d %H:%M:%S] [%n] [%l] %v");
logger->flush_on(spdlog::level::warn);
spdlog::register_logger(logger);
```
**After (StillHammer Logger):**
```cpp
auto logger = stillhammer::createDomainLogger("IntraIO", "io");
```
Much cleaner! 🎉

View File

@ -0,0 +1,61 @@
#include <logger/Logger.h>
#include <iostream>
#include <thread>
#include <chrono>
int main() {
std::cout << "=== StillHammer Logger Demo ===\n\n";
// Test 1: Simple logger
std::cout << "Test 1: Creating simple logger\n";
auto log = stillhammer::createLogger("DemoApp");
log->info("Application started");
log->debug("Debug information");
log->warn("This is a warning");
// Test 2: Domain-organized loggers
std::cout << "\nTest 2: Domain-organized loggers\n";
auto networkLog = stillhammer::createDomainLogger("NetworkIO", "network");
auto engineLog = stillhammer::createDomainLogger("EngineCore", "engine");
networkLog->info("Listening on port 8080");
engineLog->info("Engine initialized");
// Test 3: Custom configuration
std::cout << "\nTest 3: Custom configuration\n";
stillhammer::LoggerConfig config;
config.setDomain("custom")
.setConsoleLevel(stillhammer::LogLevel::Warn)
.setFileLevel(stillhammer::LogLevel::Trace);
auto customLog = stillhammer::createLogger("CustomLogger", config);
customLog->trace("This won't show on console");
customLog->warn("But this will!");
// Test 4: Multiple messages
std::cout << "\nTest 4: Logging multiple messages\n";
for (int i = 0; i < 5; i++) {
networkLog->info("Packet {} received", i);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
// Test 5: Get existing logger
std::cout << "\nTest 5: Retrieving existing logger\n";
auto retrievedLog = stillhammer::getLogger("NetworkIO");
if (retrievedLog) {
retrievedLog->info("Retrieved logger works!");
}
// Flush all loggers
std::cout << "\nFlushing all loggers...\n";
stillhammer::flushAll();
std::cout << "\n=== Demo Complete ===\n";
std::cout << "Check the logs/ directory for output files:\n";
std::cout << " - logs/demo_app.log\n";
std::cout << " - logs/network/network_io.log\n";
std::cout << " - logs/engine/engine_core.log\n";
std::cout << " - logs/custom/custom_logger.log\n";
return 0;
}

View File

@ -0,0 +1,98 @@
#pragma once
#include <memory>
#include <string>
#include <spdlog/spdlog.h>
namespace stillhammer {
/**
* @brief Wrapper around spdlog for simplified, domain-organized logging
*
* Features:
* - Auto-organize logs by domain (logs/domain/component.log)
* - Simplified API (1 line instead of 10+)
* - Centralized configuration
* - Thread-safe logger registry
*
* Example:
* auto log = stillhammer::createLogger("MyComponent");
* log->info("Hello world");
*
* auto domainLog = stillhammer::createDomainLogger("NetworkIO", "network");
* domainLog->debug("Packet received"); // → logs/network/network_io.log
*/
enum class LogLevel {
Trace = 0,
Debug = 1,
Info = 2,
Warn = 3,
Error = 4,
Critical = 5,
Off = 6
};
struct LoggerConfig {
std::string domain = ""; // Empty = root logs/ directory
LogLevel consoleLevel = LogLevel::Info;
LogLevel fileLevel = LogLevel::Debug;
bool enableConsole = true;
bool enableFile = true;
std::string pattern = "[%Y-%m-%d %H:%M:%S.%e] [%n] [%^%l%$] %v";
LoggerConfig& setDomain(const std::string& d) { domain = d; return *this; }
LoggerConfig& setConsoleLevel(LogLevel level) { consoleLevel = level; return *this; }
LoggerConfig& setFileLevel(LogLevel level) { fileLevel = level; return *this; }
LoggerConfig& setPattern(const std::string& p) { pattern = p; return *this; }
LoggerConfig& disableConsole() { enableConsole = false; return *this; }
LoggerConfig& disableFile() { enableFile = false; return *this; }
};
/**
* @brief Create a logger with automatic configuration
*
* @param name Logger name (used in log output)
* @param config Optional configuration (uses defaults if not provided)
* @return Shared pointer to spdlog logger
*/
std::shared_ptr<spdlog::logger> createLogger(
const std::string& name,
const LoggerConfig& config = LoggerConfig()
);
/**
* @brief Create a domain-scoped logger
*
* Automatically organizes logs: logs/domain/component.log
*
* @param name Component name (e.g., "IntraIO")
* @param domain Domain name (e.g., "network", "io", "engine")
* @param config Optional additional configuration
* @return Shared pointer to spdlog logger
*/
std::shared_ptr<spdlog::logger> createDomainLogger(
const std::string& name,
const std::string& domain,
const LoggerConfig& config = LoggerConfig()
);
/**
* @brief Get an existing logger by name
*
* @param name Logger name
* @return Shared pointer to logger, or nullptr if not found
*/
std::shared_ptr<spdlog::logger> getLogger(const std::string& name);
/**
* @brief Set global log level for all loggers
*/
void setGlobalLogLevel(LogLevel level);
/**
* @brief Flush all loggers (useful before shutdown)
*/
void flushAll();
} // namespace stillhammer

View File

@ -0,0 +1,135 @@
#include <logger/Logger.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/sinks/basic_file_sink.h>
#include <filesystem>
#include <algorithm>
namespace stillhammer {
namespace {
// Convert our LogLevel to spdlog level
spdlog::level::level_enum toSpdlogLevel(LogLevel level) {
switch (level) {
case LogLevel::Trace: return spdlog::level::trace;
case LogLevel::Debug: return spdlog::level::debug;
case LogLevel::Info: return spdlog::level::info;
case LogLevel::Warn: return spdlog::level::warn;
case LogLevel::Error: return spdlog::level::err;
case LogLevel::Critical: return spdlog::level::critical;
case LogLevel::Off: return spdlog::level::off;
}
return spdlog::level::info;
}
// Convert component name to filename: "IntraIO" → "intra_io"
std::string toSnakeCase(const std::string& name) {
std::string result;
result.reserve(name.size() + 5);
for (size_t i = 0; i < name.size(); ++i) {
char c = name[i];
// Insert underscore before uppercase letters (except first char)
if (i > 0 && std::isupper(c) && std::islower(name[i-1])) {
result += '_';
}
result += std::tolower(c);
}
return result;
}
// Ensure directory exists
void ensureDirectoryExists(const std::string& path) {
std::filesystem::path dirPath = std::filesystem::path(path).parent_path();
if (!dirPath.empty() && !std::filesystem::exists(dirPath)) {
std::filesystem::create_directories(dirPath);
}
}
} // anonymous namespace
std::shared_ptr<spdlog::logger> createLogger(
const std::string& name,
const LoggerConfig& config
) {
// Check if logger already exists
auto existing = spdlog::get(name);
if (existing) {
return existing;
}
std::vector<spdlog::sink_ptr> sinks;
// Console sink
if (config.enableConsole) {
auto console_sink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
console_sink->set_level(toSpdlogLevel(config.consoleLevel));
sinks.push_back(console_sink);
}
// File sink
if (config.enableFile) {
// Build file path: logs/[domain/]component.log
std::string filename = toSnakeCase(name) + ".log";
std::string filepath;
if (config.domain.empty()) {
filepath = "logs/" + filename;
} else {
filepath = "logs/" + config.domain + "/" + filename;
}
ensureDirectoryExists(filepath);
auto file_sink = std::make_shared<spdlog::sinks::basic_file_sink_mt>(filepath, true);
file_sink->set_level(toSpdlogLevel(config.fileLevel));
sinks.push_back(file_sink);
}
// Create logger
auto logger = std::make_shared<spdlog::logger>(name, sinks.begin(), sinks.end());
// Set pattern
logger->set_pattern(config.pattern);
// Set level to the minimum of console and file levels
auto minLevel = std::min(config.consoleLevel, config.fileLevel);
logger->set_level(toSpdlogLevel(minLevel));
// Flush on warning or higher
logger->flush_on(spdlog::level::warn);
// Register globally
spdlog::register_logger(logger);
return logger;
}
std::shared_ptr<spdlog::logger> createDomainLogger(
const std::string& name,
const std::string& domain,
const LoggerConfig& config
) {
LoggerConfig domainConfig = config;
domainConfig.domain = domain;
return createLogger(name, domainConfig);
}
std::shared_ptr<spdlog::logger> getLogger(const std::string& name) {
return spdlog::get(name);
}
void setGlobalLogLevel(LogLevel level) {
spdlog::set_level(toSpdlogLevel(level));
}
void flushAll() {
spdlog::apply_all([](std::shared_ptr<spdlog::logger> logger) {
logger->flush();
});
}
} // namespace stillhammer

View File

@ -0,0 +1,171 @@
#include <catch2/catch_test_macros.hpp>
#include <logger/Logger.h>
#include <filesystem>
#include <fstream>
#include <thread>
#include <chrono>
using namespace stillhammer;
// Helper to check if file exists and contains text
bool fileContains(const std::string& filepath, const std::string& text) {
if (!std::filesystem::exists(filepath)) {
return false;
}
std::ifstream file(filepath);
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
return content.find(text) != std::string::npos;
}
TEST_CASE("Logger: Basic creation and logging", "[logger]") {
// Clean up any previous test logs
std::filesystem::remove_all("logs");
auto log = createLogger("TestLogger");
REQUIRE(log != nullptr);
REQUIRE(log->name() == "TestLogger");
log->info("Test message");
log->debug("Debug message");
log->warn("Warning message");
// Flush to ensure file is written
log->flush();
// Give filesystem time to sync
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// Check that log file was created
REQUIRE(std::filesystem::exists("logs/test_logger.log"));
// Check that messages were logged
REQUIRE(fileContains("logs/test_logger.log", "Test message"));
REQUIRE(fileContains("logs/test_logger.log", "Warning message"));
}
TEST_CASE("Logger: Domain-based organization", "[logger]") {
std::filesystem::remove_all("logs");
auto networkLog = createDomainLogger("NetworkIO", "network");
auto engineLog = createDomainLogger("EngineCore", "engine");
REQUIRE(networkLog != nullptr);
REQUIRE(engineLog != nullptr);
networkLog->info("Packet received");
engineLog->info("Engine started");
networkLog->flush();
engineLog->flush();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// Check domain directories were created
REQUIRE(std::filesystem::exists("logs/network/network_io.log"));
REQUIRE(std::filesystem::exists("logs/engine/engine_core.log"));
// Check correct messages in correct files
REQUIRE(fileContains("logs/network/network_io.log", "Packet received"));
REQUIRE(fileContains("logs/engine/engine_core.log", "Engine started"));
// Ensure cross-contamination didn't happen
REQUIRE_FALSE(fileContains("logs/network/network_io.log", "Engine started"));
REQUIRE_FALSE(fileContains("logs/engine/engine_core.log", "Packet received"));
}
TEST_CASE("Logger: Custom configuration", "[logger]") {
std::filesystem::remove_all("logs");
LoggerConfig config;
config.setDomain("custom")
.setConsoleLevel(LogLevel::Warn)
.setFileLevel(LogLevel::Trace)
.setPattern("[%n] %v");
auto log = createLogger("CustomLogger", config);
REQUIRE(log != nullptr);
log->trace("Trace message");
log->debug("Debug message");
log->info("Info message");
log->warn("Warn message");
log->flush();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// File should have all messages (fileLevel = Trace)
REQUIRE(fileContains("logs/custom/custom_logger.log", "Trace message"));
REQUIRE(fileContains("logs/custom/custom_logger.log", "Debug message"));
REQUIRE(fileContains("logs/custom/custom_logger.log", "Info message"));
REQUIRE(fileContains("logs/custom/custom_logger.log", "Warn message"));
}
TEST_CASE("Logger: Get existing logger", "[logger]") {
std::filesystem::remove_all("logs");
auto log1 = createLogger("SharedLogger");
log1->info("Message 1");
// Get the same logger by name
auto log2 = getLogger("SharedLogger");
REQUIRE(log2 != nullptr);
REQUIRE(log1 == log2); // Should be the same instance
log2->info("Message 2");
log1->flush();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// Both messages should be in the same file
REQUIRE(fileContains("logs/shared_logger.log", "Message 1"));
REQUIRE(fileContains("logs/shared_logger.log", "Message 2"));
}
TEST_CASE("Logger: Snake case conversion", "[logger]") {
std::filesystem::remove_all("logs");
auto log1 = createLogger("MyTestLogger");
auto log2 = createLogger("IntraIOManager");
auto log3 = createLogger("HTTPServer");
log1->info("test");
log2->info("test");
log3->info("test");
log1->flush();
log2->flush();
log3->flush();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// Check snake_case filenames
REQUIRE(std::filesystem::exists("logs/my_test_logger.log"));
REQUIRE(std::filesystem::exists("logs/intra_io_manager.log"));
REQUIRE(std::filesystem::exists("logs/httpserver.log"));
}
TEST_CASE("Logger: Flush all loggers", "[logger]") {
std::filesystem::remove_all("logs");
auto log1 = createLogger("Logger1");
auto log2 = createLogger("Logger2");
auto log3 = createLogger("Logger3");
log1->info("Message from logger 1");
log2->info("Message from logger 2");
log3->info("Message from logger 3");
// Flush all at once
flushAll();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
REQUIRE(fileContains("logs/logger1.log", "Message from logger 1"));
REQUIRE(fileContains("logs/logger2.log", "Message from logger 2"));
REQUIRE(fileContains("logs/logger3.log", "Message from logger 3"));
}

21
external/StillHammer/topictree/LICENSE vendored Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 StillHammer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -2,7 +2,7 @@
**Ultra-fast hierarchical topic matching for pub/sub systems**
A header-only C++17 library providing O(k) topic matching using hierarchical hash maps, replacing traditional O(n×m) regex-based pattern matching.
A standalone header-only C++17 library providing O(k) topic matching using hierarchical hash maps, replacing traditional O(n×m) regex-based pattern matching.
## Features
@ -14,18 +14,9 @@ A header-only C++17 library providing O(k) topic matching using hierarchical has
- **Header-only**: No compilation required, just include and use
- **Thread-safe**: Mutex-protected operations
- **Template-based**: Generic subscriber type support
- **Zero dependencies**: Standard library only
## Performance
Replaces regex-based matching:
- **Before**: O(n patterns × m regex operations) - Test ALL patterns for EACH message
- **After**: O(k topic depth) - Walk hash tree by segments
For a typical system with 100 patterns and topics of depth 3:
- Regex: ~100 pattern tests per message
- TopicTree: ~3 hash lookups per message
## Usage
## Quick Start
```cpp
#include <topictree/TopicTree.h>
@ -49,19 +40,35 @@ tree.unregisterSubscriber("player:*:position", "subscriber1");
## Pattern Syntax
- **Separator**: `:` (colon)
- **Single wildcard**: `*` - Matches one segment
- **Single wildcard**: `*` - Matches exactly one segment
- `player:*:health` matches `player:001:health`, `player:002:health`
- Does NOT match `player:001:stats:health` (wrong depth)
- **Multi-level wildcard**: `.*` - Matches remaining segments
- **Multi-level wildcard**: `.*` - Matches all remaining segments
- `player:.*` matches `player:001`, `player:001:health`, `player:001:stats:armor`
- Equivalent to "match everything after this point"
## Performance
Replaces regex-based matching:
- **Before**: O(n patterns × m regex operations) - Test ALL patterns for EACH message
- **After**: O(k topic depth) - Walk hash tree by segments
For a typical system with 100 patterns and topics of depth 3:
- Regex: ~100 pattern tests per message
- TopicTree: ~3 hash lookups per message
**Verified Performance** (from test suite):
- Average lookup: < 1ms with 1,000 patterns
- Deep topics (10+ levels): < 100μs
- Scalability: < 5ms with 10,000 patterns
- Thread-safe under 85,000+ concurrent operations
## Integration
### CMake (via add_subdirectory)
```cmake
add_subdirectory(external/StillHammer/topictree)
add_subdirectory(path/to/topictree)
target_link_libraries(your_target PRIVATE topictree::topictree)
```
@ -69,14 +76,47 @@ target_link_libraries(your_target PRIVATE topictree::topictree)
```cmake
target_include_directories(your_target PRIVATE
external/StillHammer/topictree/include
path/to/topictree/include
)
```
## Requirements
### Direct include
- C++17 or later
- Standard library only (no external dependencies)
Just copy the `include/topictree/` directory to your project and:
```cpp
#include <topictree/TopicTree.h>
```
## API Reference
### Constructor
```cpp
topictree::TopicTree<SubscriberType> tree;
```
### Methods
```cpp
// Register a subscriber for a pattern
void registerSubscriber(const std::string& pattern, const SubscriberType& subscriber);
// Find all subscribers matching a topic
std::vector<SubscriberType> findSubscribers(const std::string& topic) const;
// Unregister from specific pattern
void unregisterSubscriber(const std::string& pattern, const SubscriberType& subscriber);
// Unregister from ALL patterns
void unregisterSubscriberAll(const SubscriberType& subscriber);
// Clear all subscriptions
void clear();
// Get total subscriber count
size_t subscriberCount() const;
```
## Architecture
@ -102,10 +142,48 @@ Lookup walks the tree level by level, collecting subscribers from:
2. Single wildcards (`*`) at each level
3. Multi-level wildcards (`.*`) that match everything below
## Testing
Comprehensive test suite with 100% pass rate:
```bash
mkdir build && cd build
cmake .. -DBUILD_TESTS=ON
cmake --build .
ctest --output-on-failure
```
**Test Coverage:**
- 10 scenarios, 63 test sections
- Functional correctness (exact, wildcards, overlapping)
- Performance benchmarks (up to 10,000 patterns)
- Thread-safety (concurrent reads/writes)
- Edge cases and stress testing
See [TEST_PLAN.md](TEST_PLAN.md) and [TEST_RESULTS.md](TEST_RESULTS.md) for details.
## Requirements
- C++17 or later
- Standard library only (no external dependencies)
- Optional: Catch2 v3.5+ for running tests (auto-fetched)
## Use Cases
- **Pub/Sub systems**: Efficient topic routing
- **Game engines**: Entity event matching
- **Message brokers**: Pattern-based message delivery
- **IoT platforms**: Device event filtering
- **Monitoring systems**: Metric subscription management
## License
MIT License - Part of the GroveEngine project
MIT License - See [LICENSE](LICENSE) file
## Version
1.0.0
## Author
StillHammer - High-performance game engine components
StillHammer

View File

@ -5,6 +5,9 @@
#include <unordered_map>
#include <vector>
#include <mutex>
#include <thread>
#include <chrono>
#include <atomic>
#include <spdlog/spdlog.h>
#include <nlohmann/json.hpp>
@ -51,6 +54,7 @@ private:
struct SubscriptionInfo {
std::string instanceId;
bool isLowFreq;
int batchInterval; // milliseconds
};
// Ultra-fast topic routing using TopicTree
@ -58,7 +62,23 @@ private:
// Track subscription info per instance (for management)
std::unordered_map<std::string, std::vector<std::string>> instancePatterns; // instanceId -> patterns
std::unordered_map<std::string, bool> subscriptionFreqMap; // pattern -> isLowFreq
std::unordered_map<std::string, SubscriptionInfo> subscriptionInfoMap; // pattern -> subscription info
// Batching for low-frequency subscriptions
struct BatchBuffer {
std::string instanceId;
std::string pattern;
int batchInterval;
std::chrono::steady_clock::time_point lastFlush;
std::vector<std::pair<std::string, json>> messages; // topic + data pairs
};
std::unordered_map<std::string, BatchBuffer> batchBuffers; // pattern -> buffer
mutable std::mutex batchMutex;
std::thread batchThread;
std::atomic<bool> batchThreadRunning{false};
void batchFlushLoop();
void flushBatchBuffer(BatchBuffer& buffer);
// Statistics
mutable std::atomic<size_t> totalRoutedMessages{0};
@ -80,7 +100,7 @@ public:
// Routing (called by IntraIO instances)
void routeMessage(const std::string& sourceid, const std::string& topic, const json& messageData);
void registerSubscription(const std::string& instanceId, const std::string& pattern, bool isLowFreq);
void registerSubscription(const std::string& instanceId, const std::string& pattern, bool isLowFreq, int batchInterval = 1000);
void unregisterSubscription(const std::string& instanceId, const std::string& pattern);
// Management

BIN
logger_demo Normal file

Binary file not shown.

View File

@ -66,7 +66,7 @@ void IntraIO::subscribeLowFreq(const std::string& topicPattern, const Subscripti
lowFreqSubscriptions.push_back(std::move(sub));
// Register subscription with central manager for routing
IntraIOManager::getInstance().registerSubscription(instanceId, topicPattern, true);
IntraIOManager::getInstance().registerSubscription(instanceId, topicPattern, true, config.batchInterval);
}
int IntraIO::hasMessages() const {

View File

@ -23,9 +23,21 @@ IntraIOManager::IntraIOManager() {
spdlog::register_logger(logger);
logger->info("🌐🔗 IntraIOManager created - Central message router initialized");
// Start batch flush thread
batchThreadRunning = true;
batchThread = std::thread(&IntraIOManager::batchFlushLoop, this);
logger->info("🔄 Batch flush thread started");
}
IntraIOManager::~IntraIOManager() {
// Stop batch thread first
batchThreadRunning = false;
if (batchThread.joinable()) {
batchThread.join();
}
logger->info("🛑 Batch flush thread stopped");
std::lock_guard<std::mutex> lock(managerMutex);
auto stats = getRoutingStats();
@ -37,7 +49,8 @@ IntraIOManager::~IntraIOManager() {
instances.clear();
topicTree.clear();
instancePatterns.clear();
subscriptionFreqMap.clear();
subscriptionInfoMap.clear();
batchBuffers.clear();
logger->info("🌐🔗 IntraIOManager destroyed");
}
@ -129,28 +142,66 @@ void IntraIOManager::routeMessage(const std::string& sourceId, const std::string
auto targetInstance = instances.find(subscriberId);
if (targetInstance != instances.end()) {
// Copy JSON data for each recipient (JSON is copyable!)
json dataCopy = messageData;
// Recreate DataNode from JSON copy
auto dataNode = std::make_unique<JsonDataNode>("message", dataCopy);
// Get frequency info (default to false if not found)
// Get subscription info for this subscriber
// IMPORTANT: We need to find which pattern actually matched this topic!
bool isLowFreq = false;
std::string matchedPattern;
// Helper lambda to check if a pattern matches a topic
auto patternMatches = [](const std::string& pattern, const std::string& topic) -> bool {
// Simple wildcard matching: convert pattern to check
// pattern: "batch:.*" matches topic: "batch:metric"
// pattern: "player:*" matches topic: "player:123" but not "player:123:health"
size_t ppos = 0, tpos = 0;
while (ppos < pattern.size() && tpos < topic.size()) {
if (pattern.substr(ppos, 2) == ".*") {
// Multi-level wildcard - matches everything from here
return true;
} else if (pattern[ppos] == '*') {
// Single-level wildcard - match until next : or end
while (tpos < topic.size() && topic[tpos] != ':') {
tpos++;
}
ppos++;
} else if (pattern[ppos] == topic[tpos]) {
ppos++;
tpos++;
} else {
return false;
}
}
return ppos == pattern.size() && tpos == topic.size();
};
for (const auto& pattern : instancePatterns[subscriberId]) {
auto it = subscriptionFreqMap.find(pattern);
if (it != subscriptionFreqMap.end()) {
isLowFreq = it->second;
auto it = subscriptionInfoMap.find(pattern);
if (it != subscriptionInfoMap.end() && patternMatches(pattern, topic)) {
isLowFreq = it->second.isLowFreq;
matchedPattern = pattern;
break;
}
}
// Deliver to target instance's queue
targetInstance->second->deliverMessage(topic, std::move(dataNode), isLowFreq);
deliveredCount++;
logger->trace(" ↪️ Delivered to '{}' ({})",
subscriberId,
isLowFreq ? "low-freq" : "high-freq");
if (isLowFreq) {
// Add to batch buffer instead of immediate delivery
std::lock_guard<std::mutex> batchLock(batchMutex);
auto& buffer = batchBuffers[matchedPattern];
buffer.instanceId = subscriberId;
buffer.pattern = matchedPattern;
buffer.messages.push_back({topic, messageData});
deliveredCount++;
logger->trace(" 📦 Buffered for '{}' (low-freq batch)", subscriberId);
} else {
// High-freq: immediate delivery
json dataCopy = messageData;
auto dataNode = std::make_unique<JsonDataNode>("message", dataCopy);
targetInstance->second->deliverMessage(topic, std::move(dataNode), false);
deliveredCount++;
logger->trace(" ↪️ Delivered to '{}' (high-freq)", subscriberId);
}
} else {
logger->warn("⚠️ Target instance '{}' not found", subscriberId);
}
@ -160,7 +211,7 @@ void IntraIOManager::routeMessage(const std::string& sourceId, const std::string
logger->trace("📤 Message '{}' delivered to {} instances", topic, deliveredCount);
}
void IntraIOManager::registerSubscription(const std::string& instanceId, const std::string& pattern, bool isLowFreq) {
void IntraIOManager::registerSubscription(const std::string& instanceId, const std::string& pattern, bool isLowFreq, int batchInterval) {
std::lock_guard<std::mutex> lock(managerMutex);
try {
@ -169,12 +220,28 @@ void IntraIOManager::registerSubscription(const std::string& instanceId, const s
// Track pattern for management
instancePatterns[instanceId].push_back(pattern);
subscriptionFreqMap[pattern] = isLowFreq;
SubscriptionInfo info;
info.instanceId = instanceId;
info.isLowFreq = isLowFreq;
info.batchInterval = batchInterval;
subscriptionInfoMap[pattern] = info;
// Initialize batch buffer if low-freq
if (isLowFreq) {
std::lock_guard<std::mutex> batchLock(batchMutex);
auto& buffer = batchBuffers[pattern];
buffer.instanceId = instanceId;
buffer.pattern = pattern;
buffer.batchInterval = batchInterval;
buffer.lastFlush = std::chrono::steady_clock::now();
buffer.messages.clear();
}
totalRoutes++;
logger->info("📋 Registered subscription: '{}' → '{}' ({})",
instanceId, pattern, isLowFreq ? "low-freq" : "high-freq");
logger->info("📋 Registered subscription: '{}' → '{}' ({}, interval={}ms)",
instanceId, pattern, isLowFreq ? "low-freq" : "high-freq", batchInterval);
} catch (const std::exception& e) {
logger->error("❌ Failed to register subscription '{}' for '{}': {}",
@ -193,7 +260,13 @@ void IntraIOManager::unregisterSubscription(const std::string& instanceId, const
auto& patterns = instancePatterns[instanceId];
patterns.erase(std::remove(patterns.begin(), patterns.end(), pattern), patterns.end());
subscriptionFreqMap.erase(pattern);
subscriptionInfoMap.erase(pattern);
// Remove batch buffer if exists
{
std::lock_guard<std::mutex> batchLock(batchMutex);
batchBuffers.erase(pattern);
}
logger->info("🗑️ Unregistered subscription: '{}' → '{}'", instanceId, pattern);
}
@ -204,7 +277,12 @@ void IntraIOManager::clearAllRoutes() {
auto clearedCount = topicTree.subscriberCount();
topicTree.clear();
instancePatterns.clear();
subscriptionFreqMap.clear();
subscriptionInfoMap.clear();
{
std::lock_guard<std::mutex> batchLock(batchMutex);
batchBuffers.clear();
}
logger->info("🧹 Cleared {} routing entries", clearedCount);
}
@ -251,6 +329,73 @@ void IntraIOManager::setLogLevel(spdlog::level::level_enum level) {
logger->info("📝 Log level set to: {}", spdlog::level::to_string_view(level));
}
// Batch flush loop - runs in separate thread
void IntraIOManager::batchFlushLoop() {
logger->info("🔄 Batch flush loop started");
while (batchThreadRunning) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// Check all batch buffers and flush if needed
std::lock_guard<std::mutex> batchLock(batchMutex);
auto now = std::chrono::steady_clock::now();
for (auto& [pattern, buffer] : batchBuffers) {
if (buffer.messages.empty()) {
continue;
}
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
now - buffer.lastFlush).count();
if (elapsed >= buffer.batchInterval) {
flushBatchBuffer(buffer);
buffer.lastFlush = now;
}
}
}
logger->info("🔄 Batch flush loop stopped");
}
void IntraIOManager::flushBatchBuffer(BatchBuffer& buffer) {
if (buffer.messages.empty()) {
return;
}
std::lock_guard<std::mutex> lock(managerMutex);
auto targetInstance = instances.find(buffer.instanceId);
if (targetInstance == instances.end()) {
logger->warn("⚠️ Cannot flush batch for '{}': instance not found", buffer.instanceId);
buffer.messages.clear();
return;
}
size_t batchSize = buffer.messages.size();
logger->debug("📦 Flushing batch for '{}': {} messages", buffer.instanceId, batchSize);
// Create a single batch message containing all messages as an array
json batchArray = json::array();
std::string firstTopic;
for (const auto& [topic, messageData] : buffer.messages) {
if (firstTopic.empty()) {
firstTopic = topic;
}
batchArray.push_back({
{"topic", topic},
{"data", messageData}
});
}
// Deliver ONE batch message containing the array
auto batchDataNode = std::make_unique<JsonDataNode>("batch", batchArray);
targetInstance->second->deliverMessage(firstTopic, std::move(batchDataNode), true);
buffer.messages.clear();
}
// Singleton implementation
IntraIOManager& IntraIOManager::getInstance() {
static IntraIOManager instance;