Files
go/maintnotifications/e2e/notiftracker_test.go

591 lines
18 KiB
Go

package e2e
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/hanzoai/kv-go/v9"
"github.com/hanzoai/kv-go/v9/internal/pool"
"github.com/hanzoai/kv-go/v9/maintnotifications"
"github.com/hanzoai/kv-go/v9/push"
)
// DiagnosticsEvent represents a notification event
// it may be a push notification or an error when processing
// push notifications
type DiagnosticsEvent struct {
// is this pre or post hook
Type string `json:"type"`
ConnID uint64 `json:"connID"`
SeqID int64 `json:"seqID"`
// ShardAddr is the address of the shard that received this notification
// Only populated when using NewTrackingNotificationsHookWithShard
ShardAddr string `json:"shardAddr,omitempty"`
Error error `json:"error"`
Pre bool `json:"pre"`
Timestamp time.Time `json:"timestamp"`
Details map[string]interface{} `json:"details"`
}
// TrackingNotificationsHook is a notification hook that tracks notifications
type TrackingNotificationsHook struct {
// unique connection count
connectionCount atomic.Int64
// timeouts
relaxedTimeoutCount atomic.Int64
unrelaxedTimeoutCount atomic.Int64
notificationProcessingErrors atomic.Int64
// notification types
totalNotifications atomic.Int64
migratingCount atomic.Int64
migratedCount atomic.Int64
sMigratingCount atomic.Int64
sMigratedCount atomic.Int64
failingOverCount atomic.Int64
failedOverCount atomic.Int64
movingCount atomic.Int64
unexpectedNotificationCount atomic.Int64
// cluster state reload tracking (actual reloads, not just SMIGRATED notifications)
clusterStateReloadCount atomic.Int64
diagnosticsLog []DiagnosticsEvent
connIds map[uint64]bool
connLogs map[uint64][]DiagnosticsEvent
mutex sync.RWMutex
// shard identifier
shardAddr string
// track last notification time for waiting
lastNotificationTime atomic.Value // stores time.Time
}
// NewTrackingNotificationsHook creates a new notification hook with counters
func NewTrackingNotificationsHook() *TrackingNotificationsHook {
hook := &TrackingNotificationsHook{
diagnosticsLog: make([]DiagnosticsEvent, 0),
connIds: make(map[uint64]bool),
connLogs: make(map[uint64][]DiagnosticsEvent),
}
hook.lastNotificationTime.Store(time.Time{})
return hook
}
// NewTrackingNotificationsHookWithShard creates a hook with shard identifier
func NewTrackingNotificationsHookWithShard(shardAddr string) *TrackingNotificationsHook {
hook := &TrackingNotificationsHook{
diagnosticsLog: make([]DiagnosticsEvent, 0),
connIds: make(map[uint64]bool),
connLogs: make(map[uint64][]DiagnosticsEvent),
shardAddr: shardAddr,
}
hook.lastNotificationTime.Store(time.Time{})
return hook
}
// SetShardAddr sets the shard address
func (tnh *TrackingNotificationsHook) SetShardAddr(addr string) {
tnh.shardAddr = addr
}
// GetLastNotificationTime returns the time of the last notification received
func (tnh *TrackingNotificationsHook) GetLastNotificationTime() time.Time {
return tnh.lastNotificationTime.Load().(time.Time)
}
// it is not reusable, but just to keep it consistent
// with the log collector
func (tnh *TrackingNotificationsHook) Clear() {
tnh.mutex.Lock()
defer tnh.mutex.Unlock()
tnh.diagnosticsLog = make([]DiagnosticsEvent, 0)
tnh.connIds = make(map[uint64]bool)
tnh.connLogs = make(map[uint64][]DiagnosticsEvent)
tnh.relaxedTimeoutCount.Store(0)
tnh.unrelaxedTimeoutCount.Store(0)
tnh.notificationProcessingErrors.Store(0)
tnh.totalNotifications.Store(0)
tnh.migratingCount.Store(0)
tnh.migratedCount.Store(0)
tnh.sMigratingCount.Store(0)
tnh.sMigratedCount.Store(0)
tnh.failingOverCount.Store(0)
tnh.failedOverCount.Store(0)
tnh.movingCount.Store(0)
tnh.unexpectedNotificationCount.Store(0)
tnh.connectionCount.Store(0)
tnh.clusterStateReloadCount.Store(0)
}
// wait for notification in prehook
func (tnh *TrackingNotificationsHook) FindOrWaitForNotification(notificationType string, timeout time.Duration) (notification []interface{}, found bool) {
if notification, found := tnh.FindNotification(notificationType); found {
return notification, true
}
// wait for notification
timeoutCh := time.After(timeout)
ticker := time.NewTicker(100 * time.Millisecond)
for {
select {
case <-timeoutCh:
return nil, false
case <-ticker.C:
if notification, found := tnh.FindNotification(notificationType); found {
return notification, true
}
}
}
}
func (tnh *TrackingNotificationsHook) FindNotification(notificationType string) (notification []interface{}, found bool) {
tnh.mutex.RLock()
defer tnh.mutex.RUnlock()
for _, event := range tnh.diagnosticsLog {
if event.Type == notificationType {
return event.Details["notification"].([]interface{}), true
}
}
return nil, false
}
// PreHook captures timeout-related events before processing
func (tnh *TrackingNotificationsHook) PreHook(_ context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}) ([]interface{}, bool) {
// Update last notification time
tnh.lastNotificationTime.Store(time.Now())
tnh.increaseNotificationCount(notificationType)
tnh.storeDiagnosticsEvent(notificationType, notification, notificationCtx)
tnh.increaseRelaxedTimeoutCount(notificationType)
return notification, true
}
func (tnh *TrackingNotificationsHook) getConnID(notificationCtx push.NotificationHandlerContext) uint64 {
if conn, ok := notificationCtx.Conn.(*pool.Conn); ok {
return conn.GetID()
}
return 0
}
func (tnh *TrackingNotificationsHook) getSeqID(notification []interface{}) int64 {
seqID, ok := notification[1].(int64)
if !ok {
return 0
}
return seqID
}
// PostHook captures the result after processing push notification
func (tnh *TrackingNotificationsHook) PostHook(_ context.Context, notificationCtx push.NotificationHandlerContext, notificationType string, notification []interface{}, err error) {
if err != nil {
event := DiagnosticsEvent{
Type: notificationType + "_ERROR",
ConnID: tnh.getConnID(notificationCtx),
SeqID: tnh.getSeqID(notification),
Error: err,
Timestamp: time.Now(),
Details: map[string]interface{}{
"notification": notification,
"context": "post-hook",
},
}
tnh.notificationProcessingErrors.Add(1)
tnh.mutex.Lock()
tnh.diagnosticsLog = append(tnh.diagnosticsLog, event)
tnh.mutex.Unlock()
}
}
func (tnh *TrackingNotificationsHook) storeDiagnosticsEvent(notificationType string, notification []interface{}, notificationCtx push.NotificationHandlerContext) {
connID := tnh.getConnID(notificationCtx)
// Get shard address - prefer explicit shardAddr, fall back to connection's remote address
shardAddr := tnh.shardAddr
if shardAddr == "" {
shardAddr = tnh.getShardAddrFromContext(notificationCtx)
}
event := DiagnosticsEvent{
Type: notificationType,
ConnID: connID,
SeqID: tnh.getSeqID(notification),
ShardAddr: shardAddr,
Pre: true,
Timestamp: time.Now(),
Details: map[string]interface{}{
"notification": notification,
"context": "pre-hook",
},
}
tnh.mutex.Lock()
if v, ok := tnh.connIds[connID]; !ok || !v {
tnh.connIds[connID] = true
tnh.connectionCount.Add(1)
}
tnh.connLogs[connID] = append(tnh.connLogs[connID], event)
tnh.diagnosticsLog = append(tnh.diagnosticsLog, event)
tnh.mutex.Unlock()
}
// getShardAddrFromContext extracts the shard address from the notification context
func (tnh *TrackingNotificationsHook) getShardAddrFromContext(notificationCtx push.NotificationHandlerContext) string {
if conn, ok := notificationCtx.Conn.(*pool.Conn); ok {
if remoteAddr := conn.RemoteAddr(); remoteAddr != nil {
return remoteAddr.String()
}
}
return ""
}
// GetRelaxedTimeoutCount returns the count of relaxed timeout events
func (tnh *TrackingNotificationsHook) GetRelaxedTimeoutCount() int64 {
return tnh.relaxedTimeoutCount.Load()
}
// GetUnrelaxedTimeoutCount returns the count of unrelaxed timeout events
func (tnh *TrackingNotificationsHook) GetUnrelaxedTimeoutCount() int64 {
return tnh.unrelaxedTimeoutCount.Load()
}
// GetNotificationProcessingErrors returns the count of timeout errors
func (tnh *TrackingNotificationsHook) GetNotificationProcessingErrors() int64 {
return tnh.notificationProcessingErrors.Load()
}
// GetTotalNotifications returns the total number of notifications processed
func (tnh *TrackingNotificationsHook) GetTotalNotifications() int64 {
return tnh.totalNotifications.Load()
}
// GetConnectionCount returns the current connection count
func (tnh *TrackingNotificationsHook) GetConnectionCount() int64 {
return tnh.connectionCount.Load()
}
// GetMovingCount returns the count of MOVING notifications
func (tnh *TrackingNotificationsHook) GetMovingCount() int64 {
return tnh.movingCount.Load()
}
// GetDiagnosticsLog returns a copy of the diagnostics log
func (tnh *TrackingNotificationsHook) GetDiagnosticsLog() []DiagnosticsEvent {
tnh.mutex.RLock()
defer tnh.mutex.RUnlock()
logCopy := make([]DiagnosticsEvent, len(tnh.diagnosticsLog))
copy(logCopy, tnh.diagnosticsLog)
return logCopy
}
func (tnh *TrackingNotificationsHook) increaseNotificationCount(notificationType string) {
tnh.totalNotifications.Add(1)
switch notificationType {
case "MOVING":
tnh.movingCount.Add(1)
case "MIGRATING":
tnh.migratingCount.Add(1)
case "MIGRATED":
tnh.migratedCount.Add(1)
case "SMIGRATING":
tnh.sMigratingCount.Add(1)
case "SMIGRATED":
tnh.sMigratedCount.Add(1)
case "FAILING_OVER":
tnh.failingOverCount.Add(1)
case "FAILED_OVER":
tnh.failedOverCount.Add(1)
default:
tnh.unexpectedNotificationCount.Add(1)
}
}
func (tnh *TrackingNotificationsHook) increaseRelaxedTimeoutCount(notificationType string) {
switch notificationType {
case "MIGRATING", "SMIGRATING", "FAILING_OVER":
tnh.relaxedTimeoutCount.Add(1)
case "MIGRATED", "SMIGRATED", "FAILED_OVER":
tnh.unrelaxedTimeoutCount.Add(1)
}
}
func (tnh *TrackingNotificationsHook) GetAnalysis() *DiagnosticsAnalysis {
return NewDiagnosticsAnalysis(tnh.GetDiagnosticsLog())
}
func (tnh *TrackingNotificationsHook) GetDiagnosticsLogForConn(connID uint64) []DiagnosticsEvent {
tnh.mutex.RLock()
defer tnh.mutex.RUnlock()
var connLogs []DiagnosticsEvent
for _, log := range tnh.diagnosticsLog {
if log.ConnID == connID {
connLogs = append(connLogs, log)
}
}
return connLogs
}
func (tnh *TrackingNotificationsHook) GetAnalysisForConn(connID uint64) *DiagnosticsAnalysis {
return NewDiagnosticsAnalysis(tnh.GetDiagnosticsLogForConn(connID))
}
type DiagnosticsAnalysis struct {
RelaxedTimeoutCount int64
UnrelaxedTimeoutCount int64
NotificationProcessingErrors int64
ConnectionCount int64
MovingCount int64
MigratingCount int64
MigratedCount int64
SMigratingCount int64
SMigratedCount int64
FailingOverCount int64
FailedOverCount int64
UnexpectedNotificationCount int64
TotalNotifications int64
diagnosticsLog []DiagnosticsEvent
connLogs map[uint64][]DiagnosticsEvent
connIds map[uint64]bool
}
func NewDiagnosticsAnalysis(diagnosticsLog []DiagnosticsEvent) *DiagnosticsAnalysis {
da := &DiagnosticsAnalysis{
diagnosticsLog: diagnosticsLog,
connLogs: make(map[uint64][]DiagnosticsEvent),
connIds: make(map[uint64]bool),
}
da.Analyze()
return da
}
func (a *DiagnosticsAnalysis) Analyze() {
for _, log := range a.diagnosticsLog {
a.TotalNotifications++
switch log.Type {
case "MOVING":
a.MovingCount++
case "MIGRATING":
a.MigratingCount++
case "MIGRATED":
a.MigratedCount++
case "SMIGRATING":
a.SMigratingCount++
case "SMIGRATED":
a.SMigratedCount++
case "FAILING_OVER":
a.FailingOverCount++
case "FAILED_OVER":
a.FailedOverCount++
default:
a.UnexpectedNotificationCount++
}
if log.Error != nil {
fmt.Printf("[ERROR] Notification processing error: %v\n", log.Error)
fmt.Printf("[ERROR] Notification: %v\n", log.Details["notification"])
fmt.Printf("[ERROR] Context: %v\n", log.Details["context"])
a.NotificationProcessingErrors++
}
switch log.Type {
case "MIGRATING", "SMIGRATING", "FAILING_OVER":
a.RelaxedTimeoutCount++
case "MIGRATED", "SMIGRATED", "FAILED_OVER":
a.UnrelaxedTimeoutCount++
}
if log.ConnID != 0 {
if v, ok := a.connIds[log.ConnID]; !ok || !v {
a.connIds[log.ConnID] = true
a.connLogs[log.ConnID] = make([]DiagnosticsEvent, 0)
a.ConnectionCount++
}
a.connLogs[log.ConnID] = append(a.connLogs[log.ConnID], log)
}
}
}
// PrintSummary prints a compact summary without detailed per-event information
func (a *DiagnosticsAnalysis) PrintSummary(t *testing.T) {
t.Logf("Notification Summary: %d events, %d connections | MOVING:%d MIGRATING:%d MIGRATED:%d FAILING_OVER:%d FAILED_OVER:%d SMIGRATING:%d SMIGRATED:%d | Errors:%d",
len(a.diagnosticsLog), len(a.connIds),
a.MovingCount, a.MigratingCount, a.MigratedCount,
a.FailingOverCount, a.FailedOverCount,
a.SMigratingCount, a.SMigratedCount,
a.NotificationProcessingErrors)
}
func (a *DiagnosticsAnalysis) Print(t *testing.T) {
t.Logf("Notification Analysis results for %d events and %d connections:", len(a.diagnosticsLog), len(a.connIds))
t.Logf("-------------")
t.Logf("-Timeout Analysis based on type of notification-")
t.Logf("Note: MIGRATED and FAILED_OVER notifications are not tracked by the hook, so they are not included in the relaxed/unrelaxed count")
t.Logf("Note: The hook only tracks timeouts that occur after the notification is processed, so timeouts that occur during processing are not included")
t.Logf("-------------")
t.Logf(" - Relaxed Timeout Count: %d", a.RelaxedTimeoutCount)
t.Logf(" - Unrelaxed Timeout Count: %d", a.UnrelaxedTimeoutCount)
t.Logf("-------------")
t.Logf("-Notification Analysis-")
t.Logf("-------------")
t.Logf(" - MOVING: %d", a.MovingCount)
t.Logf(" - MIGRATING: %d", a.MigratingCount)
t.Logf(" - MIGRATED: %d", a.MigratedCount)
t.Logf(" - FAILING_OVER: %d", a.FailingOverCount)
t.Logf(" - FAILED_OVER: %d", a.FailedOverCount)
t.Logf(" - Unexpected: %d", a.UnexpectedNotificationCount)
t.Logf("-------------")
t.Logf("- CLUSTER-SPECIFIC Notification Analysis-")
t.Logf("-------------")
t.Logf(" - SMIGRATING: %d", a.SMigratingCount)
t.Logf(" - SMIGRATED: %d", a.SMigratedCount)
t.Logf("-------------")
t.Logf(" - Total Notifications: %d", a.TotalNotifications)
t.Logf(" - Notification Processing Errors: %d", a.NotificationProcessingErrors)
t.Logf(" - Connection Count: %d", a.ConnectionCount)
t.Logf("-------------")
// Print detailed notification events grouped by seqID and type
t.Logf("-Detailed Notification Events (grouped by seqID)-")
t.Logf("-------------")
// Group events by (seqID, type)
type eventKey struct {
seqID int64
typ string
}
type shardEvent struct {
shardAddr string
connID uint64
timestamp time.Time
notification []interface{}
}
groupedEvents := make(map[eventKey][]shardEvent)
var seqIDOrder []eventKey // Track order of first occurrence
for _, event := range a.diagnosticsLog {
if !event.Pre {
continue
}
key := eventKey{seqID: event.SeqID, typ: event.Type}
if _, exists := groupedEvents[key]; !exists {
seqIDOrder = append(seqIDOrder, key)
}
// Extract notification from details
var notification []interface{}
if event.Details != nil {
if notif, ok := event.Details["notification"].([]interface{}); ok {
notification = notif
}
}
groupedEvents[key] = append(groupedEvents[key], shardEvent{
shardAddr: event.ShardAddr,
connID: event.ConnID,
timestamp: event.Timestamp,
notification: notification,
})
}
// Print grouped events
for _, key := range seqIDOrder {
events := groupedEvents[key]
t.Logf(" seqID=%d type=%s (received on %d shard(s)):", key.seqID, key.typ, len(events))
// Print notification content once (should be same for all shards)
if len(events) > 0 && events[0].notification != nil {
t.Logf(" notification: %v", events[0].notification)
}
// Print which shards received it
for _, se := range events {
shardInfo := se.shardAddr
if shardInfo == "" {
shardInfo = "unknown"
}
t.Logf(" - shard=%s connID=%d time=%s",
shardInfo, se.connID, se.timestamp.Format("15:04:05.000"))
}
}
t.Logf("-------------")
t.Logf("Diagnostics Analysis completed successfully")
}
// setupNotificationHook adds a notification hook to a cluster client
//
//nolint:unused // Used in test files
func setupNotificationHook(client *redis.ClusterClient, hook maintnotifications.NotificationHook) {
_ = client.ForEachShard(context.Background(), func(ctx context.Context, nodeClient *redis.Client) error {
manager := nodeClient.GetMaintNotificationsManager()
if manager != nil {
manager.AddNotificationHook(hook)
}
return nil
})
// Also add hook to new nodes
client.OnNewNode(func(nodeClient *redis.Client) {
manager := nodeClient.GetMaintNotificationsManager()
if manager != nil {
manager.AddNotificationHook(hook)
}
})
}
// setupNotificationHooks adds multiple notification hooks to a regular client
//
//nolint:unused // Used in test files
func setupNotificationHooks(client redis.UniversalClient, hooks ...maintnotifications.NotificationHook) {
// Try to get manager from the client
var manager *maintnotifications.Manager
// Check if it's a regular client
if regularClient, ok := client.(*redis.Client); ok {
manager = regularClient.GetMaintNotificationsManager()
}
// Check if it's a cluster client
if clusterClient, ok := client.(*redis.ClusterClient); ok {
// For cluster clients, add hooks to all shards
_ = clusterClient.ForEachShard(context.Background(), func(ctx context.Context, nodeClient *redis.Client) error {
nodeManager := nodeClient.GetMaintNotificationsManager()
if nodeManager != nil {
for _, hook := range hooks {
nodeManager.AddNotificationHook(hook)
}
}
return nil
})
// Also add hooks to new nodes
clusterClient.OnNewNode(func(nodeClient *redis.Client) {
nodeManager := nodeClient.GetMaintNotificationsManager()
if nodeManager != nil {
for _, hook := range hooks {
nodeManager.AddNotificationHook(hook)
}
}
})
return
}
// For regular clients, add hooks directly
if manager != nil {
for _, hook := range hooks {
manager.AddNotificationHook(hook)
}
}
}