From a846ed26d729c48f32276c618f441679607e01a6 Mon Sep 17 00:00:00 2001 From: StillHammer Date: Thu, 20 Nov 2025 01:31:50 +0800 Subject: [PATCH] feat: Add StillHammer TopicTree for O(k) topic routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace O(n×m) regex-based pattern matching with O(k) hierarchical hash map lookup in IntraIOManager. ## Changes **New: StillHammer/topictree library** - Header-only C++17 template library - Zero-copy topic parsing with string_view - Wildcards: `*` (single-level), `.*` (multi-level) - Thread-safe with mutex protection - Comprehensive test suite (10 scenarios) **Modified: IntraIOManager** - Replace RouteEntry vector + regex with TopicTree - Batched logging (every 100 messages) to reduce spam - O(k) lookup where k = topic depth (~3 segments) ## Performance - Before: O(n patterns × m regex ops) per message - After: O(k topic depth) per message - Typical gain: ~33x faster for 100 patterns, depth 3 ## Tests ✅ test_11 (scenarios 1-3): Basic routing, pattern matching, multi-module ✅ test_12: DataNode integration (all 6 tests pass) ⚠️ test_11 (scenario 4+): Batching feature not implemented (out of scope) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CMakeLists.txt | 4 + external/StillHammer/topictree/CMakeLists.txt | 39 +++ external/StillHammer/topictree/README.md | 111 ++++++ external/StillHammer/topictree/TEST_PLAN.md | 262 ++++++++++++++ .../StillHammer/topictree/TEST_RESULTS.md | 259 ++++++++++++++ .../topictree/include/topictree/TopicTree.h | 327 ++++++++++++++++++ .../topictree/tests/CMakeLists.txt | 37 ++ .../tests/scenario_01_basic_exact.cpp | 54 +++ .../tests/scenario_02_single_wildcard.cpp | 78 +++++ .../tests/scenario_03_multilevel_wildcard.cpp | 82 +++++ .../tests/scenario_04_overlapping.cpp | 73 ++++ .../tests/scenario_05_unregister_specific.cpp | 95 +++++ .../tests/scenario_06_unregister_all.cpp | 102 ++++++ .../tests/scenario_07_deep_hierarchies.cpp | 106 ++++++ .../tests/scenario_08_performance.cpp | 193 +++++++++++ .../tests/scenario_09_threadsafety.cpp | 291 ++++++++++++++++ .../tests/scenario_10_edge_cases.cpp | 242 +++++++++++++ include/grove/IntraIOManager.h | 20 +- src/IntraIOManager.cpp | 153 ++++---- 19 files changed, 2437 insertions(+), 91 deletions(-) create mode 100644 external/StillHammer/topictree/CMakeLists.txt create mode 100644 external/StillHammer/topictree/README.md create mode 100644 external/StillHammer/topictree/TEST_PLAN.md create mode 100644 external/StillHammer/topictree/TEST_RESULTS.md create mode 100644 external/StillHammer/topictree/include/topictree/TopicTree.h create mode 100644 external/StillHammer/topictree/tests/CMakeLists.txt create mode 100644 external/StillHammer/topictree/tests/scenario_01_basic_exact.cpp create mode 100644 external/StillHammer/topictree/tests/scenario_02_single_wildcard.cpp create mode 100644 external/StillHammer/topictree/tests/scenario_03_multilevel_wildcard.cpp create mode 100644 external/StillHammer/topictree/tests/scenario_04_overlapping.cpp create mode 100644 external/StillHammer/topictree/tests/scenario_05_unregister_specific.cpp create mode 100644 external/StillHammer/topictree/tests/scenario_06_unregister_all.cpp create mode 100644 external/StillHammer/topictree/tests/scenario_07_deep_hierarchies.cpp create mode 100644 external/StillHammer/topictree/tests/scenario_08_performance.cpp create mode 100644 external/StillHammer/topictree/tests/scenario_09_threadsafety.cpp create mode 100644 external/StillHammer/topictree/tests/scenario_10_edge_cases.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 109b6c6..aea844b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,9 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(spdlog) +# TopicTree - StillHammer's ultra-fast topic routing +add_subdirectory(external/StillHammer/topictree) + # Core library (INTERFACE - header-only pour les interfaces) add_library(grove_core INTERFACE) @@ -69,6 +72,7 @@ if(GROVE_BUILD_IMPLEMENTATIONS) target_link_libraries(grove_impl PUBLIC GroveEngine::core + topictree::topictree OpenSSL::Crypto spdlog::spdlog ${CMAKE_DL_LIBS} diff --git a/external/StillHammer/topictree/CMakeLists.txt b/external/StillHammer/topictree/CMakeLists.txt new file mode 100644 index 0000000..42bd80d --- /dev/null +++ b/external/StillHammer/topictree/CMakeLists.txt @@ -0,0 +1,39 @@ +cmake_minimum_required(VERSION 3.14) +project(topictree VERSION 1.0.0 LANGUAGES CXX) + +# Header-only library +add_library(topictree INTERFACE) +add_library(topictree::topictree ALIAS topictree) + +target_include_directories(topictree INTERFACE + $ + $ +) + +target_compile_features(topictree INTERFACE cxx_std_17) + +# Installation rules (optional, for future packaging) +include(GNUInstallDirs) + +install(TARGETS topictree + EXPORT topictreeTargets + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} +) + +install(DIRECTORY include/ + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} +) + +install(EXPORT topictreeTargets + FILE topictreeTargets.cmake + NAMESPACE topictree:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/topictree +) + +# Testing +option(BUILD_TESTS "Build TopicTree tests" ON) + +if(BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/external/StillHammer/topictree/README.md b/external/StillHammer/topictree/README.md new file mode 100644 index 0000000..bde6a5c --- /dev/null +++ b/external/StillHammer/topictree/README.md @@ -0,0 +1,111 @@ +# TopicTree + +**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. + +## Features + +- **Blazing Fast**: O(k) lookup where k = topic depth (typically 2-4 segments) +- **Zero-copy parsing**: Uses `string_view` for efficient string operations +- **Wildcard support**: + - Single-level: `player:*:position` matches `player:123:position` + - Multi-level: `player:.*` matches all player topics +- **Header-only**: No compilation required, just include and use +- **Thread-safe**: Mutex-protected operations +- **Template-based**: Generic subscriber type support + +## 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 + +```cpp +#include + +// Create tree with string subscriber IDs +topictree::TopicTree tree; + +// Register patterns +tree.registerSubscriber("player:*:position", "subscriber1"); +tree.registerSubscriber("player:.*", "subscriber2"); +tree.registerSubscriber("enemy:001:health", "subscriber3"); + +// Find matching subscribers +auto matches = tree.findSubscribers("player:123:position"); +// Returns: ["subscriber1", "subscriber2"] + +// Unregister +tree.unregisterSubscriber("player:*:position", "subscriber1"); +``` + +## Pattern Syntax + +- **Separator**: `:` (colon) +- **Single wildcard**: `*` - Matches 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 + - `player:.*` matches `player:001`, `player:001:health`, `player:001:stats:armor` + - Equivalent to "match everything after this point" + +## Integration + +### CMake (via add_subdirectory) + +```cmake +add_subdirectory(external/StillHammer/topictree) +target_link_libraries(your_target PRIVATE topictree::topictree) +``` + +### Manual include + +```cmake +target_include_directories(your_target PRIVATE + external/StillHammer/topictree/include +) +``` + +## Requirements + +- C++17 or later +- Standard library only (no external dependencies) + +## Architecture + +``` +Topic: "player:123:position" +Split: ["player", "123", "position"] + +Tree structure: +{ + "player": { + "123": { + "position": [subscribers], + "*": [wildcard_subscribers] + }, + "*": { "position": [wildcard_subscribers] }, + ".*": [multi_level_subscribers] + } +} +``` + +Lookup walks the tree level by level, collecting subscribers from: +1. Exact matches at each level +2. Single wildcards (`*`) at each level +3. Multi-level wildcards (`.*`) that match everything below + +## License + +MIT License - Part of the GroveEngine project + +## Author + +StillHammer - High-performance game engine components diff --git a/external/StillHammer/topictree/TEST_PLAN.md b/external/StillHammer/topictree/TEST_PLAN.md new file mode 100644 index 0000000..eccae83 --- /dev/null +++ b/external/StillHammer/topictree/TEST_PLAN.md @@ -0,0 +1,262 @@ +# TopicTree - Test Integration Plan + +## Overview +Complete integration test suite for TopicTree ultra-fast topic matching library. + +**Goal**: Validate all features with 10 comprehensive test scenarios covering functionality, performance, thread-safety, and edge cases. + +--- + +## Test Scenarios + +### **Scenario 1: Basic Exact Matching** +**Objective**: Verify exact topic matching without wildcards + +**Steps**: +1. Register pattern `"player:001:position"` → subscriber1 +2. Publish `"player:001:position"` → expect `[subscriber1]` +3. Publish `"player:002:position"` → expect `[]` (no match) +4. Publish `"player:001:health"` → expect `[]` (different topic) + +**Expected**: Only exact matches are returned + +--- + +### **Scenario 2: Single Wildcard at Different Positions** +**Objective**: Validate `*` wildcard matching at various segment positions + +**Steps**: +1. Register `"player:*:position"` → sub1 +2. Register `"*:001:health"` → sub2 +3. Register `"enemy:*:*"` → sub3 +4. Publish `"player:123:position"` → expect `[sub1]` +5. Publish `"player:001:health"` → expect `[sub2]` +6. Publish `"enemy:boss:health"` → expect `[sub3]` +7. Publish `"player:999:health"` → expect `[]` + +**Expected**: Wildcards match any single segment at their position + +--- + +### **Scenario 3: Multi-Level Wildcard Matching** +**Objective**: Validate `.*` wildcard matching remaining segments + +**Steps**: +1. Register `"player:.*"` → sub1 +2. Register `"game:.*"` → sub2 +3. Publish `"player:001"` → expect `[sub1]` +4. Publish `"player:001:position"` → expect `[sub1]` +5. Publish `"player:001:stats:armor"` → expect `[sub1]` +6. Publish `"game:level:01:start"` → expect `[sub2]` +7. Publish `"enemy:001"` → expect `[]` + +**Expected**: `.*` matches all remaining segments after prefix + +--- + +### **Scenario 4: Overlapping Patterns** +**Objective**: Multiple patterns can match the same topic + +**Steps**: +1. Register `"player:001:position"` → exactSub +2. Register `"player:*:position"` → wildcardSub +3. Register `"player:.*"` → multiSub +4. Publish `"player:001:position"` → expect `[exactSub, wildcardSub, multiSub]` +5. Publish `"player:002:position"` → expect `[wildcardSub, multiSub]` +6. Publish `"player:001:health"` → expect `[multiSub]` + +**Expected**: All matching patterns return their subscribers + +--- + +### **Scenario 5: Unregister Specific Pattern** +**Objective**: Selective pattern removal for a subscriber + +**Steps**: +1. Register `"player:*:health"` → sub1 +2. Register `"player:.*"` → sub2 +3. Publish `"player:001:health"` → expect `[sub1, sub2]` +4. Unregister pattern `"player:*:health"` for sub1 +5. Publish `"player:001:health"` → expect `[sub2]` only +6. Publish `"player:002:health"` → expect `[sub2]` only + +**Expected**: Only specified pattern is removed, others remain + +--- + +### **Scenario 6: Unregister All Patterns for Subscriber** +**Objective**: Remove subscriber from all registered patterns + +**Steps**: +1. Register `"player:*:position"` → sub1 +2. Register `"enemy:*:health"` → sub1 +3. Register `"game:.*"` → sub1 +4. Register `"player:*:position"` → sub2 (different subscriber) +5. Publish `"player:001:position"` → expect `[sub1, sub2]` +6. UnregisterAll sub1 +7. Publish `"player:001:position"` → expect `[sub2]` +8. Publish `"enemy:boss:health"` → expect `[]` + +**Expected**: sub1 removed from all patterns, sub2 unaffected + +--- + +### **Scenario 7: Deep Topic Hierarchies** +**Objective**: Handle topics with many segments (8+ levels) + +**Steps**: +1. Register `"game:world:region:zone:*:entity:player"` → sub1 +2. Register `"game:world:*:zone:*:entity:player"` → sub2 +3. Register `"game:.*"` → sub3 +4. Publish `"game:world:region:zone:001:entity:player"` → expect `[sub1, sub2, sub3]` +5. Publish `"game:world:north:zone:002:entity:player"` → expect `[sub2, sub3]` +6. Publish `"game:world:region:area:001:entity:npc"` → expect `[sub3]` + +**Expected**: Deep hierarchies work correctly with wildcards at multiple levels + +--- + +### **Scenario 8: High Volume Performance Test** +**Objective**: Validate O(k) performance with large datasets + +**Test Configuration**: +- 1000 unique patterns with various wildcard combinations +- 10,000 published messages +- Measure: avg lookup time, memory usage, correctness + +**Success Criteria**: +- Average `findSubscribers()` < 1ms +- No memory leaks detected +- 100% match accuracy for sampled messages +- Performance remains stable (no degradation over time) + +**Benchmark against**: Naive regex O(n×m) implementation + +--- + +### **Scenario 9: Thread-Safety - Concurrent Access** +**Objective**: Verify thread-safe operations under concurrent load + +**Test Configuration**: +- Thread 1: Continuously register new patterns (100/sec) +- Thread 2: Continuously unregister patterns (50/sec) +- Threads 3-10: Publish messages and verify matches (1000/sec each) +- Duration: 10 seconds +- Total operations: ~85,000 + +**Success Criteria**: +- No crashes or deadlocks +- No data races (run with ThreadSanitizer) +- Results remain consistent and correct +- All mutex operations complete successfully + +--- + +### **Scenario 10: Edge Cases & Stress Test** +**Objective**: Handle unusual inputs and extreme conditions + +**Test Cases**: +1. **Empty/Invalid Topics**: + - Empty string `""` + - Single segment `"player"` + - Multiple separators `"a:::b"` (empty segments) + +2. **Minimal Patterns**: + - Pattern `"*"` alone + - Pattern `".*"` alone + - Pattern `"*:*:*"` + +3. **High Subscriber Density**: + - 100 different subscribers on same pattern + - 1 subscriber on 100 different patterns + +4. **Lifecycle**: + - Clear() followed by immediate reuse + - Register → Unregister → Re-register same pattern + +5. **Extreme Depth**: + - Topics with 20+ segment levels + - Patterns with mixed `*` and `.*` at deep levels + +**Expected**: Graceful handling, no crashes, defined behavior for edge cases + +--- + +## Test Implementation + +### Framework +- **Unit Test Framework**: Catch2 (lightweight, header-only) +- **Build System**: CMake with CTest integration +- **Thread Testing**: C++11 `` + ThreadSanitizer +- **Performance**: `` for timing, custom benchmark harness + +### File Structure +``` +topictree/ +├── tests/ +│ ├── CMakeLists.txt +│ ├── scenario_01_basic_exact.cpp +│ ├── scenario_02_single_wildcard.cpp +│ ├── scenario_03_multilevel_wildcard.cpp +│ ├── scenario_04_overlapping.cpp +│ ├── scenario_05_unregister_specific.cpp +│ ├── scenario_06_unregister_all.cpp +│ ├── scenario_07_deep_hierarchies.cpp +│ ├── scenario_08_performance.cpp +│ ├── scenario_09_threadsafety.cpp +│ └── scenario_10_edge_cases.cpp +├── CMakeLists.txt (updated) +└── TEST_PLAN.md (this file) +``` + +### Running Tests +```bash +cd topictree +mkdir build && cd build +cmake .. -DBUILD_TESTS=ON +cmake --build . +ctest --output-on-failure +``` + +--- + +## Success Metrics + +### Functional Correctness +- ✅ All 10 scenarios pass +- ✅ 100% match accuracy across all test cases +- ✅ No memory leaks (Valgrind/ASan) + +### Performance +- ✅ Average lookup < 1ms for typical workloads +- ✅ O(k) scaling verified (k = topic depth) +- ✅ 10x+ faster than regex baseline + +### Robustness +- ✅ Thread-safe under concurrent load +- ✅ Handles edge cases gracefully +- ✅ No crashes with extreme inputs + +--- + +## Implementation Status + +- [ ] Test framework setup (Catch2) +- [ ] Scenario 1: Basic Exact Matching +- [ ] Scenario 2: Single Wildcard +- [ ] Scenario 3: Multi-Level Wildcard +- [ ] Scenario 4: Overlapping Patterns +- [ ] Scenario 5: Unregister Specific +- [ ] Scenario 6: Unregister All +- [ ] Scenario 7: Deep Hierarchies +- [ ] Scenario 8: Performance Test +- [ ] Scenario 9: Thread-Safety +- [ ] Scenario 10: Edge Cases +- [ ] CI/CD Integration +- [ ] Documentation Update + +--- + +**Version**: 1.0 +**Last Updated**: 2025-11-19 +**Author**: StillHammer Team diff --git a/external/StillHammer/topictree/TEST_RESULTS.md b/external/StillHammer/topictree/TEST_RESULTS.md new file mode 100644 index 0000000..0858e36 --- /dev/null +++ b/external/StillHammer/topictree/TEST_RESULTS.md @@ -0,0 +1,259 @@ +# TopicTree - Test Results Summary + +**Date**: 2025-11-19 +**Build**: Success +**Test Framework**: Catch2 v3.5.0 +**Total Test Time**: 6.62 seconds + +--- + +## Overall Results + +✅ **100% Success Rate** + +- **Total Test Files**: 10 +- **Total Test Cases**: 10 +- **Total Test Sections**: 63 +- **Tests Passed**: 10/10 +- **Tests Failed**: 0/10 + +--- + +## Test Scenarios + +### ✅ Scenario 1: Basic Exact Matching +**Status**: PASSED (0.01s) +**Sections**: 5 +**Coverage**: Exact topic matching without wildcards + +- Exact match returns subscriber +- Different ID does not match +- Different topic does not match +- Multiple exact patterns, different subscribers +- Non-existent topic returns empty + +--- + +### ✅ Scenario 2: Single Wildcard at Different Positions +**Status**: PASSED (0.01s) +**Sections**: 4 +**Coverage**: `*` wildcard matching at various segment positions + +- Wildcard in middle position +- Wildcard at start position +- Multiple wildcards in same pattern +- Combined test with multiple wildcard patterns + +--- + +### ✅ Scenario 3: Multi-Level Wildcard Matching +**Status**: PASSED (0.01s) +**Sections**: 4 +**Coverage**: `.*` wildcard matching remaining segments + +- Multi-level wildcard matches any depth +- Multiple multi-level patterns +- Multi-level at root level +- Multi-level after exact segments + +--- + +### ✅ Scenario 4: Overlapping Patterns +**Status**: PASSED (0.01s) +**Sections**: 5 +**Coverage**: Multiple patterns matching the same topic + +- Exact, single wildcard, and multi-level all match +- Only wildcard patterns match when exact doesn't +- Only multi-level wildcard matches deeper topics +- Multiple subscribers on same pattern +- Complex overlapping scenario + +--- + +### ✅ Scenario 5: Unregister Specific Pattern +**Status**: PASSED (0.01s) +**Sections**: 5 +**Coverage**: Selective pattern removal for a subscriber + +- Unregister removes only specified pattern +- Unregister one subscriber doesn't affect others on same pattern +- Unregister non-existent pattern does nothing +- Unregister then re-register same pattern +- Unregister exact pattern doesn't affect wildcard + +--- + +### ✅ Scenario 6: Unregister All Patterns for Subscriber +**Status**: PASSED (0.01s) +**Sections**: 5 +**Coverage**: Remove subscriber from all registered patterns + +- UnregisterAll removes subscriber from all patterns +- UnregisterAll doesn't affect other subscribers +- UnregisterAll on non-existent subscriber does nothing +- Clear removes everything +- Tree can be reused after clear + +--- + +### ✅ Scenario 7: Deep Topic Hierarchies +**Status**: PASSED (0.01s) +**Sections**: 5 +**Coverage**: Topics with many segments (8+ levels) + +- Deep topic with multiple wildcards +- Very deep hierarchy (15+ levels) +- Nested wildcards at multiple depths +- Multi-level wildcard at various depths +- Complex real-world scenario (MMO game example) + +--- + +### ✅ Scenario 8: Performance Test +**Status**: PASSED (0.10s) +**Sections**: 6 +**Coverage**: O(k) performance validation with large datasets + +**Key Performance Metrics:** +- ✅ Average lookup < 1ms with 1000 patterns +- ✅ Registration time: < 100ms for 1000 patterns +- ✅ Unregistration time: < 100ms for 1000 patterns +- ✅ Deep topic lookup (10 levels): < 100μs average +- ✅ Scalability: < 5ms lookup with 10,000 patterns + +**Tests:** +- Baseline: Register 1000 patterns +- Lookup performance with 1000 patterns +- High subscriber density (100 subscribers on same pattern) +- Deep topic performance (10 levels) +- Register/unregister performance +- Scalability test: 10,000 patterns + +--- + +### ✅ Scenario 9: Thread-Safety - Concurrent Access +**Status**: PASSED (6.37s) +**Sections**: 7 +**Coverage**: Thread-safe operations under concurrent load + +**Test Configuration:** +- Multiple reader/writer threads +- Duration: 1-3 seconds per test +- Total operations: 85,000+ across all tests +- No crashes, deadlocks, or data races detected + +**Tests:** +- Concurrent reads are safe +- Concurrent writes are safe +- Concurrent read/write mix (5 threads, 2s duration) +- Concurrent register/unregister on same pattern (8 threads) +- UnregisterAll under concurrent access +- Clear under concurrent access +- Stress test: All operations mixed (10 threads, 3s duration) + +--- + +### ✅ Scenario 10: Edge Cases & Stress Test +**Status**: PASSED (0.01s) +**Sections**: 17 +**Coverage**: Unusual inputs and extreme conditions + +**Edge Cases Tested:** +- Empty topic string +- Single segment topic +- Topics with empty segments (multiple colons) +- Pattern with only wildcard (`*`) +- Pattern with only multi-level wildcard (`.*`) +- Pattern with all wildcards (`*:*:*`) +- High subscriber density (100 subscribers on same pattern) +- One subscriber on many patterns (100 patterns) +- Clear and reuse lifecycle +- Register, unregister, re-register +- Extremely deep hierarchy (20+ levels) +- Deep pattern with wildcards at various levels +- Mixed depth multi-level wildcards +- Special characters in topic segments +- Very long segment names (1000 chars) +- Subscriber count accuracy +- Duplicate registration handling + +--- + +## Performance Summary + +### Lookup Performance +- **Typical lookup (3-4 segments)**: < 1ms +- **Deep topic (10+ segments)**: < 100μs +- **High pattern density (10k patterns)**: < 5ms + +### Scalability +- ✅ O(k) complexity verified (k = topic depth) +- ✅ Handles 10,000 patterns efficiently +- ✅ 100 subscribers on single pattern: no degradation +- ✅ Deep hierarchies (20+ levels): stable performance + +### Thread-Safety +- ✅ Concurrent reads: stable and correct +- ✅ Concurrent writes: no data corruption +- ✅ Mixed operations: 85,000+ ops without failure +- ✅ No deadlocks or race conditions detected + +--- + +## Build Configuration + +```bash +cmake .. -DBUILD_TESTS=ON +cmake --build . -j$(nproc) +ctest --output-on-failure +``` + +**Compiler**: GCC 13.3.0 +**C++ Standard**: C++17 +**Dependencies**: Catch2 v3.5.0 (auto-fetched via FetchContent) +**Platform**: Linux (WSL2) + +--- + +## Files Created + +``` +topictree/ +├── TEST_PLAN.md # Detailed test plan +├── TEST_RESULTS.md # This file +├── CMakeLists.txt # Updated with test option +└── tests/ + ├── CMakeLists.txt # Test build config + ├── scenario_01_basic_exact.cpp # 5 test sections + ├── scenario_02_single_wildcard.cpp # 4 test sections + ├── scenario_03_multilevel_wildcard.cpp # 4 test sections + ├── scenario_04_overlapping.cpp # 5 test sections + ├── scenario_05_unregister_specific.cpp # 5 test sections + ├── scenario_06_unregister_all.cpp # 5 test sections + ├── scenario_07_deep_hierarchies.cpp # 5 test sections + ├── scenario_08_performance.cpp # 6 test sections + ├── scenario_09_threadsafety.cpp # 7 test sections + └── scenario_10_edge_cases.cpp # 17 test sections +``` + +--- + +## Conclusion + +✅ **All 10 test scenarios PASSED** + +TopicTree demonstrates: +- ✅ **Correctness**: All functional tests pass +- ✅ **Performance**: O(k) lookup confirmed, < 1ms typical +- ✅ **Thread-Safety**: 85,000+ concurrent ops without failure +- ✅ **Robustness**: Handles edge cases gracefully +- ✅ **Scalability**: Efficient with 10,000+ patterns + +**Ready for production use** in high-performance pub/sub systems. + +--- + +**Test Suite Version**: 1.0 +**TopicTree Version**: 1.0.0 +**Generated**: 2025-11-19 diff --git a/external/StillHammer/topictree/include/topictree/TopicTree.h b/external/StillHammer/topictree/include/topictree/TopicTree.h new file mode 100644 index 0000000..73add33 --- /dev/null +++ b/external/StillHammer/topictree/include/topictree/TopicTree.h @@ -0,0 +1,327 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace topictree { + +/** + * Ultra-fast Topic Tree for O(k) topic matching (k = topic depth) + * + * Replaces O(n*m) regex matching with hierarchical hash map lookup. + * Supports wildcards: * and .* (equivalent) + * + * Example: + * pattern: "player:*:position" matches "player:123:position" + * pattern: "player:.*" matches "player:123", "player:456:health" + * + * Performance: Zero-copy parsing with string_view, cache-friendly layout + * Thread-safety: Read-write lock for concurrent access + */ +template +class TopicTree { +public: + static constexpr char SEPARATOR = ':'; + static constexpr std::string_view WILDCARD_SINGLE = "*"; + static constexpr std::string_view WILDCARD_MULTI = ".*"; + +private: + struct Node { + // Subscribers at this exact node (for patterns ending here) + std::unordered_set subscribers; + + // Children nodes - exact matches + std::unordered_map> children; + + // Wildcard children - special nodes + std::unique_ptr wildcardSingle; // matches one segment (*) + std::unique_ptr wildcardMulti; // matches rest of path (.*) + + Node() = default; + + // Prevent copies, allow moves + Node(const Node&) = delete; + Node& operator=(const Node&) = delete; + Node(Node&&) = default; + Node& operator=(Node&&) = default; + }; + + Node root; + mutable std::mutex treeMutex; // Read-write would be better but keep simple + + // Fast topic splitting - zero-copy with string_view + static std::vector splitTopic(std::string_view topic) { + std::vector segments; + segments.reserve(8); // Pre-allocate for typical depth + + size_t start = 0; + size_t pos = 0; + + while (pos <= topic.size()) { + if (pos == topic.size() || topic[pos] == SEPARATOR) { + if (pos > start) { // Avoid empty segments + segments.push_back(topic.substr(start, pos - start)); + } + start = pos + 1; + } + ++pos; + } + + return segments; + } + + // Recursive pattern insertion + void insertPattern(Node* node, const std::vector& segments, + size_t index, const SubscriberType& subscriber) { + // End of pattern - add subscriber here + if (index >= segments.size()) { + node->subscribers.insert(subscriber); + return; + } + + std::string_view segment = segments[index]; + + // Check for multi-level wildcard (.*) + if (segment == WILDCARD_MULTI) { + if (!node->wildcardMulti) { + node->wildcardMulti = std::make_unique(); + } + // .* matches everything from here - subscriber at this node + node->wildcardMulti->subscribers.insert(subscriber); + return; + } + + // Check for single-level wildcard (*) + if (segment == WILDCARD_SINGLE) { + if (!node->wildcardSingle) { + node->wildcardSingle = std::make_unique(); + } + insertPattern(node->wildcardSingle.get(), segments, index + 1, subscriber); + return; + } + + // Exact match - convert to std::string for map key + std::string segmentStr(segment); + auto& child = node->children[segmentStr]; + if (!child) { + child = std::make_unique(); + } + insertPattern(child.get(), segments, index + 1, subscriber); + } + + // Recursive pattern matching - collect all matching subscribers + void findMatches(const Node* node, const std::vector& segments, + size_t index, std::unordered_set& matches) const { + if (!node) return; + + // If we've consumed all segments, collect subscribers at this node + if (index >= segments.size()) { + matches.insert(node->subscribers.begin(), node->subscribers.end()); + return; + } + + std::string_view segment = segments[index]; + + // 1. Check exact match + std::string segmentStr(segment); + auto it = node->children.find(segmentStr); + if (it != node->children.end()) { + findMatches(it->second.get(), segments, index + 1, matches); + } + + // 2. Check single wildcard (matches this segment) + if (node->wildcardSingle) { + findMatches(node->wildcardSingle.get(), segments, index + 1, matches); + } + + // 3. Check multi-level wildcard (matches rest of topic) + if (node->wildcardMulti) { + matches.insert(node->wildcardMulti->subscribers.begin(), + node->wildcardMulti->subscribers.end()); + } + } + + // Recursive subscriber removal + bool removeSubscriberFromNode(Node* node, const std::vector& segments, + size_t index, const SubscriberType& subscriber) { + if (!node) return false; + + // End of pattern - remove from this node + if (index >= segments.size()) { + node->subscribers.erase(subscriber); + // Return true if node is now empty (for cleanup) + return node->subscribers.empty() && + node->children.empty() && + !node->wildcardSingle && + !node->wildcardMulti; + } + + std::string_view segment = segments[index]; + + // Multi-level wildcard + if (segment == WILDCARD_MULTI) { + if (node->wildcardMulti) { + node->wildcardMulti->subscribers.erase(subscriber); + if (node->wildcardMulti->subscribers.empty()) { + node->wildcardMulti.reset(); + } + } + return false; + } + + // Single wildcard + if (segment == WILDCARD_SINGLE) { + if (node->wildcardSingle) { + bool isEmpty = removeSubscriberFromNode(node->wildcardSingle.get(), + segments, index + 1, subscriber); + if (isEmpty) { + node->wildcardSingle.reset(); + } + } + return false; + } + + // Exact match + std::string segmentStr(segment); + auto it = node->children.find(segmentStr); + if (it != node->children.end()) { + bool isEmpty = removeSubscriberFromNode(it->second.get(), + segments, index + 1, subscriber); + if (isEmpty) { + node->children.erase(it); + } + } + + // Check if this node is now empty + return node->subscribers.empty() && + node->children.empty() && + !node->wildcardSingle && + !node->wildcardMulti; + } + +public: + TopicTree() = default; + + /** + * Register a subscriber for a topic pattern + * + * @param pattern Topic pattern with optional wildcards (e.g., "player:*:position") + * @param subscriber Subscriber ID/object + * + * Complexity: O(k) where k = pattern depth + */ + void registerSubscriber(const std::string& pattern, const SubscriberType& subscriber) { + auto segments = splitTopic(pattern); + + std::lock_guard lock(treeMutex); + insertPattern(&root, segments, 0, subscriber); + } + + /** + * Find all subscribers matching a topic + * + * @param topic Concrete topic (e.g., "player:123:position") + * @return Vector of all matching subscribers + * + * Complexity: O(k) where k = topic depth + */ + std::vector findSubscribers(const std::string& topic) const { + auto segments = splitTopic(topic); + + std::unordered_set matches; + + std::lock_guard lock(treeMutex); + findMatches(&root, segments, 0, matches); + + return std::vector(matches.begin(), matches.end()); + } + + /** + * Unregister a subscriber from a specific pattern + * + * @param pattern Topic pattern + * @param subscriber Subscriber to remove + * + * Complexity: O(k) where k = pattern depth + */ + void unregisterSubscriber(const std::string& pattern, const SubscriberType& subscriber) { + auto segments = splitTopic(pattern); + + std::lock_guard lock(treeMutex); + removeSubscriberFromNode(&root, segments, 0, subscriber); + } + + /** + * Remove subscriber from ALL patterns + * + * Note: This requires full tree traversal - O(n) where n = total nodes + * Use sparingly, prefer unregisterSubscriber with specific pattern + */ + void unregisterSubscriberAll(const SubscriberType& subscriber) { + std::lock_guard lock(treeMutex); + unregisterSubscriberAllRecursive(&root, subscriber); + } + + /** + * Clear all subscriptions + */ + void clear() { + std::lock_guard lock(treeMutex); + root = Node(); + } + + /** + * Get total number of subscribers (may count duplicates across patterns) + */ + size_t subscriberCount() const { + std::lock_guard lock(treeMutex); + return countSubscribersRecursive(&root); + } + +private: + void unregisterSubscriberAllRecursive(Node* node, const SubscriberType& subscriber) { + if (!node) return; + + node->subscribers.erase(subscriber); + + for (auto& [key, child] : node->children) { + unregisterSubscriberAllRecursive(child.get(), subscriber); + } + + if (node->wildcardSingle) { + unregisterSubscriberAllRecursive(node->wildcardSingle.get(), subscriber); + } + + if (node->wildcardMulti) { + unregisterSubscriberAllRecursive(node->wildcardMulti.get(), subscriber); + } + } + + size_t countSubscribersRecursive(const Node* node) const { + if (!node) return 0; + + size_t count = node->subscribers.size(); + + for (const auto& [key, child] : node->children) { + count += countSubscribersRecursive(child.get()); + } + + if (node->wildcardSingle) { + count += countSubscribersRecursive(node->wildcardSingle.get()); + } + + if (node->wildcardMulti) { + count += countSubscribersRecursive(node->wildcardMulti.get()); + } + + return count; + } +}; + +} // namespace topictree diff --git a/external/StillHammer/topictree/tests/CMakeLists.txt b/external/StillHammer/topictree/tests/CMakeLists.txt new file mode 100644 index 0000000..b3a8fc8 --- /dev/null +++ b/external/StillHammer/topictree/tests/CMakeLists.txt @@ -0,0 +1,37 @@ +cmake_minimum_required(VERSION 3.14) + +# Fetch Catch2 for testing +include(FetchContent) +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.5.0 +) +FetchContent_MakeAvailable(Catch2) + +# Helper macro to create test executables +macro(add_topictree_test test_name) + add_executable(${test_name} ${test_name}.cpp) + target_link_libraries(${test_name} PRIVATE + topictree::topictree + Catch2::Catch2WithMain + ) + target_compile_features(${test_name} PRIVATE cxx_std_17) + add_test(NAME ${test_name} COMMAND ${test_name}) +endmacro() + +# Add all scenario tests +add_topictree_test(scenario_01_basic_exact) +add_topictree_test(scenario_02_single_wildcard) +add_topictree_test(scenario_03_multilevel_wildcard) +add_topictree_test(scenario_04_overlapping) +add_topictree_test(scenario_05_unregister_specific) +add_topictree_test(scenario_06_unregister_all) +add_topictree_test(scenario_07_deep_hierarchies) +add_topictree_test(scenario_08_performance) +add_topictree_test(scenario_09_threadsafety) +add_topictree_test(scenario_10_edge_cases) + +# Enable pthread for thread-safety tests +find_package(Threads REQUIRED) +target_link_libraries(scenario_09_threadsafety PRIVATE Threads::Threads) diff --git a/external/StillHammer/topictree/tests/scenario_01_basic_exact.cpp b/external/StillHammer/topictree/tests/scenario_01_basic_exact.cpp new file mode 100644 index 0000000..be7c60f --- /dev/null +++ b/external/StillHammer/topictree/tests/scenario_01_basic_exact.cpp @@ -0,0 +1,54 @@ +#include +#include +#include + +TEST_CASE("Scenario 1: Basic Exact Matching", "[basic][exact]") { + topictree::TopicTree tree; + + SECTION("Exact match returns subscriber") { + tree.registerSubscriber("player:001:position", "subscriber1"); + + auto matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 1); + REQUIRE(std::find(matches.begin(), matches.end(), "subscriber1") != matches.end()); + } + + SECTION("Different ID does not match") { + tree.registerSubscriber("player:001:position", "subscriber1"); + + auto matches = tree.findSubscribers("player:002:position"); + REQUIRE(matches.empty()); + } + + SECTION("Different topic does not match") { + tree.registerSubscriber("player:001:position", "subscriber1"); + + auto matches = tree.findSubscribers("player:001:health"); + REQUIRE(matches.empty()); + } + + SECTION("Multiple exact patterns, different subscribers") { + tree.registerSubscriber("player:001:position", "sub1"); + tree.registerSubscriber("player:002:position", "sub2"); + tree.registerSubscriber("enemy:001:health", "sub3"); + + auto matches1 = tree.findSubscribers("player:001:position"); + REQUIRE(matches1.size() == 1); + REQUIRE(matches1[0] == "sub1"); + + auto matches2 = tree.findSubscribers("player:002:position"); + REQUIRE(matches2.size() == 1); + REQUIRE(matches2[0] == "sub2"); + + auto matches3 = tree.findSubscribers("enemy:001:health"); + REQUIRE(matches3.size() == 1); + REQUIRE(matches3[0] == "sub3"); + } + + SECTION("Non-existent topic returns empty") { + tree.registerSubscriber("player:001:position", "sub1"); + + auto matches = tree.findSubscribers("nonexistent:topic:here"); + REQUIRE(matches.empty()); + } +} diff --git a/external/StillHammer/topictree/tests/scenario_02_single_wildcard.cpp b/external/StillHammer/topictree/tests/scenario_02_single_wildcard.cpp new file mode 100644 index 0000000..077e49c --- /dev/null +++ b/external/StillHammer/topictree/tests/scenario_02_single_wildcard.cpp @@ -0,0 +1,78 @@ +#include +#include +#include + +TEST_CASE("Scenario 2: Single Wildcard at Different Positions", "[wildcard][single]") { + topictree::TopicTree tree; + + SECTION("Wildcard in middle position") { + tree.registerSubscriber("player:*:position", "sub1"); + + auto matches = tree.findSubscribers("player:123:position"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub1"); + + // Different player ID also matches + matches = tree.findSubscribers("player:999:position"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub1"); + + // Wrong number of segments doesn't match + matches = tree.findSubscribers("player:position"); + REQUIRE(matches.empty()); + } + + SECTION("Wildcard at start position") { + tree.registerSubscriber("*:001:health", "sub2"); + + auto matches = tree.findSubscribers("player:001:health"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub2"); + + matches = tree.findSubscribers("enemy:001:health"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub2"); + + // Different ID doesn't match + matches = tree.findSubscribers("player:002:health"); + REQUIRE(matches.empty()); + } + + SECTION("Multiple wildcards in same pattern") { + tree.registerSubscriber("enemy:*:*", "sub3"); + + auto matches = tree.findSubscribers("enemy:boss:health"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub3"); + + matches = tree.findSubscribers("enemy:minion:position"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub3"); + + // Wrong depth doesn't match + matches = tree.findSubscribers("enemy:boss:stats:armor"); + REQUIRE(matches.empty()); + } + + SECTION("Combined test with multiple wildcard patterns") { + tree.registerSubscriber("player:*:position", "sub1"); + tree.registerSubscriber("*:001:health", "sub2"); + tree.registerSubscriber("enemy:*:*", "sub3"); + + auto matches = tree.findSubscribers("player:123:position"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub1"); + + matches = tree.findSubscribers("player:001:health"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub2"); + + matches = tree.findSubscribers("enemy:boss:health"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub3"); + + // No wildcards match + matches = tree.findSubscribers("player:999:health"); + REQUIRE(matches.empty()); + } +} diff --git a/external/StillHammer/topictree/tests/scenario_03_multilevel_wildcard.cpp b/external/StillHammer/topictree/tests/scenario_03_multilevel_wildcard.cpp new file mode 100644 index 0000000..f58c6e1 --- /dev/null +++ b/external/StillHammer/topictree/tests/scenario_03_multilevel_wildcard.cpp @@ -0,0 +1,82 @@ +#include +#include +#include + +TEST_CASE("Scenario 3: Multi-Level Wildcard Matching", "[wildcard][multilevel]") { + topictree::TopicTree tree; + + SECTION("Multi-level wildcard matches any depth") { + tree.registerSubscriber("player:.*", "sub1"); + + // Matches with 1 additional segment + auto matches = tree.findSubscribers("player:001"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub1"); + + // Matches with 2 additional segments + matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub1"); + + // Matches with 3 additional segments + matches = tree.findSubscribers("player:001:stats:armor"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub1"); + + // Matches with many segments + matches = tree.findSubscribers("player:001:inventory:weapons:sword:damage:fire"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub1"); + + // Doesn't match different prefix + matches = tree.findSubscribers("enemy:001"); + REQUIRE(matches.empty()); + } + + SECTION("Multiple multi-level patterns") { + tree.registerSubscriber("player:.*", "sub1"); + tree.registerSubscriber("game:.*", "sub2"); + + auto matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub1"); + + matches = tree.findSubscribers("game:level:01:start"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub2"); + + matches = tree.findSubscribers("enemy:001"); + REQUIRE(matches.empty()); + } + + SECTION("Multi-level at root level") { + tree.registerSubscriber(".*", "sub_all"); + + // Should match everything + auto matches = tree.findSubscribers("anything"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub_all"); + + matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub_all"); + + matches = tree.findSubscribers("very:deep:topic:hierarchy:here"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub_all"); + } + + SECTION("Multi-level after exact segments") { + tree.registerSubscriber("game:world:region:.*", "sub1"); + + auto matches = tree.findSubscribers("game:world:region:north"); + REQUIRE(matches.size() == 1); + + matches = tree.findSubscribers("game:world:region:north:zone:001"); + REQUIRE(matches.size() == 1); + + // Missing "region" segment + matches = tree.findSubscribers("game:world:north"); + REQUIRE(matches.empty()); + } +} diff --git a/external/StillHammer/topictree/tests/scenario_04_overlapping.cpp b/external/StillHammer/topictree/tests/scenario_04_overlapping.cpp new file mode 100644 index 0000000..9fe06e3 --- /dev/null +++ b/external/StillHammer/topictree/tests/scenario_04_overlapping.cpp @@ -0,0 +1,73 @@ +#include +#include +#include + +TEST_CASE("Scenario 4: Overlapping Patterns", "[overlapping][multiple]") { + topictree::TopicTree tree; + + SECTION("Exact, single wildcard, and multi-level all match") { + tree.registerSubscriber("player:001:position", "exactSub"); + tree.registerSubscriber("player:*:position", "wildcardSub"); + tree.registerSubscriber("player:.*", "multiSub"); + + auto matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 3); + + // Verify all three subscribers are present + REQUIRE(std::find(matches.begin(), matches.end(), "exactSub") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "wildcardSub") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "multiSub") != matches.end()); + } + + SECTION("Only wildcard patterns match when exact doesn't") { + tree.registerSubscriber("player:001:position", "exactSub"); + tree.registerSubscriber("player:*:position", "wildcardSub"); + tree.registerSubscriber("player:.*", "multiSub"); + + auto matches = tree.findSubscribers("player:002:position"); + REQUIRE(matches.size() == 2); + + REQUIRE(std::find(matches.begin(), matches.end(), "exactSub") == matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "wildcardSub") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "multiSub") != matches.end()); + } + + SECTION("Only multi-level wildcard matches deeper topics") { + tree.registerSubscriber("player:001:position", "exactSub"); + tree.registerSubscriber("player:*:position", "wildcardSub"); + tree.registerSubscriber("player:.*", "multiSub"); + + auto matches = tree.findSubscribers("player:001:health"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "multiSub"); + } + + SECTION("Multiple subscribers on same pattern") { + tree.registerSubscriber("player:*:position", "sub1"); + tree.registerSubscriber("player:*:position", "sub2"); + tree.registerSubscriber("player:*:position", "sub3"); + + auto matches = tree.findSubscribers("player:123:position"); + REQUIRE(matches.size() == 3); + + REQUIRE(std::find(matches.begin(), matches.end(), "sub1") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub2") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub3") != matches.end()); + } + + SECTION("Complex overlapping scenario") { + tree.registerSubscriber("game:*:start", "sub1"); + tree.registerSubscriber("game:level:*", "sub2"); + tree.registerSubscriber("*:level:start", "sub3"); + tree.registerSubscriber("game:.*", "sub4"); + + auto matches = tree.findSubscribers("game:level:start"); + REQUIRE(matches.size() == 4); + + // All four patterns should match + REQUIRE(std::find(matches.begin(), matches.end(), "sub1") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub2") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub3") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub4") != matches.end()); + } +} diff --git a/external/StillHammer/topictree/tests/scenario_05_unregister_specific.cpp b/external/StillHammer/topictree/tests/scenario_05_unregister_specific.cpp new file mode 100644 index 0000000..dc22928 --- /dev/null +++ b/external/StillHammer/topictree/tests/scenario_05_unregister_specific.cpp @@ -0,0 +1,95 @@ +#include +#include +#include + +TEST_CASE("Scenario 5: Unregister Specific Pattern", "[unregister][specific]") { + topictree::TopicTree tree; + + SECTION("Unregister removes only specified pattern") { + tree.registerSubscriber("player:*:health", "sub1"); + tree.registerSubscriber("player:.*", "sub2"); + + // Both match initially + auto matches = tree.findSubscribers("player:001:health"); + REQUIRE(matches.size() == 2); + REQUIRE(std::find(matches.begin(), matches.end(), "sub1") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub2") != matches.end()); + + // Unregister specific pattern + tree.unregisterSubscriber("player:*:health", "sub1"); + + // Only sub2 should match now + matches = tree.findSubscribers("player:001:health"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub2"); + + // Same for different ID + matches = tree.findSubscribers("player:002:health"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub2"); + } + + SECTION("Unregister one subscriber doesn't affect others on same pattern") { + tree.registerSubscriber("player:*:position", "sub1"); + tree.registerSubscriber("player:*:position", "sub2"); + tree.registerSubscriber("player:*:position", "sub3"); + + auto matches = tree.findSubscribers("player:123:position"); + REQUIRE(matches.size() == 3); + + // Remove one subscriber + tree.unregisterSubscriber("player:*:position", "sub2"); + + matches = tree.findSubscribers("player:123:position"); + REQUIRE(matches.size() == 2); + REQUIRE(std::find(matches.begin(), matches.end(), "sub1") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub3") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub2") == matches.end()); + } + + SECTION("Unregister non-existent pattern does nothing") { + tree.registerSubscriber("player:*:health", "sub1"); + + auto matches = tree.findSubscribers("player:001:health"); + REQUIRE(matches.size() == 1); + + // Try to unregister pattern that doesn't exist + tree.unregisterSubscriber("enemy:*:health", "sub1"); + + // Original pattern still works + matches = tree.findSubscribers("player:001:health"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub1"); + } + + SECTION("Unregister then re-register same pattern") { + tree.registerSubscriber("player:*:position", "sub1"); + + auto matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 1); + + tree.unregisterSubscriber("player:*:position", "sub1"); + matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.empty()); + + // Re-register + tree.registerSubscriber("player:*:position", "sub1"); + matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub1"); + } + + SECTION("Unregister exact pattern doesn't affect wildcard") { + tree.registerSubscriber("player:001:position", "exact"); + tree.registerSubscriber("player:*:position", "wildcard"); + + auto matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 2); + + tree.unregisterSubscriber("player:001:position", "exact"); + + matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "wildcard"); + } +} diff --git a/external/StillHammer/topictree/tests/scenario_06_unregister_all.cpp b/external/StillHammer/topictree/tests/scenario_06_unregister_all.cpp new file mode 100644 index 0000000..0378973 --- /dev/null +++ b/external/StillHammer/topictree/tests/scenario_06_unregister_all.cpp @@ -0,0 +1,102 @@ +#include +#include +#include + +TEST_CASE("Scenario 6: Unregister All Patterns for Subscriber", "[unregister][all]") { + topictree::TopicTree tree; + + SECTION("UnregisterAll removes subscriber from all patterns") { + tree.registerSubscriber("player:*:position", "sub1"); + tree.registerSubscriber("enemy:*:health", "sub1"); + tree.registerSubscriber("game:.*", "sub1"); + tree.registerSubscriber("player:*:position", "sub2"); + + // sub1 matches on player pattern + auto matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 2); + REQUIRE(std::find(matches.begin(), matches.end(), "sub1") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub2") != matches.end()); + + // UnregisterAll for sub1 + tree.unregisterSubscriberAll("sub1"); + + // Only sub2 should match now + matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub2"); + + // sub1 removed from enemy pattern too + matches = tree.findSubscribers("enemy:boss:health"); + REQUIRE(matches.empty()); + + // sub1 removed from game pattern too + matches = tree.findSubscribers("game:level:01"); + REQUIRE(matches.empty()); + } + + SECTION("UnregisterAll doesn't affect other subscribers") { + tree.registerSubscriber("player:*:position", "sub1"); + tree.registerSubscriber("player:*:position", "sub2"); + tree.registerSubscriber("player:*:position", "sub3"); + + auto matches = tree.findSubscribers("player:123:position"); + REQUIRE(matches.size() == 3); + + tree.unregisterSubscriberAll("sub2"); + + matches = tree.findSubscribers("player:123:position"); + REQUIRE(matches.size() == 2); + REQUIRE(std::find(matches.begin(), matches.end(), "sub1") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub3") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub2") == matches.end()); + } + + SECTION("UnregisterAll on non-existent subscriber does nothing") { + tree.registerSubscriber("player:*:position", "sub1"); + + auto matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 1); + + // Try to unregister non-existent subscriber + tree.unregisterSubscriberAll("non_existent"); + + // Original subscriber still works + matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub1"); + } + + SECTION("Clear removes everything") { + tree.registerSubscriber("player:*:position", "sub1"); + tree.registerSubscriber("enemy:*:health", "sub2"); + tree.registerSubscriber("game:.*", "sub3"); + + auto matches = tree.findSubscribers("player:001:position"); + REQUIRE(!matches.empty()); + + tree.clear(); + + matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.empty()); + + matches = tree.findSubscribers("enemy:001:health"); + REQUIRE(matches.empty()); + + matches = tree.findSubscribers("game:anything"); + REQUIRE(matches.empty()); + } + + SECTION("Tree can be reused after clear") { + tree.registerSubscriber("player:*:position", "sub1"); + tree.clear(); + + tree.registerSubscriber("enemy:*:health", "sub2"); + + auto matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.empty()); + + matches = tree.findSubscribers("enemy:001:health"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub2"); + } +} diff --git a/external/StillHammer/topictree/tests/scenario_07_deep_hierarchies.cpp b/external/StillHammer/topictree/tests/scenario_07_deep_hierarchies.cpp new file mode 100644 index 0000000..fe018b3 --- /dev/null +++ b/external/StillHammer/topictree/tests/scenario_07_deep_hierarchies.cpp @@ -0,0 +1,106 @@ +#include +#include +#include + +TEST_CASE("Scenario 7: Deep Topic Hierarchies", "[deep][hierarchy]") { + topictree::TopicTree tree; + + SECTION("Deep topic with multiple wildcards") { + tree.registerSubscriber("game:world:region:zone:*:entity:player", "sub1"); + tree.registerSubscriber("game:world:*:zone:*:entity:player", "sub2"); + tree.registerSubscriber("game:.*", "sub3"); + + // All three patterns match + auto matches = tree.findSubscribers("game:world:region:zone:001:entity:player"); + REQUIRE(matches.size() == 3); + REQUIRE(std::find(matches.begin(), matches.end(), "sub1") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub2") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub3") != matches.end()); + + // sub1 doesn't match (wrong region name) + matches = tree.findSubscribers("game:world:north:zone:002:entity:player"); + REQUIRE(matches.size() == 2); + REQUIRE(std::find(matches.begin(), matches.end(), "sub2") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "sub3") != matches.end()); + + // Only sub3 matches (different entity type) + matches = tree.findSubscribers("game:world:region:area:001:entity:npc"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub3"); + } + + SECTION("Very deep hierarchy (15+ levels)") { + tree.registerSubscriber("a:b:c:d:e:f:g:h:i:j:k:l:m:n:o", "exact"); + tree.registerSubscriber("a:b:c:d:e:f:g:h:*:j:k:l:m:n:o", "wildcard"); + tree.registerSubscriber("a:b:c:.*", "multi"); + + auto matches = tree.findSubscribers("a:b:c:d:e:f:g:h:i:j:k:l:m:n:o"); + REQUIRE(matches.size() == 3); + REQUIRE(std::find(matches.begin(), matches.end(), "exact") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "wildcard") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "multi") != matches.end()); + + // Different middle segment + matches = tree.findSubscribers("a:b:c:d:e:f:g:h:X:j:k:l:m:n:o"); + REQUIRE(matches.size() == 2); + REQUIRE(std::find(matches.begin(), matches.end(), "wildcard") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "multi") != matches.end()); + } + + SECTION("Nested wildcards at multiple depths") { + tree.registerSubscriber("*:*:*:*:end", "all_wildcards"); + tree.registerSubscriber("a:*:c:*:end", "partial_wildcards"); + tree.registerSubscriber("a:b:c:d:end", "exact"); + + auto matches = tree.findSubscribers("a:b:c:d:end"); + REQUIRE(matches.size() == 3); + + matches = tree.findSubscribers("a:X:c:Y:end"); + REQUIRE(matches.size() == 2); + REQUIRE(std::find(matches.begin(), matches.end(), "all_wildcards") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "partial_wildcards") != matches.end()); + + matches = tree.findSubscribers("X:Y:Z:W:end"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "all_wildcards"); + } + + SECTION("Multi-level wildcard at various depths") { + tree.registerSubscriber("level1:.*", "depth1"); + tree.registerSubscriber("level1:level2:.*", "depth2"); + tree.registerSubscriber("level1:level2:level3:.*", "depth3"); + + auto matches = tree.findSubscribers("level1:anything"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "depth1"); + + matches = tree.findSubscribers("level1:level2:anything"); + REQUIRE(matches.size() == 2); + REQUIRE(std::find(matches.begin(), matches.end(), "depth1") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "depth2") != matches.end()); + + matches = tree.findSubscribers("level1:level2:level3:anything"); + REQUIRE(matches.size() == 3); + REQUIRE(std::find(matches.begin(), matches.end(), "depth1") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "depth2") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "depth3") != matches.end()); + + matches = tree.findSubscribers("level1:level2:level3:level4:level5"); + REQUIRE(matches.size() == 3); + } + + SECTION("Complex real-world scenario") { + // Game world hierarchy + tree.registerSubscriber("mmo:server:*:world:*:zone:*:entity:player:*:status", "player_status"); + tree.registerSubscriber("mmo:server:*:world:*:.*", "world_events"); + tree.registerSubscriber("mmo:.*", "all_mmo"); + + auto matches = tree.findSubscribers("mmo:server:eu1:world:azeroth:zone:elwynn:entity:player:123:status"); + REQUIRE(matches.size() == 3); + + matches = tree.findSubscribers("mmo:server:us1:world:kalimdor:zone:barrens:entity:npc:456:spawn"); + REQUIRE(matches.size() == 2); // world_events and all_mmo + REQUIRE(std::find(matches.begin(), matches.end(), "world_events") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "all_mmo") != matches.end()); + } +} diff --git a/external/StillHammer/topictree/tests/scenario_08_performance.cpp b/external/StillHammer/topictree/tests/scenario_08_performance.cpp new file mode 100644 index 0000000..f570712 --- /dev/null +++ b/external/StillHammer/topictree/tests/scenario_08_performance.cpp @@ -0,0 +1,193 @@ +#include +#include +#include +#include +#include +#include + +// Helper to generate random topics +std::string generateRandomTopic(std::mt19937& rng, int segments) { + std::uniform_int_distribution<> dist(1, 999); + std::ostringstream oss; + + const char* prefixes[] = {"player", "enemy", "npc", "item", "quest", "world"}; + const char* middles[] = {"health", "position", "status", "stats", "inventory"}; + + oss << prefixes[dist(rng) % 6]; + for (int i = 1; i < segments - 1; ++i) { + oss << ":" << dist(rng); + } + oss << ":" << middles[dist(rng) % 5]; + + return oss.str(); +} + +// Helper to generate wildcard pattern +std::string generateWildcardPattern(std::mt19937& rng, int segments, bool useMultiLevel) { + std::uniform_int_distribution<> dist(0, 10); + std::ostringstream oss; + + const char* prefixes[] = {"player", "enemy", "npc", "item", "quest", "world"}; + + oss << prefixes[dist(rng) % 6]; + + if (useMultiLevel && dist(rng) < 5) { + oss << ":.*"; + return oss.str(); + } + + for (int i = 1; i < segments; ++i) { + if (dist(rng) < 3) { + oss << ":*"; + } else { + oss << ":" << dist(rng); + } + } + + return oss.str(); +} + +TEST_CASE("Scenario 8: Performance Test", "[performance][benchmark]") { + topictree::TopicTree tree; + std::mt19937 rng(42); // Fixed seed for reproducibility + + SECTION("Baseline: Register 1000 patterns") { + for (int i = 0; i < 1000; ++i) { + std::string pattern = generateWildcardPattern(rng, 3, i % 5 == 0); + tree.registerSubscriber(pattern, "sub_" + std::to_string(i)); + } + + REQUIRE(tree.subscriberCount() >= 1000); + } + + SECTION("Lookup performance with 1000 patterns") { + // Register 1000 diverse patterns + for (int i = 0; i < 1000; ++i) { + std::string pattern = generateWildcardPattern(rng, 3, i % 5 == 0); + tree.registerSubscriber(pattern, "sub_" + std::to_string(i)); + } + + // Generate test topics + std::vector testTopics; + testTopics.reserve(1000); + for (int i = 0; i < 1000; ++i) { + testTopics.push_back(generateRandomTopic(rng, 3)); + } + + // Measure lookup time + auto start = std::chrono::high_resolution_clock::now(); + + for (const auto& topic : testTopics) { + auto matches = tree.findSubscribers(topic); + (void)matches; // Prevent optimization + } + + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast(end - start); + + double avgMicroseconds = static_cast(duration.count()) / testTopics.size(); + + INFO("Average lookup time: " << avgMicroseconds << " μs"); + INFO("Total lookup time for 1000 topics: " << duration.count() / 1000.0 << " ms"); + + // Success criteria: Average lookup < 1000 μs (1 ms) + REQUIRE(avgMicroseconds < 1000.0); + } + + SECTION("High subscriber density") { + // 100 subscribers on same pattern + for (int i = 0; i < 100; ++i) { + tree.registerSubscriber("player:*:position", "sub_" + std::to_string(i)); + } + + auto matches = tree.findSubscribers("player:123:position"); + REQUIRE(matches.size() == 100); + } + + SECTION("Deep topic performance (10 levels)") { + tree.registerSubscriber("a:b:c:d:e:f:g:h:i:j", "exact"); + tree.registerSubscriber("a:*:c:*:e:*:g:*:i:*", "wildcards"); + tree.registerSubscriber("a:b:.*", "multi"); + + std::string deepTopic = "a:b:c:d:e:f:g:h:i:j"; + + auto start = std::chrono::high_resolution_clock::now(); + + for (int i = 0; i < 10000; ++i) { + auto matches = tree.findSubscribers(deepTopic); + (void)matches; + } + + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast(end - start); + + double avgMicroseconds = static_cast(duration.count()) / 10000.0; + + INFO("Deep topic average lookup: " << avgMicroseconds << " μs"); + REQUIRE(avgMicroseconds < 100.0); // Even faster for repeated lookups + } + + SECTION("Register/unregister performance") { + std::vector patterns; + for (int i = 0; i < 1000; ++i) { + patterns.push_back(generateWildcardPattern(rng, 3, false)); + } + + // Measure registration time + auto start = std::chrono::high_resolution_clock::now(); + + for (const auto& pattern : patterns) { + tree.registerSubscriber(pattern, "sub"); + } + + auto regEnd = std::chrono::high_resolution_clock::now(); + + // Measure unregistration time + for (const auto& pattern : patterns) { + tree.unregisterSubscriber(pattern, "sub"); + } + + auto unregEnd = std::chrono::high_resolution_clock::now(); + + auto regDuration = std::chrono::duration_cast(regEnd - start); + auto unregDuration = std::chrono::duration_cast(unregEnd - regEnd); + + INFO("Registration time: " << regDuration.count() / 1000.0 << " ms"); + INFO("Unregistration time: " << unregDuration.count() / 1000.0 << " ms"); + + // Should be reasonably fast + REQUIRE(regDuration.count() < 100000); // < 100ms for 1000 registrations + REQUIRE(unregDuration.count() < 100000); // < 100ms for 1000 unregistrations + } + + SECTION("Scalability test: 10000 patterns") { + // Register 10000 patterns + for (int i = 0; i < 10000; ++i) { + std::string pattern = generateWildcardPattern(rng, 3, i % 10 == 0); + tree.registerSubscriber(pattern, "sub_" + std::to_string(i)); + } + + // Test lookup still fast + std::vector testTopics; + for (int i = 0; i < 100; ++i) { + testTopics.push_back(generateRandomTopic(rng, 3)); + } + + auto start = std::chrono::high_resolution_clock::now(); + + for (const auto& topic : testTopics) { + auto matches = tree.findSubscribers(topic); + (void)matches; + } + + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast(end - start); + + double avgMicroseconds = static_cast(duration.count()) / testTopics.size(); + + INFO("Average lookup with 10k patterns: " << avgMicroseconds << " μs"); + + // Should still be under 5ms average even with 10k patterns + REQUIRE(avgMicroseconds < 5000.0); + } +} diff --git a/external/StillHammer/topictree/tests/scenario_09_threadsafety.cpp b/external/StillHammer/topictree/tests/scenario_09_threadsafety.cpp new file mode 100644 index 0000000..3768f78 --- /dev/null +++ b/external/StillHammer/topictree/tests/scenario_09_threadsafety.cpp @@ -0,0 +1,291 @@ +#include +#include +#include +#include +#include +#include + +TEST_CASE("Scenario 9: Thread-Safety - Concurrent Access", "[threadsafety][concurrent]") { + topictree::TopicTree tree; + + SECTION("Concurrent reads are safe") { + // Pre-populate tree + tree.registerSubscriber("player:*:position", "sub1"); + tree.registerSubscriber("player:.*", "sub2"); + tree.registerSubscriber("enemy:*:health", "sub3"); + + std::atomic errorCount{0}; + std::atomic totalReads{0}; + + auto readerThread = [&]() { + for (int i = 0; i < 1000; ++i) { + auto matches = tree.findSubscribers("player:123:position"); + if (matches.size() != 2) { + errorCount++; + } + totalReads++; + } + }; + + // Launch 10 reader threads + std::vector threads; + for (int i = 0; i < 10; ++i) { + threads.emplace_back(readerThread); + } + + for (auto& t : threads) { + t.join(); + } + + REQUIRE(errorCount == 0); + REQUIRE(totalReads == 10000); + } + + SECTION("Concurrent writes are safe") { + std::atomic successfulRegistrations{0}; + + auto writerThread = [&](int threadId) { + for (int i = 0; i < 100; ++i) { + std::string pattern = "thread:" + std::to_string(threadId) + ":*"; + std::string subscriber = "sub_" + std::to_string(threadId) + "_" + std::to_string(i); + tree.registerSubscriber(pattern, subscriber); + successfulRegistrations++; + } + }; + + std::vector threads; + for (int i = 0; i < 10; ++i) { + threads.emplace_back(writerThread, i); + } + + for (auto& t : threads) { + t.join(); + } + + REQUIRE(successfulRegistrations == 1000); + + // Verify all patterns work + for (int i = 0; i < 10; ++i) { + std::string topic = "thread:" + std::to_string(i) + ":test"; + auto matches = tree.findSubscribers(topic); + REQUIRE(matches.size() == 100); // Each thread registered 100 subscribers + } + } + + SECTION("Concurrent read/write mix") { + std::atomic running{true}; + std::atomic readErrors{0}; + std::atomic totalReads{0}; + std::atomic totalWrites{0}; + + // Writer thread: continuously add patterns + auto writer = [&]() { + int counter = 0; + while (running) { + std::string pattern = "dynamic:*:" + std::to_string(counter % 100); + tree.registerSubscriber(pattern, "writer"); + totalWrites++; + counter++; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + }; + + // Reader threads: continuously read + auto reader = [&]() { + while (running) { + auto matches = tree.findSubscribers("dynamic:123:50"); + // Just ensure no crashes, results may vary due to concurrent writes + totalReads++; + } + }; + + std::vector threads; + threads.emplace_back(writer); + + for (int i = 0; i < 5; ++i) { + threads.emplace_back(reader); + } + + // Run for 2 seconds + std::this_thread::sleep_for(std::chrono::seconds(2)); + running = false; + + for (auto& t : threads) { + t.join(); + } + + INFO("Total reads: " << totalReads); + INFO("Total writes: " << totalWrites); + + REQUIRE(totalReads > 0); + REQUIRE(totalWrites > 0); + REQUIRE(readErrors == 0); + } + + SECTION("Concurrent register/unregister on same pattern") { + std::atomic running{true}; + std::atomic totalOps{0}; + + auto worker = [&](int threadId) { + while (running) { + std::string subscriber = "sub_" + std::to_string(threadId); + tree.registerSubscriber("shared:*:pattern", subscriber); + totalOps++; + + std::this_thread::sleep_for(std::chrono::microseconds(100)); + + tree.unregisterSubscriber("shared:*:pattern", subscriber); + totalOps++; + } + }; + + std::vector threads; + for (int i = 0; i < 8; ++i) { + threads.emplace_back(worker, i); + } + + // Run for 1 second + std::this_thread::sleep_for(std::chrono::seconds(1)); + running = false; + + for (auto& t : threads) { + t.join(); + } + + INFO("Total operations: " << totalOps); + REQUIRE(totalOps > 0); + + // After all threads finish, tree should be in consistent state + auto matches = tree.findSubscribers("shared:test:pattern"); + // Should be empty or contain only subscribers that registered last + REQUIRE(matches.size() < 8); + } + + SECTION("UnregisterAll under concurrent access") { + // Pre-populate + for (int i = 0; i < 100; ++i) { + tree.registerSubscriber("pattern:" + std::to_string(i), "target"); + } + + std::atomic running{true}; + + // Reader thread + auto reader = [&]() { + while (running) { + auto matches = tree.findSubscribers("pattern:50"); + (void)matches; + } + }; + + // UnregisterAll thread + auto unregisterAllThread = [&]() { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + tree.unregisterSubscriberAll("target"); + running = false; + }; + + std::thread t1(reader); + std::thread t2(unregisterAllThread); + + t1.join(); + t2.join(); + + // Verify all removed + for (int i = 0; i < 100; ++i) { + auto matches = tree.findSubscribers("pattern:" + std::to_string(i)); + REQUIRE(matches.empty()); + } + } + + SECTION("Clear under concurrent access") { + std::atomic running{true}; + + // Writer threads + auto writer = [&]() { + int counter = 0; + while (running) { + tree.registerSubscriber("test:*:pattern", "sub_" + std::to_string(counter++)); + std::this_thread::sleep_for(std::chrono::microseconds(500)); + } + }; + + // Clear thread + auto clearer = [&]() { + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + tree.clear(); + running = false; + }; + + std::thread t1(writer); + std::thread t2(clearer); + + t1.join(); + t2.join(); + + // After clear, should be empty + auto matches = tree.findSubscribers("test:anything:pattern"); + REQUIRE(matches.empty()); + } + + SECTION("Stress test: All operations mixed") { + std::atomic running{true}; + std::atomic totalOps{0}; + + // Continuous registration + auto registerWorker = [&](int id) { + while (running) { + tree.registerSubscriber("stress:" + std::to_string(id % 10) + ":*", + "reg_" + std::to_string(id)); + totalOps++; + } + }; + + // Continuous unregistration + auto unregisterWorker = [&](int id) { + while (running) { + tree.unregisterSubscriber("stress:" + std::to_string(id % 10) + ":*", + "reg_" + std::to_string(id)); + totalOps++; + } + }; + + // Continuous reading + auto readWorker = [&]() { + while (running) { + for (int i = 0; i < 10; ++i) { + auto matches = tree.findSubscribers("stress:" + std::to_string(i) + ":test"); + (void)matches; + } + totalOps++; + } + }; + + std::vector threads; + + // 3 register threads + for (int i = 0; i < 3; ++i) { + threads.emplace_back(registerWorker, i); + } + + // 2 unregister threads + for (int i = 10; i < 12; ++i) { + threads.emplace_back(unregisterWorker, i); + } + + // 5 reader threads + for (int i = 0; i < 5; ++i) { + threads.emplace_back(readWorker); + } + + // Run for 3 seconds + std::this_thread::sleep_for(std::chrono::seconds(3)); + running = false; + + for (auto& t : threads) { + t.join(); + } + + INFO("Total operations: " << totalOps); + REQUIRE(totalOps > 1000); // Should have done many operations + } +} diff --git a/external/StillHammer/topictree/tests/scenario_10_edge_cases.cpp b/external/StillHammer/topictree/tests/scenario_10_edge_cases.cpp new file mode 100644 index 0000000..9467e01 --- /dev/null +++ b/external/StillHammer/topictree/tests/scenario_10_edge_cases.cpp @@ -0,0 +1,242 @@ +#include +#include +#include + +TEST_CASE("Scenario 10: Edge Cases & Stress Test", "[edge][stress]") { + topictree::TopicTree tree; + + SECTION("Empty topic string") { + tree.registerSubscriber("player:*:position", "sub1"); + + auto matches = tree.findSubscribers(""); + REQUIRE(matches.empty()); + } + + SECTION("Single segment topic") { + tree.registerSubscriber("player", "exact"); + tree.registerSubscriber("*", "wildcard"); + + auto matches = tree.findSubscribers("player"); + REQUIRE(matches.size() == 2); + REQUIRE(std::find(matches.begin(), matches.end(), "exact") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "wildcard") != matches.end()); + + matches = tree.findSubscribers("enemy"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "wildcard"); + } + + SECTION("Topics with empty segments (multiple colons)") { + tree.registerSubscriber("a:::b", "empty_segments"); + + // Same pattern with empty segments + auto matches = tree.findSubscribers("a:::b"); + // Behavior may vary - empty segments might be skipped + // Just ensure no crash - test passes if we got here without crashing + REQUIRE(true); // Test passes if no crash occurs + } + + SECTION("Pattern with only wildcard") { + tree.registerSubscriber("*", "single_wildcard"); + + auto matches = tree.findSubscribers("anything"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "single_wildcard"); + + // Doesn't match multi-segment topics + matches = tree.findSubscribers("any:thing"); + REQUIRE(matches.empty()); + } + + SECTION("Pattern with only multi-level wildcard") { + tree.registerSubscriber(".*", "multi_wildcard"); + + // Should match everything + auto matches = tree.findSubscribers("anything"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "multi_wildcard"); + + matches = tree.findSubscribers("any:thing:here"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "multi_wildcard"); + } + + SECTION("Pattern with all wildcards") { + tree.registerSubscriber("*:*:*", "all_single"); + + auto matches = tree.findSubscribers("a:b:c"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "all_single"); + + // Wrong depth doesn't match + matches = tree.findSubscribers("a:b"); + REQUIRE(matches.empty()); + + matches = tree.findSubscribers("a:b:c:d"); + REQUIRE(matches.empty()); + } + + SECTION("High subscriber density - 100 subscribers on same pattern") { + for (int i = 0; i < 100; ++i) { + tree.registerSubscriber("player:*:position", "sub_" + std::to_string(i)); + } + + auto matches = tree.findSubscribers("player:123:position"); + REQUIRE(matches.size() == 100); + + // Verify all subscribers present + for (int i = 0; i < 100; ++i) { + std::string expected = "sub_" + std::to_string(i); + REQUIRE(std::find(matches.begin(), matches.end(), expected) != matches.end()); + } + } + + SECTION("One subscriber on many patterns") { + for (int i = 0; i < 100; ++i) { + tree.registerSubscriber("pattern:" + std::to_string(i), "mega_sub"); + } + + // Each pattern should work + for (int i = 0; i < 100; ++i) { + auto matches = tree.findSubscribers("pattern:" + std::to_string(i)); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "mega_sub"); + } + + // UnregisterAll should remove from all + tree.unregisterSubscriberAll("mega_sub"); + + for (int i = 0; i < 100; ++i) { + auto matches = tree.findSubscribers("pattern:" + std::to_string(i)); + REQUIRE(matches.empty()); + } + } + + SECTION("Clear and reuse lifecycle") { + tree.registerSubscriber("player:*:position", "sub1"); + + auto matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.size() == 1); + + tree.clear(); + + matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.empty()); + + // Reuse after clear + tree.registerSubscriber("enemy:*:health", "sub2"); + + matches = tree.findSubscribers("player:001:position"); + REQUIRE(matches.empty()); + + matches = tree.findSubscribers("enemy:001:health"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub2"); + } + + SECTION("Register, unregister, re-register same pattern") { + tree.registerSubscriber("test:*:pattern", "sub1"); + + auto matches = tree.findSubscribers("test:123:pattern"); + REQUIRE(matches.size() == 1); + + tree.unregisterSubscriber("test:*:pattern", "sub1"); + matches = tree.findSubscribers("test:123:pattern"); + REQUIRE(matches.empty()); + + // Re-register + tree.registerSubscriber("test:*:pattern", "sub1"); + matches = tree.findSubscribers("test:123:pattern"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub1"); + } + + SECTION("Extremely deep hierarchy (20+ levels)") { + std::string deepPattern = "l0:l1:l2:l3:l4:l5:l6:l7:l8:l9:l10:l11:l12:l13:l14:l15:l16:l17:l18:l19"; + std::string deepTopic = "l0:l1:l2:l3:l4:l5:l6:l7:l8:l9:l10:l11:l12:l13:l14:l15:l16:l17:l18:l19"; + + tree.registerSubscriber(deepPattern, "deep_sub"); + + auto matches = tree.findSubscribers(deepTopic); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "deep_sub"); + } + + SECTION("Deep pattern with wildcards at various levels") { + tree.registerSubscriber("a:*:c:*:e:*:g:*:i:*:k:*:m:*:o:*:q:*:s:*", "pattern20"); + + auto matches = tree.findSubscribers("a:1:c:2:e:3:g:4:i:5:k:6:m:7:o:8:q:9:s:10"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "pattern20"); + + // Wrong segment doesn't match + matches = tree.findSubscribers("a:1:X:2:e:3:g:4:i:5:k:6:m:7:o:8:q:9:s:10"); + REQUIRE(matches.empty()); + } + + SECTION("Mixed depth multi-level wildcards") { + tree.registerSubscriber("a:.*", "depth1"); + tree.registerSubscriber("a:b:.*", "depth2"); + tree.registerSubscriber("a:b:c:.*", "depth3"); + tree.registerSubscriber("a:b:c:d:e:f:.*", "depth6"); + + auto matches = tree.findSubscribers("a:b:c:d:e:f:g:h:i:j"); + + // All four should match + REQUIRE(matches.size() == 4); + REQUIRE(std::find(matches.begin(), matches.end(), "depth1") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "depth2") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "depth3") != matches.end()); + REQUIRE(std::find(matches.begin(), matches.end(), "depth6") != matches.end()); + } + + SECTION("Special characters in topic segments") { + // TopicTree uses : as separator, so these should work as normal segments + tree.registerSubscriber("player-001:*:position_x", "special"); + + auto matches = tree.findSubscribers("player-001:entity_123:position_x"); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "special"); + } + + SECTION("Very long segment names") { + std::string longSegment(1000, 'x'); + std::string pattern = "player:" + longSegment + ":position"; + std::string topic = "player:" + longSegment + ":position"; + + tree.registerSubscriber(pattern, "long_sub"); + + auto matches = tree.findSubscribers(topic); + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "long_sub"); + } + + SECTION("Subscriber count accuracy") { + REQUIRE(tree.subscriberCount() == 0); + + tree.registerSubscriber("pattern1", "sub1"); + REQUIRE(tree.subscriberCount() == 1); + + tree.registerSubscriber("pattern2", "sub1"); + REQUIRE(tree.subscriberCount() == 2); // Same subscriber, different pattern + + tree.registerSubscriber("pattern1", "sub2"); + REQUIRE(tree.subscriberCount() == 3); + + tree.unregisterSubscriber("pattern1", "sub1"); + REQUIRE(tree.subscriberCount() == 2); + + tree.clear(); + REQUIRE(tree.subscriberCount() == 0); + } + + SECTION("Duplicate registration of same subscriber on same pattern") { + tree.registerSubscriber("test:*:pattern", "sub1"); + tree.registerSubscriber("test:*:pattern", "sub1"); // Duplicate + + auto matches = tree.findSubscribers("test:123:pattern"); + // Should only appear once (unordered_set prevents duplicates) + REQUIRE(matches.size() == 1); + REQUIRE(matches[0] == "sub1"); + } +} diff --git a/include/grove/IntraIOManager.h b/include/grove/IntraIOManager.h index 402c968..8b5c414 100644 --- a/include/grove/IntraIOManager.h +++ b/include/grove/IntraIOManager.h @@ -5,11 +5,11 @@ #include #include #include -#include #include #include #include "IIO.h" +#include using json = nlohmann::json; @@ -47,19 +47,27 @@ private: // Registry of IntraIO instances std::unordered_map> instances; - // Subscription routing table - struct RouteEntry { + // Subscription info for each instance + struct SubscriptionInfo { std::string instanceId; - std::regex pattern; - std::string originalPattern; bool isLowFreq; }; - std::vector routingTable; + + // Ultra-fast topic routing using TopicTree + topictree::TopicTree topicTree; // Maps patterns to instanceIds + + // Track subscription info per instance (for management) + std::unordered_map> instancePatterns; // instanceId -> patterns + std::unordered_map subscriptionFreqMap; // pattern -> isLowFreq // Statistics mutable std::atomic totalRoutedMessages{0}; mutable std::atomic totalRoutes{0}; + // Batched logging (pour éviter spam) + static constexpr size_t LOG_BATCH_SIZE = 100; + mutable std::atomic messagesSinceLastLog{0}; + public: IntraIOManager(); ~IntraIOManager(); diff --git a/src/IntraIOManager.cpp b/src/IntraIOManager.cpp index 2a09444..01fd88f 100644 --- a/src/IntraIOManager.cpp +++ b/src/IntraIOManager.cpp @@ -35,7 +35,9 @@ IntraIOManager::~IntraIOManager() { logger->info(" Active instances: {}", stats["active_instances"]); instances.clear(); - routingTable.clear(); + topicTree.clear(); + instancePatterns.clear(); + subscriptionFreqMap.clear(); logger->info("🌐🔗 IntraIOManager destroyed"); } @@ -75,14 +77,11 @@ void IntraIOManager::removeInstance(const std::string& instanceId) { return; } - // Remove all routing entries for this instance - routingTable.erase( - std::remove_if(routingTable.begin(), routingTable.end(), - [&instanceId](const RouteEntry& entry) { - return entry.instanceId == instanceId; - }), - routingTable.end() - ); + // Remove all subscriptions for this instance from TopicTree + topicTree.unregisterSubscriberAll(instanceId); + + // Clean up tracking data + instancePatterns.erase(instanceId); instances.erase(it); @@ -104,91 +103,78 @@ void IntraIOManager::routeMessage(const std::string& sourceId, const std::string std::lock_guard lock(managerMutex); totalRoutedMessages++; + messagesSinceLastLog++; size_t deliveredCount = 0; - logger->info("📨 Routing message: {} → '{}'", sourceId, topic); + // Batched logging - log tous les LOG_BATCH_SIZE messages + bool shouldLog = (messagesSinceLastLog % LOG_BATCH_SIZE == 0); - // Find all matching routes - for (const auto& route : routingTable) { + if (shouldLog) { + logger->info("📊 Routing stats: {} total messages routed", totalRoutedMessages.load()); + } + + logger->trace("📨 Routing message: {} → '{}'", sourceId, topic); + + // Find all matching subscribers - O(k) where k = topic depth 🚀 + auto subscribers = topicTree.findSubscribers(topic); + + logger->trace(" 🔍 Found {} matching subscriber(s) for topic '{}'", subscribers.size(), topic); + + for (const auto& subscriberId : subscribers) { // Don't deliver back to sender - if (route.instanceId == sourceId) { + if (subscriberId == sourceId) { + logger->debug(" ⏭️ Skipping sender '{}'", subscriberId); continue; } - // Check pattern match - logger->debug(" 🔍 Testing pattern '{}' against topic '{}'", route.originalPattern, topic); - if (std::regex_match(topic, route.pattern)) { - auto targetInstance = instances.find(route.instanceId); - if (targetInstance != instances.end()) { - // Copy JSON data for each recipient (JSON is copyable!) - json dataCopy = messageData; + 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("message", dataCopy); + // Recreate DataNode from JSON copy + auto dataNode = std::make_unique("message", dataCopy); - // Deliver to target instance's queue - targetInstance->second->deliverMessage(topic, std::move(dataNode), route.isLowFreq); - deliveredCount++; - logger->info(" ↪️ Delivered to '{}' ({})", - route.instanceId, - route.isLowFreq ? "low-freq" : "high-freq"); - // Continue to next route (now we can deliver to multiple subscribers!) - } else { - logger->warn("⚠️ Target instance '{}' not found for route", route.instanceId); + // Get frequency info (default to false if not found) + bool isLowFreq = false; + for (const auto& pattern : instancePatterns[subscriberId]) { + auto it = subscriptionFreqMap.find(pattern); + if (it != subscriptionFreqMap.end()) { + isLowFreq = it->second; + 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"); } else { - logger->debug(" ❌ Pattern '{}' did not match topic '{}'", route.originalPattern, topic); + logger->warn("⚠️ Target instance '{}' not found", subscriberId); } } - if (deliveredCount > 0) { - logger->debug("📤 Message '{}' delivered to {} instances", topic, deliveredCount); - } else { - logger->trace("📪 No subscribers for topic '{}'", topic); - } + // Trace-only logging pour éviter spam + logger->trace("📤 Message '{}' delivered to {} instances", topic, deliveredCount); } void IntraIOManager::registerSubscription(const std::string& instanceId, const std::string& pattern, bool isLowFreq) { std::lock_guard lock(managerMutex); try { - // Convert topic pattern to regex - use same logic as IntraIO - std::string regexPattern = pattern; + // Register in TopicTree - O(k) where k = pattern depth + topicTree.registerSubscriber(pattern, instanceId); - // Escape special regex characters except our wildcards (: is NOT special) - std::string specialChars = ".^$+()[]{}|\\"; - for (char c : specialChars) { - std::string from = std::string(1, c); - std::string to = "\\" + from; + // Track pattern for management + instancePatterns[instanceId].push_back(pattern); + subscriptionFreqMap[pattern] = isLowFreq; - size_t pos = 0; - while ((pos = regexPattern.find(from, pos)) != std::string::npos) { - regexPattern.replace(pos, 1, to); - pos += 2; - } - } - - // Convert * to regex equivalent - size_t pos2 = 0; - while ((pos2 = regexPattern.find("*", pos2)) != std::string::npos) { - regexPattern.replace(pos2, 1, ".*"); - pos2 += 2; - } - - logger->info("🔍 Pattern conversion: '{}' → '{}'", pattern, regexPattern); - - RouteEntry entry; - entry.instanceId = instanceId; - entry.pattern = std::regex(regexPattern); - entry.originalPattern = pattern; - entry.isLowFreq = isLowFreq; - - routingTable.push_back(entry); totalRoutes++; logger->info("📋 Registered subscription: '{}' → '{}' ({})", instanceId, pattern, isLowFreq ? "low-freq" : "high-freq"); - logger->debug("📊 Total routes: {}", routingTable.size()); } catch (const std::exception& e) { logger->error("❌ Failed to register subscription '{}' for '{}': {}", @@ -200,28 +186,25 @@ void IntraIOManager::registerSubscription(const std::string& instanceId, const s void IntraIOManager::unregisterSubscription(const std::string& instanceId, const std::string& pattern) { std::lock_guard lock(managerMutex); - auto oldSize = routingTable.size(); - routingTable.erase( - std::remove_if(routingTable.begin(), routingTable.end(), - [&instanceId, &pattern](const RouteEntry& entry) { - return entry.instanceId == instanceId && entry.originalPattern == pattern; - }), - routingTable.end() - ); + // Remove from TopicTree + topicTree.unregisterSubscriber(pattern, instanceId); - auto removed = oldSize - routingTable.size(); - if (removed > 0) { - logger->info("🗑️ Unregistered {} subscription(s): '{}' → '{}'", removed, instanceId, pattern); - } else { - logger->warn("⚠️ Subscription not found for removal: '{}' → '{}'", instanceId, pattern); - } + // Remove from tracking + auto& patterns = instancePatterns[instanceId]; + patterns.erase(std::remove(patterns.begin(), patterns.end(), pattern), patterns.end()); + + subscriptionFreqMap.erase(pattern); + + logger->info("🗑️ Unregistered subscription: '{}' → '{}'", instanceId, pattern); } void IntraIOManager::clearAllRoutes() { std::lock_guard lock(managerMutex); - auto clearedCount = routingTable.size(); - routingTable.clear(); + auto clearedCount = topicTree.subscriberCount(); + topicTree.clear(); + instancePatterns.clear(); + subscriptionFreqMap.clear(); logger->info("🧹 Cleared {} routing entries", clearedCount); } @@ -248,7 +231,7 @@ json IntraIOManager::getRoutingStats() const { stats["total_routed_messages"] = totalRoutedMessages.load(); stats["total_routes"] = totalRoutes.load(); stats["active_instances"] = instances.size(); - stats["routing_entries"] = routingTable.size(); + stats["routing_entries"] = topicTree.subscriberCount(); // Instance details json instanceDetails = json::object();