21 changed files with 812 additions and 1243 deletions
@ -0,0 +1 @@ |
|||||||
|
no Go files in /Users/stephaniegredell/projects/systemdesigngame |
||||||
@ -1,115 +0,0 @@ |
|||||||
package simulation |
|
||||||
|
|
||||||
import ( |
|
||||||
"math/rand" |
|
||||||
"sort" |
|
||||||
) |
|
||||||
|
|
||||||
type CacheEntry struct { |
|
||||||
Value interface{} |
|
||||||
Timestamp int |
|
||||||
ExpireAt int |
|
||||||
AccessCount int |
|
||||||
} |
|
||||||
|
|
||||||
type CacheNode struct { |
|
||||||
ID string |
|
||||||
Label string |
|
||||||
CacheTTL int |
|
||||||
MaxEntries int |
|
||||||
EvictionPolicy string |
|
||||||
CurrentLoad int |
|
||||||
Queue []*Request |
|
||||||
Cache map[string]CacheEntry |
|
||||||
Alive bool |
|
||||||
Targets []string |
|
||||||
Output []*Request |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CacheNode) GetID() string { |
|
||||||
return c.ID |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CacheNode) Type() string { |
|
||||||
return "cache" |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CacheNode) IsAlive() bool { |
|
||||||
return c.Alive |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CacheNode) Tick(tick int, currentTimeMs int) { |
|
||||||
for key, entry := range c.Cache { |
|
||||||
if currentTimeMs > entry.ExpireAt { |
|
||||||
delete(c.Cache, key) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if len(c.Cache) > c.MaxEntries { |
|
||||||
evictCount := len(c.Cache) - c.MaxEntries |
|
||||||
keys := make([]string, 0, len(c.Cache)) |
|
||||||
for k := range c.Cache { |
|
||||||
keys = append(keys, k) |
|
||||||
} |
|
||||||
|
|
||||||
switch c.EvictionPolicy { |
|
||||||
case "Random": |
|
||||||
rand.Shuffle(len(keys), func(i, j int) { keys[i], keys[j] = keys[j], keys[i] }) |
|
||||||
case "LRU": |
|
||||||
sort.Slice(keys, func(i, j int) bool { |
|
||||||
return c.Cache[keys[i]].Timestamp < c.Cache[keys[j]].Timestamp |
|
||||||
}) |
|
||||||
case "LFU": |
|
||||||
sort.Slice(keys, func(i, j int) bool { |
|
||||||
return c.Cache[keys[i]].AccessCount < c.Cache[keys[j]].AccessCount |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
for i := 0; i < evictCount && i < len(keys); i++ { |
|
||||||
delete(c.Cache, keys[i]) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
toProcess := min(len(c.Queue), 10) |
|
||||||
for i := 0; i < toProcess; i++ { |
|
||||||
req := c.Queue[i] |
|
||||||
if entry, found := c.Cache[req.ID]; found && currentTimeMs <= entry.ExpireAt { |
|
||||||
// Cache hit
|
|
||||||
req.LatencyMS += 2 |
|
||||||
req.Path = append(req.Path, c.ID+"(hit)") |
|
||||||
} else { |
|
||||||
// Cache miss
|
|
||||||
req.LatencyMS += 5 |
|
||||||
req.Path = append(req.Path, c.ID+"(miss)") |
|
||||||
c.Cache[req.ID] = CacheEntry{ |
|
||||||
Value: req, |
|
||||||
Timestamp: currentTimeMs, |
|
||||||
ExpireAt: currentTimeMs + c.CacheTTL*1000, |
|
||||||
} |
|
||||||
c.Output = append(c.Output, req) |
|
||||||
} |
|
||||||
} |
|
||||||
c.Queue = c.Queue[toProcess:] |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CacheNode) Receive(req *Request) { |
|
||||||
c.Queue = append(c.Queue, req) |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CacheNode) Emit() []*Request { |
|
||||||
out := append([]*Request(nil), c.Output...) |
|
||||||
c.Output = c.Output[:0] |
|
||||||
return out |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CacheNode) AddTarget(targetID string) { |
|
||||||
c.Targets = append(c.Targets, targetID) |
|
||||||
} |
|
||||||
|
|
||||||
func (c *CacheNode) GetTargets() []string { |
|
||||||
return c.Targets |
|
||||||
} |
|
||||||
|
|
||||||
func (n *CacheNode) GetQueue() []*Request { |
|
||||||
return n.Queue |
|
||||||
} |
|
||||||
@ -0,0 +1,22 @@ |
|||||||
|
package simulation |
||||||
|
|
||||||
|
import "math/rand" |
||||||
|
|
||||||
|
type CDNLogic struct{} |
||||||
|
|
||||||
|
func (c CDNLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { |
||||||
|
hitRate := AsFloat64("hitRate") |
||||||
|
var output []*Request |
||||||
|
|
||||||
|
for _, req := range queue { |
||||||
|
if rand.Float64() < hitRate { |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
reqCopy := *req |
||||||
|
reqCopy.Path = append(reqCopy.Path, "target-0") |
||||||
|
output = append(output, &reqCopy) |
||||||
|
} |
||||||
|
|
||||||
|
return output, true |
||||||
|
} |
||||||
@ -1,93 +0,0 @@ |
|||||||
package simulation |
|
||||||
|
|
||||||
import ( |
|
||||||
"math/rand" |
|
||||||
) |
|
||||||
|
|
||||||
type CDNNode struct { |
|
||||||
ID string |
|
||||||
Label string |
|
||||||
TTL int |
|
||||||
GeoReplication string |
|
||||||
CachingStrategy string |
|
||||||
Compression string |
|
||||||
HTTP2 string |
|
||||||
CacheHitRate float64 |
|
||||||
CurrentLoad int |
|
||||||
Queue []*Request |
|
||||||
EdgeNodes map[string]*CDNNode |
|
||||||
Alive bool |
|
||||||
Targets []string |
|
||||||
output []*Request |
|
||||||
missQueue []*Request |
|
||||||
} |
|
||||||
|
|
||||||
func (n *CDNNode) GetID() string { return n.ID } |
|
||||||
func (n *CDNNode) Type() string { return "cdn" } |
|
||||||
func (n *CDNNode) IsAlive() bool { return n.Alive } |
|
||||||
func (n *CDNNode) QueueState() []*Request { return n.Queue } |
|
||||||
|
|
||||||
func (n *CDNNode) Tick(tick int, currentTimeMs int) { |
|
||||||
if len(n.Queue) == 0 { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
maxProcessPerTick := 10 |
|
||||||
processCount := min(len(n.Queue), maxProcessPerTick) |
|
||||||
|
|
||||||
queue := n.Queue |
|
||||||
n.Queue = n.Queue[:0] |
|
||||||
|
|
||||||
for i := 0; i < processCount; i++ { |
|
||||||
req := queue[i] |
|
||||||
|
|
||||||
hitRate := n.CacheHitRate |
|
||||||
if hitRate == 0 { |
|
||||||
hitRate = 0.8 |
|
||||||
} |
|
||||||
|
|
||||||
if rand.Float64() < hitRate { |
|
||||||
// Cache HIT
|
|
||||||
req.LatencyMS += 5 |
|
||||||
req.Path = append(req.Path, n.ID) |
|
||||||
n.output = append(n.output, req) |
|
||||||
} else { |
|
||||||
// Cache MISS
|
|
||||||
req.LatencyMS += 10 |
|
||||||
req.Path = append(req.Path, n.ID+"(miss)") |
|
||||||
n.missQueue = append(n.missQueue, req) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if len(queue) > processCount { |
|
||||||
n.Queue = append(n.Queue, queue[processCount:]...) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (n *CDNNode) Receive(req *Request) { |
|
||||||
if req == nil { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
geoLatency := rand.Intn(20) + 5 // 5–25ms routing delay
|
|
||||||
req.LatencyMS += geoLatency |
|
||||||
|
|
||||||
n.Queue = append(n.Queue, req) |
|
||||||
} |
|
||||||
|
|
||||||
func (n *CDNNode) Emit() []*Request { |
|
||||||
out := append(n.output, n.missQueue...) |
|
||||||
|
|
||||||
n.output = n.output[:0] |
|
||||||
n.missQueue = n.missQueue[:0] |
|
||||||
|
|
||||||
return out |
|
||||||
} |
|
||||||
|
|
||||||
func (n *CDNNode) GetTargets() []string { |
|
||||||
return n.Targets |
|
||||||
} |
|
||||||
|
|
||||||
func (n *CDNNode) GetQueue() []*Request { |
|
||||||
return n.Queue |
|
||||||
} |
|
||||||
@ -1,83 +0,0 @@ |
|||||||
package simulation |
|
||||||
|
|
||||||
type DatabaseNode struct { |
|
||||||
ID string |
|
||||||
Label string |
|
||||||
Replication int |
|
||||||
CurrentLoad int |
|
||||||
Queue []*Request |
|
||||||
Replicas []*DatabaseNode |
|
||||||
Alive bool |
|
||||||
Targets []string |
|
||||||
Output []*Request |
|
||||||
ReplicationQueue []*Request |
|
||||||
} |
|
||||||
|
|
||||||
func (n *DatabaseNode) GetID() string { return n.ID } |
|
||||||
func (n *DatabaseNode) Type() string { return "database" } |
|
||||||
func (n *DatabaseNode) IsAlive() bool { return n.Alive } |
|
||||||
func (n *DatabaseNode) GetQueue() []*Request { return n.Queue } |
|
||||||
|
|
||||||
func (n *DatabaseNode) Tick(tick int, currentTimeMs int) { |
|
||||||
if len(n.Queue) == 0 { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
maxProcessPerTick := 3 |
|
||||||
processCount := min(len(n.Queue), maxProcessPerTick) |
|
||||||
|
|
||||||
queue := n.Queue |
|
||||||
n.Queue = n.Queue[:0] |
|
||||||
|
|
||||||
for i := 0; i < processCount; i++ { |
|
||||||
req := queue[i] |
|
||||||
|
|
||||||
if req.Type == "READ" { |
|
||||||
req.LatencyMS += 20 |
|
||||||
req.Path = append(req.Path, n.ID) |
|
||||||
n.Output = append(n.Output, req) |
|
||||||
} else { |
|
||||||
req.LatencyMS += 50 |
|
||||||
req.Path = append(req.Path, n.ID) |
|
||||||
|
|
||||||
for _, replica := range n.Replicas { |
|
||||||
replicationReq := &Request{ |
|
||||||
ID: req.ID + "-repl", |
|
||||||
Timestamp: req.Timestamp, |
|
||||||
LatencyMS: req.LatencyMS + 5, |
|
||||||
Origin: req.Origin, |
|
||||||
Type: "REPLICATION", |
|
||||||
Path: append(append([]string{}, req.Path...), "->"+replica.ID), |
|
||||||
} |
|
||||||
n.ReplicationQueue = append(n.ReplicationQueue, replicationReq) |
|
||||||
} |
|
||||||
|
|
||||||
n.Output = append(n.Output, req) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if len(n.Queue) > 10 { |
|
||||||
for _, req := range n.Queue { |
|
||||||
req.LatencyMS += 10 |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (n *DatabaseNode) Receive(req *Request) { |
|
||||||
if req == nil { |
|
||||||
return |
|
||||||
} |
|
||||||
req.LatencyMS += 2 // DB connection overhead
|
|
||||||
n.Queue = append(n.Queue, req) |
|
||||||
} |
|
||||||
|
|
||||||
func (n *DatabaseNode) Emit() []*Request { |
|
||||||
out := append(n.Output, n.ReplicationQueue...) |
|
||||||
n.Output = n.Output[:0] |
|
||||||
n.ReplicationQueue = n.ReplicationQueue[:0] |
|
||||||
return out |
|
||||||
} |
|
||||||
|
|
||||||
func (n *DatabaseNode) GetTargets() []string { |
|
||||||
return n.Targets |
|
||||||
} |
|
||||||
@ -1,114 +0,0 @@ |
|||||||
package simulation |
|
||||||
|
|
||||||
import ( |
|
||||||
"math/rand" |
|
||||||
) |
|
||||||
|
|
||||||
type DataPipelineNode struct { |
|
||||||
ID string |
|
||||||
Label string |
|
||||||
BatchSize int |
|
||||||
Transformation string |
|
||||||
CurrentLoad int |
|
||||||
Queue []*Request |
|
||||||
Alive bool |
|
||||||
Targets []string |
|
||||||
Output []*Request |
|
||||||
LastFlushTimeMS int |
|
||||||
} |
|
||||||
|
|
||||||
func (n *DataPipelineNode) GetID() string { return n.ID } |
|
||||||
|
|
||||||
func (n *DataPipelineNode) Tick(tick int, currentTimeMs int) { |
|
||||||
if len(n.Queue) == 0 { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
if len(n.Queue) < n.BatchSize { |
|
||||||
if n.LastFlushTimeMS == 0 { |
|
||||||
n.LastFlushTimeMS = currentTimeMs |
|
||||||
} |
|
||||||
if currentTimeMs-n.LastFlushTimeMS >= 5000 { |
|
||||||
n.processBatch(len(n.Queue), currentTimeMs) |
|
||||||
n.LastFlushTimeMS = currentTimeMs |
|
||||||
} |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
batchesToProcess := len(n.Queue) / n.BatchSize |
|
||||||
maxBatchesPerTick := 2 |
|
||||||
actualBatches := min(batchesToProcess, maxBatchesPerTick) |
|
||||||
|
|
||||||
for i := 0; i < actualBatches; i++ { |
|
||||||
n.processBatch(n.BatchSize, currentTimeMs) |
|
||||||
n.LastFlushTimeMS = currentTimeMs |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (n *DataPipelineNode) processBatch(size int, currentTimeMs int) { |
|
||||||
if size == 0 { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
batch := n.Queue[:size] |
|
||||||
n.Queue = n.Queue[size:] |
|
||||||
batchLatency := 100 + (size * 5) |
|
||||||
|
|
||||||
for _, req := range batch { |
|
||||||
req.LatencyMS += batchLatency |
|
||||||
req.Path = append(req.Path, n.ID+"(batch)") |
|
||||||
|
|
||||||
switch n.Transformation { |
|
||||||
case "aggregate": |
|
||||||
req.Type = "AGGREGATED" |
|
||||||
n.Output = append(n.Output, req) |
|
||||||
case "filter": |
|
||||||
if rand.Float64() < 0.9 { |
|
||||||
n.Output = append(n.Output, req) |
|
||||||
} |
|
||||||
continue |
|
||||||
case "enrich": |
|
||||||
req.Type = "ENRICHED" |
|
||||||
n.Output = append(n.Output, req) |
|
||||||
case "normalize": |
|
||||||
req.Type = "NORMALIZED" |
|
||||||
n.Output = append(n.Output, req) |
|
||||||
case "dedupe": |
|
||||||
if rand.Float64() < 0.95 { |
|
||||||
req.Type = "DEDUPED" |
|
||||||
n.Output = append(n.Output, req) |
|
||||||
} |
|
||||||
continue |
|
||||||
default: |
|
||||||
n.Output = append(n.Output, req) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (n *DataPipelineNode) Receive(req *Request) { |
|
||||||
if req == nil { |
|
||||||
return |
|
||||||
} |
|
||||||
req.LatencyMS += 1 |
|
||||||
n.Queue = append(n.Queue, req) |
|
||||||
|
|
||||||
if n.LastFlushTimeMS == 0 { |
|
||||||
n.LastFlushTimeMS = req.Timestamp |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (n *DataPipelineNode) Emit() []*Request { |
|
||||||
out := append([]*Request(nil), n.Output...) |
|
||||||
n.Output = n.Output[:0] |
|
||||||
return out |
|
||||||
} |
|
||||||
|
|
||||||
func (n *DataPipelineNode) Type() string { return "datapipeline" } |
|
||||||
func (n *DataPipelineNode) IsAlive() bool { return n.Alive } |
|
||||||
func (n *DataPipelineNode) GetTargets() []string { |
|
||||||
return n.Targets |
|
||||||
} |
|
||||||
|
|
||||||
func (n *DataPipelineNode) GetQueue() []*Request { |
|
||||||
return n.Queue |
|
||||||
} |
|
||||||
@ -1,130 +1,107 @@ |
|||||||
package simulation |
package simulation |
||||||
|
|
||||||
import ( |
import ( |
||||||
"fmt" |
|
||||||
"os" |
|
||||||
"path/filepath" |
|
||||||
"testing" |
"testing" |
||||||
|
|
||||||
"encoding/json" |
|
||||||
"systemdesigngame/internal/design" |
"systemdesigngame/internal/design" |
||||||
) |
) |
||||||
|
|
||||||
func TestNewEngineFromDesign(t *testing.T) { |
func TestSimpleChainSimulation(t *testing.T) { |
||||||
designInput := &design.Design{ |
d := design.Design{ |
||||||
Nodes: []design.Node{ |
Nodes: []design.Node{ |
||||||
{ |
{ID: "a", Type: "webserver", Props: map[string]any{"capacityRPS": 1, "baseLatencyMs": 10}}, |
||||||
ID: "web1", |
{ID: "b", Type: "webserver", Props: map[string]any{"capacityRPS": 1, "baseLatencyMs": 10}}, |
||||||
Type: "webserver", |
|
||||||
Props: map[string]interface{}{ |
|
||||||
"cpu": float64(2), |
|
||||||
"ramGb": float64(4), |
|
||||||
"rpsCapacity": float64(100), |
|
||||||
"monthlyCostUsd": float64(20), |
|
||||||
}, |
|
||||||
}, |
|
||||||
{ |
|
||||||
ID: "cache1", |
|
||||||
Type: "cache", |
|
||||||
Props: map[string]interface{}{ |
|
||||||
"label": "L1 Cache", |
|
||||||
"cacheTTL": float64(60), |
|
||||||
"maxEntries": float64(1000), |
|
||||||
"evictionPolicy": "LRU", |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
}, |
||||||
Connections: []design.Connection{ |
Connections: []design.Connection{ |
||||||
{ |
{Source: "a", Target: "b"}, |
||||||
Source: "web1", |
|
||||||
Target: "cache1", |
|
||||||
}, |
|
||||||
}, |
}, |
||||||
} |
} |
||||||
|
|
||||||
engine := NewEngineFromDesign(*designInput, 10, 100) |
engine := NewEngineFromDesign(d, 100) |
||||||
|
|
||||||
if len(engine.Nodes) != 2 { |
engine.Nodes["a"].Queue = append(engine.Nodes["a"].Queue, &Request{ |
||||||
t.Fatalf("expected 2 nodes, got %d", len(engine.Nodes)) |
ID: "req-1", |
||||||
|
Origin: "a", |
||||||
|
Type: "GET", |
||||||
|
Timestamp: 0, |
||||||
|
Path: []string{"a"}, |
||||||
|
}) |
||||||
|
|
||||||
|
snaps := engine.Run(2, 100) |
||||||
|
|
||||||
|
if len(snaps) != 2 { |
||||||
|
t.Fatalf("expected 2 snapshots, got %d", len(snaps)) |
||||||
} |
} |
||||||
|
|
||||||
if len(engine.Nodes["web1"].GetTargets()) != 1 { |
if len(snaps[0].Emitted["a"]) != 1 { |
||||||
t.Fatalf("expected web1 to have 1 target, got %d", len(engine.Nodes["web1"].GetTargets())) |
t.Errorf("expected a to emit 1 request at tick 0") |
||||||
|
} |
||||||
|
if snaps[0].QueueSizes["b"] != 0 { |
||||||
|
t.Errorf("expected b's queue to be 0 after tick 0 (not yet processed)") |
||||||
} |
} |
||||||
|
|
||||||
if engine.Nodes["web1"].GetTargets()[0] != "cache1" { |
if len(snaps[1].Emitted["b"]) != 1 { |
||||||
t.Fatalf("expected web1 target to be cache1") |
t.Errorf("expected b to emit 1 request at tick 1") |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
func TestComplexSimulationRun(t *testing.T) { |
func TestSingleTickRouting(t *testing.T) { |
||||||
filePath := filepath.Join("testdata", "complex_design.json") |
d := design.Design{ |
||||||
data, err := os.ReadFile(filePath) |
Nodes: []design.Node{ |
||||||
if err != nil { |
{ID: "a", Type: "webserver", Props: map[string]any{"capacityRPS": 1.0, "baseLatencyMs": 10.0}}, |
||||||
t.Fatalf("Failed to read JSON file: %v", err) |
{ID: "b", Type: "webserver", Props: map[string]any{"capacityRPS": 1.0, "baseLatencyMs": 10.0}}, |
||||||
|
}, |
||||||
|
Connections: []design.Connection{ |
||||||
|
{Source: "a", Target: "b"}, |
||||||
|
}, |
||||||
} |
} |
||||||
|
|
||||||
var d design.Design |
engine := NewEngineFromDesign(d, 100) |
||||||
if err := json.Unmarshal([]byte(data), &d); err != nil { |
|
||||||
t.Fatalf("Failed to unmarshal JSON: %v", err) |
|
||||||
} |
|
||||||
|
|
||||||
engine := NewEngineFromDesign(d, 10, 100) |
engine.Nodes["a"].Queue = append(engine.Nodes["a"].Queue, &Request{ |
||||||
if engine == nil { |
ID: "req-1", |
||||||
t.Fatal("Engine should not be nil") |
Origin: "a", |
||||||
} |
Type: "GET", |
||||||
|
Timestamp: 0, |
||||||
|
Path: []string{"a"}, |
||||||
|
}) |
||||||
|
|
||||||
engine.Run() |
snaps := engine.Run(1, 100) |
||||||
|
|
||||||
if len(engine.Timeline) == 0 { |
if len(snaps) != 1 { |
||||||
t.Fatal("Expected timeline snapshots after Run, got none") |
t.Fatalf("expected 1 snapshot, got %d", len(snaps)) |
||||||
} |
} |
||||||
|
|
||||||
// Optional: check that some nodes received or emitted requests
|
if len(snaps[0].Emitted["a"]) != 1 { |
||||||
for id, node := range engine.Nodes { |
t.Errorf("expected a to emit 1 request, got %d", len(snaps[0].Emitted["a"])) |
||||||
if len(node.Emit()) > 0 { |
|
||||||
t.Logf("Node %s has activity", id) |
|
||||||
} |
|
||||||
} |
} |
||||||
} |
|
||||||
|
|
||||||
func TestSimulationRunEndToEnd(t *testing.T) { |
if len(engine.Nodes["b"].Queue) != 1 { |
||||||
data, err := os.ReadFile("testdata/simple_design.json") |
t.Errorf("expected b to have 1 request queued for next tick, got %d", len(engine.Nodes["b"].Queue)) |
||||||
fmt.Print(data) |
|
||||||
if err != nil { |
|
||||||
t.Fatalf("Failed to read test data: %v", err) |
|
||||||
} |
} |
||||||
|
} |
||||||
|
|
||||||
var design design.Design |
func TestHighRPSSimulation(t *testing.T) { |
||||||
if err := json.Unmarshal(data, &design); err != nil { |
d := design.Design{ |
||||||
t.Fatalf("Failed to unmarshal JSON: %v", err) |
Nodes: []design.Node{ |
||||||
|
{ID: "entry", Type: "webserver", Props: map[string]any{"capacityRPS": 5000, "baseLatencyMs": 1}}, |
||||||
|
}, |
||||||
|
Connections: []design.Connection{}, |
||||||
} |
} |
||||||
|
|
||||||
engine := NewEngineFromDesign(design, 20, 100) // 20 ticks, 100ms per tick
|
engine := NewEngineFromDesign(d, 100) |
||||||
engine.Run() |
engine.EntryNode = "entry" |
||||||
|
engine.RPS = 100000 |
||||||
if len(engine.Timeline) != 20 { |
|
||||||
t.Errorf("Expected 20 timeline entries, got %d", len(engine.Timeline)) |
|
||||||
} |
|
||||||
|
|
||||||
anyTraffic := false |
snaps := engine.Run(10, 100) |
||||||
for _, snapshot := range engine.Timeline { |
|
||||||
for _, nodeState := range snapshot.NodeHealth { |
|
||||||
if nodeState.QueueSize > 0 { |
|
||||||
anyTraffic = true |
|
||||||
break |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if !anyTraffic { |
totalEmitted := 0 |
||||||
t.Errorf("Expected at least one node to have non-zero queue size over time") |
for _, snap := range snaps { |
||||||
|
totalEmitted += len(snap.Emitted["entry"]) |
||||||
} |
} |
||||||
|
|
||||||
// Optional: check a few expected node IDs
|
expected := 10 * 5000 // capacity-limited output
|
||||||
for _, id := range []string{"node-1", "node-2"} { |
if totalEmitted != expected { |
||||||
if _, ok := engine.Nodes[id]; !ok { |
t.Errorf("expected %d total emitted requests, got %d", expected, totalEmitted) |
||||||
t.Errorf("Expected node %s to be present in simulation", id) |
|
||||||
} |
|
||||||
} |
} |
||||||
} |
} |
||||||
|
|||||||
@ -1,91 +1,64 @@ |
|||||||
package simulation |
package simulation |
||||||
|
|
||||||
import ( |
import ( |
||||||
"math/rand" |
"fmt" |
||||||
) |
) |
||||||
|
|
||||||
type LoadBalancerNode struct { |
type LoadBalancerLogic struct{} |
||||||
// unique identifier for the node
|
|
||||||
ID string |
|
||||||
// human readable name
|
|
||||||
Label string |
|
||||||
// load balancing strategy
|
|
||||||
Algorithm string |
|
||||||
// list of incoming requests to be processed
|
|
||||||
Queue []*Request |
|
||||||
// IDs of downstream nodes (e.g. webservers)
|
|
||||||
Targets []string |
|
||||||
// use to track round-robin state (i.e. which target is next)
|
|
||||||
Counter int |
|
||||||
// bool for health check
|
|
||||||
Alive bool |
|
||||||
// requests that this node has handled (ready to be emitted)
|
|
||||||
Processed []*Request |
|
||||||
} |
|
||||||
|
|
||||||
func (lb *LoadBalancerNode) GetID() string { |
func (l LoadBalancerLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { |
||||||
return lb.ID |
// Extract the load balancing algorithm from the props.
|
||||||
} |
algorithm := AsString(props["algorithm"]) |
||||||
|
// Number of downstream targets
|
||||||
|
targets := int(AsFloat64(props["_numTargets"])) |
||||||
|
|
||||||
func (lb *LoadBalancerNode) Type() string { |
if len(queue) == 0 { |
||||||
return "loadBalancer" |
return nil, true |
||||||
} |
} |
||||||
|
|
||||||
func (lb *LoadBalancerNode) IsAlive() bool { |
|
||||||
return lb.Alive |
|
||||||
} |
|
||||||
|
|
||||||
// Acceps an incoming request by adding it to the Queue which will be processed on the next tick
|
|
||||||
func (lb *LoadBalancerNode) Receive(req *Request) { |
|
||||||
lb.Queue = append(lb.Queue, req) |
|
||||||
} |
|
||||||
|
|
||||||
func (lb *LoadBalancerNode) Tick(tick int, currentTimeMs int) { |
// Hold the processed requests to be emitted
|
||||||
// clear out the process so it starts fresh
|
output := []*Request{} |
||||||
lb.Processed = nil |
|
||||||
|
|
||||||
// for each pending request...
|
switch algorithm { |
||||||
for _, req := range lb.Queue { |
case "least-connection": |
||||||
// if there are no targets to forward to, skip processing
|
// extrat current queue sizes from downstream targets
|
||||||
if len(lb.Targets) == 0 { |
queueSizesRaw, ok := props["_queueSizes"].(map[string]interface{}) |
||||||
continue |
if !ok { |
||||||
|
return nil, true |
||||||
} |
} |
||||||
|
|
||||||
// placeholder for algorithm-specific logic. TODO.
|
// find target with smallest queue
|
||||||
switch lb.Algorithm { |
for _, req := range queue { |
||||||
case "random": |
minTarget := "target-0" |
||||||
fallthrough |
minSize := int(AsFloat64(queueSizesRaw[minTarget])) |
||||||
case "round-robin": |
for i := 1; i < targets; i++ { |
||||||
fallthrough |
targetKey := fmt.Sprintf("target-%d", i) |
||||||
default: |
size := int(AsFloat64(queueSizesRaw[targetKey])) |
||||||
lb.Counter++ |
if size < minSize { |
||||||
|
minTarget = targetKey |
||||||
|
minSize = size |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Clone the request and append the selected target to its path
|
||||||
|
reqCopy := *req |
||||||
|
reqCopy.Path = append(reqCopy.Path, minTarget) |
||||||
|
output = append(output, &reqCopy) |
||||||
|
} |
||||||
|
default: |
||||||
|
// Retrieve the last used index
|
||||||
|
next := int(AsFloat64(props["_rrIndex"])) |
||||||
|
for _, req := range queue { |
||||||
|
// Clone ther equest and append the selected target to its path
|
||||||
|
reqCopy := *req |
||||||
|
reqCopy.Path = append(reqCopy.Path, fmt.Sprintf("target-%d", next)) |
||||||
|
output = append(output, &reqCopy) |
||||||
|
// Advance to next target
|
||||||
|
next = (next + 1) % targets |
||||||
} |
} |
||||||
|
|
||||||
// Append the load balancer's ID to the request's path to record it's journey through the system
|
props["_rrIndex"] = float64(next) |
||||||
req.Path = append(req.Path, lb.ID) |
|
||||||
|
|
||||||
// Simulate networking delay
|
|
||||||
req.LatencyMS += 10 |
|
||||||
|
|
||||||
// Mark the request as processed so it can be emitted to targets
|
|
||||||
lb.Processed = append(lb.Processed, req) |
|
||||||
} |
} |
||||||
|
|
||||||
// clear the queue after processing. Ready for next tick.
|
return output, true |
||||||
lb.Queue = lb.Queue[:0] |
|
||||||
} |
|
||||||
|
|
||||||
// return the list of process requests and then clear the processed requests
|
|
||||||
func (lb *LoadBalancerNode) Emit() []*Request { |
|
||||||
out := lb.Processed |
|
||||||
lb.Processed = nil |
|
||||||
return out |
|
||||||
} |
|
||||||
|
|
||||||
func (lb *LoadBalancerNode) GetTargets() []string { |
|
||||||
return lb.Targets |
|
||||||
} |
|
||||||
|
|
||||||
func (lb *LoadBalancerNode) GetQueue() []*Request { |
|
||||||
return lb.Queue |
|
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,70 @@ |
|||||||
|
package simulation |
||||||
|
|
||||||
|
import ( |
||||||
|
"systemdesigngame/internal/design" |
||||||
|
"testing" |
||||||
|
) |
||||||
|
|
||||||
|
func TestLoadBalancerAlgorithms(t *testing.T) { |
||||||
|
t.Run("round-rouble", func(t *testing.T) { |
||||||
|
d := design.Design{ |
||||||
|
Nodes: []design.Node{ |
||||||
|
{ID: "lb", Type: "loadbalancer", Props: map[string]any{"algorithm": "round-robin"}}, |
||||||
|
{ID: "a", Type: "webserver", Props: map[string]any{"capacityRPS": 10}}, |
||||||
|
{ID: "b", Type: "webserver", Props: map[string]any{"capacityRPS": 10}}, |
||||||
|
}, |
||||||
|
Connections: []design.Connection{ |
||||||
|
{Source: "lb", Target: "a"}, |
||||||
|
{Source: "lb", Target: "b"}, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
e := NewEngineFromDesign(d, 100) |
||||||
|
e.EntryNode = "lb" |
||||||
|
e.RPS = 4 |
||||||
|
|
||||||
|
snaps := e.Run(1, 100) |
||||||
|
if len(snaps[0].Emitted["lb"]) != 4 { |
||||||
|
t.Errorf("expected lb to emit 4 requests") |
||||||
|
} |
||||||
|
|
||||||
|
path0 := snaps[0].Emitted["lb"][0].Path[1] |
||||||
|
path1 := snaps[0].Emitted["lb"][1].Path[1] |
||||||
|
|
||||||
|
if path0 == path1 { |
||||||
|
t.Errorf("expecting alternating targets, got %s and %s", path0, path1) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
t.Run("least-connection", func(t *testing.T) { |
||||||
|
d := design.Design{ |
||||||
|
Nodes: []design.Node{ |
||||||
|
{ID: "lb", Type: "loadbalancer", Props: map[string]any{"algorithm": "least-connection"}}, |
||||||
|
{ID: "a", Type: "webserver", Props: map[string]any{"capacityRPS": 1}}, |
||||||
|
{ID: "b", Type: "webserver", Props: map[string]any{"capacityRPS": 1}}, |
||||||
|
}, |
||||||
|
Connections: []design.Connection{ |
||||||
|
{Source: "lb", Target: "a"}, |
||||||
|
{Source: "lb", Target: "b"}, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
e := NewEngineFromDesign(d, 100) |
||||||
|
e.EntryNode = "lb" |
||||||
|
e.RPS = 2 |
||||||
|
|
||||||
|
snaps := e.Run(1, 100) |
||||||
|
if len(snaps[0].Emitted["lb"]) != 2 { |
||||||
|
t.Errorf("expected lb to emit 2 requests") |
||||||
|
} |
||||||
|
|
||||||
|
paths := []string{ |
||||||
|
snaps[0].Emitted["lb"][0].Path[1], |
||||||
|
snaps[0].Emitted["lb"][1].Path[1], |
||||||
|
} |
||||||
|
|
||||||
|
if paths[0] == paths[1] { |
||||||
|
t.Errorf("expected requests to be balanced, go %v", paths) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
@ -1,101 +0,0 @@ |
|||||||
package simulation |
|
||||||
|
|
||||||
type MessageQueueNode struct { |
|
||||||
ID string |
|
||||||
Label string |
|
||||||
QueueSize int |
|
||||||
MessageTTL int // TTL in milliseconds
|
|
||||||
DeadLetter bool |
|
||||||
CurrentLoad int |
|
||||||
Queue []*Request |
|
||||||
Alive bool |
|
||||||
Targets []string |
|
||||||
output []*Request |
|
||||||
deadLetterOutput []*Request |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MessageQueueNode) GetID() string { return n.ID } |
|
||||||
func (n *MessageQueueNode) Type() string { return "messagequeue" } |
|
||||||
func (n *MessageQueueNode) IsAlive() bool { return n.Alive } |
|
||||||
|
|
||||||
func (n *MessageQueueNode) Tick(tick int, currentTimeMs int) { |
|
||||||
if len(n.Queue) == 0 { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
// Message queues have very high throughput
|
|
||||||
maxProcessPerTick := 20 // Higher than database (3) or CDN (10)
|
|
||||||
processCount := min(len(n.Queue), maxProcessPerTick) |
|
||||||
|
|
||||||
// Check for queue overflow (simulate back pressure)
|
|
||||||
if len(n.Queue) > n.QueueSize { |
|
||||||
// Move oldest messages to dead letter queue if enabled
|
|
||||||
if n.DeadLetter { |
|
||||||
overflow := len(n.Queue) - n.QueueSize |
|
||||||
for i := 0; i < overflow; i++ { |
|
||||||
deadReq := n.Queue[i] |
|
||||||
deadReq.Type = "DEAD_LETTER" |
|
||||||
deadReq.Path = append(deadReq.Path, n.ID+"(dead)") |
|
||||||
n.deadLetterOutput = append(n.deadLetterOutput, deadReq) |
|
||||||
} |
|
||||||
n.Queue = n.Queue[overflow:] |
|
||||||
} else { |
|
||||||
// Drop messages if no dead letter queue
|
|
||||||
n.Queue = n.Queue[:n.QueueSize] |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Process messages with TTL check
|
|
||||||
for i := 0; i < processCount; i++ { |
|
||||||
req := n.Queue[0] |
|
||||||
n.Queue = n.Queue[1:] |
|
||||||
|
|
||||||
// Check TTL (time to live) - use current time in milliseconds
|
|
||||||
messageAgeMs := currentTimeMs - req.Timestamp |
|
||||||
if messageAgeMs > n.MessageTTL { |
|
||||||
// Message expired
|
|
||||||
if n.DeadLetter { |
|
||||||
req.Type = "EXPIRED" |
|
||||||
req.Path = append(req.Path, n.ID+"(expired)") |
|
||||||
n.deadLetterOutput = append(n.deadLetterOutput, req) |
|
||||||
} |
|
||||||
// Otherwise drop expired message
|
|
||||||
continue |
|
||||||
} |
|
||||||
|
|
||||||
// Message queue adds minimal latency (very fast)
|
|
||||||
req.LatencyMS += 2 |
|
||||||
req.Path = append(req.Path, n.ID) |
|
||||||
n.output = append(n.output, req) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MessageQueueNode) Receive(req *Request) { |
|
||||||
if req == nil { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
// Message queues have very low receive overhead
|
|
||||||
req.LatencyMS += 1 |
|
||||||
|
|
||||||
n.Queue = append(n.Queue, req) |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MessageQueueNode) Emit() []*Request { |
|
||||||
// Return both normal messages and dead letter messages
|
|
||||||
allRequests := append(n.output, n.deadLetterOutput...) |
|
||||||
|
|
||||||
// Clear queues
|
|
||||||
n.output = n.output[:0] |
|
||||||
n.deadLetterOutput = n.deadLetterOutput[:0] |
|
||||||
|
|
||||||
return allRequests |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MessageQueueNode) GetTargets() []string { |
|
||||||
return n.Targets |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MessageQueueNode) GetQueue() []*Request { |
|
||||||
return n.Queue |
|
||||||
} |
|
||||||
@ -1,75 +0,0 @@ |
|||||||
package simulation |
|
||||||
|
|
||||||
import "math/rand" |
|
||||||
|
|
||||||
type MicroserviceNode struct { |
|
||||||
ID string |
|
||||||
Label string |
|
||||||
APIEndpoint string |
|
||||||
RateLimit int // max requests per tick
|
|
||||||
CircuitBreaker bool |
|
||||||
CircuitState string // "closed", "open", "half-open"
|
|
||||||
ErrorCount int |
|
||||||
CurrentLoad int |
|
||||||
Queue []*Request |
|
||||||
Output []*Request |
|
||||||
Alive bool |
|
||||||
Targets []string |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MicroserviceNode) GetID() string { return n.ID } |
|
||||||
|
|
||||||
func (n *MicroserviceNode) Type() string { return "microservice" } |
|
||||||
|
|
||||||
func (n *MicroserviceNode) IsAlive() bool { return n.Alive } |
|
||||||
|
|
||||||
func (n *MicroserviceNode) Receive(req *Request) { |
|
||||||
n.Queue = append(n.Queue, req) |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MicroserviceNode) Emit() []*Request { |
|
||||||
out := append([]*Request(nil), n.Output...) |
|
||||||
n.Output = n.Output[:0] |
|
||||||
return out |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MicroserviceNode) GetTargets() []string { |
|
||||||
return n.Targets |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MicroserviceNode) Tick(tick int, currentTimeMs int) { |
|
||||||
n.CurrentLoad = 0 |
|
||||||
n.ErrorCount = 0 |
|
||||||
n.Output = nil |
|
||||||
|
|
||||||
toProcess := min(len(n.Queue), n.RateLimit) |
|
||||||
for i := 0; i < toProcess; i++ { |
|
||||||
req := n.Queue[i] |
|
||||||
|
|
||||||
if rand.Float64() < 0.02 { |
|
||||||
n.ErrorCount++ |
|
||||||
if n.CircuitBreaker { |
|
||||||
n.CircuitState = "open" |
|
||||||
n.Alive = false |
|
||||||
} |
|
||||||
|
|
||||||
continue |
|
||||||
} |
|
||||||
|
|
||||||
req.LatencyMS += 10 |
|
||||||
req.Path = append(req.Path, n.ID) |
|
||||||
n.Output = append(n.Output, req) |
|
||||||
n.CurrentLoad++ |
|
||||||
} |
|
||||||
|
|
||||||
if n.CircuitState == "open" && tick%10 == 0 { |
|
||||||
n.CircuitState = "closed" |
|
||||||
n.Alive = true |
|
||||||
} |
|
||||||
|
|
||||||
n.Queue = n.Queue[toProcess:] |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MicroserviceNode) GetQueue() []*Request { |
|
||||||
return n.Queue |
|
||||||
} |
|
||||||
@ -1,73 +0,0 @@ |
|||||||
package simulation |
|
||||||
|
|
||||||
type MonitoringNode struct { |
|
||||||
ID string |
|
||||||
Label string |
|
||||||
Tool string |
|
||||||
AlertMetric string |
|
||||||
ThresholdValue int |
|
||||||
ThresholdUnit string |
|
||||||
Queue []*Request |
|
||||||
Alive bool |
|
||||||
Targets []string |
|
||||||
Metrics map[string]int |
|
||||||
Alerts []*Request |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MonitoringNode) GetID() string { return n.ID } |
|
||||||
|
|
||||||
func (n *MonitoringNode) Type() string { return "monitoring" } |
|
||||||
|
|
||||||
func (n *MonitoringNode) IsAlive() bool { return n.Alive } |
|
||||||
|
|
||||||
func (n *MonitoringNode) Receive(req *Request) { |
|
||||||
n.Queue = append(n.Queue, req) |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MonitoringNode) Emit() []*Request { |
|
||||||
out := append([]*Request(nil), n.Alerts...) |
|
||||||
n.Alerts = n.Alerts[:0] |
|
||||||
return out |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MonitoringNode) Tick(tick int, currentTimeMs int) { |
|
||||||
if !n.Alive { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
if n.Metrics == nil { |
|
||||||
n.Metrics = make(map[string]int) |
|
||||||
} |
|
||||||
|
|
||||||
// Simulate processing requests as metrics
|
|
||||||
for _, req := range n.Queue { |
|
||||||
// For now, pretend all requests are relevant to the AlertMetric
|
|
||||||
n.Metrics[n.AlertMetric] += 1 |
|
||||||
req.LatencyMS += 1 |
|
||||||
req.Path = append(req.Path, n.ID) |
|
||||||
|
|
||||||
if n.Metrics[n.AlertMetric] > n.ThresholdValue { |
|
||||||
alert := &Request{ |
|
||||||
ID: "alert-" + req.ID, |
|
||||||
Timestamp: currentTimeMs, |
|
||||||
Origin: n.ID, |
|
||||||
Type: "alert", |
|
||||||
LatencyMS: 0, |
|
||||||
Path: []string{n.ID, "alert"}, |
|
||||||
} |
|
||||||
n.Alerts = append(n.Alerts, alert) |
|
||||||
|
|
||||||
// Reset after alert (or you could continue accumulating)
|
|
||||||
n.Metrics[n.AlertMetric] = 0 |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
n.Queue = nil |
|
||||||
} |
|
||||||
func (n *MonitoringNode) GetTargets() []string { |
|
||||||
return n.Targets |
|
||||||
} |
|
||||||
|
|
||||||
func (n *MonitoringNode) GetQueue() []*Request { |
|
||||||
return n.Queue |
|
||||||
} |
|
||||||
@ -1,87 +0,0 @@ |
|||||||
package simulation |
|
||||||
|
|
||||||
import "math/rand" |
|
||||||
|
|
||||||
type ThirdPartyServiceNode struct { |
|
||||||
ID string |
|
||||||
Label string |
|
||||||
APIEndpoint string |
|
||||||
RateLimit int // Max requests per tick
|
|
||||||
RetryPolicy string // "exponential", "fixed", etc.
|
|
||||||
CurrentLoad int |
|
||||||
Queue []*Request |
|
||||||
ErrorCount int |
|
||||||
RetryCount int |
|
||||||
Alive bool |
|
||||||
Targets []string |
|
||||||
Output []*Request |
|
||||||
} |
|
||||||
|
|
||||||
// --- Interface Methods ---
|
|
||||||
|
|
||||||
func (n *ThirdPartyServiceNode) GetID() string { return n.ID } |
|
||||||
func (n *ThirdPartyServiceNode) Type() string { return "thirdpartyservice" } |
|
||||||
func (n *ThirdPartyServiceNode) IsAlive() bool { return n.Alive } |
|
||||||
func (n *ThirdPartyServiceNode) Receive(req *Request) { |
|
||||||
n.Queue = append(n.Queue, req) |
|
||||||
} |
|
||||||
func (n *ThirdPartyServiceNode) Emit() []*Request { |
|
||||||
out := append([]*Request(nil), n.Output...) |
|
||||||
n.Output = n.Output[:0] |
|
||||||
return out |
|
||||||
} |
|
||||||
|
|
||||||
// Add missing Queue method for interface compliance
|
|
||||||
func (n *ThirdPartyServiceNode) GetQueue() []*Request { |
|
||||||
return n.Queue |
|
||||||
} |
|
||||||
|
|
||||||
// --- Simulation Logic ---
|
|
||||||
|
|
||||||
func (n *ThirdPartyServiceNode) Tick(tick int, currentTimeMs int) { |
|
||||||
if !n.Alive { |
|
||||||
return |
|
||||||
} |
|
||||||
|
|
||||||
// Simulate third-party call behavior with success/failure
|
|
||||||
maxProcess := min(n.RateLimit, len(n.Queue)) |
|
||||||
newQueue := n.Queue[maxProcess:] |
|
||||||
n.Queue = nil |
|
||||||
|
|
||||||
for i := 0; i < maxProcess; i++ { |
|
||||||
req := newQueue[i] |
|
||||||
success := simulateThirdPartySuccess(req) |
|
||||||
|
|
||||||
if success { |
|
||||||
req.LatencyMS += 100 + randInt(0, 50) // simulate response time
|
|
||||||
req.Path = append(req.Path, n.ID) |
|
||||||
n.Output = append(n.Output, req) |
|
||||||
} else { |
|
||||||
n.ErrorCount++ |
|
||||||
n.RetryCount++ |
|
||||||
if n.RetryPolicy == "exponential" && n.RetryCount < 3 { |
|
||||||
n.Queue = append(n.Queue, req) // retry again next tick
|
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Simulate degradation if too many errors
|
|
||||||
if n.ErrorCount > 10 { |
|
||||||
n.Alive = false |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (n *ThirdPartyServiceNode) GetTargets() []string { |
|
||||||
return n.Targets |
|
||||||
} |
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
func simulateThirdPartySuccess(req *Request) bool { |
|
||||||
// 90% success rate
|
|
||||||
return randInt(0, 100) < 90 |
|
||||||
} |
|
||||||
|
|
||||||
func randInt(min, max int) int { |
|
||||||
return min + int(rand.Float64()*float64(max-min)) |
|
||||||
} |
|
||||||
@ -0,0 +1,24 @@ |
|||||||
|
package simulation |
||||||
|
|
||||||
|
func AsFloat64(v interface{}) float64 { |
||||||
|
switch val := v.(type) { |
||||||
|
case float64: |
||||||
|
return val |
||||||
|
case int: |
||||||
|
return float64(val) |
||||||
|
case int64: |
||||||
|
return float64(val) |
||||||
|
case float32: |
||||||
|
return float64(val) |
||||||
|
default: |
||||||
|
return 0 |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func AsString(v interface{}) string { |
||||||
|
s, ok := v.(string) |
||||||
|
if !ok { |
||||||
|
return "" |
||||||
|
} |
||||||
|
return s |
||||||
|
} |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
package simulation |
||||||
|
|
||||||
|
// keep this stateless. Trying to store state here is a big mistake.
|
||||||
|
// This is meant to be a pure logic handler.
|
||||||
|
type WebServerLogic struct { |
||||||
|
} |
||||||
|
|
||||||
|
func (l WebServerLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { |
||||||
|
maxRPS := int(AsFloat64(props["capacityRPS"])) |
||||||
|
|
||||||
|
toProcess := queue |
||||||
|
if len(queue) > maxRPS { |
||||||
|
toProcess = queue[:maxRPS] |
||||||
|
} |
||||||
|
|
||||||
|
var output []*Request |
||||||
|
for _, req := range toProcess { |
||||||
|
output = append(output, &Request{ |
||||||
|
ID: req.ID, |
||||||
|
Timestamp: req.Timestamp, |
||||||
|
Origin: req.Origin, |
||||||
|
Type: req.Type, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
return output, true |
||||||
|
} |
||||||
@ -1,73 +0,0 @@ |
|||||||
package simulation |
|
||||||
|
|
||||||
type WebServerNode struct { |
|
||||||
ID string |
|
||||||
Queue []*Request |
|
||||||
CapacityRPS int |
|
||||||
BaseLatencyMs int |
|
||||||
PenaltyPerRPS float64 |
|
||||||
Processed []*Request |
|
||||||
Alive bool |
|
||||||
Targets []string |
|
||||||
} |
|
||||||
|
|
||||||
func (ws *WebServerNode) GetID() string { |
|
||||||
return ws.ID |
|
||||||
} |
|
||||||
|
|
||||||
func (ws *WebServerNode) Type() string { |
|
||||||
return "webserver" |
|
||||||
} |
|
||||||
|
|
||||||
func (ws *WebServerNode) IsAlive() bool { |
|
||||||
return ws.Alive |
|
||||||
} |
|
||||||
|
|
||||||
func (ws *WebServerNode) Tick(tick int, currentTimeMs int) { |
|
||||||
toProcess := min(ws.CapacityRPS, len(ws.Queue)) |
|
||||||
for i := 0; i < toProcess; i++ { |
|
||||||
req := ws.Queue[i] |
|
||||||
req.LatencyMS += ws.BaseLatencyMs |
|
||||||
ws.Processed = append(ws.Processed, req) |
|
||||||
} |
|
||||||
|
|
||||||
// Remove processed requests from the queue
|
|
||||||
ws.Queue = ws.Queue[toProcess:] |
|
||||||
|
|
||||||
// Apply penalty for overload
|
|
||||||
if len(ws.Queue) > 0 { |
|
||||||
overload := len(ws.Queue) |
|
||||||
for _, req := range ws.Queue { |
|
||||||
req.LatencyMS += int(ws.PenaltyPerRPS * float64(overload)) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (ws *WebServerNode) Receive(req *Request) { |
|
||||||
ws.Queue = append(ws.Queue, req) |
|
||||||
} |
|
||||||
|
|
||||||
func (ws *WebServerNode) Emit() []*Request { |
|
||||||
out := ws.Processed |
|
||||||
ws.Processed = nil |
|
||||||
return out |
|
||||||
} |
|
||||||
|
|
||||||
func (ws *WebServerNode) AddTarget(targetID string) { |
|
||||||
ws.Targets = append(ws.Targets, targetID) |
|
||||||
} |
|
||||||
|
|
||||||
func min(a, b int) int { |
|
||||||
if a < b { |
|
||||||
return a |
|
||||||
} |
|
||||||
return b |
|
||||||
} |
|
||||||
|
|
||||||
func (ws *WebServerNode) GetQueue() []*Request { |
|
||||||
return ws.Queue |
|
||||||
} |
|
||||||
|
|
||||||
func (ws *WebServerNode) GetTargets() []string { |
|
||||||
return ws.Targets |
|
||||||
} |
|
||||||
@ -0,0 +1,440 @@ |
|||||||
|
/* Reusing the existing CSS from the game */ |
||||||
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&display=swap'); |
||||||
|
|
||||||
|
/* === CSS VARIABLES === */ |
||||||
|
:root { |
||||||
|
/* Colors */ |
||||||
|
--color-bg-body: #161b22; |
||||||
|
--color-bg-dark: #121212; |
||||||
|
--color-bg-sidebar: #111; |
||||||
|
--color-bg-component: #1e1e1e; |
||||||
|
--color-bg-hover: #2a2a2a; |
||||||
|
--color-bg-accent: #005f87; |
||||||
|
--color-bg-tab-active: #1a3d2a; |
||||||
|
--color-border: #444; |
||||||
|
--color-border-accent: #00ff88; |
||||||
|
--color-border-panel: #30363d; |
||||||
|
--color-text-primary: #ccc; |
||||||
|
--color-text-muted: #888; |
||||||
|
--color-text-accent: #00ff88; |
||||||
|
--color-text-white: #fff; |
||||||
|
--color-text-dark: #333; |
||||||
|
--color-button: #238636; |
||||||
|
--color-button-disabled: #555; |
||||||
|
--color-connection: #333; |
||||||
|
--color-connection-selected: #007bff; |
||||||
|
--color-tooltip-bg: #333; |
||||||
|
--color-tooltip-text: #fff; |
||||||
|
|
||||||
|
/* Sizes */ |
||||||
|
--radius-small: 4px; |
||||||
|
--radius-medium: 6px; |
||||||
|
--radius-large: 8px; |
||||||
|
--font-family-mono: 'JetBrains Mono', monospace; |
||||||
|
--font-family-code: 'Fira Code', monospace; |
||||||
|
--component-padding: 8px; |
||||||
|
--component-gap: 12px; |
||||||
|
} |
||||||
|
|
||||||
|
/* === RESET & BASE STYLES === */ |
||||||
|
* { |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
margin: 0; |
||||||
|
font-family: var(--font-family-mono); |
||||||
|
background-color: var(--color-bg-body); |
||||||
|
color: var(--color-text-primary); |
||||||
|
min-height: 100vh; |
||||||
|
} |
||||||
|
|
||||||
|
/* === LAYOUT === */ |
||||||
|
#page-container { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
width: 100%; |
||||||
|
min-height: 100vh; |
||||||
|
} |
||||||
|
|
||||||
|
#sd-header { |
||||||
|
width: 100%; |
||||||
|
background: none; |
||||||
|
padding: 12px 24px; |
||||||
|
font-weight: bold; |
||||||
|
color: var(--color-text-accent); |
||||||
|
border-bottom: 1px solid var(--color-text-dark); |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: space-between; |
||||||
|
} |
||||||
|
|
||||||
|
.header-text { |
||||||
|
font-size: 24px; |
||||||
|
margin: 0; |
||||||
|
} |
||||||
|
|
||||||
|
#main-content { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
flex: 1; |
||||||
|
padding: 60px 24px; |
||||||
|
align-items: center; |
||||||
|
background: radial-gradient(circle at 30% 50%, rgba(0, 255, 136, 0.1), transparent 50%), |
||||||
|
radial-gradient(circle at 70% 80%, rgba(255, 107, 53, 0.1), transparent 50%); |
||||||
|
} |
||||||
|
|
||||||
|
/* === REUSED EXISTING CLASSES === */ |
||||||
|
.requirements-section { |
||||||
|
background: #161b22; |
||||||
|
border: 1px solid #30363d; |
||||||
|
border-radius: 8px; |
||||||
|
padding: 20px; |
||||||
|
margin-bottom: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.requirements-list { |
||||||
|
margin: 0; |
||||||
|
padding: 0; |
||||||
|
list-style: none; |
||||||
|
} |
||||||
|
|
||||||
|
.requirement-item { |
||||||
|
position: relative; |
||||||
|
padding: 8px 0 8px 25px; |
||||||
|
margin: 0; |
||||||
|
border-bottom: 1px solid #30363d; |
||||||
|
} |
||||||
|
|
||||||
|
.requirement-item:before { |
||||||
|
content: "✓"; |
||||||
|
color: #00ff88; |
||||||
|
position: absolute; |
||||||
|
left: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.panel-title { |
||||||
|
font-weight: bold; |
||||||
|
color: var(--color-text-white); |
||||||
|
font-size: 15px; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.panel-metric { |
||||||
|
margin-bottom: 0.4rem; |
||||||
|
} |
||||||
|
|
||||||
|
.panel-metric .label { |
||||||
|
display: inline-block; |
||||||
|
width: 140px; |
||||||
|
color: var(--color-text-muted); |
||||||
|
} |
||||||
|
|
||||||
|
#github-login-btn { |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
gap: 8px; |
||||||
|
padding: 8px 14px; |
||||||
|
background-color: #fff; |
||||||
|
color: #000000; |
||||||
|
text-decoration: none; |
||||||
|
border-radius: var(--radius-medium); |
||||||
|
font-weight: 500; |
||||||
|
font-family: var(--font-family-mono); |
||||||
|
font-size: 12px; |
||||||
|
border: 1px solid #2ea043; |
||||||
|
transition: background-color 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
#github-login-btn:hover { |
||||||
|
background-color: #ccc; |
||||||
|
} |
||||||
|
|
||||||
|
/* === CUSTOM STYLES FOR GAME MODE SELECTION === */ |
||||||
|
.hero-section { |
||||||
|
text-align: center; |
||||||
|
margin-bottom: 60px; |
||||||
|
} |
||||||
|
|
||||||
|
.hero-section h1 { |
||||||
|
font-size: 48px; |
||||||
|
font-weight: 700; |
||||||
|
margin: 0 0 20px 0; |
||||||
|
background: linear-gradient(135deg, var(--color-text-accent), var(--color-connection-selected)); |
||||||
|
-webkit-background-clip: text; |
||||||
|
-webkit-text-fill-color: transparent; |
||||||
|
background-clip: text; |
||||||
|
} |
||||||
|
|
||||||
|
.hero-section p { |
||||||
|
font-size: 18px; |
||||||
|
color: var(--color-text-muted); |
||||||
|
max-width: 600px; |
||||||
|
margin: 0 auto; |
||||||
|
line-height: 1.6; |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-grid { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(2, 1fr); |
||||||
|
gap: 30px; |
||||||
|
max-width: 1000px; |
||||||
|
width: 100%; |
||||||
|
margin-bottom: 60px; |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card { |
||||||
|
background: var(--color-bg-component); |
||||||
|
border: 2px solid var(--color-border); |
||||||
|
border-radius: var(--radius-large); |
||||||
|
padding: 30px; |
||||||
|
transition: all 0.3s ease; |
||||||
|
position: relative; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card::before { |
||||||
|
content: ''; |
||||||
|
position: absolute; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
bottom: 0; |
||||||
|
background: linear-gradient(135deg, transparent, rgba(255, 255, 255, 0.05)); |
||||||
|
opacity: 0; |
||||||
|
transition: opacity 0.3s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card:hover { |
||||||
|
transform: translateY(-5px); |
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); |
||||||
|
background-color: var(--color-bg-hover); |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card:hover::before { |
||||||
|
opacity: 1; |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card.campaign { |
||||||
|
border-color: var(--color-border-accent); |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card.campaign:hover { |
||||||
|
border-color: var(--color-border-accent); |
||||||
|
box-shadow: 0 10px 30px rgba(0, 255, 136, 0.3); |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card.practice { |
||||||
|
border-color: #ff6b35; |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card.practice:hover { |
||||||
|
border-color: #ff6b35; |
||||||
|
box-shadow: 0 10px 30px rgba(255, 107, 53, 0.3); |
||||||
|
} |
||||||
|
|
||||||
|
.mode-header { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 15px; |
||||||
|
margin-bottom: 25px; |
||||||
|
} |
||||||
|
|
||||||
|
.mode-icon { |
||||||
|
width: 60px; |
||||||
|
height: 60px; |
||||||
|
border-radius: var(--radius-medium); |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
font-size: 28px; |
||||||
|
font-weight: bold; |
||||||
|
background: var(--color-bg-dark); |
||||||
|
border: 1px solid var(--color-border-panel); |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card.campaign .mode-icon { |
||||||
|
color: var(--color-text-accent); |
||||||
|
border-color: var(--color-border-accent); |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card.practice .mode-icon { |
||||||
|
color: #ff6b35; |
||||||
|
border-color: #ff6b35; |
||||||
|
} |
||||||
|
|
||||||
|
.mode-title { |
||||||
|
font-size: 28px; |
||||||
|
font-weight: 700; |
||||||
|
margin: 0 0 5px 0; |
||||||
|
color: var(--color-text-white); |
||||||
|
} |
||||||
|
|
||||||
|
.mode-subtitle { |
||||||
|
font-size: 16px; |
||||||
|
color: var(--color-text-muted); |
||||||
|
margin: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.mode-features { |
||||||
|
margin-bottom: 25px; |
||||||
|
} |
||||||
|
|
||||||
|
.mode-features .requirements-list .requirement-item { |
||||||
|
border-bottom: 1px solid var(--color-border-panel); |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card.campaign .mode-features .requirement-item:before { |
||||||
|
color: var(--color-text-accent); |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card.practice .mode-features .requirement-item:before { |
||||||
|
color: #ff6b35; |
||||||
|
} |
||||||
|
|
||||||
|
.mode-progress { |
||||||
|
background: var(--color-bg-dark); |
||||||
|
border: 1px solid var(--color-border-panel); |
||||||
|
border-radius: var(--radius-medium); |
||||||
|
padding: 16px; |
||||||
|
margin-bottom: 25px; |
||||||
|
} |
||||||
|
|
||||||
|
.progress-bar { |
||||||
|
background: var(--color-border); |
||||||
|
height: 8px; |
||||||
|
border-radius: var(--radius-small); |
||||||
|
overflow: hidden; |
||||||
|
margin-top: 8px; |
||||||
|
} |
||||||
|
|
||||||
|
.progress-fill { |
||||||
|
height: 100%; |
||||||
|
background: linear-gradient(90deg, var(--color-text-accent), var(--color-connection-selected)); |
||||||
|
width: 33%; |
||||||
|
border-radius: var(--radius-small); |
||||||
|
} |
||||||
|
|
||||||
|
.recent-activity { |
||||||
|
font-size: 14px; |
||||||
|
} |
||||||
|
|
||||||
|
.recent-activity .activity-item { |
||||||
|
margin-bottom: 4px; |
||||||
|
} |
||||||
|
|
||||||
|
.recent-activity .activity-item.active { |
||||||
|
color: #ff6b35; |
||||||
|
} |
||||||
|
|
||||||
|
.recent-activity .activity-item.inactive { |
||||||
|
color: var(--color-text-muted); |
||||||
|
} |
||||||
|
|
||||||
|
.mode-button { |
||||||
|
width: 100%; |
||||||
|
padding: 15px; |
||||||
|
border: none; |
||||||
|
border-radius: var(--radius-medium); |
||||||
|
font-size: 16px; |
||||||
|
font-weight: 600; |
||||||
|
font-family: var(--font-family-mono); |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.3s ease; |
||||||
|
text-transform: uppercase; |
||||||
|
letter-spacing: 0.5px; |
||||||
|
text-decoration: none; |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
gap: 8px; |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card.campaign .mode-button { |
||||||
|
background: linear-gradient(90deg, var(--color-text-accent), var(--color-connection-selected)); |
||||||
|
color: var(--color-bg-body); |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card.practice .mode-button { |
||||||
|
background: linear-gradient(90deg, #ff6b35, #f7931e); |
||||||
|
color: var(--color-text-white); |
||||||
|
} |
||||||
|
|
||||||
|
.mode-button:hover { |
||||||
|
opacity: 0.9; |
||||||
|
transform: translateY(-2px); |
||||||
|
} |
||||||
|
|
||||||
|
/* === STATS GRID === */ |
||||||
|
.stats-grid { |
||||||
|
display: grid; |
||||||
|
grid-template-columns: repeat(4, 1fr); |
||||||
|
gap: 20px; |
||||||
|
max-width: 800px; |
||||||
|
width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
.stat-card { |
||||||
|
background: var(--color-bg-component); |
||||||
|
border: 1px solid var(--color-border); |
||||||
|
border-radius: var(--radius-large); |
||||||
|
padding: 20px; |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
|
||||||
|
.stat-value { |
||||||
|
font-size: 28px; |
||||||
|
font-weight: 700; |
||||||
|
margin-bottom: 8px; |
||||||
|
} |
||||||
|
|
||||||
|
.stat-card:nth-child(1) .stat-value { |
||||||
|
color: var(--color-text-accent); |
||||||
|
} |
||||||
|
|
||||||
|
.stat-card:nth-child(2) .stat-value { |
||||||
|
color: var(--color-connection-selected); |
||||||
|
} |
||||||
|
|
||||||
|
.stat-card:nth-child(3) .stat-value { |
||||||
|
color: #ff6b35; |
||||||
|
} |
||||||
|
|
||||||
|
.stat-card:nth-child(4) .stat-value { |
||||||
|
color: #f7931e; |
||||||
|
} |
||||||
|
|
||||||
|
.stat-label { |
||||||
|
font-size: 14px; |
||||||
|
color: var(--color-text-muted); |
||||||
|
} |
||||||
|
|
||||||
|
/* === RESPONSIVE === */ |
||||||
|
@media (max-width: 1024px) { |
||||||
|
.game-mode-grid { |
||||||
|
grid-template-columns: 1fr; |
||||||
|
gap: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
.stats-grid { |
||||||
|
grid-template-columns: repeat(2, 1fr); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 768px) { |
||||||
|
.hero-section h1 { |
||||||
|
font-size: 36px; |
||||||
|
} |
||||||
|
|
||||||
|
.game-mode-card { |
||||||
|
padding: 20px; |
||||||
|
} |
||||||
|
|
||||||
|
#main-content { |
||||||
|
padding: 40px 16px; |
||||||
|
} |
||||||
|
|
||||||
|
.stats-grid { |
||||||
|
grid-template-columns: 1fr; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue