Browse Source

cdn and adding loadbalancer algorithms

pull/1/head
Stephanie Gredell 6 months ago
parent
commit
55da452404
  1. 20
      internal/simulation/cdn.go
  2. 63
      internal/simulation/cdn_test.go
  3. 2
      internal/simulation/engine.go
  4. 1
      internal/simulation/engine_test.go
  5. 83
      internal/simulation/loadbalancer.go
  6. 79
      internal/simulation/loadbalancer_test.go

20
internal/simulation/cdn.go

@ -3,30 +3,34 @@ package simulation
type CDNLogic struct{} type CDNLogic struct{}
func (c CDNLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { 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"])) ttl := int(AsFloat64(props["ttlMs"]))
// retrieve the cdn's cache from props
cache, ok := props["_cache"].(map[string]int) cache, ok := props["_cache"].(map[string]int)
if !ok { if !ok {
cache = make(map[string]int) cache = make(map[string]int)
props["_cache"] = cache props["_cache"] = cache
} }
// prepare a list to collect output requests (those that are forwarded past the cdn)
output := []*Request{} output := []*Request{}
// iterate over each request in the queue
for _, req := range queue { for _, req := range queue {
path := req.ID // using request ID as a stand-in for "path" path := req.ID
lastCached, ok := cache[path] 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 // Cache miss or stale
reqCopy := *req reqCopy := *req
reqCopy.Path = append(reqCopy.Path, "miss") reqCopy.Path = append(reqCopy.Path, "miss")
reqCopy.LatencyMS += 50 // simulate extra latency for cache miss reqCopy.LatencyMS += 50
output = append(output, &reqCopy) output = append(output, &reqCopy)
cache[path] = tick * 1000 cache[path] = req.Timestamp
} else {
// Cache hit — suppress forwarding
continue
} }
// else cache hit, suppressed
} }
return output, true return output, true

63
internal/simulation/cdn_test.go

@ -1,42 +1,75 @@
package simulation package simulation
import ( import (
"fmt"
"testing" "testing"
) )
func TestCDNLogic(t *testing.T) { func TestCDNLogic(t *testing.T) {
cdn := CDNLogic{} cdn := CDNLogic{}
cache := map[string]int{} // shared mutable cache
props := map[string]any{ props := map[string]any{
"ttlMs": float64(1000), "ttlMs": float64(1000),
"_cache": map[string]int{}, // initial empty cache "_cache": cache,
} }
req := &Request{ reqA := &Request{
ID: "asset-123", ID: "asset-123",
Timestamp: 0, Timestamp: 0,
Path: []string{"cdn"}, Path: []string{"cdn"},
LatencyMS: 0, LatencyMS: 0,
} }
// Tick 0 — should MISS and forward with added latency reqB := &Request{
output, _ := cdn.Tick(props, []*Request{req}, 0) ID: "asset-456",
if len(output) != 1 { Timestamp: 0,
t.Errorf("Expected request to pass through on first miss") Path: []string{"cdn"},
} else if output[0].LatencyMS != 50 { LatencyMS: 0,
t.Errorf("Expected latency to be 50ms on cache miss, got %d", output[0].LatencyMS)
} }
// Tick 1 — should HIT and suppress // Tick 0 — both A and B should MISS and forward with added latency
output, _ = cdn.Tick(props, []*Request{req}, 1) 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 { 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) // Tick 11 — simulate expiry for A (TTL = 1000ms, Tick 11 = 11000ms)
output, _ = cdn.Tick(props, []*Request{req}, 11) 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 { 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 { } 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")
} }
} }

2
internal/simulation/engine.go

@ -5,6 +5,8 @@ import (
"systemdesigngame/internal/design" "systemdesigngame/internal/design"
) )
// TODO list
type TODO interface{}
type NodeLogic interface { type NodeLogic interface {
Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool)
} }

1
internal/simulation/engine_test.go

@ -6,6 +6,7 @@ import (
"systemdesigngame/internal/design" "systemdesigngame/internal/design"
) )
// TODO: Make this better
func TestSimpleChainSimulation(t *testing.T) { func TestSimpleChainSimulation(t *testing.T) {
d := design.Design{ d := design.Design{
Nodes: []design.Node{ Nodes: []design.Node{

83
internal/simulation/loadbalancer.go

@ -1,5 +1,10 @@
package simulation package simulation
import (
"hash/fnv"
"math/rand"
)
type LoadBalancerLogic struct{} type LoadBalancerLogic struct{}
func (l LoadBalancerLogic) Tick(props map[string]any, queue []*Request, tick int) ([]*Request, bool) { 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) 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 default: // round-robin
next := int(AsFloat64(props["_rrIndex"])) next := int(AsFloat64(props["_rrIndex"]))
for _, req := range queue { for _, req := range queue {

79
internal/simulation/loadbalancer_test.go

@ -58,4 +58,83 @@ func TestLoadBalancerAlgorithms(t *testing.T) {
t.Errorf("expected lb to emit 2 requests") 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)
}
})
} }

Loading…
Cancel
Save