Files
go/maintnotifications/e2e/scenario_unified_injector_test.go
T
b53f2b0c9d feat(sch): MaintNotifications for ClusterClient (#3643)
* [maintnotif] Cluster specific handlers (#3613)

* maint notification handlers for cluster messages

* unrelax all conns

* trigger ci on feature branches

* feat(maintnotif): lazy cluster topology reload (#3614)

* lazy cluster topology reload

* fix discrepancies between options structs

* Update osscluster_lazy_reload_test.go

Co-authored-by: Copilot <[email protected]>

* Update osscluster.go

Co-authored-by: Copilot <[email protected]>

---------

Co-authored-by: Copilot <[email protected]>

* feat(e2e): mock maintnotif e2e tests (#3639)

* lazy cluster topology reload

* fix discrepancies between options structs

* Update osscluster_lazy_reload_test.go

Co-authored-by: Copilot <[email protected]>

* Update osscluster.go

Co-authored-by: Copilot <[email protected]>

* wip fault with mock proxy

* make lint happy

* fix linter issues

* faster tests with mocks

* linter once again

* add complex node test

* add ci e2e

* use correct redis container

* e2e fix

---------

Co-authored-by: Copilot <[email protected]>

* fix(retry): Add retry mechanism for NOREPLICAS error (#3647)

* fix(queuedNewConn): protect against nil context (#3649)

* fix(maintnotif): fix smigrated parser and add cluster state reload interval option (#3663)

* fix SMIGRATED parsing

* fix smigrated parser

* add ClusterStateReloadInterval to ClusterOptions

* fix tests

* set default cluster reload interval to 10s

* chore(lint): format

* feat(smigrated): new format & remember original host:port (#3697)

* lazy cluster topology reload

* fix discrepancies between options structs

* Update osscluster_lazy_reload_test.go

Co-authored-by: Copilot <[email protected]>

* Update osscluster.go

Co-authored-by: Copilot <[email protected]>

* wip fault with mock proxy

* make lint happy

* fix linter issues

* faster tests with mocks

* linter once again

* add complex node test

* add ci e2e

* use correct redis container

* e2e fix

* additional e2e tests

* fix data race

* fix random shard picker

* fix e2e tests

* fix for empty endpoint

* fix case when semaphore is full, but still need to check idle

* scenario tests

* create database from config

* wip

* feat(client): store original addrs for later use

* fix(notif): change smigrated notification

* fix(lint): fix linter

* fix(smigrated): use array

* fix(e2e): wip

* Update options.go

Co-authored-by: Copilot <[email protected]>

* Update redis.go

Co-authored-by: Copilot <[email protected]>

* fix(notif): if the conn has no original addr, trigger reload with first target

* chore(e2e): wip cluster e2e

* chore(e2e): fix nil pointer from e2e tests

* chore(e2e): fix tests and reports

* chore(e2e): proper logging in e2e

* chore(e2e): add pubsub in the tests as well

* chore(e2e): mockproxy fixes

* chore(e2e): mockproxy fixes

* chore(e2e): mockproxy fixes

* chore(e2e): mockproxy fixes v3

* stop background routines

* fix(e2e): tests

---------

Co-authored-by: Copilot <[email protected]>

* chore(docs): add example app (#3651)

* chore(lint): fmt

* chore(docs): improve docs

* chore(docs): update features.md

* chore(github): remove example

* chore(maintnotif): rename option and address pr comments

* fix(e2e): command runner should use client timeout

* chore(e2e): refactor tests

* fix(e2e): set default timeout to 90m

* fix(e2e): skip tests if proxy cannot be started

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: ofekshenawa <[email protected]>
Co-authored-by: Elena Kolevska <[email protected]>
2026-02-13 13:25:07 +02:00

435 lines
14 KiB
Go

package e2e
import (
"context"
"fmt"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
)
// TestUnifiedInjector_SMIGRATING demonstrates using the unified notification injector
// This test requires the proxy-based mock to directly inject notifications
func TestUnifiedInjector_SMIGRATING(t *testing.T) {
// Skip if using real fault injector (these tests require proxy mock for direct notification injection)
if os.Getenv("FAULT_INJECTOR_URL") != "" {
t.Skip("Skipping unified injector test - requires proxy mock, not real fault injector")
}
ctx := context.Background()
// Create notification injector (automatically chooses based on environment)
injector, err := NewNotificationInjector()
if err != nil {
t.Fatalf("[ERROR] Failed to create notification injector: %v", err)
}
// Start the injector - skip if proxy not available
if err := injector.Start(); err != nil {
t.Skipf("Skipping test - proxy not available: %v", err)
}
defer injector.Stop()
// Get test mode configuration
testMode := injector.GetTestModeConfig()
t.Logf("Using %s mode", testMode.Mode)
t.Logf("Cluster addresses: %v", injector.GetClusterAddrs())
// Create cluster client with maintnotifications enabled
client := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: injector.GetClusterAddrs(),
Protocol: 3, // RESP3 required for push notifications
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeEnabled,
RelaxedTimeout: 30 * time.Second,
},
})
defer client.Close()
// Set up notification tracking
tracker := NewTrackingNotificationsHook()
setupNotificationHook(client, tracker)
// Verify connection
if err := client.Ping(ctx).Err(); err != nil {
t.Fatalf("[ERROR] Failed to connect to cluster: %v", err)
}
// Perform some operations to establish connections
for i := 0; i < 10; i++ {
if err := client.Set(ctx, fmt.Sprintf("key%d", i), "value", 0).Err(); err != nil {
t.Logf("Warning: Failed to set key: %v", err)
}
}
// Start a blocking operation in background to keep connection active
// This ensures the proxy has an active connection to send notifications to
blockingDone := make(chan error, 1)
go func() {
// BLPOP with 10 second timeout - keeps connection active
_, err := client.BLPop(ctx, 10*time.Second, "notification-test-list").Result()
blockingDone <- err
}()
// Wait for blocking command to start (mode-aware)
time.Sleep(testMode.ConnectionEstablishDelay)
// Inject SMIGRATING notification while connection is active
t.Log("Injecting SMIGRATING notification...")
if err := injector.InjectSMIGRATING(ctx, 12345, "1000-2000", "3000"); err != nil {
t.Fatalf("[ERROR] Failed to inject SMIGRATING: %v", err)
}
// Wait for notification processing (mode-aware)
time.Sleep(testMode.NotificationDelay)
// Verify notification was received
analysis := tracker.GetAnalysis()
if analysis.SMigratingCount == 0 {
t.Errorf("[ERROR] Expected to receive SMIGRATING notification, got 0")
} else {
t.Logf("✓ Received %d SMIGRATING notification(s)", analysis.SMigratingCount)
}
// Verify operations still work (timeouts should be relaxed)
if err := client.Set(ctx, "test-key-during-migration", "value", 0).Err(); err != nil {
t.Errorf("[ERROR] Expected operations to work during migration, got error: %v", err)
}
// Print analysis
analysis.Print(t)
t.Log("✓ SMIGRATING test passed")
}
// TestUnifiedInjector_SMIGRATED demonstrates SMIGRATED notification handling
// This test requires the proxy-based mock to directly inject notifications
func TestUnifiedInjector_SMIGRATED(t *testing.T) {
// Skip if using real fault injector (these tests require proxy mock for direct notification injection)
if os.Getenv("FAULT_INJECTOR_URL") != "" {
t.Skip("Skipping unified injector test - requires proxy mock, not real fault injector")
}
ctx := context.Background()
injector, err := NewNotificationInjector()
if err != nil {
t.Fatalf("[ERROR] Failed to create notification injector: %v", err)
}
// Start the injector - skip if proxy not available
if err := injector.Start(); err != nil {
t.Skipf("Skipping test - proxy not available: %v", err)
}
defer injector.Stop()
// Get test mode configuration
testMode := injector.GetTestModeConfig()
t.Logf("Using %s mode", testMode.Mode)
// Track cluster state reloads
var reloadCount atomic.Int32
client := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: injector.GetClusterAddrs(),
Protocol: 3,
MaintNotificationsConfig: &maintnotifications.Config{
Mode: maintnotifications.ModeEnabled,
RelaxedTimeout: 30 * time.Second,
},
})
defer client.Close()
// Set up notification tracking
tracker := NewTrackingNotificationsHook()
setupNotificationHook(client, tracker)
if err := client.Ping(ctx).Err(); err != nil {
t.Fatalf("[ERROR] Failed to connect: %v", err)
}
// Set up reload callback on existing nodes
client.ForEachShard(ctx, func(ctx context.Context, nodeClient *redis.Client) error {
manager := nodeClient.GetMaintNotificationsManager()
if manager != nil {
manager.SetClusterStateReloadCallback(func(ctx context.Context, hostPort string, slotRanges []string) {
reloadCount.Add(1)
t.Logf("Cluster state reload triggered for %s, slots: %v", hostPort, slotRanges)
})
}
return nil
})
// Set up reload callback for new nodes
client.OnNewNode(func(nodeClient *redis.Client) {
manager := nodeClient.GetMaintNotificationsManager()
if manager != nil {
manager.SetClusterStateReloadCallback(func(ctx context.Context, hostPort string, slotRanges []string) {
reloadCount.Add(1)
t.Logf("Cluster state reload triggered for %s, slots: %v", hostPort, slotRanges)
})
}
})
// Perform some operations to establish connections
for i := 0; i < 10; i++ {
if err := client.Set(ctx, fmt.Sprintf("key%d", i), "value", 0).Err(); err != nil {
t.Logf("Warning: Failed to set key: %v", err)
}
}
initialReloads := reloadCount.Load()
// Start a blocking operation in background to keep connection active
blockingDone := make(chan error, 1)
go func() {
// BLPOP with 10 second timeout - keeps connection active
_, err := client.BLPop(ctx, 10*time.Second, "notification-test-list").Result()
blockingDone <- err
}()
// Wait for blocking command to start (mode-aware)
time.Sleep(testMode.ConnectionEstablishDelay)
// Get all node addresses - needed for both modes
addrs := injector.GetClusterAddrs()
var newNodeAddr string
if len(addrs) >= 4 {
newNodeAddr = addrs[3] // Node 3: 127.0.0.1:17003
} else {
// Fallback to first node if we don't have 4 nodes
newNodeAddr = addrs[0]
}
// Mode-specific behavior for triggering SMIGRATED
if testMode.IsProxyMock() {
// Proxy mock: Directly inject SMIGRATED notification
t.Log("Injecting SMIGRATED notification to swap node 2 for node 3...")
t.Logf("Using new node address: %s", newNodeAddr)
if err := injector.InjectSMIGRATED(ctx, 12346, newNodeAddr, "1000-2000", "3000"); err != nil {
t.Fatalf("[ERROR] Failed to inject SMIGRATED: %v", err)
}
} else {
// Real fault injector: Trigger slot migration which generates both SMIGRATING and SMIGRATED
t.Log("Triggering slot migration (will generate SMIGRATING and SMIGRATED)...")
// First inject SMIGRATING (this triggers the actual migration)
if err := injector.InjectSMIGRATING(ctx, 12345, "1000-2000", "3000"); err != nil {
t.Fatalf("[ERROR] Failed to trigger slot migration: %v", err)
}
// Wait for migration to complete (real FI takes longer)
t.Log("Waiting for migration to complete...")
}
// Wait for notification processing (mode-aware)
time.Sleep(testMode.NotificationDelay)
// Wait for blocking operation to complete
<-blockingDone
// Verify notification was received
analysis := tracker.GetAnalysis()
if testMode.IsProxyMock() {
// Proxy mock: SMIGRATED notifications may not always be received
// because they're sent to all connections, but the client might not be actively
// listening on all of them. This is expected behavior.
if analysis.MigratedCount > 0 {
t.Logf("✓ Received %d SMIGRATED notification(s)", analysis.MigratedCount)
} else {
t.Logf("Note: No SMIGRATED notifications received (expected in proxy mock mode)")
}
} else {
// Real FI: Should receive both SMIGRATING and SMIGRATED
if analysis.MigratingCount == 0 {
t.Errorf("[ERROR] Expected to receive SMIGRATING notification with real FI, got 0")
} else {
t.Logf("✓ Received %d SMIGRATING notification(s)", analysis.MigratingCount)
}
if analysis.MigratedCount > 0 {
t.Logf("✓ Received %d SMIGRATED notification(s)", analysis.MigratedCount)
} else {
t.Logf("Note: SMIGRATED notification not yet received (migration may still be in progress)")
}
}
// Verify cluster state reload callback
// Note: SMIGRATED notifications trigger cluster reload via the endpoint information
// However, in proxy mock mode, the callback might not be triggered because
// the notification is sent to all connections, not just the active one
finalReloads := reloadCount.Load()
if finalReloads > initialReloads {
t.Logf("✓ Cluster state reloaded %d time(s)", finalReloads-initialReloads)
} else {
t.Logf("Note: Cluster state reload callback not triggered (expected in proxy mock mode)")
}
// Verify the client discovered the new node topology
// After SMIGRATED, the client should reload cluster slots
// Give it a moment to process
time.Sleep(2 * time.Second)
// Count how many nodes the client knows about
var nodeAddrsMu sync.Mutex
nodeAddrs := make(map[string]bool)
client.ForEachShard(ctx, func(ctx context.Context, nodeClient *redis.Client) error {
addr := nodeClient.Options().Addr
nodeAddrsMu.Lock()
nodeAddrs[addr] = true
nodeAddrsMu.Unlock()
t.Logf("Client knows about node: %s", addr)
return nil
})
// We should have 3 nodes (0, 1, 3) after the swap
if len(nodeAddrs) < 3 {
t.Logf("Warning: Expected client to discover 3 nodes after SMIGRATED, got %d", len(nodeAddrs))
} else {
t.Logf("✓ Client discovered %d node(s) after SMIGRATED", len(nodeAddrs))
}
// Verify the new node (17003) is in the list
if len(addrs) >= 4 && !nodeAddrs[newNodeAddr] {
t.Logf("Warning: Client did not discover new node %s", newNodeAddr)
} else if len(addrs) >= 4 {
t.Logf("✓ Client discovered new node %s", newNodeAddr)
}
// Verify we can still perform operations after SMIGRATED
// The client should have reloaded cluster topology
successCount := 0
for i := 0; i < 10; i++ {
key := fmt.Sprintf("post-migration-key-%d", i)
if err := client.Set(ctx, key, "value", 0).Err(); err == nil {
successCount++
} else {
t.Logf("Warning: Failed to set key after SMIGRATED: %v", err)
}
}
if successCount < 8 {
t.Errorf("[ERROR] Expected most operations to succeed after SMIGRATED, got %d/10", successCount)
} else {
t.Logf("✓ Successfully performed %d/10 operations after SMIGRATED", successCount)
}
// Print analysis
analysis.Print(t)
t.Log("✓ SMIGRATED test passed")
}
// TestUnifiedInjector_ComplexScenario demonstrates a complex migration scenario
// This test requires the proxy-based mock to directly inject notifications
func TestUnifiedInjector_ComplexScenario(t *testing.T) {
// Skip if using real fault injector (these tests require proxy mock for direct notification injection)
if os.Getenv("FAULT_INJECTOR_URL") != "" {
t.Skip("Skipping unified injector test - requires proxy mock, not real fault injector")
}
ctx := context.Background()
injector, err := NewNotificationInjector()
if err != nil {
t.Fatalf("[ERROR] Failed to create notification injector: %v", err)
}
// Start the injector - skip if proxy not available
if err := injector.Start(); err != nil {
t.Skipf("Skipping test - proxy not available: %v", err)
}
defer injector.Stop()
// Get test mode configuration
testMode := injector.GetTestModeConfig()
t.Logf("Using %s mode", testMode.Mode)
var reloadCount atomic.Int32
client := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: injector.GetClusterAddrs(),
Protocol: 3,
})
defer client.Close()
tracker := NewTrackingNotificationsHook()
setupNotificationHook(client, tracker)
client.OnNewNode(func(nodeClient *redis.Client) {
manager := nodeClient.GetMaintNotificationsManager()
if manager != nil {
manager.SetClusterStateReloadCallback(func(ctx context.Context, hostPort string, slotRanges []string) {
reloadCount.Add(1)
})
}
})
if err := client.Ping(ctx).Err(); err != nil {
t.Fatalf("[ERROR] Failed to connect: %v", err)
}
// Perform operations
for i := 0; i < 20; i++ {
client.Set(ctx, fmt.Sprintf("key%d", i), "value", 0)
}
// Wait for connections to establish (mode-aware)
time.Sleep(testMode.ConnectionEstablishDelay)
// Simulate a multi-step migration scenario
t.Log("Step 1: Injecting SMIGRATING for slots 0-5000...")
if err := injector.InjectSMIGRATING(ctx, 10001, "0-5000"); err != nil {
t.Fatalf("[ERROR] Failed to inject SMIGRATING: %v", err)
}
// Wait for notification processing (mode-aware)
time.Sleep(testMode.NotificationDelay)
// Verify operations work during migration
for i := 0; i < 5; i++ {
if err := client.Set(ctx, fmt.Sprintf("migration-key%d", i), "value", 0).Err(); err != nil {
t.Logf("Warning: Operation failed during migration: %v", err)
}
}
if testMode.IsProxyMock() {
// Only inject SMIGRATED with mock injector
t.Log("Step 2: Injecting SMIGRATED for completed migration...")
addrs := injector.GetClusterAddrs()
hostPort := addrs[0]
if err := injector.InjectSMIGRATED(ctx, 10002, hostPort, "0-5000"); err != nil {
t.Fatalf("[ERROR] Failed to inject SMIGRATED: %v", err)
}
// Wait for notification processing (mode-aware)
time.Sleep(testMode.NotificationDelay)
}
// Verify operations still work
for i := 0; i < 5; i++ {
if err := client.Set(ctx, fmt.Sprintf("post-migration-key%d", i), "value", 0).Err(); err != nil {
t.Errorf("[ERROR] Operations failed after migration: %v", err)
}
}
// Print final analysis
analysis := tracker.GetAnalysis()
analysis.Print(t)
t.Logf("✓ Complex scenario test passed")
t.Logf(" - SMIGRATING notifications: %d", analysis.MigratingCount)
t.Logf(" - SMIGRATED notifications: %d", analysis.MigratedCount)
t.Logf(" - Cluster state reloads: %d", reloadCount.Load())
}