diff --git a/internal/simulation/cdn.go b/internal/simulation/cdn.go index 4699f95..02eeeea 100644 --- a/internal/simulation/cdn.go +++ b/internal/simulation/cdn.go @@ -3,30 +3,34 @@ package simulation type CDNLogic struct{} func (c CDNLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { - // TTL (time-to-live) determines how long cached content stays fresh + + // read the ttl for cached content ttl := int(AsFloat64(props["ttlMs"])) + + // retrieve the cdn's cache from props cache, ok := props["_cache"].(map[string]int) if !ok { cache = make(map[string]int) props["_cache"] = cache } + // prepare a list to collect output requests (those that are forwarded past the cdn) output := []*Request{} + // iterate over each request in the queue for _, req := range queue { - path := req.ID // using request ID as a stand-in for "path" + path := req.ID lastCached, ok := cache[path] - if !ok || tick*1000-lastCached > ttl { + // check if it has been more than ttl seconds since this content was last cached? + if !ok || (req.Timestamp-lastCached) > ttl { // Cache miss or stale reqCopy := *req reqCopy.Path = append(reqCopy.Path, "miss") - reqCopy.LatencyMS += 50 // simulate extra latency for cache miss + reqCopy.LatencyMS += 50 output = append(output, &reqCopy) - cache[path] = tick * 1000 - } else { - // Cache hit — suppress forwarding - continue + cache[path] = req.Timestamp } + // else cache hit, suppressed } return output, true diff --git a/internal/simulation/cdn_test.go b/internal/simulation/cdn_test.go index 004e323..c28987f 100644 --- a/internal/simulation/cdn_test.go +++ b/internal/simulation/cdn_test.go @@ -1,42 +1,75 @@ package simulation import ( + "fmt" "testing" ) func TestCDNLogic(t *testing.T) { cdn := CDNLogic{} + cache := map[string]int{} // shared mutable cache props := map[string]any{ "ttlMs": float64(1000), - "_cache": map[string]int{}, // initial empty cache + "_cache": cache, } - req := &Request{ + reqA := &Request{ ID: "asset-123", Timestamp: 0, Path: []string{"cdn"}, LatencyMS: 0, } - // Tick 0 — should MISS and forward with added latency - output, _ := cdn.Tick(props, []*Request{req}, 0) - if len(output) != 1 { - t.Errorf("Expected request to pass through on first miss") - } else if output[0].LatencyMS != 50 { - t.Errorf("Expected latency to be 50ms on cache miss, got %d", output[0].LatencyMS) + reqB := &Request{ + ID: "asset-456", + Timestamp: 0, + Path: []string{"cdn"}, + LatencyMS: 0, } - // Tick 1 — should HIT and suppress - output, _ = cdn.Tick(props, []*Request{req}, 1) + // Tick 0 — both A and B should MISS and forward with added latency + output, _ := cdn.Tick(props, []*Request{reqA, reqB}, 0) + fmt.Printf("Tick 0 Output: %+v\n", output) + fmt.Printf("Cache after Tick 0: %+v\n", cache) + if len(output) != 2 { + t.Errorf("Expected 2 forwarded requests on cache miss") + } + for _, o := range output { + if o.LatencyMS != 50 { + t.Errorf("Expected 50ms latency on miss, got %d", o.LatencyMS) + } + } + if len(cache) != 2 { + t.Errorf("Expected 2 items in cache after miss, got %d", len(cache)) + } + + // Tick 1 — both A and B should HIT and be suppressed + reqA1 := &Request{ID: "asset-123", Timestamp: 1000, Path: []string{"cdn"}, LatencyMS: 0} + reqB1 := &Request{ID: "asset-456", Timestamp: 1000, Path: []string{"cdn"}, LatencyMS: 0} + output, _ = cdn.Tick(props, []*Request{reqA1, reqB1}, 1) + fmt.Printf("Tick 1 Output: %+v\n", output) + fmt.Printf("Cache after Tick 1: %+v\n", cache) if len(output) != 0 { - t.Errorf("Expected request to be cached and suppressed on hit") + t.Errorf("Expected all requests to be cached and suppressed on hit") } - // Tick 11 — simulate expiry (assuming TickMs = 100, so Tick 11 = 1100ms) - output, _ = cdn.Tick(props, []*Request{req}, 11) + // Tick 11 — simulate expiry for A (TTL = 1000ms, Tick 11 = 11000ms) + reqA2 := &Request{ID: "asset-123", Timestamp: 11000, Path: []string{"cdn"}, LatencyMS: 0} + output, _ = cdn.Tick(props, []*Request{reqA2}, 11) + fmt.Printf("Tick 11 Output A: %+v\n", output) + fmt.Printf("Cache after Tick 11 A: %+v\n", cache) if len(output) != 1 { - t.Errorf("Expected request to be forwarded again after TTL expiry") + t.Errorf("Expected request A to be forwarded again after TTL expiry") } else if output[0].LatencyMS != 50 { - t.Errorf("Expected latency to be 50ms on cache refresh, got %d", output[0].LatencyMS) + t.Errorf("Expected 50ms latency on cache refresh, got %d", output[0].LatencyMS) + } + + // B should still HIT (suppressed) + reqB2 := &Request{ID: "asset-456", Timestamp: 1000, Path: []string{"cdn"}, LatencyMS: 0} + output, _ = cdn.Tick(props, []*Request{reqB2}, 11) + fmt.Printf("Tick 11 Output B: %+v\n", output) + fmt.Printf("Cache after Tick 11 B: %+v\n", cache) + if len(output) > 0 { + t.Errorf("Expected request B to be suppressed due to valid cache") } } diff --git a/internal/simulation/engine.go b/internal/simulation/engine.go index b8cc264..5aca079 100644 --- a/internal/simulation/engine.go +++ b/internal/simulation/engine.go @@ -5,6 +5,8 @@ import ( "systemdesigngame/internal/design" ) +// TODO list +type TODO interface{} type NodeLogic interface { Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) } diff --git a/internal/simulation/engine_test.go b/internal/simulation/engine_test.go index adb3a48..5366446 100644 --- a/internal/simulation/engine_test.go +++ b/internal/simulation/engine_test.go @@ -6,6 +6,7 @@ import ( "systemdesigngame/internal/design" ) +// TODO: Make this better func TestSimpleChainSimulation(t *testing.T) { d := design.Design{ Nodes: []design.Node{ diff --git a/internal/simulation/loadbalancer.go b/internal/simulation/loadbalancer.go index a14f5b3..1c1743d 100644 --- a/internal/simulation/loadbalancer.go +++ b/internal/simulation/loadbalancer.go @@ -1,5 +1,10 @@ package simulation +import ( + "hash/fnv" + "math/rand" +) + type LoadBalancerLogic struct{} func (l LoadBalancerLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { @@ -36,6 +41,84 @@ func (l LoadBalancerLogic) Tick(props map[string]any, queue []*Request, tick int output = append(output, &reqCopy) } + case "random": + for _, req := range queue { + randomIndex := rand.Intn(len(targetIDs)) + target := targetIDs[randomIndex] + + reqCopy := *req + reqCopy.Path = append(reqCopy.Path, target) + output = append(output, &reqCopy) + } + + case "ip-hash": + for _, req := range queue { + h := fnv.New32a() + h.Write([]byte(req.ID)) + index := int(h.Sum32()) % len(targetIDs) + reqCopy := *req + reqCopy.Path = append(reqCopy.Path, targetIDs[index]) + output = append(output, &reqCopy) + } + + case "first-available": + nodeHealth, ok := props["_nodeHealth"].(map[string]bool) + if !ok { + return nil, true + } + firstHealthy := "" + for _, t := range targetIDs { + if nodeHealth[t] { + firstHealthy = t + break + } + } + if firstHealthy == "" { + return nil, true + } + for _, req := range queue { + reqCopy := *req + reqCopy.Path = append(reqCopy.Path, firstHealthy) + output = append(output, &reqCopy) + } + + case "weighted-round-robin": + // Create or reuse the weighted list of targets + weighted, ok := props["_weightedTargets"].([]string) + if !ok { + weights, ok := props["_weights"].(map[string]float64) + if !ok { + return nil, true + } + flattened := []string{} + for _, id := range targetIDs { + w := int(weights[id]) + for i := 0; i < w; i++ { + flattened = append(flattened, id) + } + } + weighted = flattened + props["_weightedTargets"] = weighted + } + + next := int(AsFloat64(props["_wrIndex"])) + for _, req := range queue { + target := weighted[next] + reqCopy := *req + reqCopy.Path = append(reqCopy.Path, target) + output = append(output, &reqCopy) + next = (next + 1) % len(weighted) + } + props["_wrIndex"] = float64(next) + + case "last": + last := targetIDs[len(targetIDs)-1] + for _, req := range queue { + reqCopy := *req + reqCopy.Path = append(reqCopy.Path, last) + output = append(output, &reqCopy) + } + default: // round-robin next := int(AsFloat64(props["_rrIndex"])) for _, req := range queue { diff --git a/internal/simulation/loadbalancer_test.go b/internal/simulation/loadbalancer_test.go index 3f44da1..1521a5d 100644 --- a/internal/simulation/loadbalancer_test.go +++ b/internal/simulation/loadbalancer_test.go @@ -58,4 +58,83 @@ func TestLoadBalancerAlgorithms(t *testing.T) { t.Errorf("expected lb to emit 2 requests") } }) + + t.Run("random", func(t *testing.T) { + d := design.Design{ + Nodes: []design.Node{ + {ID: "lb", Type: "loadbalancer", Props: map[string]any{"algorithm": "random"}}, + {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 = 10 + + snaps := e.Run(1, 100) + if len(snaps[0].Emitted["lb"]) != 1 { + t.Errorf("expected lb to emit 1 request") + } + }) + + t.Run("first", func(t *testing.T) { + d := design.Design{ + Nodes: []design.Node{ + {ID: "lb", Type: "loadbalancer", Props: map[string]any{"algorithm": "first"}}, + {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 = 10 + + snaps := e.Run(1, 100) + if len(snaps[0].Emitted["lb"]) != 1 { + t.Errorf("expected lb to emit 1 request") + } + + target := snaps[0].Emitted["lb"][0].Path[1] + if target != "a" { + t.Errorf("expected request to go to 'a', got %s", target) + } + }) + + t.Run("last", func(t *testing.T) { + d := design.Design{ + Nodes: []design.Node{ + {ID: "lb", Type: "loadbalancer", Props: map[string]any{"algorithm": "last"}}, + {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 = 10 + + snaps := e.Run(1, 100) + if len(snaps[0].Emitted["lb"]) != 1 { + t.Errorf("expected lb to emit 1 request") + } + + target := snaps[0].Emitted["lb"][0].Path[1] + if target != "b" { + t.Errorf("expected request to go to 'b', got %s", target) + } + }) }