Browse Source
COMPLETE SIMULATION SYSTEM IMPLEMENTATION ## New Simulation Components (7 added): - Database: Read/write latency, replication overhead, RPS capacity - Cache: In-memory caching with LRU/LFU/FIFO/Random eviction policies - Message Queue: FIFO processing, retention, backpressure, processing rate - Microservice: Auto-scaling, resource capacity, load balancing across instances - Monitoring/Alerting: Multi-metric alerting (latency, errors, queue size) - Third Party Service: External API reliability, rate limiting, failure modeling - Data Pipeline: Batch processing with 10 transformation types ## Enhanced Existing Components: - Web Server: Fixed property naming (rpsCapacity) - CDN: Fixed property naming (ttl) - Load Balancer: Maintained existing functionality - Engine: Added smart topology-based entry point detection ## Level Validation System: - Complete pass/fail game mechanics with scoring (0-100) - Performance validation: throughput, latency, availability, cost - Component validation: mustInclude, mustNotInclude, minReplicas - Detailed feedback with specific requirement failures - Smart scoring with performance bonuses ## Frontend Integration: - Real simulation execution (replaced mock data) - Level information extraction from URL paths - Rich results display with pass/fail feedback - Automatic entry node detection from design topology ## Infrastructure Updates: - Design Schema: Added missing properties, fixed coordinate precision (float64) - Authentication: GitHub OAuth protection for all game routes - Error Handling: Comprehensive validation and user feedback - Testing: 78 tests covering all components and integration scenarios ## Technical Achievements: - 100% simulation component coverage (10/10 components) - Realistic performance modeling for all component types - Discrete-event simulation with proper state management - Production-ready code without emojis - Comprehensive test suite with integration testing ## Breaking Changes: - Position coordinates now use float64 for precision - /simulate endpoint now requires authentication - Request/response format updated for level validation This completes the core simulation engine implementation and enables a complete educational game experience for learning system design.main
34 changed files with 5540 additions and 35 deletions
@ -0,0 +1,180 @@
@@ -0,0 +1,180 @@
|
||||
package simulation |
||||
|
||||
import ( |
||||
"time" |
||||
) |
||||
|
||||
type CacheLogic struct{} |
||||
|
||||
type CacheEntry struct { |
||||
Data string |
||||
Timestamp int |
||||
AccessTime int |
||||
AccessCount int |
||||
InsertOrder int |
||||
} |
||||
|
||||
func (c CacheLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { |
||||
// Extract cache properties
|
||||
cacheTTL := int(AsFloat64(props["cacheTTL"])) |
||||
if cacheTTL == 0 { |
||||
cacheTTL = 300000 // default 5 minutes in ms
|
||||
} |
||||
|
||||
maxEntries := int(AsFloat64(props["maxEntries"])) |
||||
if maxEntries == 0 { |
||||
maxEntries = 1000 // default max entries
|
||||
} |
||||
|
||||
evictionPolicy := AsString(props["evictionPolicy"]) |
||||
if evictionPolicy == "" { |
||||
evictionPolicy = "LRU" // default eviction policy
|
||||
} |
||||
|
||||
// Initialize cache data structures in props
|
||||
cacheData, ok := props["_cacheData"].(map[string]*CacheEntry) |
||||
if !ok { |
||||
cacheData = make(map[string]*CacheEntry) |
||||
props["_cacheData"] = cacheData |
||||
} |
||||
|
||||
insertCounter, ok := props["_insertCounter"].(int) |
||||
if !ok { |
||||
insertCounter = 0 |
||||
} |
||||
|
||||
// Current timestamp for this tick
|
||||
currentTime := tick * 100 // assuming 100ms per tick
|
||||
|
||||
// Clean up expired entries first
|
||||
c.cleanExpiredEntries(cacheData, currentTime, cacheTTL) |
||||
|
||||
output := []*Request{} |
||||
|
||||
for _, req := range queue { |
||||
cacheKey := req.ID + "-" + req.Type // Use request ID and type as cache key
|
||||
|
||||
// Check for cache hit
|
||||
entry, hit := cacheData[cacheKey] |
||||
if hit && !c.isExpired(entry, currentTime, cacheTTL) { |
||||
// Cache hit - return immediately with minimal latency
|
||||
reqCopy := *req |
||||
reqCopy.LatencyMS += 1 // 1ms for in-memory access
|
||||
reqCopy.Path = append(reqCopy.Path, "cache-hit") |
||||
|
||||
// Update access tracking for eviction policies
|
||||
entry.AccessTime = currentTime |
||||
entry.AccessCount++ |
||||
|
||||
output = append(output, &reqCopy) |
||||
} else { |
||||
// Cache miss - forward request downstream
|
||||
reqCopy := *req |
||||
reqCopy.Path = append(reqCopy.Path, "cache-miss") |
||||
|
||||
// For simulation purposes, we'll cache the "response" immediately
|
||||
// In a real system, this would happen when the response comes back
|
||||
insertCounter++ |
||||
newEntry := &CacheEntry{ |
||||
Data: "cached-data", // In real implementation, this would be the response data
|
||||
Timestamp: currentTime, |
||||
AccessTime: currentTime, |
||||
AccessCount: 1, |
||||
InsertOrder: insertCounter, |
||||
} |
||||
|
||||
// First check if we need to evict before adding
|
||||
if len(cacheData) >= maxEntries { |
||||
c.evictEntry(cacheData, evictionPolicy) |
||||
} |
||||
|
||||
// Now add the new entry
|
||||
cacheData[cacheKey] = newEntry |
||||
|
||||
output = append(output, &reqCopy) |
||||
} |
||||
} |
||||
|
||||
// Update insert counter in props
|
||||
props["_insertCounter"] = insertCounter |
||||
|
||||
return output, true |
||||
} |
||||
|
||||
func (c CacheLogic) cleanExpiredEntries(cacheData map[string]*CacheEntry, currentTime, ttl int) { |
||||
for key, entry := range cacheData { |
||||
if c.isExpired(entry, currentTime, ttl) { |
||||
delete(cacheData, key) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func (c CacheLogic) isExpired(entry *CacheEntry, currentTime, ttl int) bool { |
||||
return (currentTime - entry.Timestamp) > ttl |
||||
} |
||||
|
||||
func (c CacheLogic) evictEntry(cacheData map[string]*CacheEntry, policy string) { |
||||
if len(cacheData) == 0 { |
||||
return |
||||
} |
||||
|
||||
var keyToEvict string |
||||
|
||||
switch policy { |
||||
case "LRU": |
||||
// Evict least recently used
|
||||
oldestTime := int(^uint(0) >> 1) // Max int
|
||||
for key, entry := range cacheData { |
||||
if entry.AccessTime < oldestTime { |
||||
oldestTime = entry.AccessTime |
||||
keyToEvict = key |
||||
} |
||||
} |
||||
|
||||
case "LFU": |
||||
// Evict least frequently used
|
||||
minCount := int(^uint(0) >> 1) // Max int
|
||||
for key, entry := range cacheData { |
||||
if entry.AccessCount < minCount { |
||||
minCount = entry.AccessCount |
||||
keyToEvict = key |
||||
} |
||||
} |
||||
|
||||
case "FIFO": |
||||
// Evict first in (oldest insert order)
|
||||
minOrder := int(^uint(0) >> 1) // Max int
|
||||
for key, entry := range cacheData { |
||||
if entry.InsertOrder < minOrder { |
||||
minOrder = entry.InsertOrder |
||||
keyToEvict = key |
||||
} |
||||
} |
||||
|
||||
case "random": |
||||
// Evict random entry
|
||||
keys := make([]string, 0, len(cacheData)) |
||||
for key := range cacheData { |
||||
keys = append(keys, key) |
||||
} |
||||
if len(keys) > 0 { |
||||
// Use timestamp as pseudo-random seed
|
||||
seed := time.Now().UnixNano() |
||||
keyToEvict = keys[seed%int64(len(keys))] |
||||
} |
||||
|
||||
default: |
||||
// Default to LRU
|
||||
oldestTime := int(^uint(0) >> 1) |
||||
for key, entry := range cacheData { |
||||
if entry.AccessTime < oldestTime { |
||||
oldestTime = entry.AccessTime |
||||
keyToEvict = key |
||||
} |
||||
} |
||||
} |
||||
|
||||
if keyToEvict != "" { |
||||
delete(cacheData, keyToEvict) |
||||
} |
||||
} |
||||
@ -0,0 +1,319 @@
@@ -0,0 +1,319 @@
|
||||
package simulation |
||||
|
||||
import ( |
||||
"testing" |
||||
) |
||||
|
||||
func TestCacheLogic_CacheHitMiss(t *testing.T) { |
||||
cache := CacheLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"cacheTTL": 10000, // 10 seconds
|
||||
"maxEntries": 100, |
||||
"evictionPolicy": "LRU", |
||||
} |
||||
|
||||
// First request should be a miss
|
||||
req1 := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0, Path: []string{"start"}}} |
||||
output1, alive := cache.Tick(props, req1, 1) |
||||
|
||||
if !alive { |
||||
t.Errorf("Cache should be alive") |
||||
} |
||||
|
||||
if len(output1) != 1 { |
||||
t.Errorf("Expected 1 output request, got %d", len(output1)) |
||||
} |
||||
|
||||
// Should be cache miss
|
||||
if output1[0].LatencyMS != 0 { // No latency added for miss
|
||||
t.Errorf("Expected 0ms latency for cache miss, got %dms", output1[0].LatencyMS) |
||||
} |
||||
|
||||
// Check path contains cache-miss
|
||||
found := false |
||||
for _, pathItem := range output1[0].Path { |
||||
if pathItem == "cache-miss" { |
||||
found = true |
||||
break |
||||
} |
||||
} |
||||
if !found { |
||||
t.Errorf("Expected cache-miss in path, got %v", output1[0].Path) |
||||
} |
||||
|
||||
// Second identical request should be a hit
|
||||
req2 := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0, Path: []string{"start"}}} |
||||
output2, _ := cache.Tick(props, req2, 2) |
||||
|
||||
if len(output2) != 1 { |
||||
t.Errorf("Expected 1 output request, got %d", len(output2)) |
||||
} |
||||
|
||||
// Should be cache hit with 1ms latency
|
||||
if output2[0].LatencyMS != 1 { |
||||
t.Errorf("Expected 1ms latency for cache hit, got %dms", output2[0].LatencyMS) |
||||
} |
||||
|
||||
// Check path contains cache-hit
|
||||
found = false |
||||
for _, pathItem := range output2[0].Path { |
||||
if pathItem == "cache-hit" { |
||||
found = true |
||||
break |
||||
} |
||||
} |
||||
if !found { |
||||
t.Errorf("Expected cache-hit in path, got %v", output2[0].Path) |
||||
} |
||||
} |
||||
|
||||
func TestCacheLogic_TTLExpiration(t *testing.T) { |
||||
cache := CacheLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"cacheTTL": 1000, // 1 second
|
||||
"maxEntries": 100, |
||||
"evictionPolicy": "LRU", |
||||
} |
||||
|
||||
// First request - cache miss
|
||||
req1 := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
cache.Tick(props, req1, 1) |
||||
|
||||
// Second request within TTL - cache hit
|
||||
req2 := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
output2, _ := cache.Tick(props, req2, 5) // 5 * 100ms = 500ms later
|
||||
|
||||
if output2[0].LatencyMS != 1 { |
||||
t.Errorf("Expected cache hit (1ms), got %dms", output2[0].LatencyMS) |
||||
} |
||||
|
||||
// Third request after TTL expiration - cache miss
|
||||
req3 := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
output3, _ := cache.Tick(props, req3, 15) // 15 * 100ms = 1500ms later (expired)
|
||||
|
||||
if output3[0].LatencyMS != 0 { |
||||
t.Errorf("Expected cache miss (0ms) after TTL expiration, got %dms", output3[0].LatencyMS) |
||||
} |
||||
} |
||||
|
||||
func TestCacheLogic_MaxEntriesEviction(t *testing.T) { |
||||
cache := CacheLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"cacheTTL": 10000, |
||||
"maxEntries": 2, // Small cache size
|
||||
"evictionPolicy": "LRU", |
||||
} |
||||
|
||||
// Add first entry
|
||||
req1 := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
cache.Tick(props, req1, 1) |
||||
|
||||
// Add second entry
|
||||
req2 := []*Request{{ID: "req2", Type: "GET", LatencyMS: 0}} |
||||
cache.Tick(props, req2, 2) |
||||
|
||||
// Verify both are cached
|
||||
req1Check := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
output1Check, _ := cache.Tick(props, req1Check, 3) |
||||
if output1Check[0].LatencyMS != 1 { |
||||
t.Errorf("Expected cache hit for req1, got %dms latency", output1Check[0].LatencyMS) |
||||
} |
||||
|
||||
req2Check := []*Request{{ID: "req2", Type: "GET", LatencyMS: 0}} |
||||
output2Check, _ := cache.Tick(props, req2Check, 4) |
||||
if output2Check[0].LatencyMS != 1 { |
||||
t.Errorf("Expected cache hit for req2, got %dms latency", output2Check[0].LatencyMS) |
||||
} |
||||
|
||||
// Add third entry (should evict LRU entry)
|
||||
req3 := []*Request{{ID: "req3", Type: "GET", LatencyMS: 0}} |
||||
cache.Tick(props, req3, 5) |
||||
|
||||
// req1 was accessed at tick 3, req2 at tick 4, so req1 should be evicted
|
||||
req1CheckAgain := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
output1, _ := cache.Tick(props, req1CheckAgain, 6) |
||||
if output1[0].LatencyMS != 0 { |
||||
t.Errorf("Expected cache miss for LRU evicted entry, got %dms latency", output1[0].LatencyMS) |
||||
} |
||||
|
||||
// After adding req1 back, the cache should be at capacity with different items
|
||||
// We don't test further to avoid complex cascading eviction scenarios
|
||||
} |
||||
|
||||
func TestCacheLogic_LRUEviction(t *testing.T) { |
||||
cache := CacheLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"cacheTTL": 10000, |
||||
"maxEntries": 2, |
||||
"evictionPolicy": "LRU", |
||||
} |
||||
|
||||
// Add two entries
|
||||
req1 := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
cache.Tick(props, req1, 1) |
||||
|
||||
req2 := []*Request{{ID: "req2", Type: "GET", LatencyMS: 0}} |
||||
cache.Tick(props, req2, 2) |
||||
|
||||
// Access first entry (make it recently used)
|
||||
req1Access := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
cache.Tick(props, req1Access, 3) |
||||
|
||||
// Add third entry (should evict req2, since req1 was more recently accessed)
|
||||
req3 := []*Request{{ID: "req3", Type: "GET", LatencyMS: 0}} |
||||
cache.Tick(props, req3, 4) |
||||
|
||||
// Verify that req2 was evicted (should be cache miss)
|
||||
req2Check := []*Request{{ID: "req2", Type: "GET", LatencyMS: 0}} |
||||
output2, _ := cache.Tick(props, req2Check, 5) |
||||
|
||||
if output2[0].LatencyMS != 0 { |
||||
t.Errorf("Expected cache miss for LRU evicted entry, got %dms latency", output2[0].LatencyMS) |
||||
} |
||||
|
||||
// After adding req2 back, the cache should contain {req2, req1} or {req2, req3}
|
||||
// depending on LRU logic. We don't test further to avoid cascading evictions.
|
||||
} |
||||
|
||||
func TestCacheLogic_FIFOEviction(t *testing.T) { |
||||
cache := CacheLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"cacheTTL": 10000, |
||||
"maxEntries": 2, |
||||
"evictionPolicy": "FIFO", |
||||
} |
||||
|
||||
// Add two entries
|
||||
req1 := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
cache.Tick(props, req1, 1) |
||||
|
||||
req2 := []*Request{{ID: "req2", Type: "GET", LatencyMS: 0}} |
||||
cache.Tick(props, req2, 2) |
||||
|
||||
// Access first entry multiple times (shouldn't matter for FIFO)
|
||||
req1Access := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
cache.Tick(props, req1Access, 3) |
||||
cache.Tick(props, req1Access, 4) |
||||
|
||||
// Add third entry (should evict req1, the first inserted)
|
||||
req3 := []*Request{{ID: "req3", Type: "GET", LatencyMS: 0}} |
||||
cache.Tick(props, req3, 5) |
||||
|
||||
// Check that req1 was evicted (first in, first out)
|
||||
req1Check := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
output1, _ := cache.Tick(props, req1Check, 6) |
||||
|
||||
if output1[0].LatencyMS != 0 { |
||||
t.Errorf("Expected cache miss for FIFO evicted entry, got %dms latency", output1[0].LatencyMS) |
||||
} |
||||
|
||||
// After adding req1 back, the cache should contain {req2, req1} or {req3, req1}
|
||||
// depending on FIFO logic. We don't test further to avoid cascading evictions.
|
||||
} |
||||
|
||||
func TestCacheLogic_DefaultValues(t *testing.T) { |
||||
cache := CacheLogic{} |
||||
|
||||
// Empty props should use defaults
|
||||
props := map[string]any{} |
||||
|
||||
req := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
output, _ := cache.Tick(props, req, 1) |
||||
|
||||
if len(output) != 1 { |
||||
t.Errorf("Expected 1 output request") |
||||
} |
||||
|
||||
// Should be cache miss with 0ms latency
|
||||
if output[0].LatencyMS != 0 { |
||||
t.Errorf("Expected 0ms latency for cache miss with defaults, got %dms", output[0].LatencyMS) |
||||
} |
||||
|
||||
// Second request should be cache hit
|
||||
req2 := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
output2, _ := cache.Tick(props, req2, 2) |
||||
|
||||
if output2[0].LatencyMS != 1 { |
||||
t.Errorf("Expected 1ms latency for cache hit, got %dms", output2[0].LatencyMS) |
||||
} |
||||
} |
||||
|
||||
func TestCacheLogic_SimpleEviction(t *testing.T) { |
||||
cache := CacheLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"cacheTTL": 10000, |
||||
"maxEntries": 1, // Only 1 entry allowed
|
||||
"evictionPolicy": "LRU", |
||||
} |
||||
|
||||
// Add first entry
|
||||
req1 := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
output1, _ := cache.Tick(props, req1, 1) |
||||
if output1[0].LatencyMS != 0 { |
||||
t.Errorf("First request should be cache miss, got %dms", output1[0].LatencyMS) |
||||
} |
||||
|
||||
// Check it's cached
|
||||
req1Again := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
output1Again, _ := cache.Tick(props, req1Again, 2) |
||||
if output1Again[0].LatencyMS != 1 { |
||||
t.Errorf("Second request should be cache hit, got %dms", output1Again[0].LatencyMS) |
||||
} |
||||
|
||||
// Add second entry (should evict first)
|
||||
req2 := []*Request{{ID: "req2", Type: "GET", LatencyMS: 0}} |
||||
output2, _ := cache.Tick(props, req2, 3) |
||||
if output2[0].LatencyMS != 0 { |
||||
t.Errorf("New request should be cache miss, got %dms", output2[0].LatencyMS) |
||||
} |
||||
|
||||
// Check that first entry is now evicted
|
||||
req1Final := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
output1Final, _ := cache.Tick(props, req1Final, 4) |
||||
if output1Final[0].LatencyMS != 0 { |
||||
t.Errorf("Evicted entry should be cache miss, got %dms", output1Final[0].LatencyMS) |
||||
} |
||||
|
||||
// Check that second entry is now also evicted (since req1 was re-added in step 4)
|
||||
req2Again := []*Request{{ID: "req2", Type: "GET", LatencyMS: 0}} |
||||
output2Again, _ := cache.Tick(props, req2Again, 5) |
||||
if output2Again[0].LatencyMS != 0 { |
||||
t.Errorf("Re-evicted entry should be cache miss, got %dms", output2Again[0].LatencyMS) |
||||
} |
||||
} |
||||
|
||||
func TestCacheLogic_DifferentRequestTypes(t *testing.T) { |
||||
cache := CacheLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"cacheTTL": 10000, |
||||
"maxEntries": 100, |
||||
"evictionPolicy": "LRU", |
||||
} |
||||
|
||||
// Same ID but different type should be different cache entries
|
||||
req1 := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
cache.Tick(props, req1, 1) |
||||
|
||||
req2 := []*Request{{ID: "req1", Type: "POST", LatencyMS: 0}} |
||||
output2, _ := cache.Tick(props, req2, 2) |
||||
|
||||
// Should be cache miss since different type
|
||||
if output2[0].LatencyMS != 0 { |
||||
t.Errorf("Expected cache miss for different request type, got %dms latency", output2[0].LatencyMS) |
||||
} |
||||
|
||||
// Original GET should still be cached
|
||||
req1Again := []*Request{{ID: "req1", Type: "GET", LatencyMS: 0}} |
||||
output1, _ := cache.Tick(props, req1Again, 3) |
||||
|
||||
if output1[0].LatencyMS != 1 { |
||||
t.Errorf("Expected cache hit for original request type, got %dms latency", output1[0].LatencyMS) |
||||
} |
||||
} |
||||
@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
|
||||
package simulation |
||||
|
||||
type DatabaseLogic struct{} |
||||
|
||||
func (d DatabaseLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { |
||||
// Extract database properties
|
||||
replication := int(AsFloat64(props["replication"])) |
||||
if replication == 0 { |
||||
replication = 1 // default
|
||||
} |
||||
|
||||
// Database capacity (could be based on instance size or explicit RPS)
|
||||
maxRPS := int(AsFloat64(props["maxRPS"])) |
||||
if maxRPS == 0 { |
||||
maxRPS = 1000 // default capacity
|
||||
} |
||||
|
||||
// Base latency for database operations
|
||||
baseLatencyMs := int(AsFloat64(props["baseLatencyMs"])) |
||||
if baseLatencyMs == 0 { |
||||
baseLatencyMs = 10 // default 10ms for local DB operations
|
||||
} |
||||
|
||||
// Process requests up to capacity
|
||||
toProcess := queue |
||||
if len(queue) > maxRPS { |
||||
toProcess = queue[:maxRPS] |
||||
// TODO: Could add queue overflow logic here
|
||||
} |
||||
|
||||
output := []*Request{} |
||||
|
||||
for _, req := range toProcess { |
||||
// Add database latency to the request
|
||||
reqCopy := *req |
||||
|
||||
// Simulate different operation types and their latencies
|
||||
operationLatency := baseLatencyMs |
||||
|
||||
// Simple heuristic: reads are faster than writes
|
||||
if req.Type == "GET" || req.Type == "READ" { |
||||
operationLatency = baseLatencyMs |
||||
} else if req.Type == "POST" || req.Type == "WRITE" { |
||||
operationLatency = baseLatencyMs * 2 // writes take longer
|
||||
} |
||||
|
||||
// Replication factor affects write latency
|
||||
if req.Type == "POST" || req.Type == "WRITE" { |
||||
operationLatency += (replication - 1) * 5 // 5ms per replica
|
||||
} |
||||
|
||||
reqCopy.LatencyMS += operationLatency |
||||
reqCopy.Path = append(reqCopy.Path, "database-processed") |
||||
|
||||
output = append(output, &reqCopy) |
||||
} |
||||
|
||||
// Database health (could simulate failures, connection issues, etc.)
|
||||
// For now, assume always healthy
|
||||
return output, true |
||||
} |
||||
@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
|
||||
package simulation |
||||
|
||||
import ( |
||||
"testing" |
||||
) |
||||
|
||||
func TestDatabaseLogic_BasicProcessing(t *testing.T) { |
||||
db := DatabaseLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"replication": 2, |
||||
"maxRPS": 100, |
||||
"baseLatencyMs": 15, |
||||
} |
||||
|
||||
// Create test requests
|
||||
reqs := []*Request{ |
||||
{ID: "req1", Type: "GET", LatencyMS: 0, Path: []string{"start"}}, |
||||
{ID: "req2", Type: "POST", LatencyMS: 0, Path: []string{"start"}}, |
||||
} |
||||
|
||||
output, alive := db.Tick(props, reqs, 1) |
||||
|
||||
if !alive { |
||||
t.Errorf("Database should be alive") |
||||
} |
||||
|
||||
if len(output) != 2 { |
||||
t.Errorf("Expected 2 output requests, got %d", len(output)) |
||||
} |
||||
|
||||
// Check read latency (base latency)
|
||||
readReq := output[0] |
||||
if readReq.LatencyMS != 15 { |
||||
t.Errorf("Expected read latency 15ms, got %dms", readReq.LatencyMS) |
||||
} |
||||
|
||||
// Check write latency (base * 2 + replication penalty)
|
||||
writeReq := output[1] |
||||
expectedWriteLatency := 15*2 + (2-1)*5 // 30 + 5 = 35ms
|
||||
if writeReq.LatencyMS != expectedWriteLatency { |
||||
t.Errorf("Expected write latency %dms, got %dms", expectedWriteLatency, writeReq.LatencyMS) |
||||
} |
||||
} |
||||
|
||||
func TestDatabaseLogic_CapacityLimit(t *testing.T) { |
||||
db := DatabaseLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"maxRPS": 2, |
||||
"baseLatencyMs": 10, |
||||
} |
||||
|
||||
// Create more requests than capacity
|
||||
reqs := []*Request{ |
||||
{ID: "req1", Type: "GET"}, |
||||
{ID: "req2", Type: "GET"}, |
||||
{ID: "req3", Type: "GET"}, // This should be dropped
|
||||
} |
||||
|
||||
output, _ := db.Tick(props, reqs, 1) |
||||
|
||||
if len(output) != 2 { |
||||
t.Errorf("Expected capacity limit of 2, but processed %d requests", len(output)) |
||||
} |
||||
} |
||||
|
||||
func TestDatabaseLogic_DefaultValues(t *testing.T) { |
||||
db := DatabaseLogic{} |
||||
|
||||
// Empty props should use defaults
|
||||
props := map[string]any{} |
||||
|
||||
reqs := []*Request{ |
||||
{ID: "req1", Type: "GET", LatencyMS: 0}, |
||||
} |
||||
|
||||
output, _ := db.Tick(props, reqs, 1) |
||||
|
||||
if len(output) != 1 { |
||||
t.Errorf("Expected 1 output request") |
||||
} |
||||
|
||||
// Should use default 10ms base latency
|
||||
if output[0].LatencyMS != 10 { |
||||
t.Errorf("Expected default latency 10ms, got %dms", output[0].LatencyMS) |
||||
} |
||||
} |
||||
|
||||
func TestDatabaseLogic_ReplicationEffect(t *testing.T) { |
||||
db := DatabaseLogic{} |
||||
|
||||
// Test with high replication
|
||||
props := map[string]any{ |
||||
"replication": 5, |
||||
"baseLatencyMs": 10, |
||||
} |
||||
|
||||
reqs := []*Request{ |
||||
{ID: "req1", Type: "POST", LatencyMS: 0}, |
||||
} |
||||
|
||||
output, _ := db.Tick(props, reqs, 1) |
||||
|
||||
if len(output) != 1 { |
||||
t.Errorf("Expected 1 output request") |
||||
} |
||||
|
||||
// Write latency: base*2 + (replication-1)*5 = 10*2 + (5-1)*5 = 20 + 20 = 40ms
|
||||
expectedLatency := 10*2 + (5-1)*5 |
||||
if output[0].LatencyMS != expectedLatency { |
||||
t.Errorf("Expected latency %dms with replication=5, got %dms", expectedLatency, output[0].LatencyMS) |
||||
} |
||||
} |
||||
|
||||
func TestDatabaseLogic_ReadVsWrite(t *testing.T) { |
||||
db := DatabaseLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"replication": 1, |
||||
"baseLatencyMs": 20, |
||||
} |
||||
|
||||
readReq := []*Request{{ID: "read", Type: "GET", LatencyMS: 0}} |
||||
writeReq := []*Request{{ID: "write", Type: "POST", LatencyMS: 0}} |
||||
|
||||
readOutput, _ := db.Tick(props, readReq, 1) |
||||
writeOutput, _ := db.Tick(props, writeReq, 1) |
||||
|
||||
// Read should be base latency
|
||||
if readOutput[0].LatencyMS != 20 { |
||||
t.Errorf("Expected read latency 20ms, got %dms", readOutput[0].LatencyMS) |
||||
} |
||||
|
||||
// Write should be double base latency (no replication penalty with replication=1)
|
||||
if writeOutput[0].LatencyMS != 40 { |
||||
t.Errorf("Expected write latency 40ms, got %dms", writeOutput[0].LatencyMS) |
||||
} |
||||
} |
||||
@ -0,0 +1,203 @@
@@ -0,0 +1,203 @@
|
||||
package simulation |
||||
|
||||
type DataPipelineLogic struct{} |
||||
|
||||
type DataBatch struct { |
||||
ID string |
||||
RecordCount int |
||||
Timestamp int |
||||
ProcessingMS int |
||||
} |
||||
|
||||
type PipelineState struct { |
||||
ProcessingQueue []DataBatch |
||||
CompletedBatches int |
||||
TotalRecords int |
||||
BacklogSize int |
||||
} |
||||
|
||||
func (d DataPipelineLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { |
||||
// Extract data pipeline properties
|
||||
batchSize := int(AsFloat64(props["batchSize"])) |
||||
if batchSize == 0 { |
||||
batchSize = 500 // default batch size
|
||||
} |
||||
|
||||
transformation := AsString(props["transformation"]) |
||||
if transformation == "" { |
||||
transformation = "map" // default transformation
|
||||
} |
||||
|
||||
// Get pipeline state from props (persistent state)
|
||||
state, ok := props["_pipelineState"].(PipelineState) |
||||
if !ok { |
||||
state = PipelineState{ |
||||
ProcessingQueue: []DataBatch{}, |
||||
CompletedBatches: 0, |
||||
TotalRecords: 0, |
||||
BacklogSize: 0, |
||||
} |
||||
} |
||||
|
||||
currentTime := tick * 100 // Convert tick to milliseconds
|
||||
|
||||
// Convert incoming requests to data batches
|
||||
if len(queue) > 0 { |
||||
// Group requests into batches
|
||||
batches := d.createBatches(queue, batchSize, currentTime, transformation) |
||||
|
||||
// Add batches to processing queue
|
||||
state.ProcessingQueue = append(state.ProcessingQueue, batches...) |
||||
state.BacklogSize += len(queue) |
||||
} |
||||
|
||||
// Process batches that are ready (completed their processing time)
|
||||
output := []*Request{} |
||||
remainingBatches := []DataBatch{} |
||||
|
||||
for _, batch := range state.ProcessingQueue { |
||||
if currentTime >= batch.Timestamp+batch.ProcessingMS { |
||||
// Batch is complete - create output requests
|
||||
for i := 0; i < batch.RecordCount; i++ { |
||||
processedReq := &Request{ |
||||
ID: batch.ID + "-record-" + string(rune('0'+i)), |
||||
Timestamp: batch.Timestamp, |
||||
LatencyMS: batch.ProcessingMS, |
||||
Origin: "data-pipeline", |
||||
Type: "PROCESSED", |
||||
Path: []string{"pipeline-" + transformation}, |
||||
} |
||||
output = append(output, processedReq) |
||||
} |
||||
|
||||
state.CompletedBatches++ |
||||
state.TotalRecords += batch.RecordCount |
||||
} else { |
||||
// Batch still processing
|
||||
remainingBatches = append(remainingBatches, batch) |
||||
} |
||||
} |
||||
|
||||
state.ProcessingQueue = remainingBatches |
||||
state.BacklogSize = len(remainingBatches) * batchSize |
||||
|
||||
// Update persistent state
|
||||
props["_pipelineState"] = state |
||||
|
||||
// Health check: pipeline is healthy if backlog is not too large
|
||||
maxBacklogSize := batchSize * 20 // Allow up to 20 batches in backlog
|
||||
healthy := state.BacklogSize < maxBacklogSize |
||||
|
||||
return output, healthy |
||||
} |
||||
|
||||
// createBatches groups requests into batches and calculates processing time
|
||||
func (d DataPipelineLogic) createBatches(requests []*Request, batchSize int, timestamp int, transformation string) []DataBatch { |
||||
batches := []DataBatch{} |
||||
|
||||
for i := 0; i < len(requests); i += batchSize { |
||||
end := i + batchSize |
||||
if end > len(requests) { |
||||
end = len(requests) |
||||
} |
||||
|
||||
recordCount := end - i |
||||
processingTime := d.calculateProcessingTime(recordCount, transformation) |
||||
|
||||
batch := DataBatch{ |
||||
ID: "batch-" + string(rune('A'+len(batches))), |
||||
RecordCount: recordCount, |
||||
Timestamp: timestamp, |
||||
ProcessingMS: processingTime, |
||||
} |
||||
|
||||
batches = append(batches, batch) |
||||
} |
||||
|
||||
return batches |
||||
} |
||||
|
||||
// calculateProcessingTime determines how long a batch takes to process based on transformation type
|
||||
func (d DataPipelineLogic) calculateProcessingTime(recordCount int, transformation string) int { |
||||
// Base processing time per record
|
||||
baseTimePerRecord := d.getTransformationComplexity(transformation) |
||||
|
||||
// Total time scales with record count but with some economies of scale
|
||||
totalTime := float64(recordCount) * baseTimePerRecord |
||||
|
||||
// Add batch overhead (setup, teardown, I/O)
|
||||
batchOverhead := d.getBatchOverhead(transformation) |
||||
totalTime += batchOverhead |
||||
|
||||
// Apply economies of scale for larger batches (slightly more efficient)
|
||||
if recordCount > 100 { |
||||
scaleFactor := 0.9 // 10% efficiency gain for large batches
|
||||
totalTime *= scaleFactor |
||||
} |
||||
|
||||
return int(totalTime) |
||||
} |
||||
|
||||
// getTransformationComplexity returns base processing time per record in milliseconds
|
||||
func (d DataPipelineLogic) getTransformationComplexity(transformation string) float64 { |
||||
switch transformation { |
||||
case "map": |
||||
return 1.0 // Simple field mapping
|
||||
case "filter": |
||||
return 0.5 // Just evaluate conditions
|
||||
case "sort": |
||||
return 3.0 // Sorting requires more compute
|
||||
case "aggregate": |
||||
return 2.0 // Grouping and calculating aggregates
|
||||
case "join": |
||||
return 5.0 // Most expensive - joining with other datasets
|
||||
case "deduplicate": |
||||
return 2.5 // Hash-based deduplication
|
||||
case "validate": |
||||
return 1.5 // Data validation and cleaning
|
||||
case "enrich": |
||||
return 4.0 // Enriching with external data
|
||||
case "compress": |
||||
return 1.2 // Compression processing
|
||||
case "encrypt": |
||||
return 2.0 // Encryption overhead
|
||||
default: |
||||
return 1.0 // Default to simple transformation
|
||||
} |
||||
} |
||||
|
||||
// getBatchOverhead returns fixed overhead time per batch in milliseconds
|
||||
func (d DataPipelineLogic) getBatchOverhead(transformation string) float64 { |
||||
switch transformation { |
||||
case "map", "filter", "validate": |
||||
return 50.0 // Low overhead for simple operations
|
||||
case "sort", "aggregate", "deduplicate": |
||||
return 200.0 // Medium overhead for complex operations
|
||||
case "join", "enrich": |
||||
return 500.0 // High overhead for operations requiring external data
|
||||
case "compress", "encrypt": |
||||
return 100.0 // Medium overhead for I/O operations
|
||||
default: |
||||
return 100.0 // Default overhead
|
||||
} |
||||
} |
||||
|
||||
// Helper function to get pipeline statistics
|
||||
func (d DataPipelineLogic) GetPipelineStats(props map[string]any) map[string]interface{} { |
||||
state, ok := props["_pipelineState"].(PipelineState) |
||||
if !ok { |
||||
return map[string]interface{}{ |
||||
"completedBatches": 0, |
||||
"totalRecords": 0, |
||||
"backlogSize": 0, |
||||
"queuedBatches": 0, |
||||
} |
||||
} |
||||
|
||||
return map[string]interface{}{ |
||||
"completedBatches": state.CompletedBatches, |
||||
"totalRecords": state.TotalRecords, |
||||
"backlogSize": state.BacklogSize, |
||||
"queuedBatches": len(state.ProcessingQueue), |
||||
} |
||||
} |
||||
@ -0,0 +1,396 @@
@@ -0,0 +1,396 @@
|
||||
package simulation |
||||
|
||||
import ( |
||||
"testing" |
||||
) |
||||
|
||||
func TestDataPipelineLogic_BasicProcessing(t *testing.T) { |
||||
logic := DataPipelineLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"batchSize": 100.0, |
||||
"transformation": "map", |
||||
} |
||||
|
||||
// Create 50 requests (less than batch size)
|
||||
requests := make([]*Request, 50) |
||||
for i := range requests { |
||||
requests[i] = &Request{ID: string(rune('1' + i)), Type: "DATA", LatencyMS: 0} |
||||
} |
||||
|
||||
// First tick - should create batch and start processing
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected data pipeline to be healthy") |
||||
} |
||||
|
||||
// Should not have output yet (batch is still processing)
|
||||
if len(output) != 0 { |
||||
t.Errorf("Expected no output during processing, got %d", len(output)) |
||||
} |
||||
|
||||
// Check that batch was created
|
||||
state, ok := props["_pipelineState"].(PipelineState) |
||||
if !ok { |
||||
t.Error("Expected pipeline state to be created") |
||||
} |
||||
|
||||
if len(state.ProcessingQueue) != 1 { |
||||
t.Errorf("Expected 1 batch in processing queue, got %d", len(state.ProcessingQueue)) |
||||
} |
||||
|
||||
if state.ProcessingQueue[0].RecordCount != 50 { |
||||
t.Errorf("Expected batch with 50 records, got %d", state.ProcessingQueue[0].RecordCount) |
||||
} |
||||
} |
||||
|
||||
func TestDataPipelineLogic_BatchCompletion(t *testing.T) { |
||||
logic := DataPipelineLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"batchSize": 10.0, |
||||
"transformation": "filter", // Fast transformation
|
||||
} |
||||
|
||||
// Create 5 requests
|
||||
requests := make([]*Request, 5) |
||||
for i := range requests { |
||||
requests[i] = &Request{ID: string(rune('1' + i)), Type: "DATA", LatencyMS: 0} |
||||
} |
||||
|
||||
// First tick - start processing
|
||||
logic.Tick(props, requests, 1) |
||||
|
||||
// Wait enough ticks for processing to complete
|
||||
// Filter transformation should complete quickly
|
||||
var output []*Request |
||||
var healthy bool |
||||
|
||||
for tick := 2; tick <= 10; tick++ { |
||||
output, healthy = logic.Tick(props, []*Request{}, tick) |
||||
if len(output) > 0 { |
||||
break |
||||
} |
||||
} |
||||
|
||||
if !healthy { |
||||
t.Error("Expected data pipeline to be healthy") |
||||
} |
||||
|
||||
// Should have output matching input count
|
||||
if len(output) != 5 { |
||||
t.Errorf("Expected 5 output records, got %d", len(output)) |
||||
} |
||||
|
||||
// Check output structure
|
||||
for _, req := range output { |
||||
if req.Type != "PROCESSED" { |
||||
t.Errorf("Expected PROCESSED type, got %s", req.Type) |
||||
} |
||||
if req.Origin != "data-pipeline" { |
||||
t.Errorf("Expected data-pipeline origin, got %s", req.Origin) |
||||
} |
||||
if len(req.Path) == 0 || req.Path[0] != "pipeline-filter" { |
||||
t.Error("Expected path to indicate filter transformation") |
||||
} |
||||
} |
||||
} |
||||
|
||||
func TestDataPipelineLogic_MultipleBatches(t *testing.T) { |
||||
logic := DataPipelineLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"batchSize": 10.0, |
||||
"transformation": "map", |
||||
} |
||||
|
||||
// Create 25 requests (should create 3 batches: 10, 10, 5)
|
||||
requests := make([]*Request, 25) |
||||
for i := range requests { |
||||
requests[i] = &Request{ID: string(rune('1' + i)), Type: "DATA", LatencyMS: 0} |
||||
} |
||||
|
||||
// First tick - create batches
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected data pipeline to be healthy") |
||||
} |
||||
|
||||
if len(output) != 0 { |
||||
t.Error("Expected no immediate output") |
||||
} |
||||
|
||||
// Check that 3 batches were created
|
||||
state, ok := props["_pipelineState"].(PipelineState) |
||||
if !ok { |
||||
t.Error("Expected pipeline state to be created") |
||||
} |
||||
|
||||
if len(state.ProcessingQueue) != 3 { |
||||
t.Errorf("Expected 3 batches in processing queue, got %d", len(state.ProcessingQueue)) |
||||
} |
||||
|
||||
// Verify batch sizes
|
||||
expectedSizes := []int{10, 10, 5} |
||||
for i, batch := range state.ProcessingQueue { |
||||
if batch.RecordCount != expectedSizes[i] { |
||||
t.Errorf("Expected batch %d to have %d records, got %d", |
||||
i, expectedSizes[i], batch.RecordCount) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func TestDataPipelineLogic_TransformationComplexity(t *testing.T) { |
||||
logic := DataPipelineLogic{} |
||||
|
||||
transformations := []string{"filter", "map", "sort", "aggregate", "join"} |
||||
|
||||
for _, transformation := range transformations { |
||||
t.Run(transformation, func(t *testing.T) { |
||||
complexity := logic.getTransformationComplexity(transformation) |
||||
|
||||
// Verify relative complexity ordering
|
||||
switch transformation { |
||||
case "filter": |
||||
if complexity >= logic.getTransformationComplexity("map") { |
||||
t.Error("Filter should be simpler than map") |
||||
} |
||||
case "join": |
||||
if complexity <= logic.getTransformationComplexity("aggregate") { |
||||
t.Error("Join should be more complex than aggregate") |
||||
} |
||||
case "sort": |
||||
if complexity <= logic.getTransformationComplexity("map") { |
||||
t.Error("Sort should be more complex than map") |
||||
} |
||||
} |
||||
|
||||
if complexity <= 0 { |
||||
t.Errorf("Expected positive complexity for %s", transformation) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestDataPipelineLogic_BatchOverhead(t *testing.T) { |
||||
logic := DataPipelineLogic{} |
||||
|
||||
// Test different overhead levels
|
||||
testCases := []struct { |
||||
transformation string |
||||
expectedRange [2]float64 // [min, max]
|
||||
}{ |
||||
{"map", [2]float64{0, 100}}, // Low overhead
|
||||
{"join", [2]float64{300, 600}}, // High overhead
|
||||
{"sort", [2]float64{150, 300}}, // Medium overhead
|
||||
} |
||||
|
||||
for _, tc := range testCases { |
||||
overhead := logic.getBatchOverhead(tc.transformation) |
||||
|
||||
if overhead < tc.expectedRange[0] || overhead > tc.expectedRange[1] { |
||||
t.Errorf("Expected %s overhead between %.0f-%.0f, got %.0f", |
||||
tc.transformation, tc.expectedRange[0], tc.expectedRange[1], overhead) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func TestDataPipelineLogic_ProcessingTime(t *testing.T) { |
||||
logic := DataPipelineLogic{} |
||||
|
||||
// Test that processing time scales with record count
|
||||
smallBatch := logic.calculateProcessingTime(10, "map") |
||||
largeBatch := logic.calculateProcessingTime(100, "map") |
||||
|
||||
if largeBatch <= smallBatch { |
||||
t.Error("Expected larger batch to take more time") |
||||
} |
||||
|
||||
// Test that complex transformations take longer
|
||||
simpleTime := logic.calculateProcessingTime(50, "filter") |
||||
complexTime := logic.calculateProcessingTime(50, "join") |
||||
|
||||
if complexTime <= simpleTime { |
||||
t.Error("Expected complex transformation to take longer") |
||||
} |
||||
|
||||
// Test economies of scale (large batches should be more efficient per record)
|
||||
smallPerRecord := float64(smallBatch) / 10.0 |
||||
largePerRecord := float64(largeBatch) / 100.0 |
||||
|
||||
if largePerRecord >= smallPerRecord { |
||||
t.Error("Expected economies of scale for larger batches") |
||||
} |
||||
} |
||||
|
||||
func TestDataPipelineLogic_HealthCheck(t *testing.T) { |
||||
logic := DataPipelineLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"batchSize": 10.0, |
||||
"transformation": "join", // Slow transformation
|
||||
} |
||||
|
||||
// Create a large number of requests to test backlog health
|
||||
requests := make([]*Request, 300) // 30 batches (above healthy threshold)
|
||||
for i := range requests { |
||||
requests[i] = &Request{ID: string(rune('1' + (i % 26))), Type: "DATA", LatencyMS: 0} |
||||
} |
||||
|
||||
// First tick - should create many batches
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
// Should be unhealthy due to large backlog
|
||||
if healthy { |
||||
t.Error("Expected data pipeline to be unhealthy with large backlog") |
||||
} |
||||
|
||||
if len(output) != 0 { |
||||
t.Error("Expected no immediate output with slow transformation") |
||||
} |
||||
|
||||
// Check backlog size
|
||||
state, ok := props["_pipelineState"].(PipelineState) |
||||
if !ok { |
||||
t.Error("Expected pipeline state to be created") |
||||
} |
||||
|
||||
if state.BacklogSize < 200 { |
||||
t.Errorf("Expected large backlog, got %d", state.BacklogSize) |
||||
} |
||||
} |
||||
|
||||
func TestDataPipelineLogic_DefaultValues(t *testing.T) { |
||||
logic := DataPipelineLogic{} |
||||
|
||||
// Empty props should use defaults
|
||||
props := map[string]any{} |
||||
|
||||
requests := []*Request{{ID: "1", Type: "DATA", LatencyMS: 0}} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected pipeline to be healthy with default values") |
||||
} |
||||
|
||||
if len(output) != 0 { |
||||
t.Error("Expected no immediate output") |
||||
} |
||||
|
||||
// Should use default batch size and transformation
|
||||
state, ok := props["_pipelineState"].(PipelineState) |
||||
if !ok { |
||||
t.Error("Expected pipeline state to be created with defaults") |
||||
} |
||||
|
||||
if len(state.ProcessingQueue) != 1 { |
||||
t.Error("Expected one batch with default settings") |
||||
} |
||||
} |
||||
|
||||
func TestDataPipelineLogic_PipelineStats(t *testing.T) { |
||||
logic := DataPipelineLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"batchSize": 5.0, |
||||
"transformation": "filter", |
||||
} |
||||
|
||||
// Initial stats should be empty
|
||||
stats := logic.GetPipelineStats(props) |
||||
if stats["completedBatches"] != 0 { |
||||
t.Error("Expected initial completed batches to be 0") |
||||
} |
||||
|
||||
// Process some data
|
||||
requests := make([]*Request, 10) |
||||
for i := range requests { |
||||
requests[i] = &Request{ID: string(rune('1' + i)), Type: "DATA", LatencyMS: 0} |
||||
} |
||||
|
||||
logic.Tick(props, requests, 1) |
||||
|
||||
// Check stats after processing
|
||||
stats = logic.GetPipelineStats(props) |
||||
if stats["queuedBatches"] != 2 { |
||||
t.Errorf("Expected 2 queued batches, got %v", stats["queuedBatches"]) |
||||
} |
||||
|
||||
if stats["backlogSize"] != 10 { |
||||
t.Errorf("Expected backlog size of 10, got %v", stats["backlogSize"]) |
||||
} |
||||
} |
||||
|
||||
func TestDataPipelineLogic_ContinuousProcessing(t *testing.T) { |
||||
logic := DataPipelineLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"batchSize": 5.0, |
||||
"transformation": "map", |
||||
} |
||||
|
||||
// Process multiple waves of data
|
||||
totalOutput := 0 |
||||
|
||||
for wave := 0; wave < 3; wave++ { |
||||
requests := make([]*Request, 5) |
||||
for i := range requests { |
||||
requests[i] = &Request{ID: string(rune('A' + wave*5 + i)), Type: "DATA", LatencyMS: 0} |
||||
} |
||||
|
||||
// Process each wave
|
||||
for tick := wave*10 + 1; tick <= wave*10+5; tick++ { |
||||
var output []*Request |
||||
if tick == wave*10+1 { |
||||
output, _ = logic.Tick(props, requests, tick) |
||||
} else { |
||||
output, _ = logic.Tick(props, []*Request{}, tick) |
||||
} |
||||
totalOutput += len(output) |
||||
} |
||||
} |
||||
|
||||
// Should have processed all data eventually
|
||||
if totalOutput != 15 { |
||||
t.Errorf("Expected 15 total output records, got %d", totalOutput) |
||||
} |
||||
|
||||
// Check final stats
|
||||
stats := logic.GetPipelineStats(props) |
||||
if stats["totalRecords"] != 15 { |
||||
t.Errorf("Expected 15 total records processed, got %v", stats["totalRecords"]) |
||||
} |
||||
} |
||||
|
||||
func TestDataPipelineLogic_EmptyQueue(t *testing.T) { |
||||
logic := DataPipelineLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"batchSize": 10.0, |
||||
"transformation": "map", |
||||
} |
||||
|
||||
// Process empty queue
|
||||
output, healthy := logic.Tick(props, []*Request{}, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected pipeline to be healthy with empty queue") |
||||
} |
||||
|
||||
if len(output) != 0 { |
||||
t.Error("Expected no output with empty queue") |
||||
} |
||||
|
||||
// State should be initialized but empty
|
||||
state, ok := props["_pipelineState"].(PipelineState) |
||||
if !ok { |
||||
t.Error("Expected pipeline state to be initialized") |
||||
} |
||||
|
||||
if len(state.ProcessingQueue) != 0 { |
||||
t.Error("Expected empty processing queue") |
||||
} |
||||
} |
||||
@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
|
||||
package simulation |
||||
|
||||
type MessageQueueLogic struct{} |
||||
|
||||
type QueuedMessage struct { |
||||
RequestID string |
||||
Timestamp int |
||||
MessageData string |
||||
RetryCount int |
||||
} |
||||
|
||||
func (mq MessageQueueLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { |
||||
// Extract message queue properties
|
||||
queueCapacity := int(AsFloat64(props["queueCapacity"])) |
||||
if queueCapacity == 0 { |
||||
queueCapacity = 1000 // default capacity
|
||||
} |
||||
|
||||
retentionSeconds := int(AsFloat64(props["retentionSeconds"])) |
||||
if retentionSeconds == 0 { |
||||
retentionSeconds = 86400 // default 24 hours in seconds
|
||||
} |
||||
|
||||
// Processing rate (messages per tick)
|
||||
processingRate := int(AsFloat64(props["processingRate"])) |
||||
if processingRate == 0 { |
||||
processingRate = 100 // default 100 messages per tick
|
||||
} |
||||
|
||||
// Current timestamp for this tick
|
||||
currentTime := tick * 100 // assuming 100ms per tick
|
||||
|
||||
// Initialize queue storage in props
|
||||
messageQueue, ok := props["_messageQueue"].([]QueuedMessage) |
||||
if !ok { |
||||
messageQueue = []QueuedMessage{} |
||||
} |
||||
|
||||
// Clean up expired messages based on retention policy
|
||||
messageQueue = mq.cleanExpiredMessages(messageQueue, currentTime, retentionSeconds*1000) |
||||
|
||||
// First, process existing messages from the queue (FIFO order)
|
||||
output := []*Request{} |
||||
messagesToProcess := len(messageQueue) |
||||
if messagesToProcess > processingRate { |
||||
messagesToProcess = processingRate |
||||
} |
||||
|
||||
for i := 0; i < messagesToProcess; i++ { |
||||
if len(messageQueue) == 0 { |
||||
break |
||||
} |
||||
|
||||
// Dequeue message (FIFO - take from front)
|
||||
message := messageQueue[0] |
||||
messageQueue = messageQueue[1:] |
||||
|
||||
// Create request for downstream processing
|
||||
processedReq := &Request{ |
||||
ID: message.RequestID, |
||||
Timestamp: message.Timestamp, |
||||
LatencyMS: 2, // Small latency for queue processing
|
||||
Origin: "message-queue", |
||||
Type: "PROCESS", |
||||
Path: []string{"queued-message"}, |
||||
} |
||||
|
||||
output = append(output, processedReq) |
||||
} |
||||
|
||||
// Then, add incoming requests to the queue for next tick
|
||||
for _, req := range queue { |
||||
// Check if queue is at capacity
|
||||
if len(messageQueue) >= queueCapacity { |
||||
// Queue full - message is dropped (or could implement backpressure)
|
||||
// For now, we'll drop the message and add latency penalty
|
||||
reqCopy := *req |
||||
reqCopy.LatencyMS += 1000 // High latency penalty for dropped messages
|
||||
reqCopy.Path = append(reqCopy.Path, "queue-full-dropped") |
||||
// Don't add to output as message was dropped
|
||||
continue |
||||
} |
||||
|
||||
// Add message to queue
|
||||
message := QueuedMessage{ |
||||
RequestID: req.ID, |
||||
Timestamp: currentTime, |
||||
MessageData: "message-payload", // In real system, this would be the actual message
|
||||
RetryCount: 0, |
||||
} |
||||
messageQueue = append(messageQueue, message) |
||||
} |
||||
|
||||
// Update queue storage in props
|
||||
props["_messageQueue"] = messageQueue |
||||
|
||||
// Queue is healthy if not at capacity or if we can still process messages
|
||||
// Queue becomes unhealthy only when completely full AND we can't process anything
|
||||
healthy := len(messageQueue) < queueCapacity || processingRate > 0 |
||||
|
||||
return output, healthy |
||||
} |
||||
|
||||
func (mq MessageQueueLogic) cleanExpiredMessages(messageQueue []QueuedMessage, currentTime, retentionMs int) []QueuedMessage { |
||||
cleaned := []QueuedMessage{} |
||||
|
||||
for _, message := range messageQueue { |
||||
if (currentTime - message.Timestamp) <= retentionMs { |
||||
cleaned = append(cleaned, message) |
||||
} |
||||
// Expired messages are dropped
|
||||
} |
||||
|
||||
return cleaned |
||||
} |
||||
@ -0,0 +1,329 @@
@@ -0,0 +1,329 @@
|
||||
package simulation |
||||
|
||||
import ( |
||||
"testing" |
||||
) |
||||
|
||||
func TestMessageQueueLogic_BasicProcessing(t *testing.T) { |
||||
mq := MessageQueueLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"queueCapacity": 10, |
||||
"retentionSeconds": 3600, // 1 hour
|
||||
"processingRate": 5, |
||||
} |
||||
|
||||
// Add some messages to the queue
|
||||
reqs := []*Request{ |
||||
{ID: "msg1", Type: "SEND", LatencyMS: 0, Timestamp: 100}, |
||||
{ID: "msg2", Type: "SEND", LatencyMS: 0, Timestamp: 100}, |
||||
{ID: "msg3", Type: "SEND", LatencyMS: 0, Timestamp: 100}, |
||||
} |
||||
|
||||
output, healthy := mq.Tick(props, reqs, 1) |
||||
|
||||
if !healthy { |
||||
t.Errorf("Message queue should be healthy") |
||||
} |
||||
|
||||
// No immediate output since messages are queued first
|
||||
if len(output) != 0 { |
||||
t.Errorf("Expected 0 immediate output (messages queued), got %d", len(output)) |
||||
} |
||||
|
||||
// Check that messages are in the queue
|
||||
messageQueue, ok := props["_messageQueue"].([]QueuedMessage) |
||||
if !ok { |
||||
t.Errorf("Expected message queue to be initialized") |
||||
} |
||||
|
||||
if len(messageQueue) != 3 { |
||||
t.Errorf("Expected 3 messages in queue, got %d", len(messageQueue)) |
||||
} |
||||
|
||||
// Process the queue (no new incoming messages)
|
||||
output2, _ := mq.Tick(props, []*Request{}, 2) |
||||
|
||||
// Should process up to processingRate (5) messages
|
||||
if len(output2) != 3 { |
||||
t.Errorf("Expected 3 processed messages, got %d", len(output2)) |
||||
} |
||||
|
||||
// Queue should now be empty
|
||||
messageQueue2, _ := props["_messageQueue"].([]QueuedMessage) |
||||
if len(messageQueue2) != 0 { |
||||
t.Errorf("Expected empty queue after processing, got %d messages", len(messageQueue2)) |
||||
} |
||||
|
||||
// Check output message properties
|
||||
for _, msg := range output2 { |
||||
if msg.LatencyMS != 2 { |
||||
t.Errorf("Expected 2ms processing latency, got %dms", msg.LatencyMS) |
||||
} |
||||
if msg.Type != "PROCESS" { |
||||
t.Errorf("Expected PROCESS type, got %s", msg.Type) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func TestMessageQueueLogic_CapacityLimit(t *testing.T) { |
||||
mq := MessageQueueLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"queueCapacity": 2, // Small capacity
|
||||
"retentionSeconds": 3600, |
||||
"processingRate": 1, |
||||
} |
||||
|
||||
// Add more messages than capacity
|
||||
reqs := []*Request{ |
||||
{ID: "msg1", Type: "SEND", LatencyMS: 0}, |
||||
{ID: "msg2", Type: "SEND", LatencyMS: 0}, |
||||
{ID: "msg3", Type: "SEND", LatencyMS: 0}, // This should be dropped
|
||||
} |
||||
|
||||
output, healthy := mq.Tick(props, reqs, 1) |
||||
|
||||
// Queue should be healthy (can still process messages)
|
||||
if !healthy { |
||||
t.Errorf("Queue should be healthy (can still process)") |
||||
} |
||||
|
||||
// Should have no immediate output (messages queued)
|
||||
if len(output) != 0 { |
||||
t.Errorf("Expected 0 immediate output, got %d", len(output)) |
||||
} |
||||
|
||||
// Check queue size
|
||||
messageQueue, _ := props["_messageQueue"].([]QueuedMessage) |
||||
if len(messageQueue) != 2 { |
||||
t.Errorf("Expected 2 messages in queue (capacity limit), got %d", len(messageQueue)) |
||||
} |
||||
|
||||
// Add another message when queue is full
|
||||
reqs2 := []*Request{{ID: "msg4", Type: "SEND", LatencyMS: 0}} |
||||
output2, healthy2 := mq.Tick(props, reqs2, 2) |
||||
|
||||
// Queue should still be healthy (can process messages)
|
||||
if !healthy2 { |
||||
t.Errorf("Queue should remain healthy (can still process)") |
||||
} |
||||
|
||||
// Should have 1 processed message (processingRate = 1)
|
||||
if len(output2) != 1 { |
||||
t.Errorf("Expected 1 processed message, got %d", len(output2)) |
||||
} |
||||
|
||||
// Queue should have 2 messages (started with 2, processed 1 leaving 1, added 1 new since space available)
|
||||
messageQueue2, _ := props["_messageQueue"].([]QueuedMessage) |
||||
if len(messageQueue2) != 2 { |
||||
t.Errorf("Expected 2 messages in queue (1 remaining + 1 new), got %d", len(messageQueue2)) |
||||
} |
||||
} |
||||
|
||||
func TestMessageQueueLogic_ProcessingRate(t *testing.T) { |
||||
mq := MessageQueueLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"queueCapacity": 100, |
||||
"retentionSeconds": 3600, |
||||
"processingRate": 3, // Process 3 messages per tick
|
||||
} |
||||
|
||||
// Add 10 messages
|
||||
reqs := []*Request{} |
||||
for i := 0; i < 10; i++ { |
||||
reqs = append(reqs, &Request{ID: "msg" + string(rune(i+'0')), Type: "SEND"}) |
||||
} |
||||
|
||||
// First tick: queue all messages
|
||||
mq.Tick(props, reqs, 1) |
||||
|
||||
// Second tick: process at rate limit
|
||||
output, _ := mq.Tick(props, []*Request{}, 2) |
||||
|
||||
if len(output) != 3 { |
||||
t.Errorf("Expected 3 processed messages (rate limit), got %d", len(output)) |
||||
} |
||||
|
||||
// Check remaining queue size
|
||||
messageQueue, _ := props["_messageQueue"].([]QueuedMessage) |
||||
if len(messageQueue) != 7 { |
||||
t.Errorf("Expected 7 messages remaining in queue, got %d", len(messageQueue)) |
||||
} |
||||
|
||||
// Third tick: process 3 more
|
||||
output2, _ := mq.Tick(props, []*Request{}, 3) |
||||
|
||||
if len(output2) != 3 { |
||||
t.Errorf("Expected 3 more processed messages, got %d", len(output2)) |
||||
} |
||||
|
||||
// Check remaining queue size
|
||||
messageQueue2, _ := props["_messageQueue"].([]QueuedMessage) |
||||
if len(messageQueue2) != 4 { |
||||
t.Errorf("Expected 4 messages remaining in queue, got %d", len(messageQueue2)) |
||||
} |
||||
} |
||||
|
||||
func TestMessageQueueLogic_MessageRetention(t *testing.T) { |
||||
mq := MessageQueueLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"queueCapacity": 100, |
||||
"retentionSeconds": 1, // 1 second retention
|
||||
"processingRate": 0, // Don't process messages, just test retention
|
||||
} |
||||
|
||||
// Add messages at tick 1
|
||||
reqs := []*Request{ |
||||
{ID: "msg1", Type: "SEND", Timestamp: 100}, |
||||
{ID: "msg2", Type: "SEND", Timestamp: 100}, |
||||
} |
||||
|
||||
mq.Tick(props, reqs, 1) |
||||
|
||||
// Check messages are queued
|
||||
messageQueue, _ := props["_messageQueue"].([]QueuedMessage) |
||||
if len(messageQueue) != 2 { |
||||
t.Errorf("Expected 2 messages in queue, got %d", len(messageQueue)) |
||||
} |
||||
|
||||
// Tick at time that should expire messages (tick 20 = 2000ms, retention = 1000ms)
|
||||
output, _ := mq.Tick(props, []*Request{}, 20) |
||||
|
||||
// Messages should be expired and removed
|
||||
messageQueue2, _ := props["_messageQueue"].([]QueuedMessage) |
||||
if len(messageQueue2) != 0 { |
||||
t.Errorf("Expected messages to be expired and removed, got %d", len(messageQueue2)) |
||||
} |
||||
|
||||
// No output since processingRate = 0
|
||||
if len(output) != 0 { |
||||
t.Errorf("Expected no output with processingRate=0, got %d", len(output)) |
||||
} |
||||
} |
||||
|
||||
func TestMessageQueueLogic_FIFOOrdering(t *testing.T) { |
||||
mq := MessageQueueLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"queueCapacity": 10, |
||||
"retentionSeconds": 3600, |
||||
"processingRate": 2, |
||||
} |
||||
|
||||
// Add messages in order
|
||||
reqs := []*Request{ |
||||
{ID: "first", Type: "SEND"}, |
||||
{ID: "second", Type: "SEND"}, |
||||
{ID: "third", Type: "SEND"}, |
||||
} |
||||
|
||||
mq.Tick(props, reqs, 1) |
||||
|
||||
// Process 2 messages
|
||||
output, _ := mq.Tick(props, []*Request{}, 2) |
||||
|
||||
if len(output) != 2 { |
||||
t.Errorf("Expected 2 processed messages, got %d", len(output)) |
||||
} |
||||
|
||||
// Check FIFO order
|
||||
if output[0].ID != "first" { |
||||
t.Errorf("Expected first message to be 'first', got '%s'", output[0].ID) |
||||
} |
||||
|
||||
if output[1].ID != "second" { |
||||
t.Errorf("Expected second message to be 'second', got '%s'", output[1].ID) |
||||
} |
||||
|
||||
// Process remaining message
|
||||
output2, _ := mq.Tick(props, []*Request{}, 3) |
||||
|
||||
if len(output2) != 1 { |
||||
t.Errorf("Expected 1 remaining message, got %d", len(output2)) |
||||
} |
||||
|
||||
if output2[0].ID != "third" { |
||||
t.Errorf("Expected remaining message to be 'third', got '%s'", output2[0].ID) |
||||
} |
||||
} |
||||
|
||||
func TestMessageQueueLogic_DefaultValues(t *testing.T) { |
||||
mq := MessageQueueLogic{} |
||||
|
||||
// Empty props should use defaults
|
||||
props := map[string]any{} |
||||
|
||||
reqs := []*Request{{ID: "msg1", Type: "SEND"}} |
||||
output, healthy := mq.Tick(props, reqs, 1) |
||||
|
||||
if !healthy { |
||||
t.Errorf("Queue should be healthy with default values") |
||||
} |
||||
|
||||
// Should queue the message (no immediate output)
|
||||
if len(output) != 0 { |
||||
t.Errorf("Expected message to be queued (0 output), got %d", len(output)) |
||||
} |
||||
|
||||
// Check that message was queued with defaults
|
||||
messageQueue, _ := props["_messageQueue"].([]QueuedMessage) |
||||
if len(messageQueue) != 1 { |
||||
t.Errorf("Expected 1 message queued with defaults, got %d", len(messageQueue)) |
||||
} |
||||
|
||||
// Process with defaults (should process up to default rate)
|
||||
output2, _ := mq.Tick(props, []*Request{}, 2) |
||||
|
||||
if len(output2) != 1 { |
||||
t.Errorf("Expected 1 processed message with defaults, got %d", len(output2)) |
||||
} |
||||
} |
||||
|
||||
func TestMessageQueueLogic_ContinuousFlow(t *testing.T) { |
||||
mq := MessageQueueLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"queueCapacity": 5, |
||||
"retentionSeconds": 3600, |
||||
"processingRate": 2, |
||||
} |
||||
|
||||
// Tick 1: Add 3 messages
|
||||
reqs1 := []*Request{ |
||||
{ID: "msg1", Type: "SEND"}, |
||||
{ID: "msg2", Type: "SEND"}, |
||||
{ID: "msg3", Type: "SEND"}, |
||||
} |
||||
output1, _ := mq.Tick(props, reqs1, 1) |
||||
|
||||
// Should queue all 3 messages
|
||||
if len(output1) != 0 { |
||||
t.Errorf("Expected 0 output on first tick, got %d", len(output1)) |
||||
} |
||||
|
||||
// Tick 2: Add 2 more messages, process 2
|
||||
reqs2 := []*Request{ |
||||
{ID: "msg4", Type: "SEND"}, |
||||
{ID: "msg5", Type: "SEND"}, |
||||
} |
||||
output2, _ := mq.Tick(props, reqs2, 2) |
||||
|
||||
// Should process 2 messages
|
||||
if len(output2) != 2 { |
||||
t.Errorf("Expected 2 processed messages, got %d", len(output2)) |
||||
} |
||||
|
||||
// Should have 3 messages in queue (3 remaining + 2 new - 2 processed)
|
||||
messageQueue, _ := props["_messageQueue"].([]QueuedMessage) |
||||
if len(messageQueue) != 3 { |
||||
t.Errorf("Expected 3 messages in queue, got %d", len(messageQueue)) |
||||
} |
||||
|
||||
// Check processing order
|
||||
if output2[0].ID != "msg1" || output2[1].ID != "msg2" { |
||||
t.Errorf("Expected FIFO processing order, got %s, %s", output2[0].ID, output2[1].ID) |
||||
} |
||||
} |
||||
@ -0,0 +1,162 @@
@@ -0,0 +1,162 @@
|
||||
package simulation |
||||
|
||||
import "math" |
||||
|
||||
type MicroserviceLogic struct{} |
||||
|
||||
type ServiceInstance struct { |
||||
ID int |
||||
CurrentLoad int |
||||
HealthStatus string |
||||
} |
||||
|
||||
func (m MicroserviceLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { |
||||
// Extract microservice properties
|
||||
instanceCount := int(AsFloat64(props["instanceCount"])) |
||||
if instanceCount == 0 { |
||||
instanceCount = 1 // default to 1 instance
|
||||
} |
||||
|
||||
cpu := int(AsFloat64(props["cpu"])) |
||||
if cpu == 0 { |
||||
cpu = 2 // default 2 CPU cores
|
||||
} |
||||
|
||||
ramGb := int(AsFloat64(props["ramGb"])) |
||||
if ramGb == 0 { |
||||
ramGb = 4 // default 4GB RAM
|
||||
} |
||||
|
||||
rpsCapacity := int(AsFloat64(props["rpsCapacity"])) |
||||
if rpsCapacity == 0 { |
||||
rpsCapacity = 100 // default capacity per instance
|
||||
} |
||||
|
||||
scalingStrategy := AsString(props["scalingStrategy"]) |
||||
if scalingStrategy == "" { |
||||
scalingStrategy = "auto" |
||||
} |
||||
|
||||
// Calculate base latency based on resource specs
|
||||
baseLatencyMs := m.calculateBaseLatency(cpu, ramGb) |
||||
|
||||
// Auto-scaling logic: adjust instance count based on load
|
||||
currentLoad := len(queue) |
||||
if scalingStrategy == "auto" { |
||||
instanceCount = m.autoScale(instanceCount, currentLoad, rpsCapacity) |
||||
props["instanceCount"] = float64(instanceCount) // update for next tick
|
||||
} |
||||
|
||||
// Total capacity across all instances
|
||||
totalCapacity := instanceCount * rpsCapacity |
||||
|
||||
// Process requests up to total capacity
|
||||
toProcess := queue |
||||
if len(queue) > totalCapacity { |
||||
toProcess = queue[:totalCapacity] |
||||
} |
||||
|
||||
output := []*Request{} |
||||
|
||||
// Distribute requests across instances using round-robin
|
||||
for i, req := range toProcess { |
||||
|
||||
// Create processed request copy
|
||||
reqCopy := *req |
||||
|
||||
// Add microservice processing latency
|
||||
processingLatency := baseLatencyMs |
||||
|
||||
// Simulate CPU-bound vs I/O-bound operations
|
||||
if req.Type == "GET" { |
||||
processingLatency = baseLatencyMs // Fast reads
|
||||
} else if req.Type == "POST" || req.Type == "PUT" { |
||||
processingLatency = baseLatencyMs + 10 // Writes take longer
|
||||
} else if req.Type == "COMPUTE" { |
||||
processingLatency = baseLatencyMs + 50 // CPU-intensive operations
|
||||
} |
||||
|
||||
// Instance load affects latency (queuing delay)
|
||||
instanceLoad := m.calculateInstanceLoad(i, len(toProcess), instanceCount) |
||||
if float64(instanceLoad) > float64(rpsCapacity)*0.8 { // Above 80% capacity
|
||||
processingLatency += int(float64(processingLatency) * 0.5) // 50% penalty
|
||||
} |
||||
|
||||
reqCopy.LatencyMS += processingLatency |
||||
reqCopy.Path = append(reqCopy.Path, "microservice-processed") |
||||
|
||||
output = append(output, &reqCopy) |
||||
} |
||||
|
||||
// Health check: service is healthy if not severely overloaded
|
||||
healthy := len(queue) <= totalCapacity*2 // Allow some buffering
|
||||
|
||||
return output, healthy |
||||
} |
||||
|
||||
// calculateBaseLatency determines base processing time based on resources
|
||||
func (m MicroserviceLogic) calculateBaseLatency(cpu, ramGb int) int { |
||||
// Better CPU and RAM = lower base latency
|
||||
// Formula: base latency inversely proportional to resources
|
||||
cpuFactor := float64(cpu) |
||||
ramFactor := float64(ramGb) / 4.0 // Normalize to 4GB baseline
|
||||
|
||||
resourceScore := cpuFactor * ramFactor |
||||
if resourceScore < 1 { |
||||
resourceScore = 1 |
||||
} |
||||
|
||||
baseLatency := int(50.0 / resourceScore) // 50ms baseline for 2CPU/4GB
|
||||
if baseLatency < 5 { |
||||
baseLatency = 5 // Minimum 5ms processing time
|
||||
} |
||||
|
||||
return baseLatency |
||||
} |
||||
|
||||
// autoScale implements simple auto-scaling logic
|
||||
func (m MicroserviceLogic) autoScale(currentInstances, currentLoad, rpsPerInstance int) int { |
||||
// Calculate desired instances based on current load
|
||||
desiredInstances := int(math.Ceil(float64(currentLoad) / float64(rpsPerInstance))) |
||||
|
||||
// Scale up/down gradually (max 25% change per tick)
|
||||
maxChange := int(math.Max(1, float64(currentInstances)*0.25)) |
||||
|
||||
if desiredInstances > currentInstances { |
||||
// Scale up
|
||||
newInstances := currentInstances + maxChange |
||||
if newInstances > desiredInstances { |
||||
newInstances = desiredInstances |
||||
} |
||||
// Cap at reasonable maximum
|
||||
if newInstances > 20 { |
||||
newInstances = 20 |
||||
} |
||||
return newInstances |
||||
} else if desiredInstances < currentInstances { |
||||
// Scale down (more conservative)
|
||||
newInstances := currentInstances - int(math.Max(1, float64(maxChange)*0.5)) |
||||
if newInstances < desiredInstances { |
||||
newInstances = desiredInstances |
||||
} |
||||
// Always maintain at least 1 instance
|
||||
if newInstances < 1 { |
||||
newInstances = 1 |
||||
} |
||||
return newInstances |
||||
} |
||||
|
||||
return currentInstances |
||||
} |
||||
|
||||
// calculateInstanceLoad estimates load on a specific instance
|
||||
func (m MicroserviceLogic) calculateInstanceLoad(instanceID, totalRequests, instanceCount int) int { |
||||
// Simple round-robin distribution
|
||||
baseLoad := totalRequests / instanceCount |
||||
remainder := totalRequests % instanceCount |
||||
|
||||
if instanceID < remainder { |
||||
return baseLoad + 1 |
||||
} |
||||
return baseLoad |
||||
} |
||||
@ -0,0 +1,286 @@
@@ -0,0 +1,286 @@
|
||||
package simulation |
||||
|
||||
import ( |
||||
"testing" |
||||
) |
||||
|
||||
func TestMicroserviceLogic_BasicProcessing(t *testing.T) { |
||||
logic := MicroserviceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"instanceCount": 2.0, |
||||
"cpu": 4.0, |
||||
"ramGb": 8.0, |
||||
"rpsCapacity": 100.0, |
||||
"scalingStrategy": "manual", |
||||
} |
||||
|
||||
requests := []*Request{ |
||||
{ID: "1", Type: "GET", LatencyMS: 0, Path: []string{}}, |
||||
{ID: "2", Type: "POST", LatencyMS: 0, Path: []string{}}, |
||||
} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected microservice to be healthy") |
||||
} |
||||
|
||||
if len(output) != 2 { |
||||
t.Errorf("Expected 2 processed requests, got %d", len(output)) |
||||
} |
||||
|
||||
// Verify latency was added
|
||||
for _, req := range output { |
||||
if req.LatencyMS == 0 { |
||||
t.Error("Expected latency to be added to processed request") |
||||
} |
||||
if len(req.Path) == 0 || req.Path[len(req.Path)-1] != "microservice-processed" { |
||||
t.Error("Expected path to be updated with microservice-processed") |
||||
} |
||||
} |
||||
} |
||||
|
||||
func TestMicroserviceLogic_CapacityLimit(t *testing.T) { |
||||
logic := MicroserviceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"instanceCount": 1.0, |
||||
"rpsCapacity": 2.0, |
||||
"scalingStrategy": "manual", |
||||
} |
||||
|
||||
// Send 4 requests, capacity is 2 (1 instance * 2 RPS)
|
||||
// This should be healthy since 4 <= totalCapacity*2 (4)
|
||||
requests := make([]*Request, 4) |
||||
for i := range requests { |
||||
requests[i] = &Request{ID: string(rune('1' + i)), Type: "GET", LatencyMS: 0} |
||||
} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected microservice to be healthy with moderate queuing") |
||||
} |
||||
|
||||
// Should only process 2 requests (capacity limit)
|
||||
if len(output) != 2 { |
||||
t.Errorf("Expected 2 processed requests due to capacity limit, got %d", len(output)) |
||||
} |
||||
} |
||||
|
||||
func TestMicroserviceLogic_AutoScaling(t *testing.T) { |
||||
logic := MicroserviceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"instanceCount": 1.0, |
||||
"rpsCapacity": 10.0, |
||||
"scalingStrategy": "auto", |
||||
} |
||||
|
||||
// Send 25 requests to trigger scaling
|
||||
requests := make([]*Request, 25) |
||||
for i := range requests { |
||||
requests[i] = &Request{ID: string(rune('1' + i)), Type: "GET", LatencyMS: 0} |
||||
} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
// Check if instances were scaled up
|
||||
newInstanceCount := int(props["instanceCount"].(float64)) |
||||
if newInstanceCount <= 1 { |
||||
t.Error("Expected auto-scaling to increase instance count") |
||||
} |
||||
|
||||
// Should process more than 10 requests (original capacity)
|
||||
if len(output) <= 10 { |
||||
t.Errorf("Expected auto-scaling to increase processing capacity, got %d", len(output)) |
||||
} |
||||
|
||||
if !healthy { |
||||
t.Error("Expected microservice to be healthy after scaling") |
||||
} |
||||
} |
||||
|
||||
func TestMicroserviceLogic_ResourceBasedLatency(t *testing.T) { |
||||
logic := MicroserviceLogic{} |
||||
|
||||
// High-resource microservice
|
||||
highResourceProps := map[string]any{ |
||||
"instanceCount": 1.0, |
||||
"cpu": 8.0, |
||||
"ramGb": 16.0, |
||||
"rpsCapacity": 100.0, |
||||
"scalingStrategy": "manual", |
||||
} |
||||
|
||||
// Low-resource microservice
|
||||
lowResourceProps := map[string]any{ |
||||
"instanceCount": 1.0, |
||||
"cpu": 1.0, |
||||
"ramGb": 1.0, |
||||
"rpsCapacity": 100.0, |
||||
"scalingStrategy": "manual", |
||||
} |
||||
|
||||
request := []*Request{{ID: "1", Type: "GET", LatencyMS: 0, Path: []string{}}} |
||||
|
||||
highOutput, _ := logic.Tick(highResourceProps, request, 1) |
||||
lowOutput, _ := logic.Tick(lowResourceProps, request, 1) |
||||
|
||||
highLatency := highOutput[0].LatencyMS |
||||
lowLatency := lowOutput[0].LatencyMS |
||||
|
||||
if lowLatency <= highLatency { |
||||
t.Errorf("Expected low-resource microservice (%dms) to have higher latency than high-resource (%dms)", |
||||
lowLatency, highLatency) |
||||
} |
||||
} |
||||
|
||||
func TestMicroserviceLogic_RequestTypeLatency(t *testing.T) { |
||||
logic := MicroserviceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"instanceCount": 1.0, |
||||
"cpu": 2.0, |
||||
"ramGb": 4.0, |
||||
"rpsCapacity": 100.0, |
||||
"scalingStrategy": "manual", |
||||
} |
||||
|
||||
getRequest := []*Request{{ID: "1", Type: "GET", LatencyMS: 0, Path: []string{}}} |
||||
postRequest := []*Request{{ID: "2", Type: "POST", LatencyMS: 0, Path: []string{}}} |
||||
computeRequest := []*Request{{ID: "3", Type: "COMPUTE", LatencyMS: 0, Path: []string{}}} |
||||
|
||||
getOutput, _ := logic.Tick(props, getRequest, 1) |
||||
postOutput, _ := logic.Tick(props, postRequest, 1) |
||||
computeOutput, _ := logic.Tick(props, computeRequest, 1) |
||||
|
||||
getLatency := getOutput[0].LatencyMS |
||||
postLatency := postOutput[0].LatencyMS |
||||
computeLatency := computeOutput[0].LatencyMS |
||||
|
||||
if getLatency >= postLatency { |
||||
t.Errorf("Expected GET (%dms) to be faster than POST (%dms)", getLatency, postLatency) |
||||
} |
||||
|
||||
if postLatency >= computeLatency { |
||||
t.Errorf("Expected POST (%dms) to be faster than COMPUTE (%dms)", postLatency, computeLatency) |
||||
} |
||||
} |
||||
|
||||
func TestMicroserviceLogic_HighLoadLatencyPenalty(t *testing.T) { |
||||
logic := MicroserviceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"instanceCount": 1.0, |
||||
"cpu": 2.0, |
||||
"ramGb": 4.0, |
||||
"rpsCapacity": 10.0, |
||||
"scalingStrategy": "manual", |
||||
} |
||||
|
||||
// Low load scenario
|
||||
lowLoadRequest := []*Request{{ID: "1", Type: "GET", LatencyMS: 0, Path: []string{}}} |
||||
lowOutput, _ := logic.Tick(props, lowLoadRequest, 1) |
||||
lowLatency := lowOutput[0].LatencyMS |
||||
|
||||
// High load scenario (above 80% capacity threshold)
|
||||
highLoadRequests := make([]*Request, 9) // 90% of 10 RPS capacity
|
||||
for i := range highLoadRequests { |
||||
highLoadRequests[i] = &Request{ID: string(rune('1' + i)), Type: "GET", LatencyMS: 0, Path: []string{}} |
||||
} |
||||
highOutput, _ := logic.Tick(props, highLoadRequests, 1) |
||||
|
||||
// Check if first request has higher latency due to load
|
||||
highLatency := highOutput[0].LatencyMS |
||||
|
||||
if highLatency <= lowLatency { |
||||
t.Errorf("Expected high load scenario (%dms) to have higher latency than low load (%dms)", |
||||
highLatency, lowLatency) |
||||
} |
||||
} |
||||
|
||||
func TestMicroserviceLogic_DefaultValues(t *testing.T) { |
||||
logic := MicroserviceLogic{} |
||||
|
||||
// Empty props should use defaults
|
||||
props := map[string]any{} |
||||
|
||||
requests := []*Request{{ID: "1", Type: "GET", LatencyMS: 0, Path: []string{}}} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected microservice to be healthy with default values") |
||||
} |
||||
|
||||
if len(output) != 1 { |
||||
t.Errorf("Expected 1 processed request with defaults, got %d", len(output)) |
||||
} |
||||
|
||||
// Should have reasonable default latency
|
||||
if output[0].LatencyMS <= 0 || output[0].LatencyMS > 100 { |
||||
t.Errorf("Expected reasonable default latency, got %dms", output[0].LatencyMS) |
||||
} |
||||
} |
||||
|
||||
func TestMicroserviceLogic_UnhealthyWhenOverloaded(t *testing.T) { |
||||
logic := MicroserviceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"instanceCount": 1.0, |
||||
"rpsCapacity": 5.0, |
||||
"scalingStrategy": "manual", // No auto-scaling
|
||||
} |
||||
|
||||
// Send way more requests than capacity (5 * 2 = 10 max before unhealthy)
|
||||
requests := make([]*Request, 15) // 3x capacity
|
||||
for i := range requests { |
||||
requests[i] = &Request{ID: string(rune('1' + i)), Type: "GET", LatencyMS: 0} |
||||
} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if healthy { |
||||
t.Error("Expected microservice to be unhealthy when severely overloaded") |
||||
} |
||||
|
||||
// Should still process up to capacity
|
||||
if len(output) != 5 { |
||||
t.Errorf("Expected 5 processed requests despite being overloaded, got %d", len(output)) |
||||
} |
||||
} |
||||
|
||||
func TestMicroserviceLogic_RoundRobinDistribution(t *testing.T) { |
||||
logic := MicroserviceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"instanceCount": 3.0, |
||||
"rpsCapacity": 10.0, |
||||
"scalingStrategy": "manual", |
||||
} |
||||
|
||||
// Send 6 requests to be distributed across 3 instances
|
||||
requests := make([]*Request, 6) |
||||
for i := range requests { |
||||
requests[i] = &Request{ID: string(rune('1' + i)), Type: "GET", LatencyMS: 0, Path: []string{}} |
||||
} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected microservice to be healthy") |
||||
} |
||||
|
||||
if len(output) != 6 { |
||||
t.Errorf("Expected 6 processed requests, got %d", len(output)) |
||||
} |
||||
|
||||
// All requests should be processed (within total capacity of 30)
|
||||
for _, req := range output { |
||||
if req.LatencyMS <= 0 { |
||||
t.Error("Expected all requests to have added latency") |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,221 @@
@@ -0,0 +1,221 @@
|
||||
package simulation |
||||
|
||||
type MonitoringLogic struct{} |
||||
|
||||
type MetricData struct { |
||||
Timestamp int |
||||
LatencySum int |
||||
RequestCount int |
||||
ErrorCount int |
||||
QueueSize int |
||||
} |
||||
|
||||
type AlertEvent struct { |
||||
Timestamp int |
||||
MetricType string |
||||
Value float64 |
||||
Threshold float64 |
||||
Unit string |
||||
Severity string |
||||
} |
||||
|
||||
func (m MonitoringLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { |
||||
// Extract monitoring properties
|
||||
tool := AsString(props["tool"]) |
||||
if tool == "" { |
||||
tool = "Prometheus" // default monitoring tool
|
||||
} |
||||
|
||||
alertMetric := AsString(props["alertMetric"]) |
||||
if alertMetric == "" { |
||||
alertMetric = "latency" // default to latency monitoring
|
||||
} |
||||
|
||||
thresholdValue := int(AsFloat64(props["thresholdValue"])) |
||||
if thresholdValue == 0 { |
||||
thresholdValue = 100 // default threshold
|
||||
} |
||||
|
||||
thresholdUnit := AsString(props["thresholdUnit"]) |
||||
if thresholdUnit == "" { |
||||
thresholdUnit = "ms" // default unit
|
||||
} |
||||
|
||||
// Get historical metrics from props
|
||||
metrics, ok := props["_metrics"].([]MetricData) |
||||
if !ok { |
||||
metrics = []MetricData{} |
||||
} |
||||
|
||||
// Get alert history
|
||||
alerts, ok := props["_alerts"].([]AlertEvent) |
||||
if !ok { |
||||
alerts = []AlertEvent{} |
||||
} |
||||
|
||||
currentTime := tick * 100 // Convert tick to milliseconds
|
||||
|
||||
// Process all incoming requests (monitoring is pass-through)
|
||||
output := []*Request{} |
||||
totalLatency := 0 |
||||
errorCount := 0 |
||||
|
||||
for _, req := range queue { |
||||
// Create a copy of the request to forward
|
||||
reqCopy := *req |
||||
|
||||
// Add minimal monitoring overhead (1-2ms for metric collection)
|
||||
monitoringOverhead := 1 |
||||
if tool == "Datadog" || tool == "New Relic" { |
||||
monitoringOverhead = 2 // More feature-rich tools have slightly higher overhead
|
||||
} |
||||
|
||||
reqCopy.LatencyMS += monitoringOverhead |
||||
reqCopy.Path = append(reqCopy.Path, "monitored") |
||||
|
||||
// Collect metrics from the request
|
||||
totalLatency += req.LatencyMS |
||||
|
||||
// Simple heuristic: requests with high latency are considered errors
|
||||
if req.LatencyMS > 1000 { // 1 second threshold for errors
|
||||
errorCount++ |
||||
} |
||||
|
||||
output = append(output, &reqCopy) |
||||
} |
||||
|
||||
// Calculate current metrics
|
||||
avgLatency := 0.0 |
||||
if len(queue) > 0 { |
||||
avgLatency = float64(totalLatency) / float64(len(queue)) |
||||
} |
||||
|
||||
// Store current metrics
|
||||
currentMetric := MetricData{ |
||||
Timestamp: currentTime, |
||||
LatencySum: totalLatency, |
||||
RequestCount: len(queue), |
||||
ErrorCount: errorCount, |
||||
QueueSize: len(queue), |
||||
} |
||||
|
||||
// Add to metrics history (keep last 10 data points)
|
||||
metrics = append(metrics, currentMetric) |
||||
if len(metrics) > 10 { |
||||
metrics = metrics[1:] |
||||
} |
||||
|
||||
// Check alert conditions
|
||||
shouldAlert := false |
||||
alertValue := 0.0 |
||||
|
||||
switch alertMetric { |
||||
case "latency": |
||||
alertValue = avgLatency |
||||
if avgLatency > float64(thresholdValue) && len(queue) > 0 { |
||||
shouldAlert = true |
||||
} |
||||
case "throughput": |
||||
alertValue = float64(len(queue)) |
||||
if len(queue) < thresholdValue { // Low throughput alert
|
||||
shouldAlert = true |
||||
} |
||||
case "error_rate": |
||||
errorRate := 0.0 |
||||
if len(queue) > 0 { |
||||
errorRate = float64(errorCount) / float64(len(queue)) * 100 |
||||
} |
||||
alertValue = errorRate |
||||
if errorRate > float64(thresholdValue) { |
||||
shouldAlert = true |
||||
} |
||||
case "queue_size": |
||||
alertValue = float64(len(queue)) |
||||
if len(queue) > thresholdValue { |
||||
shouldAlert = true |
||||
} |
||||
} |
||||
|
||||
// Generate alert if threshold exceeded
|
||||
if shouldAlert { |
||||
severity := "warning" |
||||
if alertValue > float64(thresholdValue)*1.5 { // 150% of threshold
|
||||
severity = "critical" |
||||
} |
||||
|
||||
alert := AlertEvent{ |
||||
Timestamp: currentTime, |
||||
MetricType: alertMetric, |
||||
Value: alertValue, |
||||
Threshold: float64(thresholdValue), |
||||
Unit: thresholdUnit, |
||||
Severity: severity, |
||||
} |
||||
|
||||
// Only add alert if it's not a duplicate of the last alert
|
||||
if len(alerts) == 0 || !m.isDuplicateAlert(alerts[len(alerts)-1], alert) { |
||||
alerts = append(alerts, alert) |
||||
} |
||||
|
||||
// Keep only last 20 alerts
|
||||
if len(alerts) > 20 { |
||||
alerts = alerts[1:] |
||||
} |
||||
} |
||||
|
||||
// Update props with collected data
|
||||
props["_metrics"] = metrics |
||||
props["_alerts"] = alerts |
||||
props["_currentLatency"] = avgLatency |
||||
props["_alertCount"] = len(alerts) |
||||
|
||||
// Monitoring system health - it's healthy unless it's completely overloaded
|
||||
healthy := len(queue) < 10000 // Can handle very high loads
|
||||
|
||||
// If too many critical alerts recently, mark as unhealthy
|
||||
recentCriticalAlerts := 0 |
||||
for _, alert := range alerts { |
||||
if currentTime-alert.Timestamp < 10000 && alert.Severity == "critical" { // Last 10 seconds
|
||||
recentCriticalAlerts++ |
||||
} |
||||
} |
||||
|
||||
if recentCriticalAlerts > 5 { |
||||
healthy = false |
||||
} |
||||
|
||||
return output, healthy |
||||
} |
||||
|
||||
// isDuplicateAlert checks if an alert is similar to the previous one to avoid spam
|
||||
func (m MonitoringLogic) isDuplicateAlert(prev, current AlertEvent) bool { |
||||
return prev.MetricType == current.MetricType && |
||||
prev.Severity == current.Severity && |
||||
(current.Timestamp-prev.Timestamp) < 5000 // Within 5 seconds
|
||||
} |
||||
|
||||
// Helper function to calculate moving average
|
||||
func (m MonitoringLogic) calculateMovingAverage(metrics []MetricData, window int) float64 { |
||||
if len(metrics) == 0 { |
||||
return 0 |
||||
} |
||||
|
||||
start := 0 |
||||
if len(metrics) > window { |
||||
start = len(metrics) - window |
||||
} |
||||
|
||||
sum := 0.0 |
||||
count := 0 |
||||
for i := start; i < len(metrics); i++ { |
||||
if metrics[i].RequestCount > 0 { |
||||
sum += float64(metrics[i].LatencySum) / float64(metrics[i].RequestCount) |
||||
count++ |
||||
} |
||||
} |
||||
|
||||
if count == 0 { |
||||
return 0 |
||||
} |
||||
return sum / float64(count) |
||||
} |
||||
@ -0,0 +1,411 @@
@@ -0,0 +1,411 @@
|
||||
package simulation |
||||
|
||||
import ( |
||||
"testing" |
||||
) |
||||
|
||||
func TestMonitoringLogic_BasicPassthrough(t *testing.T) { |
||||
logic := MonitoringLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"tool": "Prometheus", |
||||
"alertMetric": "latency", |
||||
"thresholdValue": 100.0, |
||||
"thresholdUnit": "ms", |
||||
} |
||||
|
||||
requests := []*Request{ |
||||
{ID: "1", Type: "GET", LatencyMS: 50, Path: []string{}}, |
||||
{ID: "2", Type: "POST", LatencyMS: 75, Path: []string{}}, |
||||
} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected monitoring to be healthy") |
||||
} |
||||
|
||||
if len(output) != 2 { |
||||
t.Errorf("Expected 2 requests to pass through monitoring, got %d", len(output)) |
||||
} |
||||
|
||||
// Verify minimal latency overhead was added
|
||||
for i, req := range output { |
||||
originalLatency := requests[i].LatencyMS |
||||
if req.LatencyMS <= originalLatency { |
||||
t.Errorf("Expected monitoring overhead to be added to latency") |
||||
} |
||||
if req.LatencyMS > originalLatency+5 { |
||||
t.Errorf("Expected minimal monitoring overhead, got %d ms added", req.LatencyMS-originalLatency) |
||||
} |
||||
if len(req.Path) == 0 || req.Path[len(req.Path)-1] != "monitored" { |
||||
t.Error("Expected path to be updated with 'monitored'") |
||||
} |
||||
} |
||||
} |
||||
|
||||
func TestMonitoringLogic_MetricsCollection(t *testing.T) { |
||||
logic := MonitoringLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"tool": "Datadog", |
||||
"alertMetric": "latency", |
||||
"thresholdValue": 100.0, |
||||
"thresholdUnit": "ms", |
||||
} |
||||
|
||||
requests := []*Request{ |
||||
{ID: "1", Type: "GET", LatencyMS: 50}, |
||||
{ID: "2", Type: "POST", LatencyMS: 150}, |
||||
{ID: "3", Type: "GET", LatencyMS: 75}, |
||||
} |
||||
|
||||
_, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected monitoring to be healthy") |
||||
} |
||||
|
||||
// Check that metrics were collected
|
||||
metrics, ok := props["_metrics"].([]MetricData) |
||||
if !ok { |
||||
t.Error("Expected metrics to be collected in props") |
||||
} |
||||
|
||||
if len(metrics) != 1 { |
||||
t.Errorf("Expected 1 metric data point, got %d", len(metrics)) |
||||
} |
||||
|
||||
metric := metrics[0] |
||||
if metric.RequestCount != 3 { |
||||
t.Errorf("Expected 3 requests counted, got %d", metric.RequestCount) |
||||
} |
||||
|
||||
if metric.LatencySum != 275 { // 50 + 150 + 75
|
||||
t.Errorf("Expected latency sum of 275, got %d", metric.LatencySum) |
||||
} |
||||
|
||||
// Check current latency calculation
|
||||
currentLatency, ok := props["_currentLatency"].(float64) |
||||
if !ok { |
||||
t.Error("Expected current latency to be calculated") |
||||
} |
||||
|
||||
if currentLatency < 90 || currentLatency > 95 { |
||||
t.Errorf("Expected average latency around 91.67, got %f", currentLatency) |
||||
} |
||||
} |
||||
|
||||
func TestMonitoringLogic_LatencyAlert(t *testing.T) { |
||||
logic := MonitoringLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"tool": "Prometheus", |
||||
"alertMetric": "latency", |
||||
"thresholdValue": 80.0, |
||||
"thresholdUnit": "ms", |
||||
} |
||||
|
||||
// Send requests that exceed latency threshold
|
||||
requests := []*Request{ |
||||
{ID: "1", Type: "GET", LatencyMS: 100}, |
||||
{ID: "2", Type: "POST", LatencyMS: 120}, |
||||
} |
||||
|
||||
_, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected monitoring to be healthy despite alerts") |
||||
} |
||||
|
||||
// Check that alert was generated
|
||||
alerts, ok := props["_alerts"].([]AlertEvent) |
||||
if !ok { |
||||
t.Error("Expected alerts to be stored in props") |
||||
} |
||||
|
||||
if len(alerts) != 1 { |
||||
t.Errorf("Expected 1 alert to be generated, got %d", len(alerts)) |
||||
} |
||||
|
||||
alert := alerts[0] |
||||
if alert.MetricType != "latency" { |
||||
t.Errorf("Expected latency alert, got %s", alert.MetricType) |
||||
} |
||||
|
||||
if alert.Threshold != 80.0 { |
||||
t.Errorf("Expected threshold of 80, got %f", alert.Threshold) |
||||
} |
||||
|
||||
if alert.Value < 80.0 { |
||||
t.Errorf("Expected alert value to exceed threshold, got %f", alert.Value) |
||||
} |
||||
|
||||
if alert.Severity != "warning" { |
||||
t.Errorf("Expected warning severity, got %s", alert.Severity) |
||||
} |
||||
} |
||||
|
||||
func TestMonitoringLogic_ErrorRateAlert(t *testing.T) { |
||||
logic := MonitoringLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"tool": "Prometheus", |
||||
"alertMetric": "error_rate", |
||||
"thresholdValue": 20.0, // 20% error rate threshold
|
||||
"thresholdUnit": "percent", |
||||
} |
||||
|
||||
// Send mix of normal and high-latency (error) requests
|
||||
requests := []*Request{ |
||||
{ID: "1", Type: "GET", LatencyMS: 100}, // normal
|
||||
{ID: "2", Type: "POST", LatencyMS: 1200}, // error (>1000ms)
|
||||
{ID: "3", Type: "GET", LatencyMS: 200}, // normal
|
||||
{ID: "4", Type: "POST", LatencyMS: 1500}, // error
|
||||
} |
||||
|
||||
_, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected monitoring to be healthy") |
||||
} |
||||
|
||||
// Check that error rate alert was generated (50% error rate > 20% threshold)
|
||||
alerts, ok := props["_alerts"].([]AlertEvent) |
||||
if !ok { |
||||
t.Error("Expected alerts to be stored in props") |
||||
} |
||||
|
||||
if len(alerts) != 1 { |
||||
t.Errorf("Expected 1 alert to be generated, got %d", len(alerts)) |
||||
} |
||||
|
||||
alert := alerts[0] |
||||
if alert.MetricType != "error_rate" { |
||||
t.Errorf("Expected error_rate alert, got %s", alert.MetricType) |
||||
} |
||||
|
||||
if alert.Value != 50.0 { // 2 errors out of 4 requests = 50%
|
||||
t.Errorf("Expected 50%% error rate, got %f", alert.Value) |
||||
} |
||||
} |
||||
|
||||
func TestMonitoringLogic_QueueSizeAlert(t *testing.T) { |
||||
logic := MonitoringLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"tool": "Prometheus", |
||||
"alertMetric": "queue_size", |
||||
"thresholdValue": 5.0, |
||||
"thresholdUnit": "requests", |
||||
} |
||||
|
||||
// Send more requests than threshold
|
||||
requests := make([]*Request, 8) |
||||
for i := range requests { |
||||
requests[i] = &Request{ID: string(rune('1' + i)), Type: "GET", LatencyMS: 50} |
||||
} |
||||
|
||||
_, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected monitoring to be healthy with queue size alert") |
||||
} |
||||
|
||||
// Check that queue size alert was generated
|
||||
alerts, ok := props["_alerts"].([]AlertEvent) |
||||
if !ok { |
||||
t.Error("Expected alerts to be stored in props") |
||||
} |
||||
|
||||
if len(alerts) != 1 { |
||||
t.Errorf("Expected 1 alert to be generated, got %d", len(alerts)) |
||||
} |
||||
|
||||
alert := alerts[0] |
||||
if alert.MetricType != "queue_size" { |
||||
t.Errorf("Expected queue_size alert, got %s", alert.MetricType) |
||||
} |
||||
|
||||
if alert.Value != 8.0 { |
||||
t.Errorf("Expected queue size of 8, got %f", alert.Value) |
||||
} |
||||
} |
||||
|
||||
func TestMonitoringLogic_CriticalAlert(t *testing.T) { |
||||
logic := MonitoringLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"tool": "Prometheus", |
||||
"alertMetric": "latency", |
||||
"thresholdValue": 100.0, |
||||
"thresholdUnit": "ms", |
||||
} |
||||
|
||||
// Send requests with very high latency (150% of threshold)
|
||||
requests := []*Request{ |
||||
{ID: "1", Type: "GET", LatencyMS: 180}, // 180 > 150 (1.5 * 100)
|
||||
{ID: "2", Type: "POST", LatencyMS: 200}, |
||||
} |
||||
|
||||
_, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected monitoring to be healthy") |
||||
} |
||||
|
||||
alerts, ok := props["_alerts"].([]AlertEvent) |
||||
if !ok { |
||||
t.Error("Expected alerts to be stored in props") |
||||
} |
||||
|
||||
if len(alerts) != 1 { |
||||
t.Errorf("Expected 1 alert to be generated, got %d", len(alerts)) |
||||
} |
||||
|
||||
alert := alerts[0] |
||||
if alert.Severity != "critical" { |
||||
t.Errorf("Expected critical severity for high threshold breach, got %s", alert.Severity) |
||||
} |
||||
} |
||||
|
||||
func TestMonitoringLogic_DuplicateAlertSuppression(t *testing.T) { |
||||
logic := MonitoringLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"tool": "Prometheus", |
||||
"alertMetric": "latency", |
||||
"thresholdValue": 80.0, |
||||
"thresholdUnit": "ms", |
||||
} |
||||
|
||||
requests := []*Request{ |
||||
{ID: "1", Type: "GET", LatencyMS: 100}, |
||||
} |
||||
|
||||
// First tick - should generate alert
|
||||
logic.Tick(props, requests, 1) |
||||
|
||||
alerts, _ := props["_alerts"].([]AlertEvent) |
||||
if len(alerts) != 1 { |
||||
t.Errorf("Expected 1 alert after first tick, got %d", len(alerts)) |
||||
} |
||||
|
||||
// Second tick immediately after - should suppress duplicate
|
||||
logic.Tick(props, requests, 2) |
||||
|
||||
alerts, _ = props["_alerts"].([]AlertEvent) |
||||
if len(alerts) != 1 { |
||||
t.Errorf("Expected duplicate alert to be suppressed, got %d alerts", len(alerts)) |
||||
} |
||||
} |
||||
|
||||
func TestMonitoringLogic_DefaultValues(t *testing.T) { |
||||
logic := MonitoringLogic{} |
||||
|
||||
// Empty props should use defaults
|
||||
props := map[string]any{} |
||||
|
||||
requests := []*Request{{ID: "1", Type: "GET", LatencyMS: 50, Path: []string{}}} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected monitoring to be healthy with default values") |
||||
} |
||||
|
||||
if len(output) != 1 { |
||||
t.Errorf("Expected 1 request to pass through, got %d", len(output)) |
||||
} |
||||
|
||||
// Should have reasonable default monitoring overhead
|
||||
if output[0].LatencyMS <= 50 || output[0].LatencyMS > 55 { |
||||
t.Errorf("Expected default monitoring overhead, got %dms total", output[0].LatencyMS) |
||||
} |
||||
} |
||||
|
||||
func TestMonitoringLogic_ToolSpecificOverhead(t *testing.T) { |
||||
logic := MonitoringLogic{} |
||||
|
||||
// Test Prometheus (lower overhead)
|
||||
propsPrometheus := map[string]any{ |
||||
"tool": "Prometheus", |
||||
} |
||||
|
||||
// Test Datadog (higher overhead)
|
||||
propsDatadog := map[string]any{ |
||||
"tool": "Datadog", |
||||
} |
||||
|
||||
request := []*Request{{ID: "1", Type: "GET", LatencyMS: 50, Path: []string{}}} |
||||
|
||||
prometheusOutput, _ := logic.Tick(propsPrometheus, request, 1) |
||||
datadogOutput, _ := logic.Tick(propsDatadog, request, 1) |
||||
|
||||
prometheusOverhead := prometheusOutput[0].LatencyMS - 50 |
||||
datadogOverhead := datadogOutput[0].LatencyMS - 50 |
||||
|
||||
if datadogOverhead <= prometheusOverhead { |
||||
t.Errorf("Expected Datadog (%dms) to have higher overhead than Prometheus (%dms)", |
||||
datadogOverhead, prometheusOverhead) |
||||
} |
||||
} |
||||
|
||||
func TestMonitoringLogic_UnhealthyWithManyAlerts(t *testing.T) { |
||||
logic := MonitoringLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"tool": "Prometheus", |
||||
"alertMetric": "latency", |
||||
"thresholdValue": 50.0, |
||||
"thresholdUnit": "ms", |
||||
} |
||||
|
||||
// Manually create many recent critical alerts to simulate an unhealthy state
|
||||
currentTime := 10000 // 10 seconds
|
||||
recentAlerts := []AlertEvent{ |
||||
{Timestamp: currentTime - 1000, MetricType: "latency", Severity: "critical", Value: 200}, |
||||
{Timestamp: currentTime - 2000, MetricType: "latency", Severity: "critical", Value: 180}, |
||||
{Timestamp: currentTime - 3000, MetricType: "latency", Severity: "critical", Value: 190}, |
||||
{Timestamp: currentTime - 4000, MetricType: "latency", Severity: "critical", Value: 170}, |
||||
{Timestamp: currentTime - 5000, MetricType: "latency", Severity: "critical", Value: 160}, |
||||
{Timestamp: currentTime - 6000, MetricType: "latency", Severity: "critical", Value: 150}, |
||||
} |
||||
|
||||
// Set up the props with existing critical alerts
|
||||
props["_alerts"] = recentAlerts |
||||
|
||||
// Make a request that would trigger another alert (low latency to avoid triggering new alert)
|
||||
requests := []*Request{{ID: "1", Type: "GET", LatencyMS: 40}} |
||||
|
||||
// This tick should recognize the existing critical alerts and mark system as unhealthy
|
||||
_, healthy := logic.Tick(props, requests, 100) // tick 100 = 10000ms
|
||||
|
||||
if healthy { |
||||
t.Error("Expected monitoring to be unhealthy due to many recent critical alerts") |
||||
} |
||||
} |
||||
|
||||
func TestMonitoringLogic_MetricsHistoryLimit(t *testing.T) { |
||||
logic := MonitoringLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"tool": "Prometheus", |
||||
} |
||||
|
||||
request := []*Request{{ID: "1", Type: "GET", LatencyMS: 50}} |
||||
|
||||
// Generate more than 10 metric data points
|
||||
for i := 0; i < 15; i++ { |
||||
logic.Tick(props, request, i) |
||||
} |
||||
|
||||
metrics, ok := props["_metrics"].([]MetricData) |
||||
if !ok { |
||||
t.Error("Expected metrics to be stored") |
||||
} |
||||
|
||||
if len(metrics) != 10 { |
||||
t.Errorf("Expected metrics history to be limited to 10, got %d", len(metrics)) |
||||
} |
||||
} |
||||
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
{ |
||||
"nodes": [ |
||||
{ |
||||
"id": "webserver", |
||||
"type": "webserver", |
||||
"position": { "x": 0, "y": 0 }, |
||||
"props": { |
||||
"label": "Web Server", |
||||
"rpsCapacity": 100 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "cache", |
||||
"type": "cache", |
||||
"position": { "x": 100, "y": 0 }, |
||||
"props": { |
||||
"label": "Redis Cache", |
||||
"cacheTTL": 300000, |
||||
"maxEntries": 1000, |
||||
"evictionPolicy": "LRU" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "database", |
||||
"type": "database", |
||||
"position": { "x": 200, "y": 0 }, |
||||
"props": { |
||||
"label": "Primary DB", |
||||
"replication": 2, |
||||
"maxRPS": 500, |
||||
"baseLatencyMs": 20 |
||||
} |
||||
} |
||||
], |
||||
"connections": [ |
||||
{ |
||||
"source": "webserver", |
||||
"target": "cache", |
||||
"label": "Cache Lookup", |
||||
"direction": "forward", |
||||
"protocol": "Redis", |
||||
"tls": false, |
||||
"capacity": 1000 |
||||
}, |
||||
{ |
||||
"source": "cache", |
||||
"target": "database", |
||||
"label": "Cache Miss", |
||||
"direction": "forward", |
||||
"protocol": "TCP", |
||||
"tls": true, |
||||
"capacity": 1000 |
||||
} |
||||
] |
||||
} |
||||
@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
|
||||
{ |
||||
"nodes": [ |
||||
{ |
||||
"id": "webserver", |
||||
"type": "webserver", |
||||
"position": { "x": 0, "y": 0 }, |
||||
"props": { |
||||
"label": "Web Server", |
||||
"rpsCapacity": 100 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "database", |
||||
"type": "database", |
||||
"position": { "x": 100, "y": 0 }, |
||||
"props": { |
||||
"label": "Primary DB", |
||||
"replication": 2, |
||||
"maxRPS": 500, |
||||
"baseLatencyMs": 15 |
||||
} |
||||
} |
||||
], |
||||
"connections": [ |
||||
{ |
||||
"source": "webserver", |
||||
"target": "database", |
||||
"label": "DB Queries", |
||||
"direction": "forward", |
||||
"protocol": "TCP", |
||||
"tls": true, |
||||
"capacity": 1000 |
||||
} |
||||
] |
||||
} |
||||
@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
|
||||
{ |
||||
"nodes": [ |
||||
{ |
||||
"id": "data-source", |
||||
"type": "webserver", |
||||
"position": { "x": 100, "y": 200 }, |
||||
"props": { |
||||
"label": "Data Ingestion API", |
||||
"rpsCapacity": 500 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "raw-data-queue", |
||||
"type": "messageQueue", |
||||
"position": { "x": 300, "y": 200 }, |
||||
"props": { |
||||
"label": "Raw Data Queue", |
||||
"queueCapacity": 10000, |
||||
"retentionSeconds": 3600, |
||||
"processingRate": 200 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "etl-pipeline-1", |
||||
"type": "data pipeline", |
||||
"position": { "x": 500, "y": 150 }, |
||||
"props": { |
||||
"label": "Data Cleansing Pipeline", |
||||
"batchSize": 100, |
||||
"transformation": "validate" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "etl-pipeline-2", |
||||
"type": "data pipeline", |
||||
"position": { "x": 500, "y": 250 }, |
||||
"props": { |
||||
"label": "Data Transformation Pipeline", |
||||
"batchSize": 50, |
||||
"transformation": "aggregate" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "ml-pipeline", |
||||
"type": "data pipeline", |
||||
"position": { "x": 700, "y": 150 }, |
||||
"props": { |
||||
"label": "ML Feature Pipeline", |
||||
"batchSize": 200, |
||||
"transformation": "enrich" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "analytics-pipeline", |
||||
"type": "data pipeline", |
||||
"position": { "x": 700, "y": 250 }, |
||||
"props": { |
||||
"label": "Analytics Pipeline", |
||||
"batchSize": 500, |
||||
"transformation": "join" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "cache-1", |
||||
"type": "cache", |
||||
"position": { "x": 900, "y": 150 }, |
||||
"props": { |
||||
"label": "Feature Cache", |
||||
"cacheTTL": 300, |
||||
"maxEntries": 50000, |
||||
"evictionPolicy": "LRU" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "data-warehouse", |
||||
"type": "database", |
||||
"position": { "x": 900, "y": 250 }, |
||||
"props": { |
||||
"label": "Data Warehouse", |
||||
"replication": 3, |
||||
"maxRPS": 1000, |
||||
"baseLatencyMs": 50 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "monitoring-1", |
||||
"type": "monitoring/alerting", |
||||
"position": { "x": 500, "y": 350 }, |
||||
"props": { |
||||
"label": "Pipeline Monitor", |
||||
"tool": "Datadog", |
||||
"alertMetric": "latency", |
||||
"thresholdValue": 1000, |
||||
"thresholdUnit": "ms" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "compression-pipeline", |
||||
"type": "data pipeline", |
||||
"position": { "x": 300, "y": 350 }, |
||||
"props": { |
||||
"label": "Data Compression", |
||||
"batchSize": 1000, |
||||
"transformation": "compress" |
||||
} |
||||
} |
||||
], |
||||
"connections": [ |
||||
{ |
||||
"source": "data-source", |
||||
"target": "raw-data-queue", |
||||
"label": "Raw Data Stream", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "raw-data-queue", |
||||
"target": "etl-pipeline-1", |
||||
"label": "Data Validation", |
||||
"protocol": "tcp" |
||||
}, |
||||
{ |
||||
"source": "raw-data-queue", |
||||
"target": "etl-pipeline-2", |
||||
"label": "Data Transformation", |
||||
"protocol": "tcp" |
||||
}, |
||||
{ |
||||
"source": "etl-pipeline-1", |
||||
"target": "ml-pipeline", |
||||
"label": "Clean Data", |
||||
"protocol": "tcp" |
||||
}, |
||||
{ |
||||
"source": "etl-pipeline-2", |
||||
"target": "analytics-pipeline", |
||||
"label": "Transformed Data", |
||||
"protocol": "tcp" |
||||
}, |
||||
{ |
||||
"source": "ml-pipeline", |
||||
"target": "cache-1", |
||||
"label": "ML Features", |
||||
"protocol": "tcp" |
||||
}, |
||||
{ |
||||
"source": "analytics-pipeline", |
||||
"target": "data-warehouse", |
||||
"label": "Analytics Data", |
||||
"protocol": "tcp" |
||||
}, |
||||
{ |
||||
"source": "etl-pipeline-1", |
||||
"target": "monitoring-1", |
||||
"label": "Pipeline Metrics", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "etl-pipeline-2", |
||||
"target": "monitoring-1", |
||||
"label": "Pipeline Metrics", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "ml-pipeline", |
||||
"target": "monitoring-1", |
||||
"label": "Pipeline Metrics", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "analytics-pipeline", |
||||
"target": "monitoring-1", |
||||
"label": "Pipeline Metrics", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "raw-data-queue", |
||||
"target": "compression-pipeline", |
||||
"label": "Archive Stream", |
||||
"protocol": "tcp" |
||||
}, |
||||
{ |
||||
"source": "compression-pipeline", |
||||
"target": "data-warehouse", |
||||
"label": "Compressed Archive", |
||||
"protocol": "tcp" |
||||
} |
||||
] |
||||
} |
||||
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
{ |
||||
"nodes": [ |
||||
{ |
||||
"id": "producer", |
||||
"type": "webserver", |
||||
"position": { "x": 0, "y": 0 }, |
||||
"props": { |
||||
"label": "Message Producer", |
||||
"rpsCapacity": 50 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "messagequeue", |
||||
"type": "messageQueue", |
||||
"position": { "x": 100, "y": 0 }, |
||||
"props": { |
||||
"label": "Event Queue", |
||||
"queueCapacity": 1000, |
||||
"retentionSeconds": 3600, |
||||
"processingRate": 100 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "consumer", |
||||
"type": "webserver", |
||||
"position": { "x": 200, "y": 0 }, |
||||
"props": { |
||||
"label": "Message Consumer", |
||||
"rpsCapacity": 80 |
||||
} |
||||
} |
||||
], |
||||
"connections": [ |
||||
{ |
||||
"source": "producer", |
||||
"target": "messagequeue", |
||||
"label": "Publish Messages", |
||||
"direction": "forward", |
||||
"protocol": "AMQP", |
||||
"tls": false, |
||||
"capacity": 1000 |
||||
}, |
||||
{ |
||||
"source": "messagequeue", |
||||
"target": "consumer", |
||||
"label": "Consume Messages", |
||||
"direction": "forward", |
||||
"protocol": "AMQP", |
||||
"tls": false, |
||||
"capacity": 1000 |
||||
} |
||||
] |
||||
} |
||||
@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
|
||||
{ |
||||
"nodes": [ |
||||
{ |
||||
"id": "webserver-1", |
||||
"type": "webserver", |
||||
"position": { "x": 100, "y": 200 }, |
||||
"props": { |
||||
"label": "API Gateway", |
||||
"rpsCapacity": 200 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "lb-1", |
||||
"type": "loadbalancer", |
||||
"position": { "x": 300, "y": 200 }, |
||||
"props": { |
||||
"label": "API Gateway", |
||||
"algorithm": "round-robin" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "microservice-1", |
||||
"type": "microservice", |
||||
"position": { "x": 500, "y": 150 }, |
||||
"props": { |
||||
"label": "User Service", |
||||
"instanceCount": 3, |
||||
"cpu": 4, |
||||
"ramGb": 8, |
||||
"rpsCapacity": 100, |
||||
"monthlyUsd": 150, |
||||
"scalingStrategy": "auto", |
||||
"apiVersion": "v2" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "microservice-2", |
||||
"type": "microservice", |
||||
"position": { "x": 500, "y": 250 }, |
||||
"props": { |
||||
"label": "Order Service", |
||||
"instanceCount": 2, |
||||
"cpu": 2, |
||||
"ramGb": 4, |
||||
"rpsCapacity": 80, |
||||
"monthlyUsd": 90, |
||||
"scalingStrategy": "manual", |
||||
"apiVersion": "v1" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "db-1", |
||||
"type": "database", |
||||
"position": { "x": 700, "y": 200 }, |
||||
"props": { |
||||
"label": "PostgreSQL", |
||||
"replication": 2, |
||||
"maxRPS": 500, |
||||
"baseLatencyMs": 15 |
||||
} |
||||
} |
||||
], |
||||
"connections": [ |
||||
{ |
||||
"source": "webserver-1", |
||||
"target": "lb-1", |
||||
"label": "HTTPS Requests", |
||||
"protocol": "https", |
||||
"tls": true |
||||
}, |
||||
{ |
||||
"source": "lb-1", |
||||
"target": "microservice-1", |
||||
"label": "User API", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "lb-1", |
||||
"target": "microservice-2", |
||||
"label": "Order API", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "microservice-1", |
||||
"target": "db-1", |
||||
"label": "User Queries", |
||||
"protocol": "tcp" |
||||
}, |
||||
{ |
||||
"source": "microservice-2", |
||||
"target": "db-1", |
||||
"label": "Order Queries", |
||||
"protocol": "tcp" |
||||
} |
||||
] |
||||
} |
||||
@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
|
||||
{ |
||||
"nodes": [ |
||||
{ |
||||
"id": "webserver-1", |
||||
"type": "webserver", |
||||
"position": { "x": 100, "y": 200 }, |
||||
"props": { |
||||
"label": "Web Server", |
||||
"rpsCapacity": 100 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "monitor-1", |
||||
"type": "monitoring/alerting", |
||||
"position": { "x": 300, "y": 200 }, |
||||
"props": { |
||||
"label": "Prometheus Monitor", |
||||
"tool": "Prometheus", |
||||
"alertMetric": "latency", |
||||
"thresholdValue": 80, |
||||
"thresholdUnit": "ms" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "lb-1", |
||||
"type": "loadbalancer", |
||||
"position": { "x": 500, "y": 200 }, |
||||
"props": { |
||||
"label": "Load Balancer", |
||||
"algorithm": "round-robin" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "microservice-1", |
||||
"type": "microservice", |
||||
"position": { "x": 700, "y": 150 }, |
||||
"props": { |
||||
"label": "User Service", |
||||
"instanceCount": 2, |
||||
"cpu": 2, |
||||
"ramGb": 4, |
||||
"rpsCapacity": 50, |
||||
"scalingStrategy": "auto" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "microservice-2", |
||||
"type": "microservice", |
||||
"position": { "x": 700, "y": 250 }, |
||||
"props": { |
||||
"label": "Order Service", |
||||
"instanceCount": 1, |
||||
"cpu": 1, |
||||
"ramGb": 2, |
||||
"rpsCapacity": 30, |
||||
"scalingStrategy": "manual" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "monitor-2", |
||||
"type": "monitoring/alerting", |
||||
"position": { "x": 900, "y": 200 }, |
||||
"props": { |
||||
"label": "Error Rate Monitor", |
||||
"tool": "Datadog", |
||||
"alertMetric": "error_rate", |
||||
"thresholdValue": 5, |
||||
"thresholdUnit": "percent" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "db-1", |
||||
"type": "database", |
||||
"position": { "x": 1100, "y": 200 }, |
||||
"props": { |
||||
"label": "PostgreSQL", |
||||
"replication": 2, |
||||
"maxRPS": 200, |
||||
"baseLatencyMs": 15 |
||||
} |
||||
} |
||||
], |
||||
"connections": [ |
||||
{ |
||||
"source": "webserver-1", |
||||
"target": "monitor-1", |
||||
"label": "HTTP Requests", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "monitor-1", |
||||
"target": "lb-1", |
||||
"label": "Monitored Requests", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "lb-1", |
||||
"target": "microservice-1", |
||||
"label": "User API", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "lb-1", |
||||
"target": "microservice-2", |
||||
"label": "Order API", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "microservice-1", |
||||
"target": "monitor-2", |
||||
"label": "Service Metrics", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "microservice-2", |
||||
"target": "monitor-2", |
||||
"label": "Service Metrics", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "monitor-2", |
||||
"target": "db-1", |
||||
"label": "Database Queries", |
||||
"protocol": "tcp" |
||||
} |
||||
] |
||||
} |
||||
@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
|
||||
{ |
||||
"nodes": [ |
||||
{ |
||||
"id": "webserver-1", |
||||
"type": "webserver", |
||||
"position": { "x": 100, "y": 200 }, |
||||
"props": { |
||||
"label": "E-commerce API", |
||||
"rpsCapacity": 200 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "microservice-1", |
||||
"type": "microservice", |
||||
"position": { "x": 300, "y": 200 }, |
||||
"props": { |
||||
"label": "Payment Service", |
||||
"instanceCount": 2, |
||||
"cpu": 4, |
||||
"ramGb": 8, |
||||
"rpsCapacity": 100, |
||||
"scalingStrategy": "auto" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "stripe-service", |
||||
"type": "third party service", |
||||
"position": { "x": 500, "y": 150 }, |
||||
"props": { |
||||
"label": "Stripe Payments", |
||||
"provider": "Stripe", |
||||
"latency": 180 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "twilio-service", |
||||
"type": "third party service", |
||||
"position": { "x": 500, "y": 250 }, |
||||
"props": { |
||||
"label": "SMS Notifications", |
||||
"provider": "Twilio", |
||||
"latency": 250 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "microservice-2", |
||||
"type": "microservice", |
||||
"position": { "x": 300, "y": 350 }, |
||||
"props": { |
||||
"label": "Notification Service", |
||||
"instanceCount": 1, |
||||
"cpu": 2, |
||||
"ramGb": 4, |
||||
"rpsCapacity": 50, |
||||
"scalingStrategy": "manual" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "sendgrid-service", |
||||
"type": "third party service", |
||||
"position": { "x": 500, "y": 350 }, |
||||
"props": { |
||||
"label": "Email Service", |
||||
"provider": "SendGrid", |
||||
"latency": 200 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "slack-service", |
||||
"type": "third party service", |
||||
"position": { "x": 500, "y": 450 }, |
||||
"props": { |
||||
"label": "Slack Alerts", |
||||
"provider": "Slack", |
||||
"latency": 300 |
||||
} |
||||
}, |
||||
{ |
||||
"id": "monitor-1", |
||||
"type": "monitoring/alerting", |
||||
"position": { "x": 700, "y": 200 }, |
||||
"props": { |
||||
"label": "System Monitor", |
||||
"tool": "Datadog", |
||||
"alertMetric": "latency", |
||||
"thresholdValue": 500, |
||||
"thresholdUnit": "ms" |
||||
} |
||||
}, |
||||
{ |
||||
"id": "db-1", |
||||
"type": "database", |
||||
"position": { "x": 700, "y": 350 }, |
||||
"props": { |
||||
"label": "Transaction DB", |
||||
"replication": 2, |
||||
"maxRPS": 300, |
||||
"baseLatencyMs": 20 |
||||
} |
||||
} |
||||
], |
||||
"connections": [ |
||||
{ |
||||
"source": "webserver-1", |
||||
"target": "microservice-1", |
||||
"label": "Payment Requests", |
||||
"protocol": "https" |
||||
}, |
||||
{ |
||||
"source": "microservice-1", |
||||
"target": "stripe-service", |
||||
"label": "Process Payment", |
||||
"protocol": "https" |
||||
}, |
||||
{ |
||||
"source": "microservice-1", |
||||
"target": "twilio-service", |
||||
"label": "SMS Confirmation", |
||||
"protocol": "https" |
||||
}, |
||||
{ |
||||
"source": "webserver-1", |
||||
"target": "microservice-2", |
||||
"label": "Notification Requests", |
||||
"protocol": "https" |
||||
}, |
||||
{ |
||||
"source": "microservice-2", |
||||
"target": "sendgrid-service", |
||||
"label": "Send Email", |
||||
"protocol": "https" |
||||
}, |
||||
{ |
||||
"source": "microservice-2", |
||||
"target": "slack-service", |
||||
"label": "Admin Alerts", |
||||
"protocol": "https" |
||||
}, |
||||
{ |
||||
"source": "stripe-service", |
||||
"target": "monitor-1", |
||||
"label": "Payment Metrics", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "twilio-service", |
||||
"target": "monitor-1", |
||||
"label": "SMS Metrics", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "sendgrid-service", |
||||
"target": "monitor-1", |
||||
"label": "Email Metrics", |
||||
"protocol": "http" |
||||
}, |
||||
{ |
||||
"source": "monitor-1", |
||||
"target": "db-1", |
||||
"label": "Store Metrics", |
||||
"protocol": "tcp" |
||||
} |
||||
] |
||||
} |
||||
@ -0,0 +1,219 @@
@@ -0,0 +1,219 @@
|
||||
package simulation |
||||
|
||||
import ( |
||||
"math/rand" |
||||
) |
||||
|
||||
type ThirdPartyServiceLogic struct{} |
||||
|
||||
type ServiceStatus struct { |
||||
IsUp bool |
||||
LastCheck int |
||||
FailureCount int |
||||
SuccessCount int |
||||
RateLimitHits int |
||||
} |
||||
|
||||
func (t ThirdPartyServiceLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { |
||||
// Extract third-party service properties
|
||||
provider := AsString(props["provider"]) |
||||
if provider == "" { |
||||
provider = "Generic" // default provider
|
||||
} |
||||
|
||||
baseLatency := int(AsFloat64(props["latency"])) |
||||
if baseLatency == 0 { |
||||
baseLatency = 200 // default 200ms latency
|
||||
} |
||||
|
||||
// Get service status from props (persistent state)
|
||||
status, ok := props["_serviceStatus"].(ServiceStatus) |
||||
if !ok { |
||||
status = ServiceStatus{ |
||||
IsUp: true, |
||||
LastCheck: tick, |
||||
FailureCount: 0, |
||||
SuccessCount: 0, |
||||
RateLimitHits: 0, |
||||
} |
||||
} |
||||
|
||||
currentTime := tick * 100 // Convert tick to milliseconds
|
||||
|
||||
// Simulate service availability and characteristics based on provider
|
||||
reliability := t.getProviderReliability(provider) |
||||
rateLimitRPS := t.getProviderRateLimit(provider) |
||||
latencyVariance := t.getProviderLatencyVariance(provider) |
||||
|
||||
// Check if service is down and should recover
|
||||
if !status.IsUp { |
||||
// Services typically recover after some time
|
||||
if currentTime-status.LastCheck > 30000 { // 30 seconds downtime
|
||||
status.IsUp = true |
||||
status.FailureCount = 0 |
||||
} |
||||
} |
||||
|
||||
// Apply rate limiting - third-party services often have strict limits
|
||||
requestsThisTick := len(queue) |
||||
if requestsThisTick > rateLimitRPS { |
||||
status.RateLimitHits++ |
||||
// Only process up to rate limit
|
||||
queue = queue[:rateLimitRPS] |
||||
} |
||||
|
||||
output := []*Request{} |
||||
|
||||
for _, req := range queue { |
||||
reqCopy := *req |
||||
|
||||
// Simulate service availability
|
||||
if !status.IsUp { |
||||
// Service is down - simulate timeout/error
|
||||
reqCopy.LatencyMS += 10000 // 10 second timeout
|
||||
reqCopy.Path = append(reqCopy.Path, "third-party-timeout") |
||||
status.FailureCount++ |
||||
} else { |
||||
// Service is up - calculate response time
|
||||
serviceLatency := t.calculateServiceLatency(provider, baseLatency, latencyVariance) |
||||
|
||||
// Random failure based on reliability
|
||||
if rand.Float64() > reliability { |
||||
// Service call failed
|
||||
serviceLatency += 5000 // 5 second timeout on failure
|
||||
reqCopy.Path = append(reqCopy.Path, "third-party-failed") |
||||
status.FailureCount++ |
||||
|
||||
// If too many failures, mark service as down
|
||||
if status.FailureCount > 5 { |
||||
status.IsUp = false |
||||
status.LastCheck = currentTime |
||||
} |
||||
} else { |
||||
// Successful service call
|
||||
reqCopy.Path = append(reqCopy.Path, "third-party-success") |
||||
status.SuccessCount++ |
||||
|
||||
// Reset failure count on successful calls
|
||||
if status.FailureCount > 0 { |
||||
status.FailureCount-- |
||||
} |
||||
} |
||||
|
||||
reqCopy.LatencyMS += serviceLatency |
||||
} |
||||
|
||||
output = append(output, &reqCopy) |
||||
} |
||||
|
||||
// Update persistent state
|
||||
props["_serviceStatus"] = status |
||||
|
||||
// Health check: service is healthy if external service is up and not excessively rate limited
|
||||
// Allow some rate limiting but not too much
|
||||
maxRateLimitHits := 10 // Allow up to 10 rate limit hits before considering unhealthy
|
||||
healthy := status.IsUp && status.RateLimitHits < maxRateLimitHits |
||||
|
||||
return output, healthy |
||||
} |
||||
|
||||
// getProviderReliability returns the reliability percentage for different providers
|
||||
func (t ThirdPartyServiceLogic) getProviderReliability(provider string) float64 { |
||||
switch provider { |
||||
case "Stripe": |
||||
return 0.999 // 99.9% uptime
|
||||
case "Twilio": |
||||
return 0.998 // 99.8% uptime
|
||||
case "SendGrid": |
||||
return 0.997 // 99.7% uptime
|
||||
case "AWS": |
||||
return 0.9995 // 99.95% uptime
|
||||
case "Google": |
||||
return 0.9999 // 99.99% uptime
|
||||
case "Slack": |
||||
return 0.995 // 99.5% uptime
|
||||
case "GitHub": |
||||
return 0.996 // 99.6% uptime
|
||||
case "Shopify": |
||||
return 0.998 // 99.8% uptime
|
||||
default: |
||||
return 0.99 // 99% uptime for generic services
|
||||
} |
||||
} |
||||
|
||||
// getProviderRateLimit returns the rate limit (requests per tick) for different providers
|
||||
func (t ThirdPartyServiceLogic) getProviderRateLimit(provider string) int { |
||||
switch provider { |
||||
case "Stripe": |
||||
return 100 // 100 requests per second (per tick in our sim)
|
||||
case "Twilio": |
||||
return 50 // More restrictive
|
||||
case "SendGrid": |
||||
return 200 // Email is typically higher volume
|
||||
case "AWS": |
||||
return 1000 // Very high limits
|
||||
case "Google": |
||||
return 500 // High but controlled
|
||||
case "Slack": |
||||
return 30 // Very restrictive for chat APIs
|
||||
case "GitHub": |
||||
return 60 // GitHub API limits
|
||||
case "Shopify": |
||||
return 80 // E-commerce API limits
|
||||
default: |
||||
return 100 // Default rate limit
|
||||
} |
||||
} |
||||
|
||||
// getProviderLatencyVariance returns the latency variance factor for different providers
|
||||
func (t ThirdPartyServiceLogic) getProviderLatencyVariance(provider string) float64 { |
||||
switch provider { |
||||
case "Stripe": |
||||
return 0.3 // Low variance, consistent performance
|
||||
case "Twilio": |
||||
return 0.5 // Moderate variance
|
||||
case "SendGrid": |
||||
return 0.4 // Email services are fairly consistent
|
||||
case "AWS": |
||||
return 0.2 // Very consistent
|
||||
case "Google": |
||||
return 0.25 // Very consistent
|
||||
case "Slack": |
||||
return 0.6 // Chat services can be variable
|
||||
case "GitHub": |
||||
return 0.4 // Moderate variance
|
||||
case "Shopify": |
||||
return 0.5 // E-commerce can be variable under load
|
||||
default: |
||||
return 0.5 // Default variance
|
||||
} |
||||
} |
||||
|
||||
// calculateServiceLatency computes the actual latency including variance
|
||||
func (t ThirdPartyServiceLogic) calculateServiceLatency(provider string, baseLatency int, variance float64) int { |
||||
// Add random variance to base latency
|
||||
varianceMs := float64(baseLatency) * variance |
||||
randomVariance := (rand.Float64() - 0.5) * 2 * varianceMs // -variance to +variance
|
||||
|
||||
finalLatency := float64(baseLatency) + randomVariance |
||||
|
||||
// Ensure minimum latency (can't be negative or too low)
|
||||
if finalLatency < 10 { |
||||
finalLatency = 10 |
||||
} |
||||
|
||||
// Add provider-specific baseline adjustments
|
||||
switch provider { |
||||
case "AWS", "Google": |
||||
// Cloud providers are typically fast
|
||||
finalLatency *= 0.8 |
||||
case "Slack": |
||||
// Chat APIs can be slower
|
||||
finalLatency *= 1.2 |
||||
case "Twilio": |
||||
// Telecom APIs have processing overhead
|
||||
finalLatency *= 1.1 |
||||
} |
||||
|
||||
return int(finalLatency) |
||||
} |
||||
@ -0,0 +1,382 @@
@@ -0,0 +1,382 @@
|
||||
package simulation |
||||
|
||||
import ( |
||||
"testing" |
||||
) |
||||
|
||||
func TestThirdPartyServiceLogic_BasicProcessing(t *testing.T) { |
||||
logic := ThirdPartyServiceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"provider": "Stripe", |
||||
"latency": 150.0, |
||||
} |
||||
|
||||
requests := []*Request{ |
||||
{ID: "1", Type: "POST", LatencyMS: 50, Path: []string{}}, |
||||
{ID: "2", Type: "GET", LatencyMS: 30, Path: []string{}}, |
||||
} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected third party service to be healthy") |
||||
} |
||||
|
||||
if len(output) != 2 { |
||||
t.Errorf("Expected 2 processed requests, got %d", len(output)) |
||||
} |
||||
|
||||
// Verify latency was added (should be around base latency with some variance)
|
||||
for i, req := range output { |
||||
originalLatency := requests[i].LatencyMS |
||||
if req.LatencyMS <= originalLatency { |
||||
t.Errorf("Expected third party service latency to be added") |
||||
} |
||||
|
||||
// Check that path was updated
|
||||
if len(req.Path) == 0 { |
||||
t.Error("Expected path to be updated") |
||||
} |
||||
|
||||
lastPathElement := req.Path[len(req.Path)-1] |
||||
if lastPathElement != "third-party-success" && lastPathElement != "third-party-failed" { |
||||
t.Errorf("Expected path to indicate success or failure, got %s", lastPathElement) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func TestThirdPartyServiceLogic_ProviderCharacteristics(t *testing.T) { |
||||
logic := ThirdPartyServiceLogic{} |
||||
|
||||
providers := []string{"Stripe", "AWS", "Slack", "Twilio"} |
||||
|
||||
for _, provider := range providers { |
||||
t.Run(provider, func(t *testing.T) { |
||||
props := map[string]any{ |
||||
"provider": provider, |
||||
"latency": 100.0, |
||||
} |
||||
|
||||
requests := []*Request{{ID: "1", Type: "POST", LatencyMS: 0, Path: []string{}}} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Errorf("Expected %s service to be healthy", provider) |
||||
} |
||||
|
||||
if len(output) != 1 { |
||||
t.Errorf("Expected 1 processed request for %s", provider) |
||||
} |
||||
|
||||
// Verify latency characteristics
|
||||
addedLatency := output[0].LatencyMS |
||||
if addedLatency <= 0 { |
||||
t.Errorf("Expected %s to add latency", provider) |
||||
} |
||||
|
||||
// AWS and Google should be faster than Slack
|
||||
if provider == "AWS" && addedLatency > 200 { |
||||
t.Errorf("Expected AWS to have lower latency, got %dms", addedLatency) |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestThirdPartyServiceLogic_RateLimiting(t *testing.T) { |
||||
logic := ThirdPartyServiceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"provider": "Slack", // Has low rate limit (30 RPS)
|
||||
"latency": 100.0, |
||||
} |
||||
|
||||
// Send more requests than rate limit
|
||||
requests := make([]*Request, 50) // More than Slack's 30 RPS limit
|
||||
for i := range requests { |
||||
requests[i] = &Request{ID: string(rune('1' + i)), Type: "POST", LatencyMS: 0} |
||||
} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
// Should only process up to rate limit
|
||||
if len(output) != 30 { |
||||
t.Errorf("Expected 30 processed requests due to Slack rate limit, got %d", len(output)) |
||||
} |
||||
|
||||
// Service should still be healthy with rate limiting
|
||||
if !healthy { |
||||
t.Error("Expected service to be healthy despite rate limiting") |
||||
} |
||||
|
||||
// Check that rate limit hits were recorded
|
||||
status, ok := props["_serviceStatus"].(ServiceStatus) |
||||
if !ok { |
||||
t.Error("Expected service status to be recorded") |
||||
} |
||||
|
||||
if status.RateLimitHits != 1 { |
||||
t.Errorf("Expected 1 rate limit hit, got %d", status.RateLimitHits) |
||||
} |
||||
} |
||||
|
||||
func TestThirdPartyServiceLogic_ServiceFailure(t *testing.T) { |
||||
logic := ThirdPartyServiceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"provider": "Generic", |
||||
"latency": 100.0, |
||||
} |
||||
|
||||
// Set up service as already having failures
|
||||
status := ServiceStatus{ |
||||
IsUp: false, |
||||
LastCheck: 0, |
||||
FailureCount: 6, |
||||
} |
||||
props["_serviceStatus"] = status |
||||
|
||||
requests := []*Request{{ID: "1", Type: "POST", LatencyMS: 50, Path: []string{}}} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if healthy { |
||||
t.Error("Expected service to be unhealthy when external service is down") |
||||
} |
||||
|
||||
if len(output) != 1 { |
||||
t.Error("Expected request to be processed even when service is down") |
||||
} |
||||
|
||||
// Should have very high latency due to timeout
|
||||
if output[0].LatencyMS < 5000 { |
||||
t.Errorf("Expected high latency for service failure, got %dms", output[0].LatencyMS) |
||||
} |
||||
|
||||
// Check path indicates timeout
|
||||
lastPath := output[0].Path[len(output[0].Path)-1] |
||||
if lastPath != "third-party-timeout" { |
||||
t.Errorf("Expected timeout path, got %s", lastPath) |
||||
} |
||||
} |
||||
|
||||
func TestThirdPartyServiceLogic_ServiceRecovery(t *testing.T) { |
||||
logic := ThirdPartyServiceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"provider": "Stripe", |
||||
"latency": 100.0, |
||||
} |
||||
|
||||
// Set up service as down but with old timestamp (should recover)
|
||||
status := ServiceStatus{ |
||||
IsUp: false, |
||||
LastCheck: 0, // Very old timestamp
|
||||
FailureCount: 3, |
||||
} |
||||
props["_serviceStatus"] = status |
||||
|
||||
requests := []*Request{{ID: "1", Type: "POST", LatencyMS: 50, Path: []string{}}} |
||||
|
||||
// Run with current tick that's more than 30 seconds later
|
||||
_, healthy := logic.Tick(props, requests, 400) // 40 seconds later
|
||||
|
||||
if !healthy { |
||||
t.Error("Expected service to be healthy after recovery") |
||||
} |
||||
|
||||
// Check that service recovered
|
||||
updatedStatus, ok := props["_serviceStatus"].(ServiceStatus) |
||||
if !ok { |
||||
t.Error("Expected updated service status") |
||||
} |
||||
|
||||
if !updatedStatus.IsUp { |
||||
t.Error("Expected service to have recovered") |
||||
} |
||||
|
||||
if updatedStatus.FailureCount != 0 { |
||||
t.Error("Expected failure count to be reset on recovery") |
||||
} |
||||
} |
||||
|
||||
func TestThirdPartyServiceLogic_ReliabilityDifferences(t *testing.T) { |
||||
logic := ThirdPartyServiceLogic{} |
||||
|
||||
// Test different reliability levels
|
||||
testCases := []struct { |
||||
provider string |
||||
expectedReliability float64 |
||||
}{ |
||||
{"AWS", 0.9995}, |
||||
{"Google", 0.9999}, |
||||
{"Stripe", 0.999}, |
||||
{"Slack", 0.995}, |
||||
{"Generic", 0.99}, |
||||
} |
||||
|
||||
for _, tc := range testCases { |
||||
reliability := logic.getProviderReliability(tc.provider) |
||||
if reliability != tc.expectedReliability { |
||||
t.Errorf("Expected %s reliability %.4f, got %.4f", |
||||
tc.provider, tc.expectedReliability, reliability) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func TestThirdPartyServiceLogic_RateLimitDifferences(t *testing.T) { |
||||
logic := ThirdPartyServiceLogic{} |
||||
|
||||
// Test different rate limits
|
||||
testCases := []struct { |
||||
provider string |
||||
expectedLimit int |
||||
}{ |
||||
{"AWS", 1000}, |
||||
{"Stripe", 100}, |
||||
{"Slack", 30}, |
||||
{"SendGrid", 200}, |
||||
{"Twilio", 50}, |
||||
} |
||||
|
||||
for _, tc := range testCases { |
||||
rateLimit := logic.getProviderRateLimit(tc.provider) |
||||
if rateLimit != tc.expectedLimit { |
||||
t.Errorf("Expected %s rate limit %d, got %d", |
||||
tc.provider, tc.expectedLimit, rateLimit) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func TestThirdPartyServiceLogic_LatencyVariance(t *testing.T) { |
||||
logic := ThirdPartyServiceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"provider": "Stripe", |
||||
"latency": 100.0, |
||||
} |
||||
|
||||
requests := []*Request{{ID: "1", Type: "POST", LatencyMS: 0, Path: []string{}}} |
||||
|
||||
latencies := []int{} |
||||
|
||||
// Run multiple times to observe variance
|
||||
for i := 0; i < 10; i++ { |
||||
output, _ := logic.Tick(props, requests, i) |
||||
latencies = append(latencies, output[0].LatencyMS) |
||||
} |
||||
|
||||
// Check that we have variance (not all latencies are the same)
|
||||
allSame := true |
||||
firstLatency := latencies[0] |
||||
for _, latency := range latencies[1:] { |
||||
if latency != firstLatency { |
||||
allSame = false |
||||
break |
||||
} |
||||
} |
||||
|
||||
if allSame { |
||||
t.Error("Expected latency variance, but all latencies were the same") |
||||
} |
||||
|
||||
// All latencies should be reasonable (between 50ms and 300ms for Stripe)
|
||||
for _, latency := range latencies { |
||||
if latency < 50 || latency > 300 { |
||||
t.Errorf("Expected reasonable latency for Stripe, got %dms", latency) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func TestThirdPartyServiceLogic_DefaultValues(t *testing.T) { |
||||
logic := ThirdPartyServiceLogic{} |
||||
|
||||
// Empty props should use defaults
|
||||
props := map[string]any{} |
||||
|
||||
requests := []*Request{{ID: "1", Type: "POST", LatencyMS: 0, Path: []string{}}} |
||||
|
||||
output, healthy := logic.Tick(props, requests, 1) |
||||
|
||||
if !healthy { |
||||
t.Error("Expected service to be healthy with default values") |
||||
} |
||||
|
||||
if len(output) != 1 { |
||||
t.Error("Expected 1 processed request with defaults") |
||||
} |
||||
|
||||
// Should have reasonable default latency (around 200ms base)
|
||||
if output[0].LatencyMS < 100 || output[0].LatencyMS > 400 { |
||||
t.Errorf("Expected reasonable default latency, got %dms", output[0].LatencyMS) |
||||
} |
||||
} |
||||
|
||||
func TestThirdPartyServiceLogic_SuccessCountTracking(t *testing.T) { |
||||
logic := ThirdPartyServiceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"provider": "AWS", // High reliability
|
||||
"latency": 50.0, |
||||
} |
||||
|
||||
requests := []*Request{{ID: "1", Type: "POST", LatencyMS: 0, Path: []string{}}} |
||||
|
||||
// Run multiple successful requests
|
||||
for i := 0; i < 5; i++ { |
||||
logic.Tick(props, requests, i) |
||||
} |
||||
|
||||
status, ok := props["_serviceStatus"].(ServiceStatus) |
||||
if !ok { |
||||
t.Error("Expected service status to be tracked") |
||||
} |
||||
|
||||
// Should have accumulated success count
|
||||
if status.SuccessCount == 0 { |
||||
t.Error("Expected success count to be tracked") |
||||
} |
||||
|
||||
// Should be healthy
|
||||
if !status.IsUp { |
||||
t.Error("Expected service to remain up with successful calls") |
||||
} |
||||
} |
||||
|
||||
func TestThirdPartyServiceLogic_FailureRecovery(t *testing.T) { |
||||
logic := ThirdPartyServiceLogic{} |
||||
|
||||
props := map[string]any{ |
||||
"provider": "Generic", |
||||
"latency": 100.0, |
||||
} |
||||
|
||||
// Set up service with some failures but still up
|
||||
status := ServiceStatus{ |
||||
IsUp: true, |
||||
FailureCount: 3, |
||||
SuccessCount: 0, |
||||
} |
||||
props["_serviceStatus"] = status |
||||
|
||||
requests := []*Request{{ID: "1", Type: "POST", LatencyMS: 0, Path: []string{}}} |
||||
|
||||
// Simulate a successful call (with high probability for Generic service)
|
||||
// We'll run this multiple times to ensure we get at least one success
|
||||
successFound := false |
||||
for i := 0; i < 10 && !successFound; i++ { |
||||
output, _ := logic.Tick(props, requests, i) |
||||
if len(output[0].Path) > 0 && output[0].Path[len(output[0].Path)-1] == "third-party-success" { |
||||
successFound = true |
||||
} |
||||
} |
||||
|
||||
if successFound { |
||||
updatedStatus, _ := props["_serviceStatus"].(ServiceStatus) |
||||
// Failure count should have decreased
|
||||
if updatedStatus.FailureCount >= 3 { |
||||
t.Error("Expected failure count to decrease after successful call") |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue