157 lines
4.9 KiB
Go
157 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
messages "git.tornberg.me/go-cart-actor/proto"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
// TestMultiNodeOwnershipNegotiation spins up two gRPC servers (nodeA, nodeB),
|
|
// manually links their SyncedPools (bypassing AddRemote's fixed port assumption),
|
|
// and verifies that only one node becomes the owner of a new cart while the
|
|
// other can still apply a mutation via the remote proxy path.
|
|
//
|
|
// NOTE:
|
|
// - We manually inject RemoteHostGRPC entries because AddRemote() hard-codes
|
|
// port 1337; to run two distinct servers concurrently we need distinct ports.
|
|
// - This test asserts single ownership consistency rather than the complete
|
|
// quorum semantics (which depend on real discovery + AddRemote).
|
|
func TestMultiNodeOwnershipNegotiation(t *testing.T) {
|
|
// Allocate distinct ports for the two nodes.
|
|
const (
|
|
addrA = "127.0.0.1:18081"
|
|
addrB = "127.0.0.1:18082"
|
|
hostA = "nodeA"
|
|
hostB = "nodeB"
|
|
)
|
|
|
|
// Create local grain pools.
|
|
poolA := NewGrainLocalPool(1024, time.Minute, spawn)
|
|
poolB := NewGrainLocalPool(1024, time.Minute, spawn)
|
|
|
|
// Create synced pools (no discovery).
|
|
syncedA, err := NewSyncedPool(poolA, hostA, nil)
|
|
if err != nil {
|
|
t.Fatalf("nodeA NewSyncedPool error: %v", err)
|
|
}
|
|
syncedB, err := NewSyncedPool(poolB, hostB, nil)
|
|
if err != nil {
|
|
t.Fatalf("nodeB NewSyncedPool error: %v", err)
|
|
}
|
|
|
|
// Start gRPC servers (CartActor + ControlPlane) on different ports.
|
|
grpcSrvA, err := StartGRPCServer(addrA, poolA, syncedA)
|
|
if err != nil {
|
|
t.Fatalf("StartGRPCServer A error: %v", err)
|
|
}
|
|
defer grpcSrvA.GracefulStop()
|
|
|
|
grpcSrvB, err := StartGRPCServer(addrB, poolB, syncedB)
|
|
if err != nil {
|
|
t.Fatalf("StartGRPCServer B error: %v", err)
|
|
}
|
|
defer grpcSrvB.GracefulStop()
|
|
|
|
// Helper to connect one pool to the other's server (manual AddRemote equivalent).
|
|
link := func(src *SyncedPool, remoteHost, remoteAddr string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
conn, dialErr := grpc.DialContext(ctx, remoteAddr, grpc.WithInsecure(), grpc.WithBlock())
|
|
if dialErr != nil {
|
|
t.Fatalf("dial %s (%s) failed: %v", remoteHost, remoteAddr, dialErr)
|
|
}
|
|
cartClient := messages.NewCartActorClient(conn)
|
|
controlClient := messages.NewControlPlaneClient(conn)
|
|
|
|
src.mu.Lock()
|
|
src.remoteHosts[remoteHost] = &RemoteHostGRPC{
|
|
Host: remoteHost,
|
|
Conn: conn,
|
|
CartClient: cartClient,
|
|
ControlClient: controlClient,
|
|
}
|
|
src.mu.Unlock()
|
|
}
|
|
|
|
// Cross-link the two pools.
|
|
link(syncedA, hostB, addrB)
|
|
link(syncedB, hostA, addrA)
|
|
|
|
// Allow brief stabilization (control plane pings / no real negotiation needed here).
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Create a deterministic cart id for test readability.
|
|
cartID := ToCartId(fmt.Sprintf("cart-%d", time.Now().UnixNano()))
|
|
|
|
// Mutation payload (local ownership claim expected on nodeA).
|
|
addItem := &messages.AddItem{
|
|
ItemId: 1,
|
|
Quantity: 1,
|
|
Price: 1500,
|
|
OrgPrice: 1500,
|
|
Sku: "sku-test-multi",
|
|
Name: "Multi Node Test",
|
|
Image: "/test.png",
|
|
Stock: 2,
|
|
Tax: 2500,
|
|
Country: "se",
|
|
}
|
|
|
|
// Apply mutation on nodeA (should create local grain + claim ownership).
|
|
if _, err := syncedA.Apply(cartID, addItem); err != nil {
|
|
t.Fatalf("nodeA Apply addItem error: %v", err)
|
|
}
|
|
|
|
// Validate nodeA local pool has the grain.
|
|
if _, ok := poolA.grains[cartID]; !ok {
|
|
t.Fatalf("nodeA expected local grain ownership but grain missing")
|
|
}
|
|
|
|
// Attempt to mutate same cart from nodeB (should route remotely, not create local duplication).
|
|
change := &messages.ChangeQuantity{
|
|
Id: 1, // line id is 1 after first AddItem
|
|
Quantity: 2,
|
|
}
|
|
|
|
// Apply on nodeB; if ownership logic works, this will call remote RPC and succeed without creating a local grain.
|
|
if _, err := syncedB.Apply(cartID, change); err != nil {
|
|
t.Fatalf("nodeB remote Apply changeQuantity error: %v", err)
|
|
}
|
|
|
|
// NodeB should NOT have a local grain (ownership), but may or may not have a remote proxy
|
|
// entry in remoteIndex depending on internal propagation. We assert it does NOT hold local.
|
|
if _, local := poolB.grains[cartID]; local {
|
|
t.Fatalf("nodeB unexpectedly created local grain (ownership duplication)")
|
|
}
|
|
|
|
// Fetch state from nodeB to ensure we see updated quantity (2).
|
|
grainStateB, err := syncedB.Get(cartID)
|
|
if err != nil {
|
|
t.Fatalf("nodeB Get error: %v", err)
|
|
}
|
|
if len(grainStateB.Items) != 1 || grainStateB.Items[0].Quantity != 2 {
|
|
t.Fatalf("nodeB observed inconsistent state: items=%d qty=%d (expected 1 / 2)",
|
|
len(grainStateB.Items),
|
|
func() int {
|
|
if len(grainStateB.Items) == 0 {
|
|
return -1
|
|
}
|
|
return grainStateB.Items[0].Quantity
|
|
}(),
|
|
)
|
|
}
|
|
|
|
// Cross-check from nodeA (authoritative) to ensure state matches.
|
|
grainStateA, err := syncedA.Get(cartID)
|
|
if err != nil {
|
|
t.Fatalf("nodeA Get error: %v", err)
|
|
}
|
|
if grainStateA.Items[0].Quantity != 2 {
|
|
t.Fatalf("nodeA authoritative state mismatch: expected qty=2 got %d", grainStateA.Items[0].Quantity)
|
|
}
|
|
}
|